fix(session-persistence-sqlite): JSONL parity on append snapshot + corrupt-tail load

Two parity gaps with the JSONL backend found in review:

- append() now validates serializability and structuredClones the batch
  synchronously at call time, BEFORE waiting behind the per-session
  chain. A caller that mutates the passed array (or an event inside it)
  after the call can no longer corrupt the persisted copy or advance the
  cursor past what was written. Matches the JSONL backend.

- load() now computes the last-turn/end cut from the seq+type COLUMNS
  only (cutAtLastTurnEnd is generic over {seq,type}); event `data` is
  JSON-parsed only for the committed prefix, never for the uncommitted
  tail. A malformed `data` in a crash tail is discarded, not treated as
  unloadable — only a parse error/gap in the COMMITTED region is
  unloadable (the SessionPersistence.load contract). Matches scanLog.

Regression tests for both.
This commit is contained in:
Tianyi Cui
2026-06-15 22:23:36 +08:00
parent bc670e69f3
commit ccbc4f533f
3 files changed
+114 -35

No files matched your search

@@ -18,6 +18,14 @@ async function freshDbPath(): Promise<string> {
return join(dir, 'sessions.db')
}
/** A context with the session store + SQLite backend, plus a teardown. */
async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise<void> }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
return { ctx, dispose: () => fiber.dispose() }
}
// The payoff: the SAME backend-agnostic contract the JSONL backend runs, now
// proving the SQLite backend satisfies identical semantics.
runPersistenceContract('sqlite', async () => {
@@ -105,6 +113,61 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
await fiber2.dispose()
})
it('append snapshots the batch: mutating an event after the call does not corrupt the persisted copy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const m = meta('snapshot')
await ctx.sessionPersistence.create(m)
const batch: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } } },
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
]
const p = ctx.sessionPersistence.append(m.id, batch)
// Mutate the live array AND an event's data AFTER the call but before it
// drains behind the per-session chain. The snapshot taken at call time must
// shield the persisted copy.
;(batch[1]!.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
batch.push({ type: 'user/message', seq: 3, time: 4, data: { content: [{ type: 'text', text: 'injected' }], source: { kind: 'user' } } })
await p
const loaded = await ctx.sessionPersistence.load(m.id)
expect(loaded.events).toHaveLength(3) // the pushed event was not persisted
const um = loaded.events[1]
expect(um?.type === 'user/message' && (um.data.content[0] as { text: string }).text).toBe('original')
await fiber.dispose()
})
it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => {
const path = await freshDbPath()
const m = meta('corrupt-tail')
const b1 = await backend(path)
await b1.ctx.sessionPersistence.create(m)
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed: seqs 0..5
await b1.dispose()
// Hand-insert an uncommitted tail row (seq 6, no closing turn/end) whose
// `data` is invalid JSON. The contract: only a parse error in the COMMITTED
// region is unloadable; a corrupt tail must be discarded (load cuts at the
// last turn/end using seq+type columns, never parsing tail `data`).
const db = openDatabase(path)
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
.run(m.id, 'turn/start', '{not valid json')
db.close()
const b2 = await backend(path)
const loaded = await b2.ctx.sessionPersistence.load(m.id)
expect(loaded.events).toEqual(oneTurnLog()) // tail discarded, committed intact
// The corrupt tail row was physically deleted, so a fresh append continues.
await b2.ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } },
])
const reloaded = await b2.ctx.sessionPersistence.load(m.id)
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
await b2.dispose()
})
it('append rolls back the whole batch on a mid-batch seq collision (transaction)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -294,13 +357,6 @@ describe('SessionPersistenceSqlite: write path (session/event → flush)', () =>
})
describe('SessionPersistenceSqlite: edge cases', () => {
async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise<void> }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
return { ctx, dispose: () => fiber.dispose() }
}
it('append of an empty batch is a no-op', async () => {
const { ctx, dispose } = await backend()
const m = meta('empty-batch')