mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
1013 lines
37 KiB
TypeScript
1013 lines
37 KiB
TypeScript
import { afterEach, describe, expect, it } from 'bun:test'
|
|
import { createHash } from 'node:crypto'
|
|
import { appendFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'
|
|
import {
|
|
appendFile,
|
|
mkdir,
|
|
mkdtemp,
|
|
open,
|
|
readFile,
|
|
rm,
|
|
stat,
|
|
utimes,
|
|
writeFile,
|
|
} from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { openLocalIndexDatabase } from './database.js'
|
|
import { createSessionIndex, type SessionIndex } from './sessionIndex.js'
|
|
import {
|
|
SESSION_SUMMARY_PARSER_VERSION,
|
|
MAX_PROJECTION_RECORD_BYTES,
|
|
MAX_PROJECTION_RECORDS,
|
|
createSessionProjector,
|
|
type SessionSourceCandidate,
|
|
} from './sessionProjector.js'
|
|
import { verifySourceFingerprint } from './sourceFingerprint.js'
|
|
|
|
const tempDirs: string[] = []
|
|
const exactFileTime = new Date(1_700_000_000_000)
|
|
|
|
async function createTempDir(label: string): Promise<string> {
|
|
const directory = await mkdtemp(join(tmpdir(), `cc-haha-${label}-`))
|
|
tempDirs.push(directory)
|
|
return directory
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(tempDirs.splice(0).map(directory =>
|
|
rm(directory, { recursive: true, force: true }),
|
|
))
|
|
})
|
|
|
|
function line(entry: Record<string, unknown> | string): string {
|
|
return `${typeof entry === 'string' ? entry : JSON.stringify(entry)}\n`
|
|
}
|
|
|
|
function user(title: string, timestamp: string): Record<string, unknown> {
|
|
return {
|
|
type: 'user',
|
|
message: { role: 'user', content: title },
|
|
timestamp,
|
|
}
|
|
}
|
|
|
|
function assistant(timestamp: string): Record<string, unknown> {
|
|
return {
|
|
type: 'assistant',
|
|
message: { role: 'assistant', content: 'reply' },
|
|
timestamp,
|
|
}
|
|
}
|
|
|
|
async function createCandidate(options: {
|
|
root: string
|
|
projectPath: string
|
|
sessionId: string
|
|
content: string
|
|
fileTime?: Date
|
|
}): Promise<SessionSourceCandidate> {
|
|
const path = join(options.root, 'projects', options.projectPath, `${options.sessionId}.jsonl`)
|
|
await mkdir(dirname(path), { recursive: true })
|
|
await writeFile(path, options.content)
|
|
if (options.fileTime) await utimes(path, options.fileTime, options.fileTime)
|
|
const snapshot = await stat(path)
|
|
return {
|
|
path,
|
|
sessionId: options.sessionId,
|
|
projectPath: options.projectPath,
|
|
fallbackCreatedAt: snapshot.birthtime.toISOString(),
|
|
fallbackModifiedAt: snapshot.mtime.toISOString(),
|
|
fallbackWorkDir: `/decoded/${options.projectPath}`,
|
|
modifiedAtMs: snapshot.mtimeMs,
|
|
}
|
|
}
|
|
|
|
async function sourceHash(path: string): Promise<string> {
|
|
return createHash('sha256').update(await readFile(path)).digest('hex')
|
|
}
|
|
|
|
describe('session projector', () => {
|
|
it('persists an immediate title patch without waiting for transcript reprojection', async () => {
|
|
const root = await createTempDir('projector-title-patch')
|
|
const databasePath = join(root, 'index.sqlite')
|
|
const placeholder = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-placeholder',
|
|
sessionId: 'titled-child',
|
|
content: line(user('Initial child title', '2026-01-01T00:00:00Z')),
|
|
})
|
|
const moved = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-worktree',
|
|
sessionId: 'titled-child',
|
|
content: line(user('Initial child title', '2026-01-01T00:00:01Z')),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: databasePath })
|
|
const index = createSessionIndex(database)
|
|
try {
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
expect((await projector.projectSource(placeholder)).kind).toBe('indexed')
|
|
expect((await projector.projectSource(moved)).kind).toBe('indexed')
|
|
await appendFile(moved.path, line({
|
|
type: 'custom-title',
|
|
customTitle: '最终子会话标题',
|
|
}))
|
|
|
|
expect(index.updateSessionTitle?.(moved.sessionId, '最终子会话标题')).toBe(true)
|
|
} finally {
|
|
database.close()
|
|
}
|
|
|
|
const restarted = openLocalIndexDatabase({ path: databasePath })
|
|
try {
|
|
const sessions = createSessionIndex(restarted).listSessions().sessions
|
|
expect(sessions).toHaveLength(2)
|
|
expect(sessions.every(session => session.title === '最终子会话标题')).toBe(true)
|
|
} finally {
|
|
restarted.close()
|
|
}
|
|
})
|
|
|
|
it('rejects oversized records before concatenation and preserves the canonical file', async () => {
|
|
const root = await createTempDir('projector-record-budget')
|
|
const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'large', content: line(user('x'.repeat(MAX_PROJECTION_RECORD_BYTES + 1), '2026-01-01T00:00:00Z')) })
|
|
const before = await sourceHash(candidate.path)
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
try {
|
|
const projector = createSessionProjector({ database, index: createSessionIndex(database), scope: root })
|
|
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
|
|
expect(await sourceHash(candidate.path)).toBe(before)
|
|
} finally { database.close() }
|
|
})
|
|
|
|
it('bounds locator and reducer growth for arbitrarily many tiny records', async () => {
|
|
const root = await createTempDir('projector-count-budget')
|
|
const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'many', content: line({ type: 'unknown' }).repeat(MAX_PROJECTION_RECORDS + 1) })
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
try {
|
|
const projector = createSessionProjector({ database, index: createSessionIndex(database), scope: root })
|
|
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
|
|
} finally { database.close() }
|
|
})
|
|
|
|
it('keeps the record budget across incremental appends', async () => {
|
|
const root = await createTempDir('projector-append-budget')
|
|
const item = line({ type: 'unknown' })
|
|
const candidate = await createCandidate({ root, projectPath: '-repo', sessionId: 'append', content: item.repeat(MAX_PROJECTION_RECORDS) })
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
try {
|
|
const projector = createSessionProjector({ database, index: createSessionIndex(database), scope: root })
|
|
expect((await projector.projectSource(candidate)).kind).toBe('indexed')
|
|
await appendFile(candidate.path, item)
|
|
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
|
|
} finally { database.close() }
|
|
})
|
|
|
|
it('fully builds without changing JSONL and preserves malformed/pending semantics', async () => {
|
|
const root = await createTempDir('projector-full')
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'same-id',
|
|
content: [
|
|
line(user('First title', '2026-01-01T00:00:00.000Z')),
|
|
line('{malformed}'),
|
|
line(assistant('2026-01-01T00:01:00.000Z')),
|
|
'{"type":"assistant"',
|
|
].join(''),
|
|
})
|
|
const beforeHash = await sourceHash(candidate.path)
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
})
|
|
|
|
try {
|
|
const result = await projector.projectSource(candidate, {
|
|
discovered: 1,
|
|
indexed: 1,
|
|
})
|
|
|
|
expect(result).toMatchObject({
|
|
kind: 'indexed',
|
|
action: 'full',
|
|
projection: {
|
|
summary: {
|
|
title: 'First title',
|
|
messageCount: 2,
|
|
modifiedAt: '2026-01-01T00:01:00.000Z',
|
|
},
|
|
malformedLineCount: 1,
|
|
},
|
|
})
|
|
expect(result.kind === 'indexed' && result.projection.pendingTailBytes).toBeGreaterThan(0)
|
|
expect(index.listSessions()).toMatchObject({
|
|
total: 1,
|
|
sessions: [{
|
|
id: 'same-id',
|
|
projectPath: '-repo-a',
|
|
title: 'First title',
|
|
messageCount: 2,
|
|
transcriptPath: candidate.path,
|
|
}],
|
|
})
|
|
expect(index.getSource(candidate.path)).toMatchObject({
|
|
path: candidate.path,
|
|
state: 'pending',
|
|
})
|
|
expect(await sourceHash(candidate.path)).toBe(beforeHash)
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('refreshes stale discovery fallback times from the fingerprinted source snapshot', async () => {
|
|
const root = await createTempDir('projector-fresh-fallback')
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'fresh-fallback',
|
|
content: line({ type: 'session-meta', workDir: '/before' }),
|
|
})
|
|
const staleModifiedAt = candidate.fallbackModifiedAt
|
|
await writeFile(
|
|
candidate.path,
|
|
line({ type: 'session-meta', workDir: '/after' }),
|
|
)
|
|
const forcedModifiedAt = new Date('2025-05-06T07:08:09.000Z')
|
|
await utimes(candidate.path, forcedModifiedAt, forcedModifiedAt)
|
|
const projectionSnapshot = await stat(candidate.path)
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
|
|
try {
|
|
expect(projectionSnapshot.mtime.toISOString()).not.toBe(staleModifiedAt)
|
|
expect(await projector.projectSource(candidate)).toMatchObject({
|
|
kind: 'indexed',
|
|
action: 'full',
|
|
projection: {
|
|
summary: {
|
|
createdAt: projectionSnapshot.birthtime.toISOString(),
|
|
modifiedAt: projectionSnapshot.mtime.toISOString(),
|
|
},
|
|
},
|
|
})
|
|
expect(index.listSessions().sessions[0]).toMatchObject({
|
|
createdAt: projectionSnapshot.birthtime.toISOString(),
|
|
modifiedAt: projectionSnapshot.mtime.toISOString(),
|
|
})
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('incrementally consumes a prior pending tail and only updates that source', async () => {
|
|
const root = await createTempDir('projector-append')
|
|
const first = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'first',
|
|
content: `${line(user('First', '2026-01-01T00:00:00.000Z'))}{"type":"assistant"`,
|
|
})
|
|
const untouched = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-b',
|
|
sessionId: 'untouched',
|
|
content: line(user('Untouched', '2026-01-01T00:02:00.000Z')),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
|
|
try {
|
|
await projector.projectSource(first)
|
|
await projector.projectSource(untouched)
|
|
const untouchedSource = index.getSource(untouched.path)
|
|
await appendFile(
|
|
first.path,
|
|
`,"message":{"role":"assistant","content":"done"},"timestamp":"2026-01-01T00:03:00.000Z"}\n`,
|
|
)
|
|
|
|
const result = await projector.projectSource(first)
|
|
|
|
expect(result).toMatchObject({
|
|
kind: 'indexed',
|
|
action: 'append',
|
|
projection: {
|
|
summary: { messageCount: 2 },
|
|
pendingTailBytes: 0,
|
|
},
|
|
})
|
|
expect(index.getSource(untouched.path)).toEqual(untouchedSource)
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('rebuilds an append when the prior summary depended on the file mtime fallback', async () => {
|
|
const root = await createTempDir('projector-append-fallback')
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'append-fallback',
|
|
content: line({ type: 'session-meta', workDir: '/before' }),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
|
|
try {
|
|
expect(await projector.projectSource(candidate)).toMatchObject({
|
|
kind: 'indexed',
|
|
action: 'full',
|
|
})
|
|
await appendFile(candidate.path, line({ type: 'session-meta', workDir: '/after' }))
|
|
const appendedSnapshot = await stat(candidate.path)
|
|
|
|
expect(await projector.projectSource(candidate)).toMatchObject({
|
|
kind: 'indexed',
|
|
action: 'rebuild',
|
|
projection: {
|
|
summary: {
|
|
modifiedAt: appendedSnapshot.mtime.toISOString(),
|
|
workDir: '/after',
|
|
},
|
|
},
|
|
})
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('rebuilds one source for rewrites, parser changes, and missing reducer state after restart', async () => {
|
|
const root = await createTempDir('projector-rebuild')
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'first',
|
|
content: line(user('Original', '2026-01-01T00:00:00.000Z')),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const firstProcess = createSessionProjector({ database, index, scope: root })
|
|
|
|
try {
|
|
await firstProcess.projectSource(candidate)
|
|
await writeFile(
|
|
candidate.path,
|
|
line(user('Replacement', '2026-01-02T00:00:00.000Z')),
|
|
)
|
|
expect(await firstProcess.projectSource(candidate)).toMatchObject({
|
|
kind: 'indexed',
|
|
action: 'rebuild',
|
|
projection: { summary: { title: 'Replacement', messageCount: 1 } },
|
|
})
|
|
|
|
const parserUpgrade = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
parserVersion: SESSION_SUMMARY_PARSER_VERSION + 1,
|
|
})
|
|
expect(await parserUpgrade.projectSource(candidate)).toMatchObject({
|
|
kind: 'indexed',
|
|
action: 'rebuild',
|
|
})
|
|
|
|
const restartedProcess = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
parserVersion: SESSION_SUMMARY_PARSER_VERSION + 1,
|
|
})
|
|
await appendFile(candidate.path, line(assistant('2026-01-02T00:01:00.000Z')))
|
|
expect(await restartedProcess.projectSource(candidate)).toMatchObject({
|
|
kind: 'indexed',
|
|
action: 'rebuild',
|
|
projection: { summary: { title: 'Replacement', messageCount: 2 } },
|
|
})
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('rebuilds a v5 cached session and ignores retained protocol metadata after rollback', async () => {
|
|
const root = await createTempDir('projector-protocol-rollback')
|
|
const content = line({ type: 'session-meta', sessionApiFormat: 'unknown' }) +
|
|
line(user('Existing conversation', '2026-01-01T00:00:00.000Z')) +
|
|
line(assistant('2026-01-01T00:01:00.000Z'))
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'existing',
|
|
content,
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
try {
|
|
const oldParser = createSessionProjector({ database, index, scope: root, parserVersion: 5 })
|
|
await oldParser.projectSource(candidate)
|
|
database.write(operation => operation.run(
|
|
"UPDATE sessions SET session_api_format = 'unknown' WHERE transcript_path = ?",
|
|
candidate.path,
|
|
))
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
const result = await projector.projectSource(candidate)
|
|
expect(result).toMatchObject({
|
|
kind: 'indexed',
|
|
action: 'rebuild',
|
|
projection: { summary: { title: 'Existing conversation', messageCount: 2 } },
|
|
})
|
|
if (result.kind !== 'indexed') throw new Error('Expected rebuilt session')
|
|
expect(result.projection.summary).not.toHaveProperty('sessionApiFormat')
|
|
expect(database.read(operation => operation.get<{ parser_version: number }>(
|
|
'SELECT parser_version FROM source_files WHERE path = ?', candidate.path,
|
|
)?.parser_version)).toBe(6)
|
|
expect(database.read(operation => operation.get<{ session_api_format: string }>(
|
|
'SELECT session_api_format FROM sessions WHERE transcript_path = ?', candidate.path,
|
|
)?.session_api_format)).toBe('unknown')
|
|
expect(await readFile(candidate.path, 'utf8')).toBe(content)
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('keeps duplicate ids distinct and queries project-filtered deterministic pages', async () => {
|
|
const root = await createTempDir('projector-query')
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
const candidates = [
|
|
await createCandidate({
|
|
root,
|
|
projectPath: '-repo-b',
|
|
sessionId: 'duplicate',
|
|
content: line(user('B', '2026-01-01T00:00:00.000Z')),
|
|
}),
|
|
await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'duplicate',
|
|
content: line(user('A', '2026-01-01T01:00:00.000+01:00')),
|
|
}),
|
|
await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'later',
|
|
content: line(user('Later', '2026-01-02T00:00:00.000Z')),
|
|
}),
|
|
await createCandidate({
|
|
root,
|
|
projectPath: '-repo-c',
|
|
sessionId: 'timezone-later',
|
|
content: line(user('Timezone later', '2026-01-01T00:30:00.000Z')),
|
|
}),
|
|
]
|
|
|
|
try {
|
|
for (const candidate of candidates) await projector.projectSource(candidate)
|
|
|
|
expect(index.listSessions().sessions.map(item => [item.id, item.projectPath])).toEqual([
|
|
['later', '-repo-a'],
|
|
['timezone-later', '-repo-c'],
|
|
['duplicate', '-repo-a'],
|
|
['duplicate', '-repo-b'],
|
|
])
|
|
expect(index.listSessions({ project: '-repo-a', limit: 1, offset: 1 })).toMatchObject({
|
|
total: 2,
|
|
sessions: [{ id: 'duplicate', title: 'A' }],
|
|
})
|
|
expect(index.findSearchCandidates?.({
|
|
project: '-repo-a',
|
|
modifiedAfterMs: Date.parse('2026-01-02T00:00:00.000Z'),
|
|
modifiedBeforeMs: Date.parse('2026-01-02T23:59:59.999Z'),
|
|
})).toEqual([{
|
|
transcriptPath: candidates[2]!.path,
|
|
id: 'later',
|
|
title: 'Later',
|
|
modifiedAt: '2026-01-02T00:00:00.000Z',
|
|
projectPath: '-repo-a',
|
|
workDir: '/decoded/-repo-a',
|
|
}])
|
|
expect(index.findSessionFiles('duplicate')).toEqual([
|
|
{ filePath: candidates[1]!.path, projectDir: '-repo-a' },
|
|
{ filePath: candidates[0]!.path, projectDir: '-repo-b' },
|
|
])
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('round-trips absent, explicit-null, and string runtime provider metadata', async () => {
|
|
const root = await createTempDir('projector-provider-presence')
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
const candidates = [
|
|
await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'absent',
|
|
content: line(user('Absent', '2026-01-01T00:00:00.000Z')),
|
|
}),
|
|
await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'explicit-null',
|
|
content: line({
|
|
type: 'session-meta',
|
|
runtimeProviderId: null,
|
|
timestamp: '2026-01-01T00:01:00.000Z',
|
|
}),
|
|
}),
|
|
await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'string',
|
|
content: line({
|
|
type: 'session-meta',
|
|
runtimeProviderId: 'provider-a',
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
}),
|
|
}),
|
|
]
|
|
|
|
try {
|
|
for (const candidate of candidates) await projector.projectSource(candidate)
|
|
const byId = new Map(index.listSessions({ limit: 10 }).sessions.map(row => [row.id, row]))
|
|
|
|
expect(Object.hasOwn(byId.get('absent')!, 'runtimeProviderId')).toBe(false)
|
|
expect(Object.hasOwn(byId.get('explicit-null')!, 'runtimeProviderId')).toBe(true)
|
|
expect(byId.get('explicit-null')!.runtimeProviderId).toBeNull()
|
|
expect(byId.get('string')!.runtimeProviderId).toBe('provider-a')
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('removes only a confirmed missing source projection', async () => {
|
|
const root = await createTempDir('projector-delete')
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'deleted',
|
|
content: line(user('Delete me', '2026-01-01T00:00:00.000Z')),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
|
|
try {
|
|
await projector.projectSource(candidate)
|
|
await rm(candidate.path)
|
|
|
|
expect(await projector.deleteSource(candidate.path)).toEqual({ kind: 'deleted' })
|
|
expect(index.listSessions()).toEqual({ sessions: [], total: 0 })
|
|
expect(index.getSource(candidate.path)).toBeNull()
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('retains all derived rows when a missing source is recreated before delete BEGIN', async () => {
|
|
const root = await createTempDir('projector-delete-recreate')
|
|
const canonical = line(user('Keep me', '2026-01-01T00:00:00.000Z'))
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'delete-recreate',
|
|
content: canonical,
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const initialProjector = createSessionProjector({ database, index, scope: root })
|
|
|
|
try {
|
|
await initialProjector.projectSource(candidate, { discovered: 1, indexed: 1 })
|
|
const sourceBefore = index.getSource(candidate.path)
|
|
const sessionsBefore = index.listSessions()
|
|
const backfillBefore = index.getBackfillState(root)
|
|
await rm(candidate.path)
|
|
let recreated = false
|
|
const deleteProjector = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
fileIo: {
|
|
openReadonly(path, flags) {
|
|
return open(path, flags)
|
|
},
|
|
async statPath(path) {
|
|
try {
|
|
return await stat(path)
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !recreated) {
|
|
recreated = true
|
|
await writeFile(path, canonical)
|
|
}
|
|
throw error
|
|
}
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(await deleteProjector.deleteSource(candidate.path, {
|
|
discovered: 1,
|
|
indexed: 1,
|
|
})).toEqual({ kind: 'retry', reason: 'changed-during-read' })
|
|
expect(recreated).toBe(true)
|
|
expect(index.getSource(candidate.path)).toEqual(sourceBefore)
|
|
expect(index.listSessions()).toEqual(sessionsBefore)
|
|
expect(index.getBackfillState(root)).toEqual(backfillBefore)
|
|
expect(await readFile(candidate.path, 'utf8')).toBe(canonical)
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('uses progress or COUNT(*) without materializing all source rows per projection', async () => {
|
|
const root = await createTempDir('projector-source-count')
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const realIndex = createSessionIndex(database)
|
|
let listSourcesCalls = 0
|
|
let countSourcesCalls = 0
|
|
const index: SessionIndex = {
|
|
...realIndex,
|
|
listSources() {
|
|
listSourcesCalls += 1
|
|
return realIndex.listSources()
|
|
},
|
|
countSources() {
|
|
countSourcesCalls += 1
|
|
return realIndex.countSources()
|
|
},
|
|
}
|
|
const progressed = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'progressed',
|
|
content: line(user('Progressed', '2026-01-01T00:00:00.000Z')),
|
|
})
|
|
const independent = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-b',
|
|
sessionId: 'independent',
|
|
content: line(user('Independent', '2026-01-01T00:01:00.000Z')),
|
|
})
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
|
|
try {
|
|
await projector.projectSource(progressed, { discovered: 1, indexed: 1 })
|
|
await projector.projectSource(progressed, { discovered: 1, indexed: 1 })
|
|
await rm(progressed.path)
|
|
await projector.deleteSource(progressed.path, { discovered: 1, indexed: 1 })
|
|
expect(listSourcesCalls).toBe(0)
|
|
expect(countSourcesCalls).toBe(0)
|
|
|
|
await projector.projectSource(independent)
|
|
expect(listSourcesCalls).toBe(0)
|
|
expect(countSourcesCalls).toBeGreaterThan(0)
|
|
expect(realIndex.countSources()).toBe(1)
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('does not write any row when commit-time verification sees a changed source', async () => {
|
|
const root = await createTempDir('projector-rollback')
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'race',
|
|
content: line(user('Before', '2026-01-01T00:00:00.000Z')),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
let changed = false
|
|
const projector = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
verifyFingerprint: async (options) => {
|
|
if (!changed) {
|
|
changed = true
|
|
await appendFile(candidate.path, line(assistant('2026-01-01T00:01:00.000Z')))
|
|
}
|
|
return verifySourceFingerprint(options)
|
|
},
|
|
})
|
|
|
|
try {
|
|
expect(await projector.projectSource(candidate)).toEqual({
|
|
kind: 'retry',
|
|
reason: 'changed-during-read',
|
|
})
|
|
expect(index.listSessions()).toEqual({ sessions: [], total: 0 })
|
|
expect(index.getSource(candidate.path)).toBeNull()
|
|
expect(index.getBackfillState(root)).toBeNull()
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('rolls back source, session, and backfill rows when SQL projection work fails mid-transaction', async () => {
|
|
const root = await createTempDir('projector-sql-rollback')
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'sql-race',
|
|
content: line(user('Atomic', '2026-01-01T00:00:00.000Z')),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
beforeSessionUpsert: () => {
|
|
throw new Error('injected-session-upsert-failure')
|
|
},
|
|
})
|
|
|
|
try {
|
|
await expect(projector.projectSource(candidate)).rejects.toThrow(
|
|
'injected-session-upsert-failure',
|
|
)
|
|
expect(index.getSource(candidate.path)).toBeNull()
|
|
expect(index.listSessions()).toEqual({ sessions: [], total: 0 })
|
|
expect(index.getBackfillState(root)).toBeNull()
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('rolls back all rows when the source changes inside the short write transaction', async () => {
|
|
const root = await createTempDir('projector-commit-guard')
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'commit-race',
|
|
content: line(user('Atomic', '2026-01-01T00:00:00.000Z')),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
let changed = false
|
|
const projector = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
beforeSessionUpsert: () => {
|
|
if (changed) return
|
|
changed = true
|
|
appendFileSync(candidate.path, line(assistant('2026-01-01T00:01:00.000Z')))
|
|
},
|
|
})
|
|
|
|
try {
|
|
expect(await projector.projectSource(candidate)).toEqual({
|
|
kind: 'retry',
|
|
reason: 'changed-during-read',
|
|
})
|
|
expect(index.getSource(candidate.path)).toBeNull()
|
|
expect(index.listSessions()).toEqual({ sessions: [], total: 0 })
|
|
expect(index.getBackfillState(root)).toBeNull()
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('rolls back all rows when an equal-length rewrite restores mtime inside the transaction', async () => {
|
|
const root = await createTempDir('projector-commit-ctime-guard')
|
|
const beforeContent = line(user('Before', '2026-01-01T00:00:00.000Z'))
|
|
const afterContent = line(user('Forged', '2026-01-01T00:00:00.000Z'))
|
|
expect(afterContent.length).toBe(beforeContent.length)
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'commit-ctime-race',
|
|
content: beforeContent,
|
|
fileTime: exactFileTime,
|
|
})
|
|
const originalSnapshot = await stat(candidate.path)
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
let changed = false
|
|
const projector = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
beforeSessionUpsert: () => {
|
|
if (changed) return
|
|
changed = true
|
|
writeFileSync(candidate.path, afterContent)
|
|
utimesSync(
|
|
candidate.path,
|
|
exactFileTime,
|
|
exactFileTime,
|
|
)
|
|
},
|
|
})
|
|
|
|
try {
|
|
expect(await projector.projectSource(candidate)).toEqual({
|
|
kind: 'retry',
|
|
reason: 'changed-during-read',
|
|
})
|
|
const rewrittenSnapshot = statSync(candidate.path)
|
|
expect(changed).toBe(true)
|
|
expect(rewrittenSnapshot.size).toBe(originalSnapshot.size)
|
|
expect(rewrittenSnapshot.mtimeMs).toBe(originalSnapshot.mtimeMs)
|
|
expect(rewrittenSnapshot.ctimeMs).not.toBe(originalSnapshot.ctimeMs)
|
|
expect(await readFile(candidate.path, 'utf8')).toBe(afterContent)
|
|
expect(index.getSource(candidate.path)).toBeNull()
|
|
expect(index.listSessions()).toEqual({ sessions: [], total: 0 })
|
|
expect(index.getBackfillState(root)).toBeNull()
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it('rolls back activity rows when an equal-length rewrite restores mtime inside the transaction', async () => {
|
|
const root = await createTempDir('projector-activity-commit-ctime-guard')
|
|
const beforeContent = line(user('Before', '2026-01-01T00:00:00.000Z'))
|
|
const afterContent = line(user('Forged', '2026-01-01T00:00:00.000Z'))
|
|
expect(afterContent.length).toBe(beforeContent.length)
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'activity-commit-ctime-race',
|
|
content: beforeContent,
|
|
fileTime: exactFileTime,
|
|
})
|
|
const originalSnapshot = await stat(candidate.path)
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
let changed = false
|
|
const projector = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
syncStat: (path) => {
|
|
if (!changed) {
|
|
changed = true
|
|
writeFileSync(path, afterContent)
|
|
utimesSync(
|
|
path,
|
|
exactFileTime,
|
|
exactFileTime,
|
|
)
|
|
}
|
|
return statSync(path)
|
|
},
|
|
})
|
|
|
|
try {
|
|
expect(await projector.projectActivitySource(candidate)).toEqual({
|
|
kind: 'retry',
|
|
reason: 'changed-during-read',
|
|
})
|
|
const rewrittenSnapshot = statSync(candidate.path)
|
|
expect(changed).toBe(true)
|
|
expect(rewrittenSnapshot.size).toBe(originalSnapshot.size)
|
|
expect(rewrittenSnapshot.mtimeMs).toBe(originalSnapshot.mtimeMs)
|
|
expect(rewrittenSnapshot.ctimeMs).not.toBe(originalSnapshot.ctimeMs)
|
|
expect(await readFile(candidate.path, 'utf8')).toBe(afterContent)
|
|
expect(index.getActivitySource(candidate.path)).toBeNull()
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
|
|
it.each(['after verify', 'inside transaction'] as const)(
|
|
'generation fencing prevents an old projector commit %s',
|
|
async (cancelAt) => {
|
|
const root = await createTempDir(`projector-generation-${cancelAt.replace(' ', '-')}`)
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'old-generation',
|
|
content: line(user('Old generation', '2026-01-01T00:00:00.000Z')),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
let active = true
|
|
const projector = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
canCommit: () => active,
|
|
...(cancelAt === 'after verify'
|
|
? {
|
|
verifyFingerprint: async (options) => {
|
|
const result = await verifySourceFingerprint(options)
|
|
active = false
|
|
return result
|
|
},
|
|
}
|
|
: {
|
|
beforeSessionUpsert: () => {
|
|
active = false
|
|
},
|
|
}),
|
|
})
|
|
|
|
try {
|
|
expect(await projector.projectSource(candidate)).toMatchObject({ kind: 'retry' })
|
|
expect(index.getSource(candidate.path)).toBeNull()
|
|
expect(index.listSessions()).toEqual({ sessions: [], total: 0 })
|
|
expect(index.getBackfillState(root)).toBeNull()
|
|
} finally {
|
|
database.close()
|
|
}
|
|
},
|
|
)
|
|
|
|
it('opens sources read-only, closes the handle, and bounds reducer batches', async () => {
|
|
const root = await createTempDir('projector-streaming')
|
|
const content = Array.from({ length: 1_200 }, (_, index) => line(user(
|
|
`${index.toString().padStart(4, '0')}-${'x'.repeat(1_800)}`,
|
|
new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
|
|
))).join('')
|
|
const candidate = await createCandidate({
|
|
root,
|
|
projectPath: '-repo-a',
|
|
sessionId: 'streaming',
|
|
content,
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const flags: string[] = []
|
|
let closeCount = 0
|
|
const projector = createSessionProjector({
|
|
database,
|
|
index,
|
|
scope: root,
|
|
fileIo: {
|
|
async openReadonly(path, flag) {
|
|
flags.push(flag)
|
|
const handle = await open(path, flag)
|
|
return {
|
|
stat: () => handle.stat(),
|
|
read: (buffer, offset, length, position) =>
|
|
handle.read(buffer, offset, length, position),
|
|
async close() {
|
|
closeCount += 1
|
|
await handle.close()
|
|
},
|
|
}
|
|
},
|
|
statPath: path => stat(path),
|
|
},
|
|
})
|
|
|
|
try {
|
|
const result = await projector.projectSource(candidate)
|
|
|
|
expect(result).toMatchObject({ kind: 'indexed', action: 'full' })
|
|
expect(result.kind === 'indexed' && result.work.maxBufferedChunks).toBeLessThanOrEqual(256)
|
|
expect(result.kind === 'indexed' && result.work.maxBufferedBytes).toBeLessThan(1_100_000)
|
|
expect(flags.length).toBeGreaterThanOrEqual(4)
|
|
expect(flags.every(flag => flag === 'r')).toBe(true)
|
|
expect(closeCount).toBe(flags.length)
|
|
} finally {
|
|
database.close()
|
|
}
|
|
})
|
|
it('rejects oversized retained identities while accepting large discarded bodies', async () => {
|
|
const root = await createTempDir('metadata-budget')
|
|
const candidate = await createCandidate({ root, projectPath: '-repo-a', sessionId: 'metadata',
|
|
content: line({ ...user('x'.repeat(2 * 1024 * 1024), '2026-01-01T00:00:00.000Z'), uuid: 'ordinary' }),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
try {
|
|
expect(await projector.projectSource(candidate)).toMatchObject({ kind: 'indexed' })
|
|
await appendFile(candidate.path, line({ ...user('small', '2026-01-01T00:00:01.000Z'), uuid: 'x'.repeat(4097) }))
|
|
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
|
|
} finally { database.close() }
|
|
})
|
|
|
|
it('enforces aggregate retained metadata across appended windows', async () => {
|
|
const root = await createTempDir('metadata-total-budget')
|
|
const record = (index: number) => line({ ...user('small', '2026-01-01T00:00:00.000Z'), uuid: `${index}:` + 'x'.repeat(3000) })
|
|
const candidate = await createCandidate({ root, projectPath: '-repo-a', sessionId: 'metadata-total',
|
|
content: Array.from({ length: 3000 }, (_, index) => record(index)).join(''),
|
|
})
|
|
const database = openLocalIndexDatabase({ path: join(root, 'index.sqlite') })
|
|
const index = createSessionIndex(database)
|
|
const projector = createSessionProjector({ database, index, scope: root })
|
|
try {
|
|
expect(await projector.projectSource(candidate)).toMatchObject({ kind: 'indexed' })
|
|
await appendFile(candidate.path, Array.from({ length: 3000 }, (_, index) => record(index + 3000)).join(''))
|
|
await expect(projector.projectSource(candidate)).rejects.toMatchObject({ code: 'LOCAL_INDEX_SOURCE_LIMIT' })
|
|
} finally { database.close() }
|
|
})
|
|
|
|
})
|