2026-07-28 13:55:59 +08:00
import { createUserMessage , createMessage } from '@deepseek-ai/dsh-llm'
2026-06-15 21:45:21 +08:00
import { afterEach , describe , expect , it } from 'vitest'
2026-08-10 22:04:06 +08:00
import { Context } from '@deepseek-ai/cordis'
2026-07-04 17:37:23 +08:00
import { existsSync } from 'node:fs'
2026-07-23 13:56:56 +08:00
import { chmod , mkdtemp , rm , stat , symlink , writeFile } from 'node:fs/promises'
2026-06-15 21:45:21 +08:00
import { tmpdir } from 'node:os'
2026-07-17 10:15:19 +08:00
import { dirname , join } from 'node:path'
2026-07-23 22:08:58 +08:00
import { DatabaseSync } from 'node:sqlite'
2026-06-21 07:17:25 +08:00
import SessionStore , { SessionId } from '@deepseek-ai/dsh-session'
2026-06-22 10:35:59 +08:00
import type { Session , SessionEvent , SurfaceEvent , SurfaceEventType } from '@deepseek-ai/dsh-session'
2026-06-15 21:45:21 +08:00
import SessionPersistenceSqlite , { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
2026-07-24 10:35:09 +08:00
import {
openDatabase ,
rowToEvent ,
rowToMeta ,
scanRows ,
SESSION_PERSISTENCE_SQLITE_APPLICATION_ID ,
type EventRow ,
} from '../src/schema.ts'
2026-06-24 17:45:48 +08:00
import { runPersistenceContract , meta , oneTurnLog , appendLog } from '../../session-persistence/tests/contract.ts'
2026-06-20 03:47:28 +08:00
import { runCoordinatorContract , type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
2026-06-15 21:45:21 +08:00
const dirs : string [ ] = [ ]
afterEach ( async ( ) = > { for ( const d of dirs . splice ( 0 ) ) await rm ( d , { recursive : true , force : true } ) } )
2026-07-19 22:13:50 +08:00
async function expectFlushError ( promise : Promise < unknown > , message : RegExp ) : Promise < void > {
2026-07-15 11:31:55 +08:00
try {
await promise
} catch ( error ) {
2026-07-19 22:13:50 +08:00
expect ( error ) . toBeInstanceOf ( Error )
expect ( ( error as Error ) . message ) . toMatch ( message )
2026-07-15 11:31:55 +08:00
return
}
2026-07-19 22:13:50 +08:00
throw new Error ( 'expected flush to reject' )
2026-07-15 11:31:55 +08:00
}
2026-06-15 21:45:21 +08:00
async function freshDbPath ( ) : Promise < string > {
const dir = await mkdtemp ( join ( tmpdir ( ) , 'dsh-sqlite-' ) )
dirs . push ( dir )
return join ( dir , 'sessions.db' )
}
2026-06-15 22:23:36 +08:00
/** 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 ( ) }
}
2026-07-13 23:27:00 +08:00
// Run the same backend-agnostic contract as JSONL to pin identical semantics.
2026-06-15 21:45:21 +08:00
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 ( ) } ,
}
} )
2026-07-13 23:27:00 +08:00
// A file-backed database lets two mounts share rows across reload. `corruptTail` inserts invalid
// JSON past the committed seq, exercising coordinator repair against real database rows.
2026-06-20 03:47:28 +08:00
runCoordinatorContract ( 'sqlite' , async ( ) : Promise < CoordinatorFixture > = > {
const dir = await mkdtemp ( join ( tmpdir ( ) , 'dsh-sqlite-coord-' ) )
const path = join ( dir , 'sessions.db' )
return {
mount : async ctx = > ctx . plugin ( SessionPersistenceSqlite , { path } ) ,
corruptTail : async ( id ) = > {
// A row past the committed region whose `data` does not parse: scanRows
// bounds the preserved prefix at it and returns its seq as tornFrom, which
// the backend surfaces to the coordinator as the tornMarker to delete from.
2026-07-04 17:37:23 +08:00
const db = openDatabase ( path , 'wal' )
2026-06-20 03:47:28 +08:00
const next = ( db . prepare ( 'SELECT COALESCE(MAX(seq), -1) + 1 AS n FROM events WHERE session_id = ?' )
. get ( id ) as { n : number } ) . n
db . prepare ( 'INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)' )
. run ( id , next , 'assistant/chunk' , 99 , '{not valid json' )
db . close ( )
} ,
cleanup : async ( ) = > { await rm ( dir , { recursive : true , force : true } ) } ,
}
} )
2026-06-16 22:56:17 +08:00
describe ( 'scanRows' , ( ) = > {
2026-07-12 03:36:43 +08:00
// scanRows works off EventRows (data is a JSON string column); build them from SessionEvents
2026-07-13 23:27:00 +08:00
// so the unit tests read in terms of the event vocabulary. Surface metadata is serialized to
// its nullable columns so the conversion remains faithful.
2026-06-16 22:56:17 +08:00
const rows = ( events : SessionEvent [ ] ) : EventRow [ ] = >
2026-06-24 17:45:48 +08:00
events . map ( ( e ) = > {
const se = e as SessionEvent < SurfaceEventType >
return {
seq : e.seq , type : e . type , time : e.time , data : JSON.stringify ( e . data ) ,
source_event_seqs : se.sourceEventSeqs !== undefined ? JSON . stringify ( se . sourceEventSeqs ) : null ,
surface_op : se.surfaceOp !== undefined ? JSON . stringify ( se . surfaceOp ) : null ,
2026-08-10 15:22:50 +08:00
ignorable : e.ignorable === true ? 1 : null ,
2026-06-24 17:45:48 +08:00
}
} )
2026-06-16 22:56:17 +08:00
it ( 'preserves the full log when it ends exactly on a turn/end (no torn tail)' , ( ) = > {
const { preserved , tornFrom } = scanRows ( rows ( oneTurnLog ( ) ) )
expect ( preserved ) . toEqual ( oneTurnLog ( ) )
expect ( tornFrom ) . toBeUndefined ( )
} )
it ( 'PRESERVES the real events of an interrupted turn after the last turn/end' , ( ) = > {
// turn 1 committed (0..5) + a crashed turn 2 (turn/start 6, step/start 7, no
// close): all 8 rows are intact, so the whole prefix is preserved and there
// is no torn fragment to delete. (load() then synthesizes the closers.)
const withOpenTurn : SessionEvent [ ] = [
. . . oneTurnLog ( ) ,
2026-07-30 13:49:57 +08:00
{ type : 'turn/start' , seq : 6 , time : 7 , data : { turn : 2 } } ,
2026-06-16 22:56:17 +08:00
{ type : 'step/start' , seq : 7 , time : 8 , data : { turn : 2 , step : 1 } } ,
2026-06-15 21:45:21 +08:00
]
2026-06-16 22:56:17 +08:00
const { preserved , tornFrom } = scanRows ( rows ( withOpenTurn ) )
expect ( preserved . map ( e = > e . seq ) ) . toEqual ( [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 ] )
expect ( tornFrom ) . toBeUndefined ( )
2026-06-15 21:45:21 +08:00
} )
2026-06-16 22:56:17 +08:00
it ( 'preserves the contiguous prefix and flags a torn tail at a seq gap' , ( ) = > {
// A gap after seq 0 (no committed turn/end): seq 0 is the preserved
// interrupted-turn event; the gap bounds it and marks the torn fragment.
const gapped : SessionEvent [ ] = [
2026-07-30 13:49:57 +08:00
{ type : 'turn/start' , seq : 0 , time : 1 , data : { turn : 1 } } ,
2026-06-16 22:56:17 +08:00
{ type : 'step/start' , seq : 2 , time : 2 , data : { turn : 1 , step : 1 } } , // seq 1 missing
2026-06-15 21:45:21 +08:00
]
2026-06-16 22:56:17 +08:00
const { preserved , tornFrom } = scanRows ( rows ( gapped ) )
expect ( preserved . map ( e = > e . seq ) ) . toEqual ( [ 0 ] )
expect ( tornFrom ) . toBe ( 1 )
2026-06-15 21:45:21 +08:00
} )
2026-06-16 22:56:17 +08:00
it ( 'an empty log preserves nothing and has no torn tail' , ( ) = > {
expect ( scanRows ( [ ] ) ) . toEqual ( { preserved : [ ] } )
2026-06-15 21:45:21 +08:00
} )
2026-06-16 22:56:17 +08:00
it ( 'throws on a seq gap inside the committed region (before the last turn/end)' , ( ) = > {
2026-06-15 21:45:21 +08:00
const gapped : SessionEvent [ ] = [
2026-07-30 13:49:57 +08:00
{ type : 'turn/start' , seq : 0 , time : 1 , data : { turn : 1 } } ,
2026-06-15 21:45:21 +08:00
{ type : 'step/start' , seq : 2 , time : 2 , data : { turn : 1 , step : 1 } } , // seq 1 missing
2026-08-04 14:09:52 +08:00
{ type : 'turn/end' , seq : 3 , time : 3 , data : { turn : 1 , reason : { kind : 'completed' } } } ,
2026-06-15 21:45:21 +08:00
]
2026-06-16 22:56:17 +08:00
expect ( ( ) = > scanRows ( rows ( gapped ) ) ) . toThrow ( /seq gap in committed region/ )
} )
it ( 'throws on an unparsable row inside the committed region' , ( ) = > {
const withCorruptCommitted : EventRow [ ] = [
2026-08-10 15:22:50 +08:00
{ seq : 0 , type : 'turn/start' , time : 1 , data : '{not json' , source_event_seqs : null , surface_op : null , ignorable : null } , // corrupt, sits before a turn/end
{ seq : 1 , type : 'turn/end' , time : 2 , data : JSON.stringify ( { turn : 1 , reason : { kind : 'completed' } } ) , source_event_seqs : null , surface_op : null , ignorable : null } ,
2026-06-16 22:56:17 +08:00
]
expect ( ( ) = > scanRows ( withCorruptCommitted ) ) . toThrow ( /unparsable committed event/ )
} )
it ( 'tolerates an unparsable torn-tail row after the last turn/end' , ( ) = > {
const withCorruptTail : EventRow [ ] = [
. . . rows ( oneTurnLog ( ) ) ,
2026-08-10 15:22:50 +08:00
{ seq : 6 , type : 'turn/start' , time : 7 , data : '{not json' , source_event_seqs : null , surface_op : null , ignorable : null } , // torn fragment, no committed turn/end after
2026-06-16 22:56:17 +08:00
]
const { preserved , tornFrom } = scanRows ( withCorruptTail )
expect ( preserved ) . toEqual ( oneTurnLog ( ) )
expect ( tornFrom ) . toBe ( 6 )
2026-06-15 21:45:21 +08:00
} )
} )
2026-07-24 10:35:09 +08:00
describe ( 'rowToMeta' , ( ) = > {
2026-08-03 12:25:33 +08:00
it ( 'restores optional origin metadata' , ( ) = > {
expect ( rowToMeta ( {
id : 'with-origin' ,
version : 0 ,
created_at : 1 ,
cwd : null ,
parent_session : null ,
seed_length : null ,
origin : 'subagent' ,
incarnation : 'with-origin' ,
revision : 1 ,
delegation_depth : null ,
2026-08-05 18:51:11 +08:00
agent_preset : null ,
2026-08-03 12:25:33 +08:00
} ) ) . toMatchObject ( { id : 'with-origin' , origin : 'subagent' } )
} )
2026-07-24 10:35:09 +08:00
it ( 'rejects fractional stored creation metadata' , ( ) = > {
expect ( ( ) = > rowToMeta ( {
id : 'fractional' ,
version : 0 ,
created_at : 1.5 ,
cwd : null ,
parent_session : null ,
seed_length : null ,
2026-08-01 09:41:52 +08:00
origin : null ,
2026-07-24 10:35:09 +08:00
incarnation : 'fractional' ,
revision : 1 ,
delegation_depth : null ,
2026-08-05 18:51:11 +08:00
agent_preset : null ,
2026-07-24 10:35:09 +08:00
} ) ) . toThrow ( 'stored session createdAt must be a non-negative safe integer' )
} )
2026-08-05 18:51:11 +08:00
it ( 'restores the agent preset a session was composed from' , ( ) = > {
// The preset decides the resumed session's tools and prompt; a row that
// dropped it would rebuild a composition the stored history contradicts.
expect ( rowToMeta ( {
id : 'composed' ,
version : 0 ,
created_at : 1 ,
cwd : null ,
parent_session : null ,
seed_length : null ,
origin : null ,
incarnation : 'composed' ,
revision : 1 ,
delegation_depth : null ,
agent_preset : 'minimal' ,
} ) ) . toMatchObject ( { agentPreset : 'minimal' } )
} )
2026-07-24 10:35:09 +08:00
} )
2026-06-15 21:45:21 +08:00
describe ( 'SessionPersistenceSqlite: durability and crash semantics' , ( ) = > {
2026-07-13 23:56:10 +08:00
it ( 'rejects a stored v0 log containing a legacy request/header-delta event' , async ( ) = > {
const path = await freshDbPath ( )
const m = meta ( 'legacy-header-delta' , '/legacy' )
const db = openDatabase ( path , 'wal' )
2026-07-23 13:56:56 +08:00
db . prepare ( 'INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)' )
. run ( m . id , m . version , m . createdAt , m . cwd ? ? null , 'legacy-header-delta' )
2026-07-13 23:56:10 +08:00
const insert = db . prepare ( 'INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)' )
2026-07-30 13:49:57 +08:00
insert . run ( m . id , 0 , 'turn/start' , 1 , JSON . stringify ( { turn : 1 } ) )
2026-07-13 23:56:10 +08:00
insert . run ( m . id , 1 , 'request/header-delta' , 2 , JSON . stringify ( { config : { model : 'legacy' } } ) )
2026-08-04 14:09:52 +08:00
insert . run ( m . id , 2 , 'turn/end' , 3 , JSON . stringify ( { turn : 1 , reason : { kind : 'completed' } } ) )
2026-07-13 23:56:10 +08:00
db . close ( )
const mounted = await backend ( path )
await expect ( mounted . ctx . sessionPersistence . load ( m . id ) ) . rejects . toThrow ( /unsupported legacy request\/header-delta event at seq 1/ )
await mounted . dispose ( )
} )
2026-07-14 12:32:44 +08:00
it ( 'rejects a stored v0 full header carrying the legacy fallback reason' , async ( ) = > {
const path = await freshDbPath ( )
const m = meta ( 'legacy-header-fallback' , '/legacy' )
const db = openDatabase ( path , 'wal' )
2026-07-23 13:56:56 +08:00
db . prepare ( 'INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)' )
. run ( m . id , m . version , m . createdAt , m . cwd ? ? null , 'legacy-header-fallback' )
2026-07-14 12:32:44 +08:00
db . prepare ( 'INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)' )
. run ( m . id , 0 , 'request/header' , 1 , JSON . stringify ( {
header : { config : { model : 'legacy' } } ,
reason : 'fallback' ,
} ) )
db . close ( )
const mounted = await backend ( path )
await expect ( mounted . ctx . sessionPersistence . load ( m . id ) )
. rejects . toThrow ( /unsupported legacy request\/header reason "fallback" at seq 0/ )
await mounted . dispose ( )
} )
2026-07-10 20:52:27 +08:00
it ( 'has no independent per-session log location' , async ( ) = > {
const { ctx , dispose } = await backend ( )
expect ( ctx . sessionPersistence . locate ( meta ( 'sqlite-location' ) ) ) . toBeUndefined ( )
await dispose ( )
} )
2026-06-16 22:56:17 +08:00
it ( 'an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load' , async ( ) = > {
2026-06-15 21:45:21 +08:00
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 , [
2026-07-30 13:49:57 +08:00
{ type : 'turn/start' , seq : 6 , time : 7 , data : { turn : 2 } } ,
2026-06-16 22:56:17 +08:00
{ type : 'step/start' , seq : 7 , time : 8 , data : { turn : 2 , step : 1 } } ,
2026-06-15 21:45:21 +08:00
] )
await fiber1 . dispose ( )
2026-06-16 22:56:17 +08:00
// Run 2: load PRESERVES the interrupted turn's real events (a turn can be huge
// — never truncated) and closes the orphaned turn with synthetic boundary
// events: step/end (the step was open) then turn/end {interrupted}.
2026-06-15 21:45:21 +08:00
const ctx2 = new Context ( )
await ctx2 . plugin ( SessionStore )
const fiber2 = await ctx2 . plugin ( SessionPersistenceSqlite , { path } )
const loaded = await ctx2 . sessionPersistence . load ( m . id )
2026-06-16 22:56:17 +08:00
expect ( loaded . events . map ( e = > e . type ) ) . toEqual ( [
'turn/start' , 'user/message' , 'step/start' , 'assistant/message' , 'step/end' , 'turn/end' , // turn 1
'turn/start' , 'step/start' , 'step/end' , 'turn/end' , // turn 2: real events + synthetic closers
] )
expect ( loaded . events . map ( e = > e . seq ) ) . toEqual ( [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 ] )
const last = loaded . events . at ( - 1 ) !
expect ( last . type === 'turn/end' && last . data . reason ) . toEqual ( { kind : 'interrupted' } )
2026-06-15 21:45:21 +08:00
2026-06-16 22:56:17 +08:00
// load durably closed the turn, so the next append continues at the balanced
// length (seq 10) and a reload round-trips identically.
2026-06-15 21:45:21 +08:00
await ctx2 . sessionPersistence . append ( m . id , [
2026-07-30 13:49:57 +08:00
{ type : 'turn/start' , seq : 10 , time : 9 , data : { turn : 3 } } ,
2026-08-04 14:09:52 +08:00
{ type : 'turn/end' , seq : 11 , time : 10 , data : { turn : 3 , reason : { kind : 'completed' } } } ,
2026-06-15 21:45:21 +08:00
] )
const reloaded = await ctx2 . sessionPersistence . load ( m . id )
2026-06-16 22:56:17 +08:00
expect ( reloaded . events . map ( e = > e . seq ) ) . toEqual ( [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 , 11 ] )
2026-06-15 21:45:21 +08:00
await fiber2 . dispose ( )
} )
2026-06-16 22:56:17 +08:00
it ( 'load() durably closes the interrupted turn: the synthetic closers are on disk after load' , async ( ) = > {
2026-06-16 00:05:25 +08:00
const path = await freshDbPath ( )
2026-06-16 22:56:17 +08:00
const m = meta ( 'load-closes' )
2026-06-16 00:05:25 +08:00
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 ( )
2026-06-16 22:56:17 +08:00
// Hand-write an interrupted turn (turn/start seq 6, no turn/end).
2026-07-04 17:37:23 +08:00
const db = openDatabase ( path , 'wal' )
2026-06-16 00:05:25 +08:00
db . prepare ( 'INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)' )
2026-07-30 13:49:57 +08:00
. run ( m . id , 'turn/start' , JSON . stringify ( { turn : 2 } ) )
2026-06-16 00:05:25 +08:00
db . close ( )
const b2 = await backend ( path )
const loaded = await b2 . ctx . sessionPersistence . load ( m . id )
2026-06-16 22:56:17 +08:00
// turn 2's real turn/start (seq 6) is preserved + a synthetic turn/end (seq 7).
expect ( loaded . events . map ( e = > e . seq ) ) . toEqual ( [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 ] )
expect ( loaded . events . at ( - 1 ) ! . type ) . toBe ( 'turn/end' )
// load() is mutating: the synthetic turn/end MUST be on disk so the stored log
// is balanced and the cursor is truthful (contract: load closes, not defers).
2026-07-04 17:37:23 +08:00
const probe = openDatabase ( path , 'wal' )
2026-06-16 22:56:17 +08:00
const stored = probe . prepare ( 'SELECT seq, type FROM events WHERE session_id = ? ORDER BY seq' ) . all ( m . id ) as { seq : number ; type : string } [ ]
2026-06-16 00:05:25 +08:00
probe . close ( )
2026-06-16 22:56:17 +08:00
expect ( stored . map ( r = > r . seq ) ) . toEqual ( [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 ] )
expect ( stored . at ( - 1 ) ! . type ) . toBe ( 'turn/end' )
2026-06-16 00:05:25 +08:00
await b2 . dispose ( )
} )
2026-06-16 22:56:17 +08:00
it ( 'all-tail load: a session whose only turn never closed is preserved and closed on load' , async ( ) = > {
2026-06-16 00:05:25 +08:00
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 , [
2026-07-30 13:49:57 +08:00
{ type : 'turn/start' , seq : 0 , time : 1 , data : { turn : 1 } } ,
2026-07-28 13:55:59 +08:00
{ type : 'user/message' , seq : 1 , time : 2 , data : createUserMessage ( {
content : [ { type : 'text' , text : 'hi' } ] , source : { kind : 'user' } ,
2026-08-05 22:54:27 +08:00
} ) , surfaceOp : 'append' } ,
2026-06-16 00:05:25 +08:00
] )
await b1 . dispose ( )
2026-06-16 22:56:17 +08:00
// A fresh backend loads it: the interrupted (only) turn's real events are
// preserved and closed with a synthetic turn/end {interrupted} — NOT
2026-06-21 02:17:27 +08:00
// truncated. The session was materialized, so list() reports it present.
2026-06-16 00:05:25 +08:00
const b2 = await backend ( path )
const loaded = await b2 . ctx . sessionPersistence . load ( m . id )
2026-06-16 22:56:17 +08:00
expect ( loaded . events . map ( e = > e . type ) ) . toEqual ( [ 'turn/start' , 'user/message' , 'turn/end' ] )
expect ( loaded . events . at ( - 1 ) ! . type === 'turn/end' && loaded . events . at ( - 1 ) ! . data ) . toMatchObject ( { reason : { kind : 'interrupted' } } )
2026-06-16 20:35:01 +08:00
expect ( ( await b2 . ctx . sessionPersistence . list ( ) ) . map ( x = > x . id ) ) . toContain ( m . id )
2026-06-16 00:05:25 +08:00
await b2 . dispose ( )
} )
2026-06-20 01:03:57 +08:00
it ( 'rejects opening a database whose schema version is not the current build (newer OR older)' , async ( ) = > {
2026-06-16 00:05:25 +08:00
const path = await freshDbPath ( )
2026-07-04 17:37:23 +08:00
openDatabase ( path , 'wal' ) . close ( ) // stamp user_version = SCHEMA_VERSION
2026-06-16 00:05:25 +08:00
// Bump user_version past what this build supports.
2026-07-04 17:37:23 +08:00
const dbNewer = openDatabase ( path , 'wal' )
2026-06-20 01:03:57 +08:00
dbNewer . exec ( ` PRAGMA user_version = ${ SCHEMA_VERSION + 1 } ` )
dbNewer . close ( )
2026-07-04 17:37:23 +08:00
expect ( ( ) = > openDatabase ( path , 'wal' ) ) . toThrow ( /incompatible with this build/ )
2026-06-20 01:03:57 +08:00
2026-07-15 12:32:18 +08:00
// The immediately preceding layout lacks the required store identity and is
// rejected rather than migrated (unreleased software, no backward-compat).
2026-06-20 01:03:57 +08:00
const olderPath = await freshDbPath ( )
2026-07-04 17:37:23 +08:00
openDatabase ( olderPath , 'wal' ) . close ( )
const dbOlder = openDatabase ( olderPath , 'wal' )
2026-07-15 12:32:18 +08:00
dbOlder . exec ( ` PRAGMA user_version = ${ SCHEMA_VERSION - 1 } ` )
2026-06-20 01:03:57 +08:00
dbOlder . close ( )
2026-07-04 17:37:23 +08:00
expect ( ( ) = > openDatabase ( olderPath , 'wal' ) ) . toThrow ( /incompatible with this build/ )
2026-06-16 00:05:25 +08:00
} )
2026-07-24 10:35:09 +08:00
it ( 'rejects a table-backed unversioned database before stamping or changing journal mode' , async ( ) = > {
2026-07-23 22:08:58 +08:00
const path = await freshDbPath ( )
const legacy = new DatabaseSync ( path )
legacy . exec ( 'CREATE TABLE sessions (id TEXT PRIMARY KEY)' )
legacy . close ( )
2026-07-24 10:35:09 +08:00
expect ( ( ) = > openDatabase ( path , 'wal' ) ) . toThrow ( /unversioned schema or application identity/ )
2026-07-23 22:08:58 +08:00
const unchanged = new DatabaseSync ( path )
expect ( unchanged . prepare ( 'PRAGMA user_version' ) . get ( ) ) . toEqual ( { user_version : 0 } )
expect ( unchanged . prepare ( 'PRAGMA journal_mode' ) . get ( ) ) . toEqual ( { journal_mode : 'delete' } )
expect ( unchanged . prepare (
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'sessions'" ,
) . get ( ) ) . toEqual ( { name : 'sessions' } )
unchanged . close ( )
} )
2026-07-24 11:11:45 +08:00
it ( 'counts a sqliteX table as user-owned instead of mistaking it for SQLite metadata' , async ( ) = > {
const path = await freshDbPath ( )
const unrelated = new DatabaseSync ( path )
unrelated . exec ( 'CREATE TABLE sqliteX (value TEXT)' )
unrelated . exec ( "INSERT INTO sqliteX VALUES ('safe')" )
unrelated . close ( )
expect ( ( ) = > openDatabase ( path , 'wal' ) ) . toThrow ( /unversioned schema or application identity/ )
const unchanged = new DatabaseSync ( path )
expect ( unchanged . prepare ( 'SELECT value FROM sqliteX' ) . get ( ) ) . toEqual ( { value : 'safe' } )
expect ( unchanged . prepare ( 'PRAGMA application_id' ) . get ( ) ) . toEqual ( { application_id : 0 } )
expect ( unchanged . prepare ( 'PRAGMA user_version' ) . get ( ) ) . toEqual ( { user_version : 0 } )
expect ( unchanged . prepare ( 'PRAGMA journal_mode' ) . get ( ) ) . toEqual ( { journal_mode : 'delete' } )
unchanged . close ( )
} )
2026-07-24 10:35:09 +08:00
it ( 'rejects view-only and foreign-application unversioned databases without mutation' , async ( ) = > {
const viewPath = await freshDbPath ( )
const viewOnly = new DatabaseSync ( viewPath )
viewOnly . exec ( 'CREATE VIEW foreign_view AS SELECT 1 AS value' )
viewOnly . close ( )
expect ( ( ) = > openDatabase ( viewPath , 'wal' ) ) . toThrow ( /unversioned schema or application identity/ )
const unchangedView = new DatabaseSync ( viewPath )
expect ( unchangedView . prepare ( 'PRAGMA journal_mode' ) . get ( ) ) . toEqual ( { journal_mode : 'delete' } )
expect ( unchangedView . prepare (
"SELECT type FROM sqlite_schema WHERE name = 'foreign_view'" ,
) . get ( ) ) . toEqual ( { type : 'view' } )
unchangedView . close ( )
const applicationPath = await freshDbPath ( )
const foreignApplication = new DatabaseSync ( applicationPath )
foreignApplication . exec ( 'PRAGMA application_id = 12345' )
foreignApplication . close ( )
expect ( ( ) = > openDatabase ( applicationPath , 'wal' ) ) . toThrow ( /unversioned schema or application identity/ )
const unchangedApplication = new DatabaseSync ( applicationPath )
expect ( unchangedApplication . prepare ( 'PRAGMA application_id' ) . get ( ) ) . toEqual ( { application_id : 12345 } )
expect ( unchangedApplication . prepare ( 'PRAGMA user_version' ) . get ( ) ) . toEqual ( { user_version : 0 } )
expect ( unchangedApplication . prepare ( 'PRAGMA journal_mode' ) . get ( ) ) . toEqual ( { journal_mode : 'delete' } )
unchangedApplication . close ( )
} )
it ( 'rejects a current-version database with a foreign application identity' , async ( ) = > {
const path = await freshDbPath ( )
const foreign = new DatabaseSync ( path )
foreign . exec ( 'PRAGMA application_id = 12345' )
foreign . exec ( ` PRAGMA user_version = ${ SCHEMA_VERSION } ` )
foreign . close ( )
expect ( ( ) = > openDatabase ( path , 'wal' ) ) . toThrow ( /has application id 12345/ )
const unchanged = new DatabaseSync ( path )
expect ( unchanged . prepare ( 'PRAGMA application_id' ) . get ( ) ) . toEqual ( { application_id : 12345 } )
expect ( unchanged . prepare ( 'PRAGMA user_version' ) . get ( ) ) . toEqual ( { user_version : SCHEMA_VERSION } )
expect ( unchanged . prepare ( 'PRAGMA journal_mode' ) . get ( ) ) . toEqual ( { journal_mode : 'delete' } )
unchanged . close ( )
} )
it ( 'rolls back schema objects and identity stamps when initialization fails' , async ( ) = > {
const path = await freshDbPath ( )
const conflicting = new DatabaseSync ( path )
conflicting . exec ( ` PRAGMA application_id = ${ SESSION_PERSISTENCE_SQLITE_APPLICATION_ID } ` )
conflicting . exec ( ` PRAGMA user_version = ${ SCHEMA_VERSION } ` )
conflicting . exec ( "CREATE VIEW persistence_state AS SELECT 1 AS singleton, 'foreign' AS store_id" )
conflicting . close ( )
expect ( ( ) = > openDatabase ( path , 'wal' ) ) . toThrow ( )
const unchanged = new DatabaseSync ( path )
expect ( unchanged . prepare (
"SELECT type FROM sqlite_schema WHERE name = 'persistence_state'" ,
) . get ( ) ) . toEqual ( { type : 'view' } )
expect ( unchanged . prepare (
"SELECT type FROM sqlite_schema WHERE name = 'sessions'" ,
) . get ( ) ) . toBeUndefined ( )
expect ( unchanged . prepare (
"SELECT type FROM sqlite_schema WHERE name = 'events'" ,
) . get ( ) ) . toBeUndefined ( )
expect ( unchanged . prepare ( 'PRAGMA application_id' ) . get ( ) )
. toEqual ( { application_id : SESSION_PERSISTENCE_SQLITE_APPLICATION_ID } )
expect ( unchanged . prepare ( 'PRAGMA user_version' ) . get ( ) ) . toEqual ( { user_version : SCHEMA_VERSION } )
2026-07-24 11:20:47 +08:00
expect ( unchanged . prepare ( 'PRAGMA journal_mode' ) . get ( ) ) . toEqual ( { journal_mode : 'delete' } )
2026-07-24 10:35:09 +08:00
unchanged . close ( )
} )
it ( 'stamps the persistence application identity with the schema version' , async ( ) = > {
const path = await freshDbPath ( )
openDatabase ( path , 'wal' ) . close ( )
const db = new DatabaseSync ( path )
expect ( db . prepare ( 'PRAGMA application_id' ) . get ( ) )
. toEqual ( { application_id : SESSION_PERSISTENCE_SQLITE_APPLICATION_ID } )
expect ( db . prepare ( 'PRAGMA user_version' ) . get ( ) ) . toEqual ( { user_version : SCHEMA_VERSION } )
db . close ( )
} )
2026-06-24 17:45:48 +08:00
it ( 'rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns' , async ( ) = > {
2026-07-15 16:17:59 +08:00
// Version 3 identified two incompatible sibling layouts, so it is always rejected.
2026-06-24 17:45:48 +08:00
const path = await freshDbPath ( )
2026-07-15 12:10:24 +08:00
openDatabase ( path , 'wal' ) . close ( ) // creates + stamps user_version = SCHEMA_VERSION
2026-07-04 17:37:23 +08:00
const db = openDatabase ( path , 'wal' )
2026-06-24 17:45:48 +08:00
db . exec ( 'PRAGMA user_version = 3' )
db . close ( )
2026-07-04 17:37:23 +08:00
expect ( ( ) = > openDatabase ( path , 'wal' ) ) . toThrow ( /schema version 3, incompatible with this build/ )
2026-06-24 17:45:48 +08:00
} )
2026-06-15 22:23:36 +08:00
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 ( )
2026-07-13 23:27:00 +08:00
// A torn row after the last committed turn has invalid JSON. `scanRows` locates the boundary
// from seq/type columns without parsing the tail, preserves the committed prefix, and load
// deletes the row; invalid JSON inside the committed region would remain fatal.
2026-07-04 17:37:23 +08:00
const db = openDatabase ( path , 'wal' )
2026-06-15 22:23:36 +08:00
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 )
2026-06-16 22:56:17 +08:00
expect ( loaded . events ) . toEqual ( oneTurnLog ( ) ) // torn tail discarded, committed intact (turn 1 already balanced → no closers)
// load physically deleted the corrupt tail row, so a fresh append continues.
2026-06-15 22:23:36 +08:00
await b2 . ctx . sessionPersistence . append ( m . id , [
2026-07-30 13:49:57 +08:00
{ type : 'turn/start' , seq : 6 , time : 8 , data : { turn : 2 } } ,
2026-08-04 14:09:52 +08:00
{ type : 'turn/end' , seq : 7 , time : 9 , data : { turn : 2 , reason : { kind : 'completed' } } } ,
2026-06-15 22:23:36 +08:00
] )
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 ( )
} )
2026-06-15 21:45:21 +08:00
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 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 )
2026-06-20 01:03:57 +08:00
expect ( loaded . meta ) . toMatchObject ( { id : m.id , cwd : '/proj' } )
2026-06-15 21:45:21 +08:00
expect ( loaded . events ) . toEqual ( oneTurnLog ( ) )
await fiber2 . dispose ( )
} )
2026-07-15 12:32:18 +08:00
it ( 'source-qualifies revisions across stores while preserving same-file reopen identity' , async ( ) = > {
const pathA = await freshDbPath ( )
const pathB = await freshDbPath ( )
const m = meta ( 'revision-source' )
const a = await backend ( pathA )
await a . ctx . sessionPersistence . create ( m )
await a . ctx . sessionPersistence . append ( m . id , oneTurnLog ( ) )
const revisionA = ( await a . ctx . sessionPersistence . listSnapshots ( ) ) [ 0 ] ? . revision
await a . dispose ( )
const probeA = openDatabase ( pathA , 'wal' )
const storeIdA = ( probeA . prepare (
'SELECT store_id FROM persistence_state WHERE singleton = 1' ,
) . get ( ) as { store_id : string } ) . store_id
probeA . close ( )
const aliasA = ` ${ pathA } .alias `
await symlink ( pathA , aliasA )
const reopenedA = await backend ( aliasA )
expect ( ( await reopenedA . ctx . sessionPersistence . listSnapshots ( ) ) [ 0 ] ? . revision ) . toBe ( revisionA )
await reopenedA . dispose ( )
const b = await backend ( pathB )
await b . ctx . sessionPersistence . create ( m )
await b . ctx . sessionPersistence . append ( m . id , oneTurnLog ( ) )
const revisionB = ( await b . ctx . sessionPersistence . listSnapshots ( ) ) [ 0 ] ? . revision
const probeB = openDatabase ( pathB , 'wal' )
const storeIdB = ( probeB . prepare (
'SELECT store_id FROM persistence_state WHERE singleton = 1' ,
) . get ( ) as { store_id : string } ) . store_id
probeB . close ( )
expect ( storeIdB ) . not . toBe ( storeIdA )
expect ( revisionB ) . not . toBe ( revisionA )
expect ( String ( revisionA ) ) . toMatch ( /:revision:1$/ )
expect ( String ( revisionB ) ) . toMatch ( /:revision:1$/ )
await b . dispose ( )
} )
2026-08-06 03:45:22 +08:00
it ( 'binds a full stored prefix to the same revision as a lightweight read' , async ( ) = > {
const b = await backend ( )
const m = meta ( 'stored-prefix-revision' )
await b . ctx . sessionPersistence . create ( m )
await b . ctx . sessionPersistence . append ( m . id , oneTurnLog ( ) )
const persistence = b . ctx . sessionPersistence as SessionPersistenceSqlite
const stored = await persistence . loadStored ( m . id )
expect ( stored ? . revision ) . toBe ( await persistence . readStoredRevision ( m . id ) )
expect ( await persistence . readStoredRevision ( SessionId ( 'missing-revision' ) ) ) . toBeUndefined ( )
await b . dispose ( )
} )
2026-07-17 09:39:39 +08:00
it ( 'changes revisions when a deleted session id is materialized again in the same database' , async ( ) = > {
const path = await freshDbPath ( )
const m = meta ( 'recreated-revision' )
const first = await backend ( path )
await first . ctx . sessionPersistence . create ( m )
await first . ctx . sessionPersistence . append ( m . id , oneTurnLog ( ) )
const before = ( await first . ctx . sessionPersistence . listSnapshots ( ) ) [ 0 ] ? . revision
await first . dispose ( )
const cleanup = openDatabase ( path , 'wal' )
cleanup . prepare ( 'DELETE FROM sessions WHERE id = ?' ) . run ( m . id )
cleanup . close ( )
const second = await backend ( path )
await second . ctx . sessionPersistence . create ( m )
await second . ctx . sessionPersistence . append ( m . id , oneTurnLog ( ) )
const after = ( await second . ctx . sessionPersistence . listSnapshots ( ) ) [ 0 ] ? . revision
expect ( after ) . not . toBe ( before )
expect ( String ( before ) ) . toMatch ( /:revision:1$/ )
expect ( String ( after ) ) . toMatch ( /:revision:1$/ )
await second . dispose ( )
} )
2026-07-24 21:27:07 +08:00
it ( 'awaits in-flight readiness before surfacing snapshot-list cancellation' , async ( ) = > {
const b = await backend ( )
const internals = b . ctx . sessionPersistence as unknown as { ready : Promise < void > }
const originalReady = internals . ready
const readiness = Promise . withResolvers < undefined > ( )
internals . ready = readiness . promise
const reason = new Error ( 'SQLite snapshot readiness cancelled' )
const controller = new AbortController ( )
const pending = b . ctx . sessionPersistence . listSnapshots ( controller . signal )
let settled = false
void pending . then (
( ) = > { settled = true } ,
( ) = > { settled = true } ,
)
controller . abort ( reason )
await Promise . resolve ( )
expect ( settled ) . toBe ( false )
readiness . resolve ( undefined )
await expect ( pending ) . rejects . toBe ( reason )
internals . ready = originalReady
await b . dispose ( )
} )
2026-06-15 21:45:21 +08:00
it ( 'exposes the schema version constant' , ( ) = > {
2026-08-10 15:22:50 +08:00
expect ( SCHEMA_VERSION ) . toBe ( 15 )
2026-07-15 12:10:24 +08:00
} )
it ( 'keeps the revision stable for an empty repair hook' , async ( ) = > {
const b = await backend ( )
const m = meta ( 'empty-repair' )
await b . ctx . sessionPersistence . create ( m )
await b . ctx . sessionPersistence . append ( m . id , oneTurnLog ( ) )
const before = await b . ctx . sessionPersistence . listSnapshots ( )
await ( b . ctx . sessionPersistence as SessionPersistenceSqlite ) . commitRepair ( m , undefined , [ ] )
expect ( await b . ctx . sessionPersistence . listSnapshots ( ) ) . toEqual ( before )
await b . dispose ( )
2026-06-15 21:45:21 +08:00
} )
} )
describe ( 'SessionPersistenceSqlite: edge cases' , ( ) = > {
2026-08-06 04:09:16 +08:00
it ( 'resolves the preparation-cache default without schema normalization' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( SessionStore )
let persistence ! : SessionPersistenceSqlite
await ctx . plugin ( Object . assign ( ( inner : Context ) = > {
persistence = new SessionPersistenceSqlite ( inner , {
path : ':memory:' ,
journalMode : 'wal' ,
} )
} , { inject : [ 'sessions' ] } ) )
expect ( await persistence . list ( ) ) . toEqual ( [ ] )
await ctx . fiber . dispose ( )
} )
2026-08-06 01:12:17 +08:00
it ( 'uses the configured preparation cache through the public service' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( SessionStore )
const fiber = await ctx . plugin ( SessionPersistenceSqlite , {
path : ':memory:' ,
preparedSessionCacheSize : 1 ,
2026-08-08 15:43:52 +08:00
writeBatchMaxDelayMs : 1 ,
2026-08-06 01:12:17 +08:00
} )
const m = meta ( 'sqlite-preparation-cache' )
await ctx . sessionPersistence . create ( m )
await ctx . sessionPersistence . append ( m . id , oneTurnLog ( ) )
const preparation = await ctx . sessionPersistence . prepare ( m . id )
expect ( preparation . session . header ) . toEqual ( m )
preparation [ Symbol . dispose ] ( )
await fiber . dispose ( )
} )
2026-07-15 12:32:18 +08:00
it ( 'rejects and closes a current-schema database with an invalid store identity' , async ( ) = > {
const path = await freshDbPath ( )
const db = openDatabase ( path , 'wal' )
db . exec ( "UPDATE persistence_state SET store_id = '' WHERE singleton = 1" )
db . close ( )
const b = await backend ( path )
await expect ( b . ctx . sessionPersistence . listSnapshots ( ) ) . rejects . toThrow ( /no valid store identity/ )
await expect ( b . dispose ( ) ) . resolves . toBeUndefined ( )
} )
2026-07-19 11:37:41 +08:00
it ( 'creates a new database and WAL sidecars with owner-only modes without changing its parent mode' , async ( ) = > {
2026-07-17 10:15:19 +08:00
if ( process . platform === 'win32' ) return
const path = await freshDbPath ( )
const dir = dirname ( path )
await chmod ( dir , 0 o755 )
const b = await backend ( path )
await b . ctx . sessionPersistence . list ( )
expect ( ( await stat ( dir ) ) . mode & 0 o777 ) . toBe ( 0 o755 )
expect ( ( await stat ( path ) ) . mode & 0 o777 ) . toBe ( 0 o600 )
expect ( ( await stat ( ` ${ path } -wal ` ) ) . mode & 0 o777 ) . toBe ( 0 o600 )
expect ( ( await stat ( ` ${ path } -shm ` ) ) . mode & 0 o777 ) . toBe ( 0 o600 )
await b . dispose ( )
} )
2026-07-19 12:12:16 +08:00
it ( 'creates a persistent rollback journal with owner-only mode' , async ( ) = > {
if ( process . platform === 'win32' ) return
const path = await freshDbPath ( )
const ctx = new Context ( )
await ctx . plugin ( SessionStore )
const fiber = await ctx . plugin ( SessionPersistenceSqlite , { path , journalMode : 'persist' } )
const m = meta ( 'persist-permissions' )
await ctx . sessionPersistence . create ( m )
await ctx . sessionPersistence . append ( m . id , oneTurnLog ( ) )
expect ( ( await stat ( path ) ) . mode & 0 o777 ) . toBe ( 0 o600 )
expect ( ( await stat ( ` ${ path } -journal ` ) ) . mode & 0 o777 ) . toBe ( 0 o600 )
await fiber . dispose ( )
} )
2026-07-17 10:15:19 +08:00
it ( 'preserves the mode of an existing database file' , async ( ) = > {
if ( process . platform === 'win32' ) return
const path = await freshDbPath ( )
await writeFile ( path , '' , { mode : 0o644 } )
await chmod ( path , 0 o644 )
const ctx = new Context ( )
await ctx . plugin ( SessionStore )
const fiber = await ctx . plugin ( SessionPersistenceSqlite , { path , journalMode : 'delete' } )
await ctx . sessionPersistence . list ( )
expect ( ( await stat ( path ) ) . mode & 0 o777 ) . toBe ( 0 o644 )
await fiber . dispose ( )
} )
2026-07-19 11:37:41 +08:00
it ( 'surfaces an invalid database path during pre-creation' , async ( ) = > {
2026-07-17 10:15:19 +08:00
const path = await freshDbPath ( )
2026-07-17 10:47:32 +08:00
const b = await backend ( ` ${ path } \ 0 ` )
await expect ( b . ctx . sessionPersistence . list ( ) ) . rejects . toMatchObject ( { code : 'ERR_INVALID_ARG_VALUE' } )
await b . dispose ( )
2026-07-17 10:15:19 +08:00
} )
2026-06-15 21:45:21 +08:00
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 [ ] = [
2026-07-30 13:49:57 +08:00
{ type : 'turn/start' , seq : 6 , time : 7 , data : { turn : 2 } } ,
2026-08-04 14:09:52 +08:00
{ type : 'turn/end' , seq : 7 , time : 8 , data : { turn : 2 , reason : { kind : 'completed' } } } ,
2026-06-15 21:45:21 +08:00
]
// 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 ( )
} )
2026-07-04 17:37:23 +08:00
it ( 'journalMode config reaches the database (default wal, rollback modes selectable)' , async ( ) = > {
// :memory: databases always report journal_mode=memory, so probe file DBs.
const walPath = await freshDbPath ( )
const bWal = await backend ( walPath )
await bWal . ctx . sessionPersistence . create ( meta ( 'jm-wal' ) )
2026-07-05 17:47:24 +08:00
const probe = openDatabase ( walPath , 'wal' )
expect ( ( probe . prepare ( 'PRAGMA journal_mode' ) . get ( ) as { journal_mode : string } ) . journal_mode ) . toBe ( 'wal' )
probe . close ( )
2026-07-04 17:37:23 +08:00
await bWal . dispose ( )
const deletePath = await freshDbPath ( )
const ctx = new Context ( )
await ctx . plugin ( SessionStore )
const fiber = await ctx . plugin ( SessionPersistenceSqlite , { path : deletePath , journalMode : 'delete' } )
await ctx . sessionPersistence . create ( meta ( 'jm-delete' ) )
// Probe through a second connection: journal_mode=delete is a per-database
// property only insofar as no WAL files exist — assert the world, not the
// backend's self-report (no -wal sidecar after writes in delete mode).
const db = openDatabase ( deletePath , 'delete' )
expect ( ( db . prepare ( 'PRAGMA journal_mode' ) . get ( ) as { journal_mode : string } ) . journal_mode ) . toBe ( 'delete' )
db . close ( )
expect ( existsSync ( ` ${ deletePath } -wal ` ) ) . toBe ( false )
await fiber . dispose ( )
} )
2026-06-15 21:45:21 +08:00
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 )
2026-06-21 07:17:25 +08:00
const s1 = b1 . ctx . sessions . create ( SessionId ( 'hmr-collide' ) )
2026-06-24 17:45:48 +08:00
appendLog ( s1 , oneTurnLog ( ) )
2026-07-19 22:13:50 +08:00
await b1 . ctx . sessions . flush ( s1 )
2026-06-15 21:45:21 +08:00
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 ) = > {
2026-06-21 07:17:25 +08:00
session = inner . sessions . create ( SessionId ( 'hmr-collide' ) )
2026-06-15 21:45:21 +08:00
} , { inject : [ 'sessions' ] } ) )
2026-07-30 13:49:57 +08:00
session . append ( 'turn/start' , { turn : 1 } )
2026-06-15 21:45:21 +08:00
await ctx . plugin ( SessionPersistenceSqlite , { path } )
2026-07-19 22:13:50 +08:00
await expectFlushError ( ctx . sessions . flush ( session ) , /id collision/ )
2026-06-15 21:45:21 +08:00
await ctx . fiber . dispose ( )
} )
} )
2026-06-22 10:35:59 +08:00
describe ( 'surface field round-trip' , ( ) = > {
it ( 'rowToEvent parses surface fields from EventRow columns' , ( ) = > {
const row : EventRow = {
seq : 0 , type : 'assistant/message' , time : 1 ,
data : JSON.stringify ( { turn : 1 , step : 1 , content : [ ] } ) ,
source_event_seqs : JSON.stringify ( [ 3 , 5 ] ) ,
surface_op : JSON.stringify ( 'append' ) ,
2026-08-10 15:22:50 +08:00
ignorable : null ,
2026-06-22 10:35:59 +08:00
}
const event = rowToEvent ( row )
expect ( ( event as SurfaceEvent ) . sourceEventSeqs ) . toEqual ( [ 3 , 5 ] )
expect ( ( event as SurfaceEvent ) . surfaceOp ) . toBe ( 'append' )
} )
it ( 'rowToEvent handles replace surfaceOp object' , ( ) = > {
const row : EventRow = {
seq : 0 , type : 'assistant/message' , time : 1 ,
data : JSON.stringify ( { turn : 1 , step : 1 , content : [ ] } ) ,
source_event_seqs : JSON.stringify ( [ 0 , 1 ] ) ,
surface_op : JSON.stringify ( { op : 'replace' , start : 0 , end : 1 } ) ,
2026-08-10 15:22:50 +08:00
ignorable : null ,
2026-06-22 10:35:59 +08:00
}
const event = rowToEvent ( row )
expect ( ( event as SurfaceEvent ) . sourceEventSeqs ) . toEqual ( [ 0 , 1 ] )
expect ( ( event as SurfaceEvent ) . surfaceOp ) . toEqual ( { op : 'replace' , start : 0 , end : 1 } )
} )
it ( 'scanRows with surface columns reconstructs events with surface fields' , ( ) = > {
const rows : EventRow [ ] = [
{ seq : 0 , type : 'user/message' , time : 1 ,
data : JSON.stringify ( { content : [ { type : 'text' , text : 'hi' } ] , source : { kind : 'user' } } ) ,
2026-08-10 15:22:50 +08:00
source_event_seqs : null , surface_op : '{"op":"replace","start":0,"end":0}' , ignorable : null } ,
2026-06-22 10:35:59 +08:00
{ seq : 1 , type : 'turn/end' , time : 2 ,
2026-08-04 14:09:52 +08:00
data : JSON.stringify ( { turn : 1 , reason : { kind : 'completed' } } ) ,
2026-08-10 15:22:50 +08:00
source_event_seqs : null , surface_op : null , ignorable : 1 } ,
2026-06-22 10:35:59 +08:00
]
const { preserved } = scanRows ( rows )
expect ( preserved ) . toHaveLength ( 2 )
expect ( ( preserved [ 0 ] ! as SurfaceEvent ) . surfaceOp ) . toEqual ( { op : 'replace' , start : 0 , end : 0 } )
expect ( ( preserved [ 0 ] ! as SurfaceEvent ) . sourceEventSeqs ) . toBeUndefined ( )
expect ( ( preserved [ 1 ] as SessionEvent < SurfaceEventType > ) . surfaceOp ) . toBeUndefined ( )
} )
it ( 'append and load round-trips surface fields through SQLite' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( SessionStore )
const fiber = await ctx . plugin ( SessionPersistenceSqlite , { path : ':memory:' } )
const session = ctx . sessions . create ( SessionId ( 'roundtrip-surface' ) )
2026-07-30 13:49:57 +08:00
session . append ( 'turn/start' , { turn : 1 } )
2026-07-19 22:13:50 +08:00
session . append ( 'step/start' , { turn : 1 , step : 1 } )
2026-07-28 13:55:59 +08:00
session . append ( 'user/message' , createUserMessage ( {
content : [ { type : 'text' , text : 'hi' } ] , source : { kind : 'user' } ,
} ) , { surfaceOp : 'append' } )
session . append ( 'assistant/message' , {
turn : 1 , step : 1 ,
message : createMessage ( {
role : 'assistant' ,
content : [ ] ,
source : {
kind : 'model' ,
. . . { provider : 'mock' , model : 'mock' } ,
} ,
} ) ,
} , { surfaceOp : 'append' , sourceEventSeqs : [ 2 ] } )
2026-07-19 22:13:50 +08:00
session . append ( 'step/end' , { turn : 1 , step : 1 } )
2026-08-04 14:09:52 +08:00
session . append ( 'turn/end' , { turn : 1 , reason : { kind : 'completed' } } )
2026-07-19 22:13:50 +08:00
await ctx . sessions . flush ( session )
2026-06-22 10:35:59 +08:00
const loaded = await ctx . sessionPersistence . load ( SessionId ( 'roundtrip-surface' ) )
2026-07-19 22:13:50 +08:00
expect ( loaded . events ) . toHaveLength ( 6 )
const um = loaded . events [ 2 ] !
2026-06-22 10:35:59 +08:00
expect ( ( um as SurfaceEvent ) . surfaceOp ) . toBe ( 'append' )
expect ( ( um as SurfaceEvent ) . sourceEventSeqs ) . toBeUndefined ( )
2026-07-19 22:13:50 +08:00
const am = loaded . events [ 3 ] !
2026-06-22 10:35:59 +08:00
expect ( ( am as SurfaceEvent ) . surfaceOp ) . toBe ( 'append' )
2026-07-19 22:13:50 +08:00
expect ( ( am as SurfaceEvent ) . sourceEventSeqs ) . toEqual ( [ 2 ] )
2026-06-22 10:35:59 +08:00
await fiber . dispose ( )
} )
it ( 'persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( SessionStore )
const fiber = await ctx . plugin ( SessionPersistenceSqlite , { path : ':memory:' } )
const session = ctx . sessions . create ( SessionId ( 'surface-noseq' ) )
2026-07-30 13:49:57 +08:00
session . append ( 'turn/start' , { turn : 1 } )
2026-08-03 16:04:26 +08:00
session . append ( 'user/message' , createUserMessage ( {
content : [ ] ,
source : { kind : 'user' } ,
} ) , { surfaceOp : 'append' } )
2026-08-04 14:09:52 +08:00
session . append ( 'turn/end' , { turn : 1 , reason : { kind : 'completed' } } )
2026-07-19 22:13:50 +08:00
await ctx . sessions . flush ( session )
2026-06-22 10:35:59 +08:00
const loaded = await ctx . sessionPersistence . load ( SessionId ( 'surface-noseq' ) )
expect ( ( loaded . events [ 1 ] ! as SurfaceEvent ) . surfaceOp ) . toBe ( 'append' )
expect ( ( loaded . events [ 1 ] ! as SurfaceEvent ) . sourceEventSeqs ) . toBeUndefined ( )
await fiber . dispose ( )
} )
} )