sqlite.spec.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { spawn } from 'node:child_process'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { once } from 'node:events'
  5. import { chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
  6. import { tmpdir } from 'node:os'
  7. import { join } from 'node:path'
  8. import { performance } from 'node:perf_hooks'
  9. import { pathToFileURL } from 'node:url'
  10. import { DatabaseSync } from 'node:sqlite'
  11. import Loader from '@deepseek-ai/cordis-plugin-loader'
  12. import Include from '@deepseek-ai/cordis-plugin-include'
  13. import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  14. import SessionPersistenceSqlite, {
  15. DEFAULT_BUSY_TIMEOUT_MS,
  16. SCHEMA_VERSION,
  17. } from '@deepseek-ai/dsh-session-persistence-sqlite'
  18. import { SessionPersistenceRevisionConflictError } from '@deepseek-ai/dsh-session-persistence'
  19. import {
  20. runCoordinatorContract,
  21. type CoordinatorFixture,
  22. } from '../../session-persistence/tests/coordinator-contract.ts'
  23. import {
  24. meta,
  25. oneTurnLog,
  26. runPersistenceContract,
  27. } from '../../session-persistence/tests/contract.ts'
  28. import { MAX_PACKED_DATA_BYTES } from '../src/codec.ts'
  29. import {
  30. decodeEventRow,
  31. decodeSessionRow,
  32. decodeStoreIdentity,
  33. openDatabase,
  34. validateSchemaForMutation,
  35. rowToMeta,
  36. SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
  37. type SessionRow,
  38. } from '../src/schema.ts'
  39. import { SqliteStore } from '../src/store.ts'
  40. import { sql } from '../src/sql.ts'
  41. import { testSql } from './test-sql.ts'
  42. const dirs: string[] = []
  43. afterEach(async () => {
  44. for (const directory of dirs.splice(0)) await rm(directory, { recursive: true, force: true })
  45. })
  46. async function freshDbPath(prefix = 'dsh-sqlite-'): Promise<string> {
  47. const directory = await mkdtemp(join(tmpdir(), prefix))
  48. dirs.push(directory)
  49. return join(directory, 'sessions.db')
  50. }
  51. async function backendFailure(path: string): Promise<unknown> {
  52. const ctx = new Context()
  53. await ctx.plugin(SessionStore)
  54. try {
  55. await ctx.plugin(SessionPersistenceSqlite, { path })
  56. await ctx.sessionPersistence.list()
  57. return undefined
  58. } catch (error: unknown) {
  59. return error
  60. } finally {
  61. await ctx.fiber.dispose()
  62. }
  63. }
  64. function errorMessage(error: unknown): string {
  65. return error instanceof Error ? error.message : String(error)
  66. }
  67. function databaseWithJournalFailure(
  68. nextFailure: () => Error | undefined,
  69. ): typeof DatabaseSync {
  70. return class JournalFailureDatabase extends DatabaseSync {
  71. override prepare(source: string) {
  72. if (source !== sql('journal-mode-wal')) return super.prepare(source)
  73. const statement = super.prepare(sql('journal-mode-wal'))
  74. const get = statement.get.bind(statement)
  75. Object.defineProperty(statement, 'get', {
  76. value: () => {
  77. const failure = nextFailure()
  78. if (failure !== undefined) throw failure
  79. return get()
  80. },
  81. })
  82. return statement
  83. }
  84. }
  85. }
  86. function chunk(seq: number, text = `token-${seq}`): SessionEvent {
  87. return {
  88. type: 'assistant/chunk',
  89. seq,
  90. time: 1_000 + seq,
  91. data: {
  92. turn: 1,
  93. step: 1,
  94. chunk: { type: 'text-delta', index: 0, text },
  95. },
  96. }
  97. }
  98. function chunkLog(count: number): SessionEvent[] {
  99. return [
  100. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  101. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  102. ...Array.from({ length: count }, (_, index) => chunk(index + 2)),
  103. { type: 'step/end', seq: count + 2, time: count + 3, data: { turn: 1, step: 1 } },
  104. {
  105. type: 'turn/end',
  106. seq: count + 3,
  107. time: count + 4,
  108. data: { turn: 1, reason: { kind: 'completed' } },
  109. },
  110. ]
  111. }
  112. async function measureWriteTraffic(
  113. path: string,
  114. events: readonly SessionEvent[],
  115. ): Promise<{
  116. readonly walBytes: number
  117. readonly idleWalBytes: number
  118. readonly rows: number
  119. readonly largest: number
  120. readonly inserted: number
  121. readonly changed: number
  122. readonly removed: number
  123. }> {
  124. interface PhysicalRow {
  125. readonly rowid: number
  126. readonly seq: number
  127. readonly type: string
  128. readonly time: number
  129. readonly data: string | Uint8Array
  130. readonly source_event_seqs: Uint8Array | null
  131. readonly surface_op: string | null
  132. readonly ignorable: number | null
  133. }
  134. const sameValue = (left: string | Uint8Array | null, right: string | Uint8Array | null): boolean => (
  135. typeof left === 'string' || left === null
  136. ? left === right
  137. : right instanceof Uint8Array && Buffer.from(left).equals(Buffer.from(right))
  138. )
  139. const sameRow = (left: PhysicalRow, right: PhysicalRow): boolean => (
  140. left.rowid === right.rowid
  141. && left.seq === right.seq
  142. && left.type === right.type
  143. && left.time === right.time
  144. && sameValue(left.data, right.data)
  145. && sameValue(left.source_event_seqs, right.source_event_seqs)
  146. && left.surface_op === right.surface_op
  147. && left.ignorable === right.ignorable
  148. )
  149. const ctx = new Context()
  150. await ctx.plugin(SessionStore)
  151. await ctx.plugin(SessionPersistenceSqlite, { path, writeBatchMaxDelayMs: 200 })
  152. try {
  153. const header = meta('traffic')
  154. await ctx.sessionPersistence.create(header)
  155. let previous = new Map<number, PhysicalRow>()
  156. let inserted = 0
  157. let changed = 0
  158. let removed = 0
  159. const probe = new DatabaseSync(path, { readOnly: true })
  160. try {
  161. const selectRows = probe.prepare(testSql('select-event-rows'))
  162. for (let offset = 0; offset < events.length; offset += 40) {
  163. await ctx.sessionPersistence.append(header.id, events.slice(offset, offset + 40))
  164. const current = new Map((selectRows.all(header.id) as unknown as PhysicalRow[])
  165. .map(row => [row.seq, row]))
  166. for (const [seq, row] of current) {
  167. const old = previous.get(seq)
  168. if (old === undefined) inserted += 1
  169. else if (!sameRow(old, row)) changed += 1
  170. }
  171. for (const seq of previous.keys()) if (!current.has(seq)) removed += 1
  172. previous = current
  173. }
  174. } finally {
  175. probe.close()
  176. }
  177. const db = new DatabaseSync(path, { readOnly: true })
  178. const measured = db.prepare(testSql('measure-write-traffic')).get() as { rows: number; largest: number }
  179. db.close()
  180. const walBytes = (await stat(`${path}-wal`)).size
  181. await new Promise(resolve => setTimeout(resolve, 250))
  182. return {
  183. walBytes,
  184. idleWalBytes: (await stat(`${path}-wal`)).size,
  185. rows: measured.rows,
  186. largest: measured.largest,
  187. inserted,
  188. changed,
  189. removed,
  190. }
  191. } finally {
  192. await ctx.fiber.dispose()
  193. }
  194. }
  195. /** Yield immutable event copies as one replacement stream. */
  196. async function* replacementEvents(events: readonly SessionEvent[]): AsyncIterable<SessionEvent> {
  197. for (const event of events) yield structuredClone(event)
  198. }
  199. runPersistenceContract('sqlite', async () => {
  200. const ctx = new Context()
  201. await ctx.plugin(SessionStore)
  202. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
  203. return {
  204. persistence: ctx.sessionPersistence,
  205. dispose: async () => { await fiber.dispose() },
  206. }
  207. })
  208. runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
  209. const directory = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
  210. const path = join(directory, 'sessions.db')
  211. return {
  212. mount: async ctx => ctx.plugin(SessionPersistenceSqlite, { path }),
  213. corruptTail: async (id) => {
  214. const db = new DatabaseSync(path)
  215. const last = db.prepare(testSql('select-last-event'))
  216. .get(id) as { seq: number; type: string; data: string }
  217. const logicalLength = last.type === 'text-chunks'
  218. ? (JSON.parse(last.data) as { texts: string[] }).texts.length
  219. : 1
  220. const next = last.seq + logicalLength
  221. db.prepare(testSql('insert-corrupt-event'))
  222. .run(id, next, 'assistant/chunk', 99, '{not valid json', null)
  223. db.close()
  224. },
  225. cleanup: async () => { await rm(directory, { recursive: true, force: true }) },
  226. }
  227. })
  228. describe('SessionPersistenceSqlite physical packing', () => {
  229. it('loads from cordis.yml and packs through the assembled service', async () => {
  230. const path = await freshDbPath('dsh-sqlite-loader-')
  231. const configPath = join(path, '..', 'cordis.yml')
  232. await writeFile(configPath, [
  233. "- name: '@deepseek-ai/dsh-session'",
  234. "- name: '@deepseek-ai/dsh-session-persistence-sqlite'",
  235. ' config:',
  236. ` path: ${JSON.stringify(path)}`,
  237. '',
  238. ].join('\n'))
  239. const ctx = new Context()
  240. ctx.baseUrl = pathToFileURL(join(path, '..')).href + '/'
  241. await ctx.plugin(Loader)
  242. ctx.loader.builtins.include = Include
  243. ctx.loader.internal = {
  244. version: 'sqlite',
  245. async import(specifier: string) {
  246. if (specifier === '@deepseek-ai/dsh-session') return SessionStore
  247. if (specifier === '@deepseek-ai/dsh-session-persistence-sqlite') {
  248. return SessionPersistenceSqlite
  249. }
  250. throw new Error(`unexpected Loader import: ${specifier}`)
  251. },
  252. } as unknown as NonNullable<typeof ctx.loader.internal>
  253. await ctx.loader.create({
  254. name: 'cordis:include',
  255. config: { path: pathToFileURL(configPath).href },
  256. })
  257. await ctx.loader.await()
  258. const header = meta('loader')
  259. const events = chunkLog(4)
  260. await ctx.sessionPersistence.create(header)
  261. await ctx.sessionPersistence.append(header.id, events)
  262. expect((await ctx.sessionPersistence.inspect(header.id)).events).toEqual(events)
  263. await ctx.fiber.dispose()
  264. const db = new DatabaseSync(path)
  265. expect(db.prepare(testSql('count-packed-events')).get())
  266. .toEqual({ count: 1 })
  267. db.close()
  268. })
  269. it('packs each append once without rewriting earlier rows and seeks inside packed rows', async () => {
  270. const path = await freshDbPath()
  271. const ctx = new Context()
  272. await ctx.plugin(SessionStore)
  273. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
  274. const header = meta('packed')
  275. const events = chunkLog(100)
  276. await ctx.sessionPersistence.create(header)
  277. await ctx.sessionPersistence.append(header.id, events.slice(0, 3))
  278. await ctx.sessionPersistence.append(header.id, events.slice(3, 4))
  279. const before = new DatabaseSync(path, { readOnly: true })
  280. const originalRows = before.prepare(testSql('select-event-rowids')).all()
  281. before.close()
  282. await ctx.sessionPersistence.append(header.id, events.slice(4))
  283. const inspected = await ctx.sessionPersistence.inspect(header.id)
  284. expect(inspected.events).toEqual(events)
  285. for (const fromSeq of [0, 2, 25, 101, 104, 105]) {
  286. expect((await ctx.sessionPersistence.readFrom(header.id, fromSeq)).events)
  287. .toEqual(events.filter(event => event.seq >= fromSeq))
  288. }
  289. await fiber.dispose()
  290. const db = new DatabaseSync(path)
  291. expect(db.prepare(testSql('select-user-version')).get()).toEqual({ user_version: SCHEMA_VERSION })
  292. expect(db.prepare(testSql('count-events')).get()).toEqual({ count: 7 })
  293. expect(db.prepare(testSql('count-packed-events')).get())
  294. .toEqual({ count: 1 })
  295. expect(db.prepare(testSql('select-event-rowids')).all().slice(0, originalRows.length))
  296. .toEqual(originalRows)
  297. db.close()
  298. })
  299. it.runIf(process.platform !== 'win32')('bounds paced-stream WAL extent without rewriting committed rows', async () => {
  300. const events = chunkLog(1_000)
  301. const measured = await measureWriteTraffic(await freshDbPath('dsh-sqlite-traffic-'), events)
  302. expect(measured).toMatchObject({ rows: 31, inserted: 31, changed: 0, removed: 0 })
  303. expect(measured.inserted).toBe(measured.rows)
  304. expect(measured.largest).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES)
  305. expect(measured.idleWalBytes).toBe(measured.walBytes)
  306. })
  307. it('includes a packed predecessor when an overlapping scalar tail hides it', async () => {
  308. const path = await freshDbPath('dsh-sqlite-overlap-')
  309. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  310. const header = meta('overlap')
  311. await store.appendBatch(header, [chunk(0), chunk(1), chunk(2)], false)
  312. const db = new DatabaseSync(path)
  313. db.prepare(testSql('insert-corrupt-event'))
  314. .run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), null)
  315. db.close()
  316. expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([chunk(2)])
  317. const malformed = new DatabaseSync(path)
  318. malformed.prepare(testSql('delete-session-events')).run(header.id)
  319. malformed.prepare(testSql('insert-corrupt-event'))
  320. .run(header.id, 0, 'text-chunks', 1, '{not json', 0)
  321. malformed.close()
  322. expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([])
  323. await store.close()
  324. })
  325. it('waits for a competing process within the configured busy timeout', async () => {
  326. const path = await freshDbPath('dsh-sqlite-busy-')
  327. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: 1_000 })
  328. const header = meta('busy')
  329. await store.appendBatch(header, [chunk(0)], false)
  330. const holder = spawn(process.execPath, ['--input-type=module', '-e', String.raw`
  331. import { DatabaseSync } from 'node:sqlite';
  332. const db = new DatabaseSync(process.argv[1]);
  333. db.exec('BEGIN IMMEDIATE');
  334. process.stdout.write('locked\n');
  335. setTimeout(() => { db.exec('COMMIT'); db.close(); }, 100);
  336. `, path], { stdio: ['ignore', 'pipe', 'pipe'] })
  337. const exited = new Promise<number | null>((resolve, reject) => {
  338. holder.once('error', reject)
  339. holder.once('exit', resolve)
  340. })
  341. try {
  342. await once(holder.stdout, 'data')
  343. await expect(store.appendBatch(header, [chunk(1)], true)).resolves.toBeUndefined()
  344. const code = await exited
  345. expect(code).toBe(0)
  346. expect((await store.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)])
  347. } finally {
  348. if (holder.exitCode === null) holder.kill()
  349. await store.close()
  350. }
  351. })
  352. it('rejects an older SQLite physical schema', async () => {
  353. const path = await freshDbPath('dsh-sqlite-old-schema-')
  354. const seed = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
  355. seed.exec(testSql('set-user-version-16'))
  356. seed.close()
  357. await chmod(path, 0o600)
  358. await expect(openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS))
  359. .rejects.toThrow(/schema version 16.*incompatible/)
  360. })
  361. it('rejects a stale physical append without replacing the winning tail', async () => {
  362. const path = await freshDbPath('dsh-sqlite-stale-')
  363. const first = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  364. const second = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  365. const header = meta(SessionId('stale'))
  366. await first.appendBatch(header, [chunk(0)], false)
  367. await second.appendBatch(header, [chunk(1)], true)
  368. await expect(first.appendBatch(header, [chunk(1)], true)).rejects.toThrow(/stored next seq is 2/)
  369. expect((await first.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)])
  370. await first.close()
  371. await second.close()
  372. })
  373. it('rejects a stale repair without deleting a newer winning tail', async () => {
  374. const path = await freshDbPath('dsh-sqlite-stale-repair-')
  375. const stale = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  376. const winner = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  377. const header = meta(SessionId('stale-repair'))
  378. await stale.appendBatch(header, [chunk(0)], false)
  379. const db = new DatabaseSync(path)
  380. db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
  381. db.close()
  382. expect((await stale.loadStored(header.id))?.tornMarker).toBe(1)
  383. await winner.commitRepair(header, 1, [])
  384. await winner.appendBatch(header, [chunk(1), chunk(2)], true)
  385. await expect(stale.commitRepair(header, 1, [])).rejects.toThrow(/repair is stale/)
  386. expect((await stale.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1), chunk(2)])
  387. await stale.close()
  388. await winner.close()
  389. })
  390. })
  391. describe('SessionPersistenceSqlite schema ownership', () => {
  392. it('accepts every configured journal mode and SQLite memory mode result', async () => {
  393. const resources = {
  394. wal: 'journal-mode-wal',
  395. delete: 'journal-mode-delete',
  396. truncate: 'journal-mode-truncate',
  397. persist: 'journal-mode-persist',
  398. } as const
  399. for (const mode of ['wal', 'delete', 'truncate', 'persist'] as const) {
  400. ;(await openDatabase(DatabaseSync, ':memory:', mode, DEFAULT_BUSY_TIMEOUT_MS)).close()
  401. const path = await freshDbPath(`dsh-sqlite-journal-${mode}-`)
  402. const db = await openDatabase(DatabaseSync, path, mode, DEFAULT_BUSY_TIMEOUT_MS)
  403. expect(db.prepare(sql(resources[mode])).get()).toEqual({ journal_mode: mode })
  404. expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 })
  405. expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 })
  406. expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 })
  407. db.close()
  408. }
  409. })
  410. it('retries a busy journal-mode transition within its retry budget', async () => {
  411. const path = await freshDbPath('dsh-sqlite-journal-busy-')
  412. let attempts = 0
  413. const BusyOnceDatabase = databaseWithJournalFailure(() => {
  414. attempts += 1
  415. return attempts === 1
  416. ? Object.assign(new Error('database is locked'), {
  417. code: 'ERR_SQLITE_ERROR',
  418. errcode: 5,
  419. errstr: 'database is locked',
  420. })
  421. : undefined
  422. })
  423. const db = await openDatabase(BusyOnceDatabase, path, 'wal', 100)
  424. expect(attempts).toBe(2)
  425. expect(db.prepare(sql('journal-mode-wal')).get()).toEqual({ journal_mode: 'wal' })
  426. expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 })
  427. expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 })
  428. expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 })
  429. db.close()
  430. })
  431. it('does not retry journal failures outside the available busy budget', async () => {
  432. for (const { errcode, timeout } of [
  433. { errcode: 5, timeout: 0 },
  434. { errcode: 6, timeout: 100 },
  435. ]) {
  436. let attempts = 0
  437. const FailingDatabase = databaseWithJournalFailure(() => {
  438. attempts += 1
  439. return Object.assign(new Error(`SQLite error ${errcode}`), { errcode })
  440. })
  441. await expect(openDatabase(
  442. FailingDatabase,
  443. await freshDbPath(`dsh-sqlite-journal-failure-${errcode}-`),
  444. 'wal',
  445. timeout,
  446. )).rejects.toThrow(`SQLite error ${errcode}`)
  447. expect(attempts).toBe(1)
  448. }
  449. })
  450. it('starts no journal retry after its open-relative cutoff', async () => {
  451. let attempts = 0
  452. const BusyDatabase = databaseWithJournalFailure(() => {
  453. attempts += 1
  454. return Object.assign(new Error('database is locked'), { errcode: 5 })
  455. })
  456. const clock = vi.spyOn(performance, 'now')
  457. .mockReturnValueOnce(0)
  458. .mockReturnValueOnce(50)
  459. .mockReturnValueOnce(100)
  460. try {
  461. await expect(openDatabase(
  462. BusyDatabase,
  463. await freshDbPath('dsh-sqlite-journal-cutoff-'),
  464. 'wal',
  465. 100,
  466. )).rejects.toThrow('database is locked')
  467. } finally {
  468. clock.mockRestore()
  469. }
  470. expect(attempts).toBe(1)
  471. })
  472. it('paces repeated busy journal-mode attempts', async () => {
  473. const attemptedAt: number[] = []
  474. const BusyTwiceDatabase = databaseWithJournalFailure(() => {
  475. attemptedAt.push(performance.now())
  476. return attemptedAt.length <= 2
  477. ? Object.assign(new Error('database is locked'), { errcode: 5 })
  478. : undefined
  479. })
  480. const db = await openDatabase(
  481. BusyTwiceDatabase,
  482. await freshDbPath('dsh-sqlite-journal-paced-'),
  483. 'wal',
  484. DEFAULT_BUSY_TIMEOUT_MS,
  485. )
  486. db.close()
  487. expect(attemptedAt).toHaveLength(3)
  488. for (let index = 1; index < attemptedAt.length; index += 1) {
  489. const previous = attemptedAt[index - 1]
  490. const current = attemptedAt[index]
  491. if (previous === undefined || current === undefined) throw new Error('missing journal attempt timestamp')
  492. expect(current - previous).toBeGreaterThanOrEqual(5)
  493. }
  494. })
  495. it('rejects unversioned, incompatible, and foreign-application databases', async () => {
  496. const unversionedPath = await freshDbPath('dsh-sqlite-unversioned-')
  497. const unversioned = new DatabaseSync(unversionedPath)
  498. unversioned.exec(testSql('create-unrelated-table'))
  499. unversioned.close()
  500. await expect(openDatabase(DatabaseSync, unversionedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/unversioned schema/)
  501. const incompatiblePath = await freshDbPath('dsh-sqlite-incompatible-')
  502. const incompatible = new DatabaseSync(incompatiblePath)
  503. incompatible.exec(testSql('set-user-version-16'))
  504. incompatible.close()
  505. await expect(openDatabase(DatabaseSync, incompatiblePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/incompatible with this build/)
  506. const foreignPath = await freshDbPath('dsh-sqlite-foreign-')
  507. const foreign = new DatabaseSync(foreignPath)
  508. foreign.exec(testSql('set-user-version-17'))
  509. foreign.exec(testSql('set-application-id-12345'))
  510. foreign.close()
  511. await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/)
  512. })
  513. it('rejects changed columns and non-strict owned tables', async () => {
  514. const changedPath = await freshDbPath('dsh-sqlite-columns-')
  515. ;(await openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close()
  516. const changed = new DatabaseSync(changedPath)
  517. changed.exec(testSql('add-unexpected-column'))
  518. changed.close()
  519. await expect(openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
  520. const nonStrictPath = await freshDbPath('dsh-sqlite-nonstrict-')
  521. ;(await openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close()
  522. const nonStrict = new DatabaseSync(nonStrictPath)
  523. nonStrict.exec(testSql('replace-events-with-nonstrict-table'))
  524. nonStrict.close()
  525. await expect(openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
  526. const loosePath = await freshDbPath('dsh-sqlite-loose-')
  527. const loose = new DatabaseSync(loosePath)
  528. loose.exec(testSql('create-loose-schema'))
  529. loose.close()
  530. await expect(openDatabase(DatabaseSync, loosePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
  531. })
  532. it('rejects schema ownership changes observed at mutation time', async () => {
  533. const changedVersion = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
  534. changedVersion.exec(testSql('set-user-version-16'))
  535. expect(() => { validateSchemaForMutation(DatabaseSync, changedVersion, ':memory:') })
  536. .toThrow(/schema changed before mutation/)
  537. changedVersion.close()
  538. const changedApplication = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
  539. changedApplication.exec(testSql('set-application-id-12345'))
  540. expect(() => { validateSchemaForMutation(DatabaseSync, changedApplication, ':memory:') })
  541. .toThrow(/application id changed before mutation/)
  542. changedApplication.close()
  543. })
  544. it('validates creation time and restores every optional header field', () => {
  545. const base: SessionRow = {
  546. id: 'stored-header',
  547. version: 0,
  548. created_at: 1,
  549. cwd: '/project',
  550. parent_session: 'parent',
  551. seed_length: 4,
  552. origin: 'subagent',
  553. incarnation: '00000000-0000-4000-8000-000000000000',
  554. revision: 1,
  555. delegation_depth: 2,
  556. agent_preset: 'minimal',
  557. }
  558. expect(rowToMeta(decodeSessionRow(base))).toMatchObject({
  559. cwd: '/project',
  560. parentSession: 'parent',
  561. seedLength: 4,
  562. origin: 'subagent',
  563. delegationDepth: 2,
  564. agentPreset: 'minimal',
  565. })
  566. expect(() => decodeSessionRow({ ...base, created_at: -1 })).toThrow(/created_at/)
  567. expect(() => decodeSessionRow({ ...base, origin: 'external' })).toThrow(/origin/)
  568. expect(() => decodeSessionRow({ ...base, delegation_depth: -1 })).toThrow(/delegation_depth/)
  569. })
  570. it('rejects malformed SQLite row primitives generically', () => {
  571. const base: SessionRow = {
  572. id: 'stored-header',
  573. version: 0,
  574. created_at: 1,
  575. cwd: '/project',
  576. parent_session: null,
  577. seed_length: null,
  578. origin: null,
  579. incarnation: '00000000-0000-4000-8000-000000000000',
  580. revision: 1,
  581. delegation_depth: null,
  582. agent_preset: null,
  583. }
  584. for (const [value, message] of [
  585. [null, /object/],
  586. [{ ...base, id: 1 }, /id.*string/],
  587. [{ ...base, id: '' }, /id.*empty/],
  588. [{ ...base, version: '0' }, /version.*safe integer/],
  589. [{ ...base, cwd: 'relative' }, /cwd.*absolute/],
  590. [{ ...base, cwd: 1 }, /cwd.*string or null/],
  591. [{ ...base, incarnation: 'invalid' }, /incarnation.*UUID/],
  592. [{ ...base, seed_length: '1' }, /seed_length.*safe integer or null/],
  593. [{ ...base, agent_preset: 1 }, /agent_preset.*string or null/],
  594. ] as const) {
  595. expect(() => decodeSessionRow(value)).toThrow(message)
  596. }
  597. const eventRow = {
  598. seq: 0, type: 'turn/start', time: 1, data: '{}',
  599. source_event_seqs: null, surface_op: null, ignorable: null,
  600. }
  601. for (const [value, message] of [
  602. [null, /object/],
  603. [{ ...eventRow, seq: '0' }, /seq.*safe integer/],
  604. [{ ...eventRow, type: '' }, /type.*empty/],
  605. [{ ...eventRow, time: '1' }, /time.*safe integer/],
  606. [{ ...eventRow, data: 1 }, /data.*string or blob/],
  607. [{ ...eventRow, source_event_seqs: 1 }, /source_event_seqs.*blob or null/],
  608. [{ ...eventRow, ignorable: 2 }, /ignorable.*0, 1, or null/],
  609. ] as const) {
  610. expect(() => decodeEventRow(value)).toThrow(message)
  611. }
  612. expect(() => decodeStoreIdentity({ store_id: 'invalid' })).toThrow(/store_id.*UUID/)
  613. })
  614. it('rejects invalid durable metadata before exposing a session header', async () => {
  615. const path = await freshDbPath('dsh-sqlite-metadata-')
  616. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  617. const header = meta('invalid-metadata')
  618. await store.appendBatch(header, [chunk(0)], false)
  619. const db = new DatabaseSync(path)
  620. db.prepare(testSql('update-invalid-session-metadata')).run(header.id)
  621. db.close()
  622. await expect(store.list()).rejects.toThrow(/seed_length|origin|delegation_depth/)
  623. await expect(store.loadStored(header.id)).rejects.toThrow(/seed_length|origin|delegation_depth/)
  624. await store.close()
  625. })
  626. it('uses the shared persistence application identity', () => {
  627. expect(SESSION_PERSISTENCE_SQLITE_APPLICATION_ID).toBe(0x44534850)
  628. })
  629. })
  630. describe('SessionPersistenceSqlite edge behavior', () => {
  631. it('materializes an explicitly durable empty live session', async () => {
  632. const path = await freshDbPath('dsh-sqlite-empty-')
  633. const ctx = new Context()
  634. await ctx.plugin(SessionStore)
  635. await ctx.plugin(SessionPersistenceSqlite, { path })
  636. const session = ctx.sessions.create(SessionId('empty'), { meta: { cwd: '/workspace' } })
  637. await ctx.sessionPersistence.ensureMaterialized(session)
  638. await expect(ctx.sessionPersistence.list()).resolves.toEqual([session.header])
  639. await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ meta: session.header, events: [] })
  640. await ctx.fiber.dispose()
  641. })
  642. it('keeps a fresh database unopened until the first persistence operation', async () => {
  643. const path = await freshDbPath('dsh-sqlite-lazy-')
  644. const ctx = new Context()
  645. await ctx.plugin(SessionStore)
  646. await ctx.plugin(SessionPersistenceSqlite, { path })
  647. await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
  648. const emitWarning = Reflect.get(process, 'emitWarning')
  649. expect(await ctx.sessionPersistence.list()).toEqual([])
  650. expect(Reflect.get(process, 'emitWarning')).toBe(emitWarning)
  651. expect(typeof (await stat(path)).size).toBe('number')
  652. await ctx.fiber.dispose()
  653. })
  654. it('disposes after path validation without opening the database', async () => {
  655. const path = await freshDbPath('dsh-sqlite-unused-')
  656. const ctx = new Context()
  657. await ctx.plugin(SessionStore)
  658. await ctx.plugin(SessionPersistenceSqlite, { path })
  659. await ctx.fiber.dispose()
  660. await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
  661. const untouchedPath = await freshDbPath('dsh-sqlite-never-validated-')
  662. const untouched = new SqliteStore({
  663. path: untouchedPath,
  664. journalMode: 'wal',
  665. busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS,
  666. })
  667. await untouched.close()
  668. await expect(stat(untouchedPath)).rejects.toMatchObject({ code: 'ENOENT' })
  669. })
  670. it('uses constructor defaults and exposes locate and prepare directly', async () => {
  671. const ctx = new Context()
  672. await ctx.plugin(SessionStore)
  673. let persistence!: SessionPersistenceSqlite
  674. await ctx.plugin(Object.assign((inner: Context) => {
  675. persistence = new SessionPersistenceSqlite(inner, { path: ':memory:' })
  676. }, { inject: ['sessions'] }))
  677. const header = meta('direct-provider')
  678. const events = chunkLog(3)
  679. expect(persistence.locate(header)).toBeUndefined()
  680. await persistence.create(header)
  681. await persistence.append(header.id, events)
  682. const preparation = await persistence.prepare(header.id)
  683. expect(preparation.session.header).toEqual(header)
  684. preparation[Symbol.dispose]()
  685. await ctx.fiber.dispose()
  686. })
  687. it('keeps empty mutations inert and rolls back a repair without metadata', async () => {
  688. const store = new SqliteStore({
  689. path: ':memory:',
  690. journalMode: 'wal',
  691. busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS,
  692. })
  693. const header = meta('empty-store')
  694. await store.appendBatch(header, [], false)
  695. await store.commitRepair(header, undefined, [])
  696. expect(await store.readStoredRevision(header.id)).toBeUndefined()
  697. await expect(store.commitRepair(header, 0, [])).rejects.toThrow(/metadata row is missing/)
  698. await store.close()
  699. })
  700. it('rejects omitted torn markers and stale closer positions', async () => {
  701. const path = await freshDbPath('dsh-sqlite-repair-validation-')
  702. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  703. const header = meta('repair-validation')
  704. await store.appendBatch(header, [chunk(0)], false)
  705. const db = new DatabaseSync(path)
  706. db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
  707. db.close()
  708. await expect(store.commitRepair(header, undefined, [chunk(1)])).rejects.toThrow(/omitted current torn tail/)
  709. await store.commitRepair(header, 1, [])
  710. await expect(store.commitRepair(header, undefined, [chunk(2)])).rejects.toThrow(/closer starts at seq 2/)
  711. const cleared = new DatabaseSync(path)
  712. cleared.prepare(testSql('delete-session-events')).run(header.id)
  713. cleared.close()
  714. await store.commitRepair(header, undefined, [chunk(0)])
  715. expect((await store.loadStored(header.id))?.events).toEqual([chunk(0)])
  716. await store.close()
  717. })
  718. it('rejects malformed physical tail rows before appending', async () => {
  719. const path = await freshDbPath('dsh-sqlite-tail-')
  720. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  721. const header = meta('invalid-tail')
  722. await store.appendBatch(header, [chunk(0)], false)
  723. const db = new DatabaseSync(path)
  724. db.prepare(testSql('insert-corrupt-event'))
  725. .run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
  726. db.close()
  727. await expect(store.appendBatch(header, [chunk(2)], true)).rejects.toThrow(/invalid physical tail/)
  728. await store.close()
  729. })
  730. it('rejects missing and empty store identities', async () => {
  731. for (const mode of ['missing', 'empty'] as const) {
  732. const path = await freshDbPath(`dsh-sqlite-identity-${mode}-`)
  733. const db = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
  734. if (mode === 'missing') db.exec(testSql('delete-persistence-state'))
  735. else db.exec(testSql('empty-store-id'))
  736. db.close()
  737. await chmod(path, 0o600)
  738. expect(errorMessage(await backendFailure(path))).toMatch(/no valid store identity/)
  739. }
  740. })
  741. it('rejects invalid paths during service initialization', async () => {
  742. const path = await freshDbPath('dsh-sqlite-invalid-path-')
  743. const ctx = new Context()
  744. await ctx.plugin(SessionStore)
  745. await expect(ctx.plugin(SessionPersistenceSqlite, { path: `${path}\0` })).rejects.toMatchObject({
  746. code: 'ERR_INVALID_ARG_VALUE',
  747. })
  748. await ctx.fiber.dispose()
  749. })
  750. it('rejects non-files and symbolic links', async () => {
  751. const directoryPath = await freshDbPath('dsh-sqlite-directory-')
  752. await mkdir(directoryPath)
  753. expect(errorMessage(await backendFailure(directoryPath)))
  754. .toMatch(/must be a regular file/)
  755. const linkPath = await freshDbPath('dsh-sqlite-link-')
  756. const target = join(linkPath, '..', 'target.db')
  757. await writeFile(target, '')
  758. await symlink(target, linkPath)
  759. expect(errorMessage(await backendFailure(linkPath)))
  760. .toMatch(/not a symbolic link/)
  761. const parentLinkPath = await freshDbPath('dsh-sqlite-parent-link-')
  762. const realParent = join(parentLinkPath, '..', 'real-parent')
  763. const linkedParent = join(parentLinkPath, '..', 'linked-parent')
  764. await mkdir(realParent, { mode: 0o700 })
  765. await symlink(realParent, linkedParent)
  766. expect(errorMessage(await backendFailure(join(linkedParent, 'sessions.db'))))
  767. .toMatch(/must be a real directory/)
  768. })
  769. it.runIf(
  770. process.getuid !== undefined && process.getuid() !== 0,
  771. )('rejects permissive files and writable parents', async () => {
  772. const permissivePath = await freshDbPath('dsh-sqlite-permissive-')
  773. await writeFile(permissivePath, '')
  774. await chmod(permissivePath, 0o644)
  775. expect(errorMessage(await backendFailure(permissivePath)))
  776. .toMatch(/accessible only by that user/)
  777. const writableParentPath = await freshDbPath('dsh-sqlite-parent-')
  778. await chmod(join(writableParentPath, '..'), 0o770)
  779. expect(errorMessage(await backendFailure(writableParentPath)))
  780. .toMatch(/not group\/world-writable/)
  781. })
  782. it('surfaces database creation failures after path validation', async () => {
  783. const path = await freshDbPath('dsh-sqlite-create-failure-')
  784. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  785. await store.validatePath()
  786. const parent = join(path, '..')
  787. await rm(parent, { recursive: true })
  788. await writeFile(parent, 'not a directory')
  789. await expect(store.open()).rejects.toThrow(/ENOENT|ENOTDIR/)
  790. await store.close()
  791. })
  792. })
  793. describe('SessionPersistenceSqlite stored-source and replacement primitives', () => {
  794. it('binds a stored source to the same revision as a lightweight read', async () => {
  795. const path = await freshDbPath()
  796. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  797. const m = meta('stored-prefix-revision')
  798. await store.appendBatch(m, oneTurnLog(), false)
  799. const stored = await store.openStored(m.id)
  800. expect(stored?.revision).toBe(await store.readStoredRevision(m.id))
  801. expect(await store.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
  802. await store.close()
  803. })
  804. it('rejects revision-bound full and suffix readers after the row changes or disappears', async () => {
  805. const path = await freshDbPath()
  806. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  807. const m = meta('stored-reader-conflict')
  808. await store.appendBatch(m, oneTurnLog(), false)
  809. const changed = await store.openStored(m.id)
  810. if (changed === undefined) throw new Error('test session must be materialized')
  811. await store.appendBatch(m, [
  812. { type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } },
  813. ], true)
  814. const changedRead = changed.readEvents()
  815. const changedCompletion = changedRead.completed.catch((error: unknown) => error)
  816. await expect((async () => { for await (const _event of changedRead.events) { /* consume */ } })())
  817. .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  818. await expect(changedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  819. const removed = await store.openStored(m.id)
  820. if (removed === undefined) throw new Error('test session must remain materialized')
  821. const db = (store as unknown as { db: DatabaseSync }).db
  822. db.prepare(testSql('delete-session-by-id')).run(m.id)
  823. const removedRead = removed.readEvents({ fromSeq: 1 })
  824. const removedCompletion = removedRead.completed.catch((error: unknown) => error)
  825. await expect((async () => { for await (const _event of removedRead.events) { /* consume */ } })())
  826. .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  827. await expect(removedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  828. await store.close()
  829. })
  830. it('rolls back a suffix snapshot when its SQL read fails and reports absent direct snapshots', async () => {
  831. const path = await freshDbPath()
  832. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  833. expect(await store.loadStored(SessionId('missing-prefix'))).toBeUndefined()
  834. expect(await store.loadStoredFrom(SessionId('missing-suffix'), 1)).toBeUndefined()
  835. const m = meta('suffix-rollback')
  836. await store.appendBatch(m, oneTurnLog(), false)
  837. const db = (store as unknown as { db: DatabaseSync }).db
  838. const prepare = db.prepare.bind(db)
  839. const spy = vi.spyOn(db, 'prepare').mockImplementation((source) => {
  840. if (source.includes('seq >= ?')) throw new Error('simulated suffix SELECT failure')
  841. return prepare(source)
  842. })
  843. await expect(store.loadStoredFrom(m.id, 1)).rejects.toThrow('simulated suffix SELECT failure')
  844. spy.mockRestore()
  845. expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n)
  846. .toBe(oneTurnLog().length)
  847. await store.close()
  848. })
  849. it('atomically replaces one exact revision and rejects a stale replacement', async () => {
  850. const path = await freshDbPath()
  851. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  852. const m = meta('format-replace')
  853. const original = [
  854. ...oneTurnLog(),
  855. { type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } },
  856. { type: 'turn/end', seq: oneTurnLog().length + 1, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
  857. ] as SessionEvent[]
  858. await store.appendBatch(m, original, false)
  859. const source = await store.openStored(m.id)
  860. if (source === undefined) throw new Error('test session must be materialized')
  861. await store.replaceStored(source.revision, m, replacementEvents(oneTurnLog()))
  862. const replaced = await store.openStored(m.id)
  863. if (replaced === undefined) throw new Error('replacement must preserve the session')
  864. expect(replaced.revision).not.toBe(source.revision)
  865. expect((await store.loadStored(m.id))?.events).toEqual(oneTurnLog())
  866. await expect(
  867. store.replaceStored(source.revision, m, replacementEvents(original)),
  868. ).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  869. expect((await store.loadStored(m.id))?.events).toEqual(oneTurnLog())
  870. await store.close()
  871. })
  872. it('rejects replacement identity changes before and during the transaction', async () => {
  873. const path = await freshDbPath()
  874. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  875. const m = meta('format-replace-identity', '/work')
  876. await store.appendBatch(m, oneTurnLog(), false)
  877. const source = await store.openStored(m.id)
  878. if (source === undefined) throw new Error('test session must be materialized')
  879. await expect(store.replaceStored(
  880. source.revision,
  881. { ...m, cwd: '/other' },
  882. replacementEvents(oneTurnLog()),
  883. )).rejects.toThrow(/changes its stored identity/)
  884. const db = (store as unknown as { db: DatabaseSync }).db
  885. const changesDuringStaging = (async function* (): AsyncIterable<SessionEvent> {
  886. yield* oneTurnLog()
  887. db.prepare(testSql('update-session-cwd')).run('/raced', m.id)
  888. })()
  889. await expect(store.replaceStored(source.revision, m, changesDuringStaging))
  890. .rejects.toThrow(/changes its stored identity/)
  891. expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n)
  892. .toBe(oneTurnLog().length)
  893. await store.close()
  894. })
  895. it('rejects a revision change that occurs while replacement events are staged', async () => {
  896. const path = await freshDbPath()
  897. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  898. const m = meta('format-replace-staging-race', '/work')
  899. await store.appendBatch(m, oneTurnLog(), false)
  900. const source = await store.openStored(m.id)
  901. if (source === undefined) throw new Error('test session must be materialized')
  902. const db = (store as unknown as { db: DatabaseSync }).db
  903. const changesDuringStaging = (async function* (): AsyncIterable<SessionEvent> {
  904. yield* oneTurnLog()
  905. db.prepare(testSql('update-session-revision')).run(m.id)
  906. })()
  907. await expect(store.replaceStored(source.revision, m, changesDuringStaging))
  908. .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
  909. expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n)
  910. .toBe(oneTurnLog().length)
  911. await store.close()
  912. })
  913. it('rolls back the complete replacement when the transaction fails after it begins', async () => {
  914. const path = await freshDbPath()
  915. const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
  916. const m = meta('format-replace-rollback')
  917. await store.appendBatch(m, oneTurnLog(), false)
  918. const source = await store.openStored(m.id)
  919. if (source === undefined) throw new Error('test session must be materialized')
  920. const db = (store as unknown as { db: DatabaseSync }).db
  921. db.exec(testSql('create-temp-replace-trigger'))
  922. await expect(
  923. store.replaceStored(source.revision, m, replacementEvents([])),
  924. ).rejects.toThrow(/simulated format replacement failure/)
  925. db.exec(testSql('drop-temp-replace-trigger'))
  926. expect((await store.loadStored(m.id))?.events).toEqual(oneTurnLog())
  927. await store.close()
  928. })
  929. })