sqlite.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { mkdtemp, rm } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  7. import type { Session, SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session'
  8. import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
  9. import { cutAtLastTurnEnd, openDatabase } from '../src/schema.ts'
  10. import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
  11. const dirs: string[] = []
  12. afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
  13. async function freshDbPath(): Promise<string> {
  14. const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-'))
  15. dirs.push(dir)
  16. return join(dir, 'sessions.db')
  17. }
  18. // The payoff: the SAME backend-agnostic contract the JSONL backend runs, now
  19. // proving the SQLite backend satisfies identical semantics.
  20. runPersistenceContract('sqlite', async () => {
  21. const ctx = new Context()
  22. await ctx.plugin(SessionStore)
  23. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
  24. return {
  25. persistence: ctx.sessionPersistence,
  26. dispose: async () => { await fiber.dispose() },
  27. }
  28. })
  29. describe('cutAtLastTurnEnd', () => {
  30. it('returns the prefix through the last complete turn/end and flags a cut tail', () => {
  31. const log = oneTurnLog()
  32. const withTail: SessionEvent[] = [
  33. ...log,
  34. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  35. { type: 'user/message', seq: 7, time: 8, data: { content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } } },
  36. ]
  37. const { committed, cutTail } = cutAtLastTurnEnd(withTail)
  38. expect(committed).toEqual(log)
  39. expect(cutTail).toBe(true)
  40. })
  41. it('treats a log with no turn/end as fully uncommitted', () => {
  42. const partial: SessionEvent[] = [
  43. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  44. { type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } } },
  45. ]
  46. expect(cutAtLastTurnEnd(partial)).toEqual({ committed: [], cutTail: true })
  47. })
  48. it('reports no cut when the log ends exactly on a turn/end', () => {
  49. const { committed, cutTail } = cutAtLastTurnEnd(oneTurnLog())
  50. expect(committed).toEqual(oneTurnLog())
  51. expect(cutTail).toBe(false)
  52. })
  53. it('an empty log is committed-empty with no tail', () => {
  54. expect(cutAtLastTurnEnd([])).toEqual({ committed: [], cutTail: false })
  55. })
  56. it('throws on a seq gap inside the committed region', () => {
  57. const gapped: SessionEvent[] = [
  58. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  59. { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
  60. { type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
  61. ]
  62. expect(() => cutAtLastTurnEnd(gapped)).toThrow(/seq gap in committed region/)
  63. })
  64. })
  65. describe('SessionPersistenceSqlite: durability and crash semantics', () => {
  66. it('a crash tail (rows after the last turn/end) is excluded and deleted on load', async () => {
  67. const path = await freshDbPath()
  68. const m = meta('crash')
  69. // Run 1: persist a complete turn, then a half-written second turn (no turn/end).
  70. const ctx1 = new Context()
  71. await ctx1.plugin(SessionStore)
  72. const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
  73. await ctx1.sessionPersistence.create(m)
  74. await ctx1.sessionPersistence.append(m.id, oneTurnLog())
  75. await ctx1.sessionPersistence.append(m.id, [
  76. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  77. { type: 'user/message', seq: 7, time: 8, data: { content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } } },
  78. ])
  79. await fiber1.dispose()
  80. // Run 2: load returns only the committed first turn; the tail is gone.
  81. const ctx2 = new Context()
  82. await ctx2.plugin(SessionStore)
  83. const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
  84. const loaded = await ctx2.sessionPersistence.load(m.id)
  85. expect(loaded.events).toEqual(oneTurnLog())
  86. // The next append continues at seq 6 (the committed length) and the cut
  87. // tail was physically deleted, so there is no UNIQUE collision.
  88. await ctx2.sessionPersistence.append(m.id, [
  89. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  90. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  91. ])
  92. const reloaded = await ctx2.sessionPersistence.load(m.id)
  93. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  94. await fiber2.dispose()
  95. })
  96. it('append rolls back the whole batch on a mid-batch seq collision (transaction)', async () => {
  97. const ctx = new Context()
  98. await ctx.plugin(SessionStore)
  99. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
  100. const m = meta('rollback')
  101. await ctx.sessionPersistence.create(m)
  102. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
  103. // A batch that re-states an already-stored seq must be rejected and leave
  104. // the stored log unchanged (the UNIQUE (session_id, seq) constraint fires
  105. // inside the transaction → ROLLBACK).
  106. await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow()
  107. const loaded = await ctx.sessionPersistence.load(m.id)
  108. expect(loaded.events).toEqual(oneTurnLog()) // unchanged
  109. await fiber.dispose()
  110. })
  111. it('persists across separate backend instances over the same file', async () => {
  112. const path = await freshDbPath()
  113. const m = meta('persist', '/proj')
  114. const ctx1 = new Context()
  115. await ctx1.plugin(SessionStore)
  116. const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
  117. await ctx1.sessionPersistence.create(m)
  118. await ctx1.sessionPersistence.append(m.id, oneTurnLog())
  119. await ctx1.sessionPersistence.update(m.id, { title: 'T', firstPrompt: 'hi' })
  120. await fiber1.dispose()
  121. const ctx2 = new Context()
  122. await ctx2.plugin(SessionStore)
  123. const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
  124. expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
  125. const loaded = await ctx2.sessionPersistence.load(m.id)
  126. expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj', title: 'T', firstPrompt: 'hi' })
  127. expect(loaded.events).toEqual(oneTurnLog())
  128. await fiber2.dispose()
  129. })
  130. it('rejects an unknown format version on load', async () => {
  131. const path = await freshDbPath()
  132. // Materialize a row with version 2 directly via the real schema.
  133. const db = openDatabase(path)
  134. db.prepare('INSERT INTO sessions (id, version, created_at, updated_at, materialized) VALUES (?, ?, ?, ?, 1)')
  135. .run('v2', 2, 1, 1)
  136. db.close()
  137. const ctx = new Context()
  138. await ctx.plugin(SessionStore)
  139. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
  140. await expect(ctx.sessionPersistence.load(SessionId('v2'))).rejects.toThrow(/version 2/)
  141. await fiber.dispose()
  142. })
  143. it('create rejects a duplicate id (in memory and on a persisted row)', async () => {
  144. const path = await freshDbPath()
  145. const m = meta('dup')
  146. const ctx = new Context()
  147. await ctx.plugin(SessionStore)
  148. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
  149. await ctx.sessionPersistence.create(m)
  150. // Same in-memory state.
  151. await expect(ctx.sessionPersistence.create(m)).rejects.toThrow(/already exists/)
  152. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  153. await fiber.dispose()
  154. // A fresh instance over the same file sees the persisted row.
  155. const ctx2 = new Context()
  156. await ctx2.plugin(SessionStore)
  157. const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
  158. await expect(ctx2.sessionPersistence.create(m)).rejects.toThrow(/already has a persisted row/)
  159. await fiber2.dispose()
  160. })
  161. it('exposes the schema version constant', () => {
  162. expect(SCHEMA_VERSION).toBe(1)
  163. })
  164. })
  165. describe('SessionPersistenceSqlite: write path (session/event → flush)', () => {
  166. function send(session: Session, events: SessionEvent[]): void {
  167. for (const e of events) session.append(e.type, e.data)
  168. }
  169. it('persists a turn appended through the live session on flush', async () => {
  170. const ctx = new Context()
  171. await ctx.plugin(SessionStore)
  172. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
  173. const session = ctx.sessions.create('w1')
  174. send(session, oneTurnLog())
  175. await ctx.parallel('session/flush', session)
  176. const loaded = await ctx.sessionPersistence.load(SessionId('w1'))
  177. expect(loaded.events.map(e => e.type)).toEqual(oneTurnLog().map(e => e.type))
  178. await fiber.dispose()
  179. })
  180. it('a resumed session does not re-append its seed', async () => {
  181. const path = await freshDbPath()
  182. // Run 1: persist a full turn through the live session.
  183. const ctx1 = new Context()
  184. await ctx1.plugin(SessionStore)
  185. const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
  186. const s1 = ctx1.sessions.create('resume')
  187. for (const e of oneTurnLog()) s1.append(e.type, e.data)
  188. await ctx1.parallel('session/flush', s1)
  189. await fiber1.dispose()
  190. // Run 2: reconstruct the live session from the loaded log (seed), then add a
  191. // second turn. The seed must NOT be re-appended (no UNIQUE collision), and
  192. // the second turn continues the seq.
  193. const ctx2 = new Context()
  194. await ctx2.plugin(SessionStore)
  195. const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
  196. const { events } = await ctx2.sessionPersistence.load(SessionId('resume'))
  197. const s2 = ctx2.sessions.create('resume', { seed: events })
  198. s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
  199. s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  200. await ctx2.parallel('session/flush', s2)
  201. const reloaded = await ctx2.sessionPersistence.load(SessionId('resume'))
  202. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  203. await fiber2.dispose()
  204. })
  205. it('HMR: applying the plugin seeds existing live sessions', async () => {
  206. const ctx = new Context()
  207. await ctx.plugin(SessionStore)
  208. const session = ctx.sessions.create('hmr')
  209. for (const e of oneTurnLog()) session.append(e.type, e.data)
  210. // Plugin applied AFTER the session already has events.
  211. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
  212. await ctx.parallel('session/flush', session)
  213. expect(await ctx.sessionPersistence.has(SessionId('hmr'))).toBe(true)
  214. await fiber.dispose()
  215. })
  216. it('dispose drains a pending buffer before closing the database', async () => {
  217. const path = await freshDbPath()
  218. const ctx = new Context()
  219. await ctx.plugin(SessionStore)
  220. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
  221. const session = ctx.sessions.create('drain')
  222. for (const e of oneTurnLog()) session.append(e.type, e.data)
  223. // No explicit flush — dispose must drain the buffer.
  224. await fiber.dispose()
  225. const ctx2 = new Context()
  226. await ctx2.plugin(SessionStore)
  227. const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
  228. expect(await ctx2.sessionPersistence.has(SessionId('drain'))).toBe(true)
  229. await fiber2.dispose()
  230. })
  231. it('rejects a different live session colliding on a persisted id', async () => {
  232. const path = await freshDbPath()
  233. const ctx1 = new Context()
  234. await ctx1.plugin(SessionStore)
  235. const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
  236. const s1 = ctx1.sessions.create('collide')
  237. for (const e of oneTurnLog()) s1.append(e.type, e.data)
  238. await ctx1.parallel('session/flush', s1)
  239. await fiber1.dispose()
  240. // A fresh, unrelated session reusing the id (no seed) must be rejected.
  241. const ctx2 = new Context()
  242. await ctx2.plugin(SessionStore)
  243. const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
  244. const s2 = ctx2.sessions.create('collide')
  245. s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  246. await expect(ctx2.parallel('session/flush', s2)).rejects.toThrow(/id collision/)
  247. await fiber2.dispose()
  248. })
  249. it('update before the first append keeps the summary in memory and the session lazy', async () => {
  250. const ctx = new Context()
  251. await ctx.plugin(SessionStore)
  252. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
  253. const m = meta('lazy-update')
  254. await ctx.sessionPersistence.create(m)
  255. await ctx.sessionPersistence.update(m.id, { title: 'pending' })
  256. // Still lazy: no materialized row yet.
  257. expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
  258. // The first append materializes and carries the pending title.
  259. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  260. const loaded = await ctx.sessionPersistence.load(m.id)
  261. expect(loaded.meta.title).toBe('pending')
  262. await fiber.dispose()
  263. })
  264. })
  265. describe('SessionPersistenceSqlite: edge cases', () => {
  266. async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise<void> }> {
  267. const ctx = new Context()
  268. await ctx.plugin(SessionStore)
  269. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
  270. return { ctx, dispose: () => fiber.dispose() }
  271. }
  272. it('append of an empty batch is a no-op', async () => {
  273. const { ctx, dispose } = await backend()
  274. const m = meta('empty-batch')
  275. await ctx.sessionPersistence.create(m)
  276. await ctx.sessionPersistence.append(m.id, [])
  277. expect(await ctx.sessionPersistence.has(m.id)).toBe(false) // still lazy
  278. await dispose()
  279. })
  280. it('load rejects a missing session', async () => {
  281. const { ctx, dispose } = await backend()
  282. await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/)
  283. await dispose()
  284. })
  285. it('delete of a non-existent session is a no-op', async () => {
  286. const { ctx, dispose } = await backend()
  287. await ctx.sessionPersistence.delete(SessionId('ghost'))
  288. expect(await ctx.sessionPersistence.has(SessionId('ghost'))).toBe(false)
  289. await dispose()
  290. })
  291. it('append adopts a session that exists only in the DB (fresh instance)', async () => {
  292. const path = await freshDbPath()
  293. const m = meta('adopt-append')
  294. const b1 = await backend(path)
  295. await b1.ctx.sessionPersistence.create(m)
  296. await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
  297. await b1.dispose()
  298. // A fresh instance appends a second turn WITHOUT a prior create/load: append
  299. // must adopt the on-disk row (cursor = stored length) and continue the seq.
  300. const b2 = await backend(path)
  301. await b2.ctx.sessionPersistence.append(m.id, [
  302. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  303. { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
  304. ])
  305. const loaded = await b2.ctx.sessionPersistence.load(m.id)
  306. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  307. await b2.dispose()
  308. })
  309. it('update adopts a session that exists only in the DB (fresh instance)', async () => {
  310. const path = await freshDbPath()
  311. const m = meta('adopt-update')
  312. const b1 = await backend(path)
  313. await b1.ctx.sessionPersistence.create(m)
  314. await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
  315. await b1.dispose()
  316. const b2 = await backend(path)
  317. await b2.ctx.sessionPersistence.update(m.id, { title: 'after restart' })
  318. const loaded = await b2.ctx.sessionPersistence.load(m.id)
  319. expect(loaded.meta.title).toBe('after restart')
  320. await b2.dispose()
  321. })
  322. it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
  323. const path = await freshDbPath()
  324. const m = meta('rollback-insert')
  325. const b1 = await backend(path)
  326. await b1.ctx.sessionPersistence.create(m)
  327. await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
  328. // A SECOND backend over the same file loads the session first, so it adopts
  329. // cursor 6 (the committed length) into its OWN in-memory state.
  330. const b2 = await backend(path)
  331. await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2
  332. const turn2: SessionEvent[] = [
  333. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  334. { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
  335. ]
  336. // b1 commits seq 6..7 first.
  337. await b1.ctx.sessionPersistence.append(m.id, turn2)
  338. // b2 still thinks its cursor is 6, so this batch passes the contiguity check
  339. // but its INSERT of seq 6 hits the UNIQUE (session_id, seq) constraint
  340. // mid-transaction → ROLLBACK + rethrow.
  341. await expect(b2.ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/UNIQUE/)
  342. // b1's turn is intact; b2's rolled-back attempt left nothing extra.
  343. const loaded = await b1.ctx.sessionPersistence.load(m.id)
  344. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  345. await b1.dispose()
  346. await b2.dispose()
  347. })
  348. it('round-trips a header with parentSession (fork lineage)', async () => {
  349. const { ctx, dispose } = await backend()
  350. const m: SessionMeta = { ...meta('child'), parentSession: SessionId('parent') }
  351. await ctx.sessionPersistence.create(m)
  352. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  353. const loaded = await ctx.sessionPersistence.load(m.id)
  354. expect(loaded.meta.parentSession).toBe(SessionId('parent'))
  355. await dispose()
  356. })
  357. it('a fresh live session reusing a previously-loaded id is rejected (ownerless guard)', async () => {
  358. const path = await freshDbPath()
  359. const m = meta('ownerless')
  360. const b1 = await backend(path)
  361. await b1.ctx.sessionPersistence.create(m)
  362. await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
  363. await b1.dispose()
  364. const b2 = await backend(path)
  365. // load() leaves ownerless state with cursor 6.
  366. await b2.ctx.sessionPersistence.load(m.id)
  367. // A fresh, unrelated live session reusing the id has a shorter/non-matching
  368. // seed → its onCreated must reject rather than graft onto the loaded prefix.
  369. const s = b2.ctx.sessions.create('ownerless')
  370. s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  371. await expect(b2.ctx.parallel('session/flush', s)).rejects.toThrow(/id collision/)
  372. await b2.dispose()
  373. })
  374. it('a live session whose seed matches the loaded prefix claims ownerless state and persists the suffix', async () => {
  375. const path = await freshDbPath()
  376. const m = meta('claim')
  377. const b1 = await backend(path)
  378. await b1.ctx.sessionPersistence.create(m)
  379. await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
  380. await b1.dispose()
  381. const b2 = await backend(path)
  382. const { events } = await b2.ctx.sessionPersistence.load(m.id) // ownerless, cursor 6
  383. // A live session seeded with the loaded log PLUS a new turn claims the state
  384. // and persists only the suffix.
  385. const s = b2.ctx.sessions.create('claim', { seed: [
  386. ...events,
  387. { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  388. { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
  389. ] })
  390. await b2.ctx.parallel('session/flush', s)
  391. const loaded = await b2.ctx.sessionPersistence.load(m.id)
  392. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  393. await b2.dispose()
  394. })
  395. it('an abandoned lazy session (never materialized) releases its id for reuse', async () => {
  396. const { ctx, dispose } = await backend()
  397. const inits = (ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }).inits
  398. let first!: Session
  399. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
  400. first = inner.sessions.create('reuse')
  401. }, { inject: ['sessions'] }))
  402. await inits.get(first) // let the lazy create register the state
  403. await firstFiber.dispose() // disposed before any append → never materialized
  404. let reuse!: Session
  405. await ctx.plugin(Object.assign((inner: Context) => {
  406. reuse = inner.sessions.create('reuse')
  407. }, { inject: ['sessions'] }))
  408. await expect(inits.get(reuse)).resolves.toBeUndefined()
  409. reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  410. reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  411. await ctx.parallel('session/flush', reuse)
  412. expect(await ctx.sessionPersistence.has(SessionId('reuse'))).toBe(true)
  413. await dispose()
  414. })
  415. it('does NOT reclaim an id whose abandoned owner still has buffered (unflushed) events', async () => {
  416. const { ctx, dispose } = await backend()
  417. const inits = (ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }).inits
  418. let first!: Session
  419. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
  420. first = inner.sessions.create('buffered')
  421. }, { inject: ['sessions'] }))
  422. await inits.get(first)
  423. // Append a turn but do NOT flush — events sit in the write-behind buffer.
  424. first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  425. first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  426. await firstFiber.dispose() // disposed before flush; not materialized, buffer pending
  427. let reuse!: Session
  428. await ctx.plugin(Object.assign((inner: Context) => {
  429. reuse = inner.sessions.create('buffered')
  430. }, { inject: ['sessions'] }))
  431. await expect(inits.get(reuse)).rejects.toThrow(/already bound to a different live session/)
  432. await dispose()
  433. })
  434. it('initFor is idempotent: re-emitting session/created does not re-initialize', async () => {
  435. const { ctx, dispose } = await backend()
  436. const session = ctx.sessions.create('idem')
  437. ctx.emit('session/created', session) // second create event for the same object
  438. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  439. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  440. await ctx.parallel('session/flush', session)
  441. expect(await ctx.sessionPersistence.has(SessionId('idem'))).toBe(true)
  442. await dispose()
  443. })
  444. it('a live session claims cursor-0 ownerless state created via the public API and persists its seed', async () => {
  445. const { ctx, dispose } = await backend()
  446. // create() registers ownerless state with cursor 0 (no events yet).
  447. await ctx.sessionPersistence.create(meta('cursor0'))
  448. // A live session reusing that id, seeded with a turn, claims the ownerless
  449. // state (cursor 0 trivially matches any seed) and persists the whole seed.
  450. const s = ctx.sessions.create('cursor0', { seed: [
  451. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  452. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
  453. ] })
  454. await ctx.parallel('session/flush', s)
  455. const loaded = await ctx.sessionPersistence.load(SessionId('cursor0'))
  456. expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
  457. await dispose()
  458. })
  459. it('HMR: reloading the backend adopts a still-live, already-materialized session', async () => {
  460. const path = await freshDbPath()
  461. const ctx = new Context()
  462. await ctx.plugin(SessionStore)
  463. // The session lives in its OWN fiber so it survives the backend reload.
  464. let session!: Session
  465. await ctx.plugin(Object.assign((inner: Context) => {
  466. session = inner.sessions.create('hmr-adopt')
  467. }, { inject: ['sessions'] }))
  468. // Backend instance 1 materializes the session on disk.
  469. const backend1 = await ctx.plugin(SessionPersistenceSqlite, { path })
  470. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  471. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
  472. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  473. await ctx.parallel('session/flush', session)
  474. // Hot-reload: dispose instance 1, plug in instance 2 over the SAME file
  475. // while the session stays live. Instance 2 has an empty states map but the
  476. // row is materialized on disk and is a prefix of the live events — it must
  477. // ADOPT (not reject), and a second turn then persists.
  478. await backend1.dispose()
  479. await ctx.plugin(SessionPersistenceSqlite, { path })
  480. session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
  481. session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  482. await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow()
  483. const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
  484. expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
  485. await ctx.fiber.dispose()
  486. })
  487. it('HMR: adoption persists the live SUFFIX that was ahead of the on-disk prefix', async () => {
  488. const path = await freshDbPath()
  489. const ctx = new Context()
  490. await ctx.plugin(SessionStore)
  491. let session!: Session
  492. await ctx.plugin(Object.assign((inner: Context) => {
  493. session = inner.sessions.create('hmr-suffix')
  494. }, { inject: ['sessions'] }))
  495. const backend1 = await ctx.plugin(SessionPersistenceSqlite, { path })
  496. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  497. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  498. await ctx.parallel('session/flush', session)
  499. // Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
  500. // flushing turn 2: it is now ONLY in the live session's events.
  501. await backend1.dispose()
  502. session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
  503. session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  504. // Instance 2 adopts the on-disk prefix (turn 1) and MUST persist the live
  505. // suffix (turn 2) carried in the session's events.
  506. await ctx.plugin(SessionPersistenceSqlite, { path })
  507. await ctx.parallel('session/flush', session)
  508. const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix'))
  509. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3])
  510. expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
  511. await ctx.fiber.dispose()
  512. })
  513. it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => {
  514. const path = await freshDbPath()
  515. // Instance 1 materializes a session and disposes.
  516. const b1 = await backend(path)
  517. const s1 = b1.ctx.sessions.create('hmr-collide')
  518. for (const e of oneTurnLog()) s1.append(e.type, e.data)
  519. await b1.ctx.parallel('session/flush', s1)
  520. await b1.dispose()
  521. // A fresh context with an UNRELATED live session reusing the id meets a
  522. // materialized row that is NOT a prefix of its events → reject.
  523. const ctx = new Context()
  524. await ctx.plugin(SessionStore)
  525. let session!: Session
  526. await ctx.plugin(Object.assign((inner: Context) => {
  527. session = inner.sessions.create('hmr-collide')
  528. }, { inject: ['sessions'] }))
  529. session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } })
  530. await ctx.plugin(SessionPersistenceSqlite, { path })
  531. await expect(ctx.parallel('session/flush', session)).rejects.toThrow(/id collision/)
  532. await ctx.fiber.dispose()
  533. })
  534. })