sqlite.spec.ts 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158
  1. import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { existsSync } from 'node:fs'
  5. import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
  6. import { tmpdir } from 'node:os'
  7. import { dirname, join } from 'node:path'
  8. import { DatabaseSync } from 'node:sqlite'
  9. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  10. import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
  11. import SqliteSessionPersistence, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
  12. import { SessionPersistenceRevisionConflictError } from '@deepseek-ai/dsh-session-persistence'
  13. import {
  14. openDatabase,
  15. rowToEvent,
  16. rowToMeta,
  17. rowToStoredMeta,
  18. scanRows,
  19. SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
  20. type EventRow,
  21. } from '../src/schema.ts'
  22. import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
  23. import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
  24. const dirs: string[] = []
  25. afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
  26. async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
  27. try {
  28. await promise
  29. } catch (error) {
  30. expect(error).toBeInstanceOf(Error)
  31. expect((error as Error).message).toMatch(message)
  32. return
  33. }
  34. throw new Error('expected flush to reject')
  35. }
  36. async function freshDbPath(): Promise<string> {
  37. const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-'))
  38. dirs.push(dir)
  39. return join(dir, 'sessions.db')
  40. }
  41. /** A context with the session store + SQLite backend, plus a teardown. */
  42. async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise<void> }> {
  43. const ctx = new Context()
  44. await ctx.plugin(SessionStore)
  45. const fiber = await ctx.plugin(SqliteSessionPersistence, { path })
  46. return { ctx, dispose: () => fiber.dispose() }
  47. }
  48. async function* replacementEvents(events: readonly SessionEvent[]): AsyncIterable<SessionEvent> {
  49. for (const event of events) yield structuredClone(event)
  50. }
  51. // Run the same backend-agnostic contract as JSONL to pin identical semantics.
  52. runPersistenceContract('sqlite', async () => {
  53. const ctx = new Context()
  54. await ctx.plugin(SessionStore)
  55. const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
  56. return {
  57. persistence: ctx.sessionPersistence,
  58. dispose: async () => { await fiber.dispose() },
  59. }
  60. })
  61. // A file-backed database lets two mounts share rows across reload. `corruptTail` inserts invalid
  62. // JSON past the committed seq, exercising coordinator repair against real database rows.
  63. runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
  64. const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
  65. const path = join(dir, 'sessions.db')
  66. return {
  67. mount: async ctx => ctx.plugin(SqliteSessionPersistence, { path }),
  68. corruptTail: async (id) => {
  69. // A row past the committed region whose `data` does not parse: scanRows
  70. // bounds the preserved prefix at it and returns its seq as tornFrom, which
  71. // the backend surfaces to the coordinator as the tornMarker to delete from.
  72. const db = openDatabase(path, 'wal')
  73. const next = (db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS n FROM events WHERE session_id = ?')
  74. .get(id) as { n: number }).n
  75. db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
  76. .run(id, next, 'assistant/chunk', 99, '{not valid json')
  77. db.close()
  78. },
  79. cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
  80. }
  81. })
  82. describe('scanRows', () => {
  83. // scanRows works off EventRows (data is a JSON string column); build them from SessionEvents
  84. // so the unit tests read in terms of the event vocabulary. Surface metadata is serialized to
  85. // its nullable columns so the conversion remains faithful.
  86. const rows = (events: SessionEvent[]): EventRow[] =>
  87. events.map((e) => {
  88. const se = e as SessionEvent<SurfaceEventType>
  89. return {
  90. seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data),
  91. source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null,
  92. surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
  93. ignorable: e.ignorable === true ? 1 : null,
  94. }
  95. })
  96. it('preserves the full log when it ends exactly on a turn/end (no torn tail)', () => {
  97. const { preserved, tornFrom } = scanRows(rows(oneTurnLog()))
  98. expect(preserved).toEqual(oneTurnLog())
  99. expect(tornFrom).toBeUndefined()
  100. })
  101. it('PRESERVES the real events of an interrupted turn after the last turn/end', () => {
  102. // turn 1 committed (0..5) + a crashed turn 2 (turn/start 6, step/start 7, no
  103. // close): all 8 rows are intact, so the whole prefix is preserved and there
  104. // is no torn fragment to delete. (load() then synthesizes the closers.)
  105. const withOpenTurn: SessionEvent[] = [
  106. ...oneTurnLog(),
  107. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
  108. { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
  109. ]
  110. const { preserved, tornFrom } = scanRows(rows(withOpenTurn))
  111. expect(preserved.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  112. expect(tornFrom).toBeUndefined()
  113. })
  114. it('preserves the contiguous prefix and flags a torn tail at a seq gap', () => {
  115. // A gap after seq 0 (no committed turn/end): seq 0 is the preserved
  116. // interrupted-turn event; the gap bounds it and marks the torn fragment.
  117. const gapped: SessionEvent[] = [
  118. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  119. { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
  120. ]
  121. const { preserved, tornFrom } = scanRows(rows(gapped))
  122. expect(preserved.map(e => e.seq)).toEqual([0])
  123. expect(tornFrom).toBe(1)
  124. })
  125. it('an empty log preserves nothing and has no torn tail', () => {
  126. expect(scanRows([])).toEqual({ preserved: [] })
  127. })
  128. it('throws on a seq gap inside the committed region (before the last turn/end)', () => {
  129. const gapped: SessionEvent[] = [
  130. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  131. { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
  132. { type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
  133. ]
  134. expect(() => scanRows(rows(gapped))).toThrow(/seq gap in committed region/)
  135. })
  136. it('throws on an unparsable row inside the committed region', () => {
  137. const withCorruptCommitted: EventRow[] = [
  138. { 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
  139. { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null },
  140. ]
  141. expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
  142. })
  143. it('tolerates an unparsable torn-tail row after the last turn/end', () => {
  144. const withCorruptTail: EventRow[] = [
  145. ...rows(oneTurnLog()),
  146. { 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
  147. ]
  148. const { preserved, tornFrom } = scanRows(withCorruptTail)
  149. expect(preserved).toEqual(oneTurnLog())
  150. expect(tornFrom).toBe(6)
  151. })
  152. })
  153. describe('rowToMeta', () => {
  154. it('projects every optional physical header field without current-format decoding', () => {
  155. const row = {
  156. id: 'stored-meta',
  157. version: 7,
  158. created_at: 1,
  159. cwd: '/work',
  160. parent_session: 'parent',
  161. seed_length: 3,
  162. origin: 'subagent' as const,
  163. incarnation: 'stored-meta',
  164. revision: 1,
  165. delegation_depth: 2,
  166. agent_preset: 'minimal',
  167. }
  168. expect(rowToStoredMeta(row)).toEqual({
  169. version: 7,
  170. id: 'stored-meta',
  171. createdAt: 1,
  172. cwd: '/work',
  173. parentSession: 'parent',
  174. seedLength: 3,
  175. origin: 'subagent',
  176. delegationDepth: 2,
  177. agentPreset: 'minimal',
  178. })
  179. expect(rowToStoredMeta({
  180. ...row,
  181. cwd: null,
  182. parent_session: null,
  183. seed_length: null,
  184. origin: null,
  185. delegation_depth: null,
  186. agent_preset: null,
  187. })).toEqual({ version: 7, id: 'stored-meta', createdAt: 1 })
  188. expect(rowToMeta(row)).toEqual({
  189. version: 7,
  190. id: 'stored-meta',
  191. createdAt: 1,
  192. cwd: '/work',
  193. parentSession: 'parent',
  194. seedLength: 3,
  195. origin: 'subagent',
  196. delegationDepth: 2,
  197. agentPreset: 'minimal',
  198. })
  199. })
  200. it('restores optional origin metadata', () => {
  201. expect(rowToMeta({
  202. id: 'with-origin',
  203. version: 0,
  204. created_at: 1,
  205. cwd: null,
  206. parent_session: null,
  207. seed_length: null,
  208. origin: 'subagent',
  209. incarnation: 'with-origin',
  210. revision: 1,
  211. delegation_depth: null,
  212. agent_preset: null,
  213. })).toMatchObject({ id: 'with-origin', origin: 'subagent' })
  214. })
  215. it('rejects fractional stored creation metadata', () => {
  216. expect(() => rowToMeta({
  217. id: 'fractional',
  218. version: 0,
  219. created_at: 1.5,
  220. cwd: null,
  221. parent_session: null,
  222. seed_length: null,
  223. origin: null,
  224. incarnation: 'fractional',
  225. revision: 1,
  226. delegation_depth: null,
  227. agent_preset: null,
  228. })).toThrow('stored session createdAt must be a non-negative safe integer')
  229. })
  230. it('restores the agent preset a session was composed from', () => {
  231. // The preset decides the resumed session's tools and prompt; a row that
  232. // dropped it would rebuild a composition the stored history contradicts.
  233. expect(rowToMeta({
  234. id: 'composed',
  235. version: 0,
  236. created_at: 1,
  237. cwd: null,
  238. parent_session: null,
  239. seed_length: null,
  240. origin: null,
  241. incarnation: 'composed',
  242. revision: 1,
  243. delegation_depth: null,
  244. agent_preset: 'minimal',
  245. })).toMatchObject({ agentPreset: 'minimal' })
  246. })
  247. })
  248. describe('SqliteSessionPersistence: durability and crash semantics', () => {
  249. it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
  250. const path = await freshDbPath()
  251. const m = meta('legacy-header-delta', '/legacy')
  252. const db = openDatabase(path, 'wal')
  253. db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)')
  254. .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-delta')
  255. const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
  256. insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1 }))
  257. insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } }))
  258. insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } }))
  259. db.close()
  260. const mounted = await backend(path)
  261. await expect(mounted.ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
  262. await mounted.dispose()
  263. })
  264. it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
  265. const path = await freshDbPath()
  266. const m = meta('legacy-header-fallback', '/legacy')
  267. const db = openDatabase(path, 'wal')
  268. db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)')
  269. .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-fallback')
  270. db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
  271. .run(m.id, 0, 'request/header', 1, JSON.stringify({
  272. header: { config: { model: 'legacy' } },
  273. reason: 'fallback',
  274. }))
  275. db.close()
  276. const mounted = await backend(path)
  277. await expect(mounted.ctx.sessionPersistence.load(m.id))
  278. .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
  279. await mounted.dispose()
  280. })
  281. it('has no independent per-session log location', async () => {
  282. const { ctx, dispose } = await backend()
  283. expect(ctx.sessionPersistence.locate(meta('sqlite-location'))).toBeUndefined()
  284. await dispose()
  285. })
  286. it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => {
  287. const path = await freshDbPath()
  288. const m = meta('crash')
  289. // Run 1: persist a complete turn, then a half-written second turn (no turn/end).
  290. const ctx1 = new Context()
  291. await ctx1.plugin(SessionStore)
  292. const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path })
  293. await ctx1.sessionPersistence.create(m)
  294. await ctx1.sessionPersistence.append(m.id, oneTurnLog())
  295. await ctx1.sessionPersistence.append(m.id, [
  296. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
  297. { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
  298. ])
  299. await fiber1.dispose()
  300. // Run 2: load PRESERVES the interrupted turn's real events (a turn can be huge
  301. // — never truncated) and closes the orphaned turn with synthetic boundary
  302. // events: step/end (the step was open) then turn/end {interrupted}.
  303. const ctx2 = new Context()
  304. await ctx2.plugin(SessionStore)
  305. const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path })
  306. const loaded = await ctx2.sessionPersistence.load(m.id)
  307. expect(loaded.events.map(e => e.type)).toEqual([
  308. 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
  309. 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers
  310. ])
  311. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  312. const last = loaded.events.at(-1)!
  313. expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
  314. // load durably closed the turn, so the next append continues at the balanced
  315. // length (seq 10) and a reload round-trips identically.
  316. await ctx2.sessionPersistence.append(m.id, [
  317. { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
  318. { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
  319. ])
  320. const reloaded = await ctx2.sessionPersistence.load(m.id)
  321. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
  322. await fiber2.dispose()
  323. })
  324. it('load() durably closes the interrupted turn: the synthetic closers are on disk after load', async () => {
  325. const path = await freshDbPath()
  326. const m = meta('load-closes')
  327. const b1 = await backend(path)
  328. await b1.ctx.sessionPersistence.create(m)
  329. await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
  330. await b1.dispose()
  331. // Hand-write an interrupted turn (turn/start seq 6, no turn/end).
  332. const db = openDatabase(path, 'wal')
  333. db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
  334. .run(m.id, 'turn/start', JSON.stringify({ turn: 2 }))
  335. db.close()
  336. const b2 = await backend(path)
  337. const loaded = await b2.ctx.sessionPersistence.load(m.id)
  338. // turn 2's real turn/start (seq 6) is preserved + a synthetic turn/end (seq 7).
  339. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  340. expect(loaded.events.at(-1)!.type).toBe('turn/end')
  341. // load() is mutating: the synthetic turn/end MUST be on disk so the stored log
  342. // is balanced and the cursor is truthful (contract: load closes, not defers).
  343. const probe = openDatabase(path, 'wal')
  344. const stored = probe.prepare('SELECT seq, type FROM events WHERE session_id = ? ORDER BY seq').all(m.id) as { seq: number; type: string }[]
  345. probe.close()
  346. expect(stored.map(r => r.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  347. expect(stored.at(-1)!.type).toBe('turn/end')
  348. await b2.dispose()
  349. })
  350. it('all-tail load: a session whose only turn never closed is preserved and closed on load', async () => {
  351. const path = await freshDbPath()
  352. const m = meta('all-tail')
  353. const b1 = await backend(path)
  354. await b1.ctx.sessionPersistence.create(m)
  355. // A first turn that NEVER completed: turn/start + user/message, no turn/end.
  356. await b1.ctx.sessionPersistence.append(m.id, [
  357. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  358. { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
  359. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  360. }), surfaceOp: 'append' },
  361. ])
  362. await b1.dispose()
  363. // A fresh backend loads it: the interrupted (only) turn's real events are
  364. // preserved and closed with a synthetic turn/end {interrupted} — NOT
  365. // truncated. The session was materialized, so list() reports it present.
  366. const b2 = await backend(path)
  367. const loaded = await b2.ctx.sessionPersistence.load(m.id)
  368. expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
  369. expect(loaded.events.at(-1)!.type === 'turn/end' && loaded.events.at(-1)!.data).toMatchObject({ reason: { kind: 'interrupted' } })
  370. expect((await b2.ctx.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
  371. await b2.dispose()
  372. })
  373. it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => {
  374. const path = await freshDbPath()
  375. openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION
  376. // Bump user_version past what this build supports.
  377. const dbNewer = openDatabase(path, 'wal')
  378. dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`)
  379. dbNewer.close()
  380. expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/)
  381. // The immediately preceding layout lacks the required store identity and is
  382. // rejected rather than migrated (unreleased software, no backward-compat).
  383. const olderPath = await freshDbPath()
  384. openDatabase(olderPath, 'wal').close()
  385. const dbOlder = openDatabase(olderPath, 'wal')
  386. dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`)
  387. dbOlder.close()
  388. expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
  389. })
  390. it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => {
  391. const path = await freshDbPath()
  392. const legacy = new DatabaseSync(path)
  393. legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)')
  394. legacy.close()
  395. expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
  396. const unchanged = new DatabaseSync(path)
  397. expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
  398. expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
  399. expect(unchanged.prepare(
  400. "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'sessions'",
  401. ).get()).toEqual({ name: 'sessions' })
  402. unchanged.close()
  403. })
  404. it('counts a sqliteX table as user-owned instead of mistaking it for SQLite metadata', async () => {
  405. const path = await freshDbPath()
  406. const unrelated = new DatabaseSync(path)
  407. unrelated.exec('CREATE TABLE sqliteX (value TEXT)')
  408. unrelated.exec("INSERT INTO sqliteX VALUES ('safe')")
  409. unrelated.close()
  410. expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
  411. const unchanged = new DatabaseSync(path)
  412. expect(unchanged.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' })
  413. expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 })
  414. expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
  415. expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
  416. unchanged.close()
  417. })
  418. it('rejects view-only and foreign-application unversioned databases without mutation', async () => {
  419. const viewPath = await freshDbPath()
  420. const viewOnly = new DatabaseSync(viewPath)
  421. viewOnly.exec('CREATE VIEW foreign_view AS SELECT 1 AS value')
  422. viewOnly.close()
  423. expect(() => openDatabase(viewPath, 'wal')).toThrow(/unversioned schema or application identity/)
  424. const unchangedView = new DatabaseSync(viewPath)
  425. expect(unchangedView.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
  426. expect(unchangedView.prepare(
  427. "SELECT type FROM sqlite_schema WHERE name = 'foreign_view'",
  428. ).get()).toEqual({ type: 'view' })
  429. unchangedView.close()
  430. const applicationPath = await freshDbPath()
  431. const foreignApplication = new DatabaseSync(applicationPath)
  432. foreignApplication.exec('PRAGMA application_id = 12345')
  433. foreignApplication.close()
  434. expect(() => openDatabase(applicationPath, 'wal')).toThrow(/unversioned schema or application identity/)
  435. const unchangedApplication = new DatabaseSync(applicationPath)
  436. expect(unchangedApplication.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
  437. expect(unchangedApplication.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
  438. expect(unchangedApplication.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
  439. unchangedApplication.close()
  440. })
  441. it('rejects a current-version database with a foreign application identity', async () => {
  442. const path = await freshDbPath()
  443. const foreign = new DatabaseSync(path)
  444. foreign.exec('PRAGMA application_id = 12345')
  445. foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
  446. foreign.close()
  447. expect(() => openDatabase(path, 'wal')).toThrow(/has application id 12345/)
  448. const unchanged = new DatabaseSync(path)
  449. expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
  450. expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
  451. expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
  452. unchanged.close()
  453. })
  454. it('rolls back schema objects and identity stamps when initialization fails', async () => {
  455. const path = await freshDbPath()
  456. const conflicting = new DatabaseSync(path)
  457. conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
  458. conflicting.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
  459. conflicting.exec("CREATE VIEW persistence_state AS SELECT 1 AS singleton, 'foreign' AS store_id")
  460. conflicting.close()
  461. expect(() => openDatabase(path, 'wal')).toThrow()
  462. const unchanged = new DatabaseSync(path)
  463. expect(unchanged.prepare(
  464. "SELECT type FROM sqlite_schema WHERE name = 'persistence_state'",
  465. ).get()).toEqual({ type: 'view' })
  466. expect(unchanged.prepare(
  467. "SELECT type FROM sqlite_schema WHERE name = 'sessions'",
  468. ).get()).toBeUndefined()
  469. expect(unchanged.prepare(
  470. "SELECT type FROM sqlite_schema WHERE name = 'events'",
  471. ).get()).toBeUndefined()
  472. expect(unchanged.prepare('PRAGMA application_id').get())
  473. .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
  474. expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
  475. expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
  476. unchanged.close()
  477. })
  478. it('stamps the persistence application identity with the schema version', async () => {
  479. const path = await freshDbPath()
  480. openDatabase(path, 'wal').close()
  481. const db = new DatabaseSync(path)
  482. expect(db.prepare('PRAGMA application_id').get())
  483. .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
  484. expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
  485. db.close()
  486. })
  487. it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
  488. // Version 3 identified two incompatible sibling layouts, so it is always rejected.
  489. const path = await freshDbPath()
  490. openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION
  491. const db = openDatabase(path, 'wal')
  492. db.exec('PRAGMA user_version = 3')
  493. db.close()
  494. expect(() => openDatabase(path, 'wal')).toThrow(/schema version 3, incompatible with this build/)
  495. })
  496. it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => {
  497. const path = await freshDbPath()
  498. const m = meta('corrupt-tail')
  499. const b1 = await backend(path)
  500. await b1.ctx.sessionPersistence.create(m)
  501. await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed: seqs 0..5
  502. await b1.dispose()
  503. // A torn row after the last committed turn has invalid JSON. `scanRows` locates the boundary
  504. // from seq/type columns without parsing the tail, preserves the committed prefix, and load
  505. // deletes the row; invalid JSON inside the committed region would remain fatal.
  506. const db = openDatabase(path, 'wal')
  507. db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
  508. .run(m.id, 'turn/start', '{not valid json')
  509. db.close()
  510. const b2 = await backend(path)
  511. const loaded = await b2.ctx.sessionPersistence.load(m.id)
  512. expect(loaded.events).toEqual(oneTurnLog()) // torn tail discarded, committed intact (turn 1 already balanced → no closers)
  513. // load physically deleted the corrupt tail row, so a fresh append continues.
  514. await b2.ctx.sessionPersistence.append(m.id, [
  515. { type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } },
  516. { type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } },
  517. ])
  518. const reloaded = await b2.ctx.sessionPersistence.load(m.id)
  519. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  520. await b2.dispose()
  521. })
  522. it('append rolls back the whole batch on a mid-batch seq collision (transaction)', async () => {
  523. const ctx = new Context()
  524. await ctx.plugin(SessionStore)
  525. const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
  526. const m = meta('rollback')
  527. await ctx.sessionPersistence.create(m)
  528. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
  529. // A batch that re-states an already-stored seq must be rejected and leave
  530. // the stored log unchanged (the UNIQUE (session_id, seq) constraint fires
  531. // inside the transaction → ROLLBACK).
  532. await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow()
  533. const loaded = await ctx.sessionPersistence.load(m.id)
  534. expect(loaded.events).toEqual(oneTurnLog()) // unchanged
  535. await fiber.dispose()
  536. })
  537. it('persists across separate backend instances over the same file', async () => {
  538. const path = await freshDbPath()
  539. const m = meta('persist', '/proj')
  540. const ctx1 = new Context()
  541. await ctx1.plugin(SessionStore)
  542. const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path })
  543. await ctx1.sessionPersistence.create(m)
  544. await ctx1.sessionPersistence.append(m.id, oneTurnLog())
  545. await fiber1.dispose()
  546. const ctx2 = new Context()
  547. await ctx2.plugin(SessionStore)
  548. const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path })
  549. expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
  550. const loaded = await ctx2.sessionPersistence.load(m.id)
  551. expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj' })
  552. expect(loaded.events).toEqual(oneTurnLog())
  553. await fiber2.dispose()
  554. })
  555. it('source-qualifies revisions across stores while preserving same-file reopen identity', async () => {
  556. const pathA = await freshDbPath()
  557. const pathB = await freshDbPath()
  558. const m = meta('revision-source')
  559. const a = await backend(pathA)
  560. await a.ctx.sessionPersistence.create(m)
  561. await a.ctx.sessionPersistence.append(m.id, oneTurnLog())
  562. const revisionA = (await a.ctx.sessionPersistence.listSnapshots())[0]?.revision
  563. await a.dispose()
  564. const probeA = openDatabase(pathA, 'wal')
  565. const storeIdA = (probeA.prepare(
  566. 'SELECT store_id FROM persistence_state WHERE singleton = 1',
  567. ).get() as { store_id: string }).store_id
  568. probeA.close()
  569. const aliasA = `${pathA}.alias`
  570. await symlink(pathA, aliasA)
  571. const reopenedA = await backend(aliasA)
  572. expect((await reopenedA.ctx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revisionA)
  573. await reopenedA.dispose()
  574. const b = await backend(pathB)
  575. await b.ctx.sessionPersistence.create(m)
  576. await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
  577. const revisionB = (await b.ctx.sessionPersistence.listSnapshots())[0]?.revision
  578. const probeB = openDatabase(pathB, 'wal')
  579. const storeIdB = (probeB.prepare(
  580. 'SELECT store_id FROM persistence_state WHERE singleton = 1',
  581. ).get() as { store_id: string }).store_id
  582. probeB.close()
  583. expect(storeIdB).not.toBe(storeIdA)
  584. expect(revisionB).not.toBe(revisionA)
  585. expect(String(revisionA)).toMatch(/:revision:1$/)
  586. expect(String(revisionB)).toMatch(/:revision:1$/)
  587. await b.dispose()
  588. })
  589. it('binds a stored source to the same revision as a lightweight read', async () => {
  590. const b = await backend()
  591. const m = meta('stored-prefix-revision')
  592. await b.ctx.sessionPersistence.create(m)
  593. await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
  594. const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence
  595. const stored = await persistence.openStored(m.id)
  596. expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
  597. expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
  598. await b.dispose()
  599. })
  600. it('rejects revision-bound full and suffix readers after the row changes or disappears', async () => {
  601. const { ctx, dispose } = await backend()
  602. const m = meta('stored-reader-conflict')
  603. await ctx.sessionPersistence.create(m)
  604. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  605. const persistence = ctx.sessionPersistence as SqliteSessionPersistence
  606. const changed = await persistence.openStored(m.id)
  607. if (changed === undefined) throw new Error('test session must be materialized')
  608. await ctx.sessionPersistence.append(m.id, [
  609. { type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } },
  610. ])
  611. const changedRead = changed.readEvents()
  612. const changedCompletion = changedRead.completed.catch((error: unknown) => error)
  613. await expect((async () => { for await (const _event of changedRead.events) { /* consume */ } })())
  614. .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  615. await expect(changedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  616. const removed = await persistence.openStored(m.id)
  617. if (removed === undefined) throw new Error('test session must remain materialized')
  618. const internals = persistence as unknown as { db: DatabaseSync }
  619. internals.db.prepare('DELETE FROM sessions WHERE id = ?').run(m.id)
  620. const removedRead = removed.readEvents({ fromSeq: 1 })
  621. const removedCompletion = removedRead.completed.catch((error: unknown) => error)
  622. await expect((async () => { for await (const _event of removedRead.events) { /* consume */ } })())
  623. .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  624. await expect(removedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  625. await dispose()
  626. })
  627. it('rolls back a suffix snapshot when its SQL read fails and reports absent direct snapshots', async () => {
  628. const { ctx, dispose } = await backend()
  629. const persistence = ctx.sessionPersistence as SqliteSessionPersistence
  630. const internals = persistence as unknown as {
  631. db: DatabaseSync
  632. readPrefix(id: SessionId): Promise<unknown>
  633. readSuffix(id: SessionId, fromSeq: number): Promise<unknown>
  634. }
  635. expect(await internals.readPrefix(SessionId('missing-prefix'))).toBeUndefined()
  636. expect(await internals.readSuffix(SessionId('missing-suffix'), 1)).toBeUndefined()
  637. const m = meta('suffix-rollback')
  638. await ctx.sessionPersistence.create(m)
  639. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  640. const prepare = internals.db.prepare.bind(internals.db)
  641. const spy = vi.spyOn(internals.db, 'prepare').mockImplementation((sql) => {
  642. if (sql.includes('seq >= ?')) throw new Error('simulated suffix SELECT failure')
  643. return prepare(sql)
  644. })
  645. await expect(internals.readSuffix(m.id, 1)).rejects.toThrow('simulated suffix SELECT failure')
  646. spy.mockRestore()
  647. expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n)
  648. .toBe(oneTurnLog().length)
  649. await dispose()
  650. })
  651. it('atomically replaces one exact revision and rejects a stale replacement', async () => {
  652. const b = await backend()
  653. const m = meta('format-replace')
  654. const original = [
  655. ...oneTurnLog(),
  656. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
  657. { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
  658. ] as SessionEvent[]
  659. await b.ctx.sessionPersistence.create(m)
  660. await b.ctx.sessionPersistence.append(m.id, original)
  661. const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence
  662. const source = await persistence.openStored(m.id)
  663. if (source === undefined) throw new Error('test session must be materialized')
  664. await persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog()))
  665. const replaced = await persistence.openStored(m.id)
  666. if (replaced === undefined) throw new Error('replacement must preserve the session')
  667. expect(replaced.revision).not.toBe(source.revision)
  668. expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog())
  669. await expect(
  670. persistence.replaceStored(source.revision, m, replacementEvents(original)),
  671. ).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  672. expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog())
  673. await b.dispose()
  674. })
  675. it('rejects replacement identity changes before and during the transaction', async () => {
  676. const first = await backend()
  677. const m = meta('format-replace-identity', '/work')
  678. await first.ctx.sessionPersistence.create(m)
  679. await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
  680. const persistence = first.ctx.sessionPersistence as SqliteSessionPersistence
  681. const source = await persistence.openStored(m.id)
  682. if (source === undefined) throw new Error('test session must be materialized')
  683. await expect(persistence.replaceStored(
  684. source.revision,
  685. { ...m, cwd: '/other' },
  686. replacementEvents(oneTurnLog()),
  687. )).rejects.toThrow(/changes its stored identity/)
  688. const internals = persistence as unknown as { db: DatabaseSync }
  689. const changesDuringStaging = (async function* (): AsyncIterable<SessionEvent> {
  690. yield* oneTurnLog()
  691. internals.db.prepare('UPDATE sessions SET cwd = ? WHERE id = ?').run('/raced', m.id)
  692. })()
  693. await expect(persistence.replaceStored(source.revision, m, changesDuringStaging))
  694. .rejects.toThrow(/changes its stored identity/)
  695. expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n)
  696. .toBe(oneTurnLog().length)
  697. await first.dispose()
  698. })
  699. it('rejects a revision change that occurs while replacement events are staged', async () => {
  700. const mounted = await backend()
  701. const m = meta('format-replace-staging-race', '/work')
  702. await mounted.ctx.sessionPersistence.create(m)
  703. await mounted.ctx.sessionPersistence.append(m.id, oneTurnLog())
  704. const persistence = mounted.ctx.sessionPersistence as SqliteSessionPersistence
  705. const source = await persistence.openStored(m.id)
  706. if (source === undefined) throw new Error('test session must be materialized')
  707. const internals = persistence as unknown as { db: DatabaseSync }
  708. const changesDuringStaging = (async function* (): AsyncIterable<SessionEvent> {
  709. yield* oneTurnLog()
  710. internals.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(m.id)
  711. })()
  712. await expect(persistence.replaceStored(source.revision, m, changesDuringStaging))
  713. .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  714. expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n)
  715. .toBe(oneTurnLog().length)
  716. await mounted.dispose()
  717. })
  718. it('rolls back the complete replacement when the transaction fails after it begins', async () => {
  719. const b = await backend()
  720. const m = meta('format-replace-rollback')
  721. await b.ctx.sessionPersistence.create(m)
  722. await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
  723. const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence
  724. const source = await persistence.openStored(m.id)
  725. if (source === undefined) throw new Error('test session must be materialized')
  726. const db = (persistence as unknown as { db: DatabaseSync }).db
  727. db.exec(`
  728. CREATE TEMP TRIGGER fail_format_replace
  729. BEFORE UPDATE ON sessions
  730. BEGIN
  731. SELECT RAISE(ABORT, 'simulated format replacement failure');
  732. END
  733. `)
  734. await expect(
  735. persistence.replaceStored(source.revision, m, replacementEvents([])),
  736. ).rejects.toThrow(/simulated format replacement failure/)
  737. db.exec('DROP TRIGGER fail_format_replace')
  738. expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog())
  739. await b.dispose()
  740. })
  741. it('changes revisions when a deleted session id is materialized again in the same database', async () => {
  742. const path = await freshDbPath()
  743. const m = meta('recreated-revision')
  744. const first = await backend(path)
  745. await first.ctx.sessionPersistence.create(m)
  746. await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
  747. const before = (await first.ctx.sessionPersistence.listSnapshots())[0]?.revision
  748. await first.dispose()
  749. const cleanup = openDatabase(path, 'wal')
  750. cleanup.prepare('DELETE FROM sessions WHERE id = ?').run(m.id)
  751. cleanup.close()
  752. const second = await backend(path)
  753. await second.ctx.sessionPersistence.create(m)
  754. await second.ctx.sessionPersistence.append(m.id, oneTurnLog())
  755. const after = (await second.ctx.sessionPersistence.listSnapshots())[0]?.revision
  756. expect(after).not.toBe(before)
  757. expect(String(before)).toMatch(/:revision:1$/)
  758. expect(String(after)).toMatch(/:revision:1$/)
  759. await second.dispose()
  760. })
  761. it('awaits in-flight readiness before surfacing snapshot-list cancellation', async () => {
  762. const b = await backend()
  763. const internals = b.ctx.sessionPersistence as unknown as { ready: Promise<void> }
  764. const originalReady = internals.ready
  765. const readiness = Promise.withResolvers<undefined>()
  766. internals.ready = readiness.promise
  767. const reason = new Error('SQLite snapshot readiness cancelled')
  768. const controller = new AbortController()
  769. const pending = b.ctx.sessionPersistence.listSnapshots(controller.signal)
  770. let settled = false
  771. void pending.then(
  772. () => { settled = true },
  773. () => { settled = true },
  774. )
  775. controller.abort(reason)
  776. await Promise.resolve()
  777. expect(settled).toBe(false)
  778. readiness.resolve(undefined)
  779. await expect(pending).rejects.toBe(reason)
  780. internals.ready = originalReady
  781. await b.dispose()
  782. })
  783. it('exposes the schema version constant', () => {
  784. expect(SCHEMA_VERSION).toBe(15)
  785. })
  786. it('keeps the revision stable for an empty repair hook', async () => {
  787. const b = await backend()
  788. const m = meta('empty-repair')
  789. await b.ctx.sessionPersistence.create(m)
  790. await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
  791. const before = await b.ctx.sessionPersistence.listSnapshots()
  792. await (b.ctx.sessionPersistence as SqliteSessionPersistence).commitRepair(m, undefined, [])
  793. expect(await b.ctx.sessionPersistence.listSnapshots()).toEqual(before)
  794. await b.dispose()
  795. })
  796. })
  797. describe('SqliteSessionPersistence: edge cases', () => {
  798. it('resolves the preparation-cache default without schema normalization', async () => {
  799. const ctx = new Context()
  800. await ctx.plugin(SessionStore)
  801. let persistence!: SqliteSessionPersistence
  802. await ctx.plugin(Object.assign((inner: Context) => {
  803. persistence = new SqliteSessionPersistence(inner, {
  804. path: ':memory:',
  805. journalMode: 'wal',
  806. })
  807. }, { inject: ['sessions'] }))
  808. expect(await persistence.list()).toEqual([])
  809. await ctx.fiber.dispose()
  810. })
  811. it('uses the configured preparation cache through the public service', async () => {
  812. const ctx = new Context()
  813. await ctx.plugin(SessionStore)
  814. const fiber = await ctx.plugin(SqliteSessionPersistence, {
  815. path: ':memory:',
  816. preparedSessionCacheSize: 1,
  817. writeBatchMaxDelayMs: 1,
  818. })
  819. const m = meta('sqlite-preparation-cache')
  820. await ctx.sessionPersistence.create(m)
  821. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  822. const preparation = await ctx.sessionPersistence.prepare(m.id)
  823. expect(preparation.session.header).toEqual(m)
  824. preparation[Symbol.dispose]()
  825. await fiber.dispose()
  826. })
  827. it('rejects and closes a current-schema database with an invalid store identity', async () => {
  828. const path = await freshDbPath()
  829. const db = openDatabase(path, 'wal')
  830. db.exec("UPDATE persistence_state SET store_id = '' WHERE singleton = 1")
  831. db.close()
  832. const b = await backend(path)
  833. await expect(b.ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/no valid store identity/)
  834. await expect(b.dispose()).resolves.toBeUndefined()
  835. })
  836. it('creates a new database and WAL sidecars with owner-only modes without changing its parent mode', async () => {
  837. if (process.platform === 'win32') return
  838. const path = await freshDbPath()
  839. const dir = dirname(path)
  840. await chmod(dir, 0o755)
  841. const b = await backend(path)
  842. await b.ctx.sessionPersistence.list()
  843. expect((await stat(dir)).mode & 0o777).toBe(0o755)
  844. expect((await stat(path)).mode & 0o777).toBe(0o600)
  845. expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600)
  846. expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600)
  847. await b.dispose()
  848. })
  849. it('creates a persistent rollback journal with owner-only mode', async () => {
  850. if (process.platform === 'win32') return
  851. const path = await freshDbPath()
  852. const ctx = new Context()
  853. await ctx.plugin(SessionStore)
  854. const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'persist' })
  855. const m = meta('persist-permissions')
  856. await ctx.sessionPersistence.create(m)
  857. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  858. expect((await stat(path)).mode & 0o777).toBe(0o600)
  859. expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600)
  860. await fiber.dispose()
  861. })
  862. it('preserves the mode of an existing database file', async () => {
  863. if (process.platform === 'win32') return
  864. const path = await freshDbPath()
  865. await writeFile(path, '', { mode: 0o644 })
  866. await chmod(path, 0o644)
  867. const ctx = new Context()
  868. await ctx.plugin(SessionStore)
  869. const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'delete' })
  870. await ctx.sessionPersistence.list()
  871. expect((await stat(path)).mode & 0o777).toBe(0o644)
  872. await fiber.dispose()
  873. })
  874. it('surfaces an invalid database path during pre-creation', async () => {
  875. const path = await freshDbPath()
  876. const b = await backend(`${path}\0`)
  877. await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' })
  878. await b.dispose()
  879. })
  880. it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
  881. const path = await freshDbPath()
  882. const m = meta('rollback-insert')
  883. const b1 = await backend(path)
  884. await b1.ctx.sessionPersistence.create(m)
  885. await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
  886. // A SECOND backend over the same file loads the session first, so it adopts
  887. // cursor 6 (the committed length) into its OWN in-memory state.
  888. const b2 = await backend(path)
  889. await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2
  890. const turn2: SessionEvent[] = [
  891. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
  892. { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
  893. ]
  894. // b1 commits seq 6..7 first.
  895. await b1.ctx.sessionPersistence.append(m.id, turn2)
  896. // b2 still thinks its cursor is 6, so this batch passes the contiguity check
  897. // but its INSERT of seq 6 hits the UNIQUE (session_id, seq) constraint
  898. // mid-transaction → ROLLBACK + rethrow.
  899. await expect(b2.ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/UNIQUE/)
  900. // b1's turn is intact; b2's rolled-back attempt left nothing extra.
  901. const loaded = await b1.ctx.sessionPersistence.load(m.id)
  902. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  903. await b1.dispose()
  904. await b2.dispose()
  905. })
  906. it('journalMode config reaches the database (default wal, rollback modes selectable)', async () => {
  907. // :memory: databases always report journal_mode=memory, so probe file DBs.
  908. const walPath = await freshDbPath()
  909. const bWal = await backend(walPath)
  910. await bWal.ctx.sessionPersistence.create(meta('jm-wal'))
  911. const probe = openDatabase(walPath, 'wal')
  912. expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
  913. probe.close()
  914. await bWal.dispose()
  915. const deletePath = await freshDbPath()
  916. const ctx = new Context()
  917. await ctx.plugin(SessionStore)
  918. const fiber = await ctx.plugin(SqliteSessionPersistence, { path: deletePath, journalMode: 'delete' })
  919. await ctx.sessionPersistence.create(meta('jm-delete'))
  920. // Probe through a second connection: journal_mode=delete is a per-database
  921. // property only insofar as no WAL files exist — assert the world, not the
  922. // backend's self-report (no -wal sidecar after writes in delete mode).
  923. const db = openDatabase(deletePath, 'delete')
  924. expect((db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('delete')
  925. db.close()
  926. expect(existsSync(`${deletePath}-wal`)).toBe(false)
  927. await fiber.dispose()
  928. })
  929. it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => {
  930. const path = await freshDbPath()
  931. // Instance 1 materializes a session and disposes.
  932. const b1 = await backend(path)
  933. const s1 = b1.ctx.sessions.create(SessionId('hmr-collide'))
  934. appendLog(s1, oneTurnLog())
  935. await b1.ctx.sessions.flush(s1)
  936. await b1.dispose()
  937. // A fresh context with an UNRELATED live session reusing the id meets a
  938. // materialized row that is NOT a prefix of its events → reject.
  939. const ctx = new Context()
  940. await ctx.plugin(SessionStore)
  941. let session!: Session
  942. await ctx.plugin(Object.assign((inner: Context) => {
  943. session = inner.sessions.create(SessionId('hmr-collide'))
  944. }, { inject: ['sessions'] }))
  945. session.append('turn/start', { turn: 1 })
  946. await ctx.plugin(SqliteSessionPersistence, { path })
  947. await expectFlushError(ctx.sessions.flush(session), /id collision/)
  948. await ctx.fiber.dispose()
  949. })
  950. })
  951. describe('surface field round-trip', () => {
  952. it('rowToEvent parses surface fields from EventRow columns', () => {
  953. const row: EventRow = {
  954. seq: 0, type: 'assistant/message', time: 1,
  955. data: JSON.stringify({ turn: 1, step: 1, content: [] }),
  956. source_event_seqs: JSON.stringify([3, 5]),
  957. surface_op: JSON.stringify('append'),
  958. ignorable: null,
  959. }
  960. const event = rowToEvent(row)
  961. expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5])
  962. expect((event as SurfaceEvent).surfaceOp).toBe('append')
  963. })
  964. it('rowToEvent handles replace surfaceOp object', () => {
  965. const row: EventRow = {
  966. seq: 0, type: 'assistant/message', time: 1,
  967. data: JSON.stringify({ turn: 1, step: 1, content: [] }),
  968. source_event_seqs: JSON.stringify([0, 1]),
  969. surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }),
  970. ignorable: null,
  971. }
  972. const event = rowToEvent(row)
  973. expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
  974. expect((event as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 1 })
  975. })
  976. it('scanRows with surface columns reconstructs events with surface fields', () => {
  977. const rows: EventRow[] = [
  978. { seq: 0, type: 'user/message', time: 1,
  979. data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
  980. source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null },
  981. { seq: 1, type: 'turn/end', time: 2,
  982. data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
  983. source_event_seqs: null, surface_op: null, ignorable: 1 },
  984. ]
  985. const { preserved } = scanRows(rows)
  986. expect(preserved).toHaveLength(2)
  987. expect((preserved[0]! as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
  988. expect((preserved[0]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
  989. expect((preserved[1] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
  990. })
  991. it('append and load round-trips surface fields through SQLite', async () => {
  992. const ctx = new Context()
  993. await ctx.plugin(SessionStore)
  994. const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
  995. const session = ctx.sessions.create(SessionId('roundtrip-surface'))
  996. session.append('turn/start', { turn: 1 })
  997. session.append('step/start', { turn: 1, step: 1 })
  998. session.append('user/message', createUserMessage({
  999. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  1000. }), { surfaceOp: 'append' })
  1001. session.append('assistant/message', {
  1002. turn: 1, step: 1,
  1003. message: createMessage({
  1004. role: 'assistant',
  1005. content: [],
  1006. source: {
  1007. kind: 'model',
  1008. ...{ provider: 'mock', model: 'mock' },
  1009. },
  1010. }),
  1011. }, { surfaceOp: 'append', sourceEventSeqs: [2] })
  1012. session.append('step/end', { turn: 1, step: 1 })
  1013. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  1014. await ctx.sessions.flush(session)
  1015. const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
  1016. expect(loaded.events).toHaveLength(6)
  1017. const um = loaded.events[2]!
  1018. expect((um as SurfaceEvent).surfaceOp).toBe('append')
  1019. expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined()
  1020. const am = loaded.events[3]!
  1021. expect((am as SurfaceEvent).surfaceOp).toBe('append')
  1022. expect((am as SurfaceEvent).sourceEventSeqs).toEqual([2])
  1023. await fiber.dispose()
  1024. })
  1025. it('persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)', async () => {
  1026. const ctx = new Context()
  1027. await ctx.plugin(SessionStore)
  1028. const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
  1029. const session = ctx.sessions.create(SessionId('surface-noseq'))
  1030. session.append('turn/start', { turn: 1 })
  1031. session.append('user/message', createUserMessage({
  1032. content: [],
  1033. source: { kind: 'user' },
  1034. }), { surfaceOp: 'append' })
  1035. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  1036. await ctx.sessions.flush(session)
  1037. const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq'))
  1038. expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append')
  1039. expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
  1040. await fiber.dispose()
  1041. })
  1042. })