store.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. /**
  2. * SQLite storage primitives: transactional append-batch packing, physical
  3. * reads, schema validation, revisions, repair, and lifecycle closure.
  4. * @module @deepseek-ai/dsh-session-persistence-sqlite/store
  5. */
  6. import { randomUUID } from 'node:crypto'
  7. import { statSync } from 'node:fs'
  8. import { lstat, mkdir, open } from 'node:fs/promises'
  9. import { dirname, resolve } from 'node:path'
  10. import type { DatabaseSync, StatementSync } from 'node:sqlite'
  11. import {
  12. type SessionEvent,
  13. type SessionHeader,
  14. type SessionId,
  15. } from '@deepseek-ai/dsh-session'
  16. import {
  17. SessionPersistenceRevision,
  18. type PersistenceBackend,
  19. type SessionPersistenceRevision as PersistenceRevision,
  20. type SessionPersistenceSnapshot,
  21. type StoredPrefix,
  22. type StoredSuffix,
  23. } from '@deepseek-ai/dsh-session-persistence'
  24. import {
  25. MAX_PACKED_ROW_MEMBERS,
  26. packChunkRuns,
  27. } from './codec.ts'
  28. import {
  29. bindRecord,
  30. decodeRow,
  31. scanRows,
  32. type BoundRecord,
  33. } from './compression.ts'
  34. import {
  35. type EventRow,
  36. type JournalMode,
  37. decodeEventRow,
  38. decodeSessionRow,
  39. decodeStoreIdentity,
  40. openDatabase,
  41. validateSchemaForMutation,
  42. rowToMeta,
  43. type SessionRow,
  44. } from './schema.ts'
  45. import { sql } from './sql.ts'
  46. /** Storage options resolved by the service provider. */
  47. export interface SqliteStoreOptions {
  48. readonly path: string
  49. readonly journalMode: JournalMode
  50. readonly busyTimeoutMs: number
  51. }
  52. /** SQLite implementation of the coordinator's physical backend hooks. */
  53. export class SqliteStore implements PersistenceBackend<number> {
  54. readonly name = 'session-persistence-sqlite'
  55. private db!: DatabaseSync
  56. private databaseConstructor!: typeof import('node:sqlite')['DatabaseSync']
  57. private storeIdentity!: string
  58. private databasePath!: string
  59. private opened = false
  60. private pathReady: Promise<void> | undefined
  61. private ready: Promise<void> | undefined
  62. constructor(private readonly options: SqliteStoreOptions) {}
  63. /**
  64. * Validate filesystem ownership without importing or opening Node SQLite.
  65. * @returns settlement of the store's one path-validation operation.
  66. */
  67. validatePath(): Promise<void> {
  68. this.pathReady ??= this.preparePath(this.options.path)
  69. return this.pathReady
  70. }
  71. /**
  72. * Lazily open and validate the database on first persistence use.
  73. * @returns settlement of the store's one database-open operation.
  74. */
  75. open(): Promise<void> {
  76. this.ready ??= this.openDb()
  77. return this.ready
  78. }
  79. private async preparePath(path: string): Promise<void> {
  80. const actual = path === ':memory:' ? path : resolve(path)
  81. if (actual !== ':memory:') {
  82. await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
  83. await validateParentDirectory(dirname(actual))
  84. await validateDatabaseFileIfPresent(actual)
  85. }
  86. this.databasePath = actual
  87. }
  88. private async openDb(): Promise<void> {
  89. await this.validatePath()
  90. if (this.databasePath !== ':memory:') {
  91. await createDatabaseFile(this.databasePath)
  92. await validateDatabaseFile(this.databasePath)
  93. }
  94. const { DatabaseSync } = await loadNodeSqlite()
  95. this.databaseConstructor = DatabaseSync
  96. this.db = await openDatabase(
  97. DatabaseSync,
  98. this.databasePath,
  99. this.options.journalMode,
  100. this.options.busyTimeoutMs,
  101. )
  102. try {
  103. const row = this.db.prepare(sql('select-store-id')).get()
  104. if (row === undefined) {
  105. throw new Error(`session database at "${this.databasePath}" has no valid store identity`)
  106. }
  107. let storeId: string
  108. try {
  109. storeId = decodeStoreIdentity(row)
  110. } catch (error: unknown) {
  111. throw new Error(`session database at "${this.databasePath}" has no valid store identity`, { cause: error })
  112. }
  113. if (this.databasePath === ':memory:') {
  114. this.storeIdentity = `memory:store:${storeId}`
  115. } else {
  116. const identity = statSync(this.databasePath, { bigint: true })
  117. this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${storeId}`
  118. }
  119. this.opened = true
  120. } catch (error: unknown) {
  121. this.db.close()
  122. throw error
  123. }
  124. }
  125. async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<number> | undefined> {
  126. await this.observe(signal)
  127. const snapshot = this.readTransaction(() => {
  128. const row = this.rowFor(id)
  129. if (row === undefined) return undefined
  130. const eventRows = this.db.prepare(sql('select-events')).all(id).map(decodeEventRow)
  131. return { row, eventRows }
  132. })
  133. signal?.throwIfAborted()
  134. if (snapshot === undefined) return undefined
  135. const scanned = scanRows(snapshot.eventRows)
  136. return {
  137. meta: rowToMeta(snapshot.row),
  138. events: scanned.preserved,
  139. revision: sqliteRevision(this.storeIdentity, snapshot.row),
  140. ...scanned.tornFrom === undefined ? {} : { tornMarker: scanned.tornFrom },
  141. }
  142. }
  143. async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise<PersistenceRevision | undefined> {
  144. await this.observe(signal)
  145. const row = this.rowFor(id)
  146. signal?.throwIfAborted()
  147. return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row)
  148. }
  149. async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined> {
  150. await this.observe(signal)
  151. const snapshot = this.readTransaction(() => {
  152. const row = this.rowFor(id)
  153. if (row === undefined) return undefined
  154. return { row, ...this.physicalSpanFrom(id, fromSeq) }
  155. })
  156. signal?.throwIfAborted()
  157. if (snapshot === undefined) return undefined
  158. const { preserved } = scanRows(snapshot.eventRows, snapshot.base)
  159. return { meta: rowToMeta(snapshot.row), events: preserved.filter(event => event.seq >= fromSeq) }
  160. }
  161. async appendBatch(
  162. meta: SessionHeader,
  163. events: readonly SessionEvent[],
  164. isMaterialized: boolean,
  165. ): Promise<void> {
  166. await this.open()
  167. if (events.length === 0) return
  168. this.db.exec(sql('begin-immediate'))
  169. try {
  170. validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath)
  171. const tailRows = this.tailRows(meta.id)
  172. const currentLast = this.logicalLastEvent(meta.id, tailRows)
  173. const expected = currentLast === undefined ? 0 : currentLast.seq + 1
  174. const first = events[0] as SessionEvent
  175. if (first.seq !== expected) {
  176. throw new Error(`session ${meta.id} append starts at seq ${first.seq}, stored next seq is ${expected}`)
  177. }
  178. if (!isMaterialized) this.writeRow(meta)
  179. const insert = this.insertStatement()
  180. for (const record of packChunkRuns(events)) this.insertRecord(insert, meta.id, bindRecord(record))
  181. this.incrementRevision(meta.id)
  182. this.db.exec(sql('commit'))
  183. } catch (error: unknown) {
  184. this.rollback(error, 'append')
  185. }
  186. }
  187. async materializeHeader(meta: SessionHeader): Promise<void> {
  188. await this.open()
  189. this.db.exec(sql('begin-immediate'))
  190. try {
  191. validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath)
  192. this.writeRow(meta)
  193. this.db.exec(sql('commit'))
  194. } catch (error: unknown) {
  195. /* v8 ignore next -- validate/write failure uses the same transaction rollback path covered by append and repair. */
  196. this.rollback(error, 'materialize empty session')
  197. }
  198. }
  199. async commitRepair(
  200. meta: SessionHeader,
  201. tornMarker: number | undefined,
  202. closers: readonly SessionEvent[],
  203. ): Promise<void> {
  204. await this.open()
  205. if (tornMarker === undefined && closers.length === 0) return
  206. this.db.exec(sql('begin-immediate'))
  207. try {
  208. validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath)
  209. const row = this.rowFor(meta.id)
  210. if (row === undefined) throw new Error(`session ${meta.id} metadata row is missing`)
  211. const currentRows = this.db.prepare(sql('select-events')).all(meta.id).map(decodeEventRow)
  212. const current = scanRows(currentRows)
  213. if (tornMarker !== undefined) {
  214. if (current.tornFrom !== tornMarker) {
  215. throw new Error(`session ${meta.id} repair is stale: physical tail no longer starts at seq ${tornMarker}`)
  216. }
  217. this.db.prepare(sql('delete-events-from'))
  218. .run(meta.id, tornMarker)
  219. } else if (current.tornFrom !== undefined) {
  220. throw new Error(`session ${meta.id} repair omitted current torn tail at seq ${current.tornFrom}`)
  221. }
  222. if (closers.length > 0) {
  223. const expected = current.preserved.at(-1)?.seq === undefined
  224. ? 0
  225. : (current.preserved.at(-1) as SessionEvent).seq + 1
  226. if (closers[0]?.seq !== expected) {
  227. throw new Error(`session ${meta.id} repair is stale: closer starts at seq ${closers[0]?.seq}, stored next seq is ${expected}`)
  228. }
  229. const insert = this.insertStatement()
  230. for (const closer of closers) this.insertRecord(insert, meta.id, bindRecord(closer))
  231. }
  232. this.incrementRevision(meta.id)
  233. this.db.exec(sql('commit'))
  234. } catch (error: unknown) {
  235. this.rollback(error, 'repair')
  236. }
  237. }
  238. async list(signal?: AbortSignal): Promise<SessionHeader[]> {
  239. await this.observe(signal)
  240. const rows = this.sessionRows()
  241. signal?.throwIfAborted()
  242. return rows.map(rowToMeta)
  243. }
  244. /**
  245. * Return every materialized header with its source-qualified revision.
  246. * @param signal - optional cancellation before or after the metadata query.
  247. * @returns stored headers and revisions without loading event rows.
  248. */
  249. async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
  250. await this.observe(signal)
  251. const rows = this.sessionRows()
  252. signal?.throwIfAborted()
  253. return rows.map(row => ({
  254. header: rowToMeta(row),
  255. revision: sqliteRevision(this.storeIdentity, row),
  256. }))
  257. }
  258. async close(): Promise<void> {
  259. if (this.ready === undefined) {
  260. if (this.pathReady !== undefined) await Promise.allSettled([this.pathReady])
  261. return
  262. }
  263. await Promise.allSettled([this.ready])
  264. if (!this.opened) return
  265. this.opened = false
  266. this.db.close()
  267. }
  268. private rowFor(id: SessionId): SessionRow | undefined {
  269. const value = this.db.prepare(sql('select-session')).get(id)
  270. return value === undefined ? undefined : decodeSessionRow(value)
  271. }
  272. private async observe(signal: AbortSignal | undefined): Promise<void> {
  273. signal?.throwIfAborted()
  274. await this.open()
  275. signal?.throwIfAborted()
  276. }
  277. private readTransaction<T>(read: () => T): T {
  278. this.db.exec(sql('begin'))
  279. try {
  280. const value = read()
  281. this.db.exec(sql('commit'))
  282. return value
  283. } catch (error: unknown) {
  284. this.rollback(error, 'read')
  285. }
  286. }
  287. private sessionRows(): SessionRow[] {
  288. return this.db.prepare(sql('select-sessions')).all().map(decodeSessionRow)
  289. }
  290. private rollback(error: unknown, operation: string): never {
  291. try {
  292. this.db.exec(sql('rollback'))
  293. } catch (rollbackError: unknown) {
  294. /* v8 ignore next -- requires SQLite to fail both an operation and its immediate rollback. */
  295. throw new AggregateError([error, rollbackError], `${this.name} ${operation} failed and rollback also failed`)
  296. }
  297. throw error
  298. }
  299. private incrementRevision(id: SessionId): void {
  300. const updated = this.db.prepare(sql('update-session-revision'))
  301. .run(id)
  302. /* v8 ignore next -- materialized writes follow coordinator create(); other writes upsert in this transaction. */
  303. if (Number(updated.changes) !== 1) throw new Error(`session ${id} metadata row is missing`)
  304. }
  305. private tailRows(id: SessionId): EventRow[] {
  306. const tail = this.db.prepare(sql('select-tail-events')).all(id, 2).map(decodeEventRow).reverse()
  307. if (tail.length === 0) return []
  308. return this.physicalSpanFrom(id, (tail[0] as EventRow).seq).eventRows
  309. }
  310. /** Select the bounded physical span that may represent `fromSeq`. */
  311. private physicalSpanFrom(
  312. id: SessionId,
  313. fromSeq: number,
  314. ): { readonly base: number; readonly eventRows: EventRow[] } {
  315. const packedFloor = Math.max(0, fromSeq - MAX_PACKED_ROW_MEMBERS + 1)
  316. const packedPredecessors = this.db.prepare(sql('select-packed-predecessors'))
  317. .all(id, packedFloor, fromSeq)
  318. .map(decodeEventRow)
  319. let base = fromSeq
  320. for (const predecessor of packedPredecessors) {
  321. try {
  322. const last = decodeRow(predecessor).at(-1)
  323. if (last !== undefined && last.seq >= fromSeq) base = Math.min(base, predecessor.seq)
  324. } catch {
  325. // A malformed bounded predecessor may cover fromSeq; include it so the scanner fails closed.
  326. base = Math.min(base, predecessor.seq)
  327. }
  328. }
  329. const eventRows = this.db.prepare(sql('select-events-from')).all(id, base).map(decodeEventRow)
  330. return { base, eventRows }
  331. }
  332. private logicalLastEvent(id: SessionId, tailRows: readonly EventRow[]): SessionEvent | undefined {
  333. if (tailRows.length === 0) return undefined
  334. const { preserved, tornFrom } = scanRows(tailRows, (tailRows[0] as EventRow).seq)
  335. if (tornFrom !== undefined) throw new Error(`session ${id} has an invalid physical tail at seq ${tornFrom}`)
  336. return preserved.at(-1)
  337. }
  338. private insertStatement(): StatementSync {
  339. return this.db.prepare(sql('insert-event'))
  340. }
  341. private insertRecord(insert: StatementSync, id: SessionId, record: BoundRecord): void {
  342. insert.run(
  343. id,
  344. record.seq,
  345. record.type,
  346. record.time,
  347. record.data,
  348. record.sourceEventSeqs,
  349. record.surfaceOp,
  350. record.ignorable,
  351. )
  352. }
  353. private writeRow(meta: SessionHeader): void {
  354. this.db.prepare(sql('upsert-session')).run(
  355. meta.id,
  356. meta.version,
  357. meta.createdAt,
  358. meta.cwd ?? null,
  359. meta.parentSession ?? null,
  360. meta.seedLength ?? null,
  361. meta.origin ?? null,
  362. meta.delegationDepth ?? null,
  363. meta.agentPreset ?? null,
  364. randomUUID(),
  365. )
  366. }
  367. }
  368. function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision {
  369. return SessionPersistenceRevision(
  370. `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
  371. )
  372. }
  373. async function createDatabaseFile(path: string): Promise<void> {
  374. try {
  375. const handle = await open(path, 'wx', 0o600)
  376. await handle.close()
  377. } catch (error: unknown) {
  378. if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
  379. }
  380. }
  381. async function validateParentDirectory(path: string): Promise<void> {
  382. const parent = await lstat(path)
  383. if (parent.isSymbolicLink() || !parent.isDirectory()) {
  384. throw new Error(`session database parent "${path}" must be a real directory`)
  385. }
  386. const uid = process.getuid?.()
  387. /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful
  388. * uid/mode bits; POSIX tests cover owner and mode rejection. */
  389. if (uid !== undefined && (parent.uid !== uid || (parent.mode & 0o022) !== 0)) {
  390. throw new Error(`session database parent "${path}" must be owned by the current user and not group/world-writable`)
  391. }
  392. /* v8 ignore stop */
  393. }
  394. async function validateDatabaseFile(path: string): Promise<void> {
  395. const file = await lstat(path)
  396. if (file.isSymbolicLink() || !file.isFile()) {
  397. throw new Error(`session database "${path}" must be a regular file, not a symbolic link`)
  398. }
  399. const uid = process.getuid?.()
  400. /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful
  401. * uid/mode bits; POSIX tests cover owner and mode rejection. */
  402. if (uid !== undefined && (file.uid !== uid || (file.mode & 0o077) !== 0)) {
  403. throw new Error(`session database "${path}" must be owned by the current user and accessible only by that user`)
  404. }
  405. /* v8 ignore stop */
  406. }
  407. async function validateDatabaseFileIfPresent(path: string): Promise<void> {
  408. try {
  409. await validateDatabaseFile(path)
  410. } catch (error: unknown) {
  411. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  412. }
  413. }
  414. let nodeSqlite: Promise<typeof import('node:sqlite')> | undefined
  415. /** Load Node SQLite once so concurrent stores share one warning-filter lifetime. */
  416. function loadNodeSqlite(): Promise<typeof import('node:sqlite')> {
  417. nodeSqlite ??= importNodeSqlite()
  418. return nodeSqlite
  419. }
  420. /** Import Node 22's SQLite dependency without its process-wide experimental warning. */
  421. async function importNodeSqlite(): Promise<typeof import('node:sqlite')> {
  422. const emitWarning = Reflect.get(process, 'emitWarning')
  423. /* v8 ignore start -- Node 22 alone emits this warning; primary coverage runs on Node 24. */
  424. const filteredEmitWarning = (warning: string | Error, ...args: unknown[]): void => {
  425. const message = warning instanceof Error ? warning.message : warning
  426. const first = args[0]
  427. const type = warning instanceof Error
  428. ? warning.name
  429. : typeof first === 'string'
  430. ? first
  431. : typeof first === 'object' && first !== null && 'type' in first
  432. ? first.type
  433. : undefined
  434. if (message === 'SQLite is an experimental feature and might change at any time'
  435. && type === 'ExperimentalWarning') return
  436. Reflect.apply(emitWarning, process, [warning, ...args])
  437. }
  438. Reflect.set(process, 'emitWarning', filteredEmitWarning)
  439. try {
  440. return await import('node:sqlite')
  441. } finally {
  442. Reflect.set(process, 'emitWarning', emitWarning)
  443. }
  444. /* v8 ignore stop */
  445. }