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.
652 lines
31 KiB
TypeScript
652 lines
31 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { Session, SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session'
|
|
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
|
|
import { cutAtLastTurnEnd, openDatabase } from '../src/schema.ts'
|
|
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
|
|
|
|
const dirs: string[] = []
|
|
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
|
|
|
|
async function freshDbPath(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-'))
|
|
dirs.push(dir)
|
|
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 () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
|
return {
|
|
persistence: ctx.sessionPersistence,
|
|
dispose: async () => { await fiber.dispose() },
|
|
}
|
|
})
|
|
|
|
describe('cutAtLastTurnEnd', () => {
|
|
it('returns the prefix through the last complete turn/end and flags a cut tail', () => {
|
|
const log = oneTurnLog()
|
|
const withTail: SessionEvent[] = [
|
|
...log,
|
|
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
{ type: 'user/message', seq: 7, time: 8, data: { content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } } },
|
|
]
|
|
const { committed, cutTail } = cutAtLastTurnEnd(withTail)
|
|
expect(committed).toEqual(log)
|
|
expect(cutTail).toBe(true)
|
|
})
|
|
|
|
it('treats a log with no turn/end as fully uncommitted', () => {
|
|
const partial: 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: 'hi' }], source: { kind: 'user' } } },
|
|
]
|
|
expect(cutAtLastTurnEnd(partial)).toEqual({ committed: [], cutTail: true })
|
|
})
|
|
|
|
it('reports no cut when the log ends exactly on a turn/end', () => {
|
|
const { committed, cutTail } = cutAtLastTurnEnd(oneTurnLog())
|
|
expect(committed).toEqual(oneTurnLog())
|
|
expect(cutTail).toBe(false)
|
|
})
|
|
|
|
it('an empty log is committed-empty with no tail', () => {
|
|
expect(cutAtLastTurnEnd([])).toEqual({ committed: [], cutTail: false })
|
|
})
|
|
|
|
it('throws on a seq gap inside the committed region', () => {
|
|
const gapped: SessionEvent[] = [
|
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
|
|
{ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
]
|
|
expect(() => cutAtLastTurnEnd(gapped)).toThrow(/seq gap in committed region/)
|
|
})
|
|
})
|
|
|
|
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
|
it('a crash tail (rows after the last turn/end) is excluded and deleted on load', async () => {
|
|
const path = await freshDbPath()
|
|
const m = meta('crash')
|
|
// Run 1: persist a complete turn, then a half-written second turn (no turn/end).
|
|
const ctx1 = new Context()
|
|
await ctx1.plugin(SessionStore)
|
|
const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
|
|
await ctx1.sessionPersistence.create(m)
|
|
await ctx1.sessionPersistence.append(m.id, oneTurnLog())
|
|
await ctx1.sessionPersistence.append(m.id, [
|
|
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
{ type: 'user/message', seq: 7, time: 8, data: { content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } } },
|
|
])
|
|
await fiber1.dispose()
|
|
|
|
// Run 2: load returns only the committed first turn; the tail is gone.
|
|
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.
|
|
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' } } },
|
|
])
|
|
const reloaded = await ctx2.sessionPersistence.load(m.id)
|
|
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
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)
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
|
const m = meta('rollback')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
|
|
|
|
// A batch that re-states an already-stored seq must be rejected and leave
|
|
// the stored log unchanged (the UNIQUE (session_id, seq) constraint fires
|
|
// inside the transaction → ROLLBACK).
|
|
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow()
|
|
const loaded = await ctx.sessionPersistence.load(m.id)
|
|
expect(loaded.events).toEqual(oneTurnLog()) // unchanged
|
|
await fiber.dispose()
|
|
})
|
|
|
|
it('persists across separate backend instances over the same file', async () => {
|
|
const path = await freshDbPath()
|
|
const m = meta('persist', '/proj')
|
|
const ctx1 = new Context()
|
|
await ctx1.plugin(SessionStore)
|
|
const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
|
|
await ctx1.sessionPersistence.create(m)
|
|
await ctx1.sessionPersistence.append(m.id, oneTurnLog())
|
|
await ctx1.sessionPersistence.update(m.id, { title: 'T', firstPrompt: 'hi' })
|
|
await fiber1.dispose()
|
|
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(SessionStore)
|
|
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
|
|
expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
|
|
const loaded = await ctx2.sessionPersistence.load(m.id)
|
|
expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj', title: 'T', firstPrompt: 'hi' })
|
|
expect(loaded.events).toEqual(oneTurnLog())
|
|
await fiber2.dispose()
|
|
})
|
|
|
|
it('rejects an unknown format version on load', async () => {
|
|
const path = await freshDbPath()
|
|
// Materialize a row with version 2 directly via the real schema.
|
|
const db = openDatabase(path)
|
|
db.prepare('INSERT INTO sessions (id, version, created_at, updated_at, materialized) VALUES (?, ?, ?, ?, 1)')
|
|
.run('v2', 2, 1, 1)
|
|
db.close()
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
await expect(ctx.sessionPersistence.load(SessionId('v2'))).rejects.toThrow(/version 2/)
|
|
await fiber.dispose()
|
|
})
|
|
|
|
it('create rejects a duplicate id (in memory and on a persisted row)', async () => {
|
|
const path = await freshDbPath()
|
|
const m = meta('dup')
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
await ctx.sessionPersistence.create(m)
|
|
// Same in-memory state.
|
|
await expect(ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists/)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
await fiber.dispose()
|
|
|
|
// A fresh instance over the same file sees the persisted row.
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(SessionStore)
|
|
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
|
|
await expect(ctx2.sessionPersistence.create(m)).rejects.toThrow(/already has a persisted row/)
|
|
await fiber2.dispose()
|
|
})
|
|
|
|
it('exposes the schema version constant', () => {
|
|
expect(SCHEMA_VERSION).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('SessionPersistenceSqlite: write path (session/event → flush)', () => {
|
|
function send(session: Session, events: SessionEvent[]): void {
|
|
for (const e of events) session.append(e.type, e.data)
|
|
}
|
|
|
|
it('persists a turn appended through the live session on flush', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
|
const session = ctx.sessions.create('w1')
|
|
send(session, oneTurnLog())
|
|
await ctx.parallel('session/flush', session)
|
|
const loaded = await ctx.sessionPersistence.load(SessionId('w1'))
|
|
expect(loaded.events.map(e => e.type)).toEqual(oneTurnLog().map(e => e.type))
|
|
await fiber.dispose()
|
|
})
|
|
|
|
it('a resumed session does not re-append its seed', async () => {
|
|
const path = await freshDbPath()
|
|
// Run 1: persist a full turn through the live session.
|
|
const ctx1 = new Context()
|
|
await ctx1.plugin(SessionStore)
|
|
const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
|
|
const s1 = ctx1.sessions.create('resume')
|
|
for (const e of oneTurnLog()) s1.append(e.type, e.data)
|
|
await ctx1.parallel('session/flush', s1)
|
|
await fiber1.dispose()
|
|
|
|
// Run 2: reconstruct the live session from the loaded log (seed), then add a
|
|
// second turn. The seed must NOT be re-appended (no UNIQUE collision), and
|
|
// the second turn continues the seq.
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(SessionStore)
|
|
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
|
|
const { events } = await ctx2.sessionPersistence.load(SessionId('resume'))
|
|
const s2 = ctx2.sessions.create('resume', { seed: events })
|
|
s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
|
await ctx2.parallel('session/flush', s2)
|
|
const reloaded = await ctx2.sessionPersistence.load(SessionId('resume'))
|
|
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
await fiber2.dispose()
|
|
})
|
|
|
|
it('HMR: applying the plugin seeds existing live sessions', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const session = ctx.sessions.create('hmr')
|
|
for (const e of oneTurnLog()) session.append(e.type, e.data)
|
|
// Plugin applied AFTER the session already has events.
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
|
await ctx.parallel('session/flush', session)
|
|
expect(await ctx.sessionPersistence.has(SessionId('hmr'))).toBe(true)
|
|
await fiber.dispose()
|
|
})
|
|
|
|
it('dispose drains a pending buffer before closing the database', async () => {
|
|
const path = await freshDbPath()
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
const session = ctx.sessions.create('drain')
|
|
for (const e of oneTurnLog()) session.append(e.type, e.data)
|
|
// No explicit flush — dispose must drain the buffer.
|
|
await fiber.dispose()
|
|
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(SessionStore)
|
|
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
|
|
expect(await ctx2.sessionPersistence.has(SessionId('drain'))).toBe(true)
|
|
await fiber2.dispose()
|
|
})
|
|
|
|
it('rejects a different live session colliding on a persisted id', async () => {
|
|
const path = await freshDbPath()
|
|
const ctx1 = new Context()
|
|
await ctx1.plugin(SessionStore)
|
|
const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
|
|
const s1 = ctx1.sessions.create('collide')
|
|
for (const e of oneTurnLog()) s1.append(e.type, e.data)
|
|
await ctx1.parallel('session/flush', s1)
|
|
await fiber1.dispose()
|
|
|
|
// A fresh, unrelated session reusing the id (no seed) must be rejected.
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(SessionStore)
|
|
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
|
|
const s2 = ctx2.sessions.create('collide')
|
|
s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
await expect(ctx2.parallel('session/flush', s2)).rejects.toThrow(/id collision/)
|
|
await fiber2.dispose()
|
|
})
|
|
|
|
it('update before the first append keeps the summary in memory and the session lazy', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
|
const m = meta('lazy-update')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.update(m.id, { title: 'pending' })
|
|
// Still lazy: no materialized row yet.
|
|
expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
|
|
// The first append materializes and carries the pending title.
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
const loaded = await ctx.sessionPersistence.load(m.id)
|
|
expect(loaded.meta.title).toBe('pending')
|
|
await fiber.dispose()
|
|
})
|
|
})
|
|
|
|
describe('SessionPersistenceSqlite: edge cases', () => {
|
|
it('append of an empty batch is a no-op', async () => {
|
|
const { ctx, dispose } = await backend()
|
|
const m = meta('empty-batch')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, [])
|
|
expect(await ctx.sessionPersistence.has(m.id)).toBe(false) // still lazy
|
|
await dispose()
|
|
})
|
|
|
|
it('load rejects a missing session', async () => {
|
|
const { ctx, dispose } = await backend()
|
|
await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/)
|
|
await dispose()
|
|
})
|
|
|
|
it('delete of a non-existent session is a no-op', async () => {
|
|
const { ctx, dispose } = await backend()
|
|
await ctx.sessionPersistence.delete(SessionId('ghost'))
|
|
expect(await ctx.sessionPersistence.has(SessionId('ghost'))).toBe(false)
|
|
await dispose()
|
|
})
|
|
|
|
it('append adopts a session that exists only in the DB (fresh instance)', async () => {
|
|
const path = await freshDbPath()
|
|
const m = meta('adopt-append')
|
|
const b1 = await backend(path)
|
|
await b1.ctx.sessionPersistence.create(m)
|
|
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
await b1.dispose()
|
|
|
|
// A fresh instance appends a second turn WITHOUT a prior create/load: append
|
|
// must adopt the on-disk row (cursor = stored length) and continue the seq.
|
|
const b2 = await backend(path)
|
|
await b2.ctx.sessionPersistence.append(m.id, [
|
|
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
|
])
|
|
const loaded = await b2.ctx.sessionPersistence.load(m.id)
|
|
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
await b2.dispose()
|
|
})
|
|
|
|
it('update adopts a session that exists only in the DB (fresh instance)', async () => {
|
|
const path = await freshDbPath()
|
|
const m = meta('adopt-update')
|
|
const b1 = await backend(path)
|
|
await b1.ctx.sessionPersistence.create(m)
|
|
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
await b1.dispose()
|
|
|
|
const b2 = await backend(path)
|
|
await b2.ctx.sessionPersistence.update(m.id, { title: 'after restart' })
|
|
const loaded = await b2.ctx.sessionPersistence.load(m.id)
|
|
expect(loaded.meta.title).toBe('after restart')
|
|
await b2.dispose()
|
|
})
|
|
|
|
it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
|
|
const path = await freshDbPath()
|
|
const m = meta('rollback-insert')
|
|
const b1 = await backend(path)
|
|
await b1.ctx.sessionPersistence.create(m)
|
|
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
// A SECOND backend over the same file loads the session first, so it adopts
|
|
// cursor 6 (the committed length) into its OWN in-memory state.
|
|
const b2 = await backend(path)
|
|
await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2
|
|
const turn2: SessionEvent[] = [
|
|
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
|
]
|
|
// b1 commits seq 6..7 first.
|
|
await b1.ctx.sessionPersistence.append(m.id, turn2)
|
|
// b2 still thinks its cursor is 6, so this batch passes the contiguity check
|
|
// but its INSERT of seq 6 hits the UNIQUE (session_id, seq) constraint
|
|
// mid-transaction → ROLLBACK + rethrow.
|
|
await expect(b2.ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/UNIQUE/)
|
|
// b1's turn is intact; b2's rolled-back attempt left nothing extra.
|
|
const loaded = await b1.ctx.sessionPersistence.load(m.id)
|
|
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
await b1.dispose()
|
|
await b2.dispose()
|
|
})
|
|
|
|
it('round-trips a header with parentSession (fork lineage)', async () => {
|
|
const { ctx, dispose } = await backend()
|
|
const m: SessionMeta = { ...meta('child'), parentSession: SessionId('parent') }
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
const loaded = await ctx.sessionPersistence.load(m.id)
|
|
expect(loaded.meta.parentSession).toBe(SessionId('parent'))
|
|
await dispose()
|
|
})
|
|
|
|
it('a fresh live session reusing a previously-loaded id is rejected (ownerless guard)', async () => {
|
|
const path = await freshDbPath()
|
|
const m = meta('ownerless')
|
|
const b1 = await backend(path)
|
|
await b1.ctx.sessionPersistence.create(m)
|
|
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
await b1.dispose()
|
|
|
|
const b2 = await backend(path)
|
|
// load() leaves ownerless state with cursor 6.
|
|
await b2.ctx.sessionPersistence.load(m.id)
|
|
// A fresh, unrelated live session reusing the id has a shorter/non-matching
|
|
// seed → its onCreated must reject rather than graft onto the loaded prefix.
|
|
const s = b2.ctx.sessions.create('ownerless')
|
|
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
await expect(b2.ctx.parallel('session/flush', s)).rejects.toThrow(/id collision/)
|
|
await b2.dispose()
|
|
})
|
|
|
|
it('a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix', async () => {
|
|
const path = await freshDbPath()
|
|
const m = meta('claim')
|
|
const b1 = await backend(path)
|
|
await b1.ctx.sessionPersistence.create(m)
|
|
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
await b1.dispose()
|
|
|
|
const b2 = await backend(path)
|
|
const { events } = await b2.ctx.sessionPersistence.load(m.id) // ownerless, cursor 6
|
|
// A live session seeded with the loaded log PLUS a new turn claims the state
|
|
// and persists only the suffix.
|
|
const s = b2.ctx.sessions.create('claim', { seed: [
|
|
...events,
|
|
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
|
] })
|
|
await b2.ctx.parallel('session/flush', s)
|
|
const loaded = await b2.ctx.sessionPersistence.load(m.id)
|
|
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
await b2.dispose()
|
|
})
|
|
|
|
it('an abandoned lazy session (never materialized) releases its id for reuse', async () => {
|
|
const { ctx, dispose } = await backend()
|
|
const inits = (ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }).inits
|
|
let first!: Session
|
|
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
first = inner.sessions.create('reuse')
|
|
}, { inject: ['sessions'] }))
|
|
await inits.get(first) // let the lazy create register the state
|
|
await firstFiber.dispose() // disposed before any append → never materialized
|
|
|
|
let reuse!: Session
|
|
await ctx.plugin(Object.assign((inner: Context) => {
|
|
reuse = inner.sessions.create('reuse')
|
|
}, { inject: ['sessions'] }))
|
|
await expect(inits.get(reuse)).resolves.toBeUndefined()
|
|
reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
await ctx.parallel('session/flush', reuse)
|
|
expect(await ctx.sessionPersistence.has(SessionId('reuse'))).toBe(true)
|
|
await dispose()
|
|
})
|
|
|
|
it('does NOT reclaim an id whose abandoned owner still has buffered (unflushed) events', async () => {
|
|
const { ctx, dispose } = await backend()
|
|
const inits = (ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }).inits
|
|
let first!: Session
|
|
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
first = inner.sessions.create('buffered')
|
|
}, { inject: ['sessions'] }))
|
|
await inits.get(first)
|
|
// Append a turn but do NOT flush — events sit in the write-behind buffer.
|
|
first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
await firstFiber.dispose() // disposed before flush; not materialized, buffer pending
|
|
|
|
let reuse!: Session
|
|
await ctx.plugin(Object.assign((inner: Context) => {
|
|
reuse = inner.sessions.create('buffered')
|
|
}, { inject: ['sessions'] }))
|
|
await expect(inits.get(reuse)).rejects.toThrow(/already bound to a different live session/)
|
|
await dispose()
|
|
})
|
|
|
|
it('initFor is idempotent: re-emitting session/created does not re-initialize', async () => {
|
|
const { ctx, dispose } = await backend()
|
|
const session = ctx.sessions.create('idem')
|
|
ctx.emit('session/created', session) // second create event for the same object
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
await ctx.parallel('session/flush', session)
|
|
expect(await ctx.sessionPersistence.has(SessionId('idem'))).toBe(true)
|
|
await dispose()
|
|
})
|
|
|
|
it('a live session claims cursor-0 ownerless state created via the public API and persists its seed', async () => {
|
|
const { ctx, dispose } = await backend()
|
|
// create() registers ownerless state with cursor 0 (no events yet).
|
|
await ctx.sessionPersistence.create(meta('cursor0'))
|
|
// A live session reusing that id, seeded with a turn, claims the ownerless
|
|
// state (cursor 0 trivially matches any seed) and persists the whole seed.
|
|
const s = ctx.sessions.create('cursor0', { seed: [
|
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
] })
|
|
await ctx.parallel('session/flush', s)
|
|
const loaded = await ctx.sessionPersistence.load(SessionId('cursor0'))
|
|
expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
|
|
await dispose()
|
|
})
|
|
|
|
it('HMR: reloading the backend adopts a still-live, already-materialized session', async () => {
|
|
const path = await freshDbPath()
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
// The session lives in its OWN fiber so it survives the backend reload.
|
|
let session!: Session
|
|
await ctx.plugin(Object.assign((inner: Context) => {
|
|
session = inner.sessions.create('hmr-adopt')
|
|
}, { inject: ['sessions'] }))
|
|
|
|
// Backend instance 1 materializes the session on disk.
|
|
const backend1 = await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
await ctx.parallel('session/flush', session)
|
|
|
|
// Hot-reload: dispose instance 1, plug in instance 2 over the SAME file
|
|
// while the session stays live. Instance 2 has an empty states map but the
|
|
// row is materialized on disk and is a prefix of the live events — it must
|
|
// ADOPT (not reject), and a second turn then persists.
|
|
await backend1.dispose()
|
|
await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
|
await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow()
|
|
|
|
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
|
|
expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('HMR: adoption persists the live SUFFIX that was ahead of the on-disk prefix', async () => {
|
|
const path = await freshDbPath()
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
let session!: Session
|
|
await ctx.plugin(Object.assign((inner: Context) => {
|
|
session = inner.sessions.create('hmr-suffix')
|
|
}, { inject: ['sessions'] }))
|
|
|
|
const backend1 = await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
await ctx.parallel('session/flush', session)
|
|
|
|
// Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
|
|
// flushing turn 2: it is now ONLY in the live session's events.
|
|
await backend1.dispose()
|
|
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
|
|
|
// Instance 2 adopts the on-disk prefix (turn 1) and MUST persist the live
|
|
// suffix (turn 2) carried in the session's events.
|
|
await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
await ctx.parallel('session/flush', session)
|
|
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix'))
|
|
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3])
|
|
expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => {
|
|
const path = await freshDbPath()
|
|
// Instance 1 materializes a session and disposes.
|
|
const b1 = await backend(path)
|
|
const s1 = b1.ctx.sessions.create('hmr-collide')
|
|
for (const e of oneTurnLog()) s1.append(e.type, e.data)
|
|
await b1.ctx.parallel('session/flush', s1)
|
|
await b1.dispose()
|
|
|
|
// A fresh context with an UNRELATED live session reusing the id meets a
|
|
// materialized row that is NOT a prefix of its events → reject.
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
let session!: Session
|
|
await ctx.plugin(Object.assign((inner: Context) => {
|
|
session = inner.sessions.create('hmr-collide')
|
|
}, { inject: ['sessions'] }))
|
|
session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
await expect(ctx.parallel('session/flush', session)).rejects.toThrow(/id collision/)
|
|
await ctx.fiber.dispose()
|
|
})
|
|
})
|