fix(session-persistence-sqlite): defer crash-tail repair to append; fix all-tail materialized flag; persist schema version (review #35)
- load() no longer DELETEs the crash tail — it stays non-mutating w.r.t. the event log and records a repair point (repairFrom). The next append runs the DELETE inside its own transaction before inserting. This makes the SQLite backend honor the SAME public contract as JSONL (load returns the committed prefix; the subsequent append performs the one-time physical truncation-repair), instead of mutating during load. - All-tail load: when the discarded crash tail was the session's only committed content (committed.length === 0), the metadata row still read materialized = 1 from the prior append, so has()/list() reported a session load() had just emptied. load() now flips the row's materialized flag to 0 (metadata only — the orphaned event rows are still removed by the deferred repair), so has()/list() are immediately consistent. - Schema version: openDatabase now stores SCHEMA_VERSION in PRAGMA user_version on a fresh database and rejects opening one whose user_version is newer than this build supports, protecting against a future incompatible layout. Regression tests: load is non-mutating (tail rows survive until the next append), all-tail load makes has()/list() false, and a newer-schema database is rejected on open.
This commit is contained in:
@@ -80,7 +80,7 @@ describe('cutAtLastTurnEnd', () => {
|
||||
})
|
||||
|
||||
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
it('a crash tail (rows after the last turn/end) is excluded and deleted on load', async () => {
|
||||
it('a crash tail (rows after the last turn/end) is excluded on load and repaired on the next append', async () => {
|
||||
const path = await freshDbPath()
|
||||
const m = meta('crash')
|
||||
// Run 1: persist a complete turn, then a half-written second turn (no turn/end).
|
||||
@@ -95,15 +95,16 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
])
|
||||
await fiber1.dispose()
|
||||
|
||||
// Run 2: load returns only the committed first turn; the tail is gone.
|
||||
// Run 2: load returns only the committed first turn (tail excluded).
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(SessionStore)
|
||||
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
|
||||
const loaded = await ctx2.sessionPersistence.load(m.id)
|
||||
expect(loaded.events).toEqual(oneTurnLog())
|
||||
|
||||
// The next append continues at seq 6 (the committed length) and the cut
|
||||
// tail was physically deleted, so there is no UNIQUE collision.
|
||||
// The next append continues at seq 6 and performs the deferred truncation-
|
||||
// repair inside its transaction (DELETE seq >= 6 before inserting), so the
|
||||
// orphaned tail rows are gone and there is no UNIQUE collision.
|
||||
await ctx2.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
@@ -113,6 +114,64 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
await fiber2.dispose()
|
||||
})
|
||||
|
||||
it('load() is non-mutating: the crash tail rows survive until the next append repairs them', async () => {
|
||||
const path = await freshDbPath()
|
||||
const m = meta('load-nonmutating')
|
||||
const b1 = await backend(path)
|
||||
await b1.ctx.sessionPersistence.create(m)
|
||||
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
|
||||
await b1.dispose()
|
||||
// Hand-write an uncommitted tail (seq 6, no turn/end).
|
||||
const db = openDatabase(path)
|
||||
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
|
||||
.run(m.id, 'turn/start', JSON.stringify({ turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
db.close()
|
||||
|
||||
const b2 = await backend(path)
|
||||
const loaded = await b2.ctx.sessionPersistence.load(m.id)
|
||||
expect(loaded.events).toEqual(oneTurnLog())
|
||||
// load() must NOT have deleted the tail row (contract: load returns the
|
||||
// prefix; the next append repairs). Verify the row is still on disk.
|
||||
const probe = openDatabase(path)
|
||||
const tailRows = probe.prepare('SELECT seq FROM events WHERE session_id = ? AND seq >= 6').all(m.id)
|
||||
probe.close()
|
||||
expect(tailRows).toHaveLength(1)
|
||||
await b2.dispose()
|
||||
})
|
||||
|
||||
it('all-tail load: a session whose only content is a crash tail is absent from has()/list()', async () => {
|
||||
const path = await freshDbPath()
|
||||
const m = meta('all-tail')
|
||||
const b1 = await backend(path)
|
||||
await b1.ctx.sessionPersistence.create(m)
|
||||
// A first turn that NEVER completed: turn/start + user/message, no turn/end.
|
||||
await b1.ctx.sessionPersistence.append(m.id, [
|
||||
{ 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: 'hi' }], source: { kind: 'user' } } },
|
||||
])
|
||||
expect(await b1.ctx.sessionPersistence.has(m.id)).toBe(true) // materialized
|
||||
await b1.dispose()
|
||||
|
||||
// A fresh backend loads it: the committed prefix is empty (no turn/end), so
|
||||
// the session has no committed content. has()/list() must NOT report it.
|
||||
const b2 = await backend(path)
|
||||
const loaded = await b2.ctx.sessionPersistence.load(m.id)
|
||||
expect(loaded.events).toEqual([])
|
||||
expect(await b2.ctx.sessionPersistence.has(m.id)).toBe(false)
|
||||
expect((await b2.ctx.sessionPersistence.list()).map(x => x.id)).not.toContain(m.id)
|
||||
await b2.dispose()
|
||||
})
|
||||
|
||||
it('rejects opening a database whose schema version is newer than this build', async () => {
|
||||
const path = await freshDbPath()
|
||||
openDatabase(path).close() // stamp user_version = SCHEMA_VERSION
|
||||
// Bump user_version past what this build supports.
|
||||
const db = openDatabase(path)
|
||||
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`)
|
||||
db.close()
|
||||
expect(() => openDatabase(path)).toThrow(/newer than this build/)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user