store.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  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 commitRepair(
  188. meta: SessionHeader,
  189. tornMarker: number | undefined,
  190. closers: readonly SessionEvent[],
  191. ): Promise<void> {
  192. await this.open()
  193. if (tornMarker === undefined && closers.length === 0) return
  194. this.db.exec(sql('begin-immediate'))
  195. try {
  196. validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath)
  197. const row = this.rowFor(meta.id)
  198. if (row === undefined) throw new Error(`session ${meta.id} metadata row is missing`)
  199. const currentRows = this.db.prepare(sql('select-events')).all(meta.id).map(decodeEventRow)
  200. const current = scanRows(currentRows)
  201. if (tornMarker !== undefined) {
  202. if (current.tornFrom !== tornMarker) {
  203. throw new Error(`session ${meta.id} repair is stale: physical tail no longer starts at seq ${tornMarker}`)
  204. }
  205. this.db.prepare(sql('delete-events-from'))
  206. .run(meta.id, tornMarker)
  207. } else if (current.tornFrom !== undefined) {
  208. throw new Error(`session ${meta.id} repair omitted current torn tail at seq ${current.tornFrom}`)
  209. }
  210. if (closers.length > 0) {
  211. const expected = current.preserved.at(-1)?.seq === undefined
  212. ? 0
  213. : (current.preserved.at(-1) as SessionEvent).seq + 1
  214. if (closers[0]?.seq !== expected) {
  215. throw new Error(`session ${meta.id} repair is stale: closer starts at seq ${closers[0]?.seq}, stored next seq is ${expected}`)
  216. }
  217. const insert = this.insertStatement()
  218. for (const closer of closers) this.insertRecord(insert, meta.id, bindRecord(closer))
  219. }
  220. this.incrementRevision(meta.id)
  221. this.db.exec(sql('commit'))
  222. } catch (error: unknown) {
  223. this.rollback(error, 'repair')
  224. }
  225. }
  226. async list(signal?: AbortSignal): Promise<SessionHeader[]> {
  227. await this.observe(signal)
  228. const rows = this.sessionRows()
  229. signal?.throwIfAborted()
  230. return rows.map(rowToMeta)
  231. }
  232. /**
  233. * Return every materialized header with its source-qualified revision.
  234. * @param signal - optional cancellation before or after the metadata query.
  235. * @returns stored headers and revisions without loading event rows.
  236. */
  237. async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
  238. await this.observe(signal)
  239. const rows = this.sessionRows()
  240. signal?.throwIfAborted()
  241. return rows.map(row => ({
  242. header: rowToMeta(row),
  243. revision: sqliteRevision(this.storeIdentity, row),
  244. }))
  245. }
  246. async close(): Promise<void> {
  247. if (this.ready === undefined) {
  248. if (this.pathReady !== undefined) await Promise.allSettled([this.pathReady])
  249. return
  250. }
  251. await Promise.allSettled([this.ready])
  252. if (!this.opened) return
  253. this.opened = false
  254. this.db.close()
  255. }
  256. private rowFor(id: SessionId): SessionRow | undefined {
  257. const value = this.db.prepare(sql('select-session')).get(id)
  258. return value === undefined ? undefined : decodeSessionRow(value)
  259. }
  260. private async observe(signal: AbortSignal | undefined): Promise<void> {
  261. signal?.throwIfAborted()
  262. await this.open()
  263. signal?.throwIfAborted()
  264. }
  265. private readTransaction<T>(read: () => T): T {
  266. this.db.exec(sql('begin'))
  267. try {
  268. const value = read()
  269. this.db.exec(sql('commit'))
  270. return value
  271. } catch (error: unknown) {
  272. this.rollback(error, 'read')
  273. }
  274. }
  275. private sessionRows(): SessionRow[] {
  276. return this.db.prepare(sql('select-sessions')).all().map(decodeSessionRow)
  277. }
  278. private rollback(error: unknown, operation: string): never {
  279. try {
  280. this.db.exec(sql('rollback'))
  281. } catch (rollbackError: unknown) {
  282. /* v8 ignore next -- requires SQLite to fail both an operation and its immediate rollback. */
  283. throw new AggregateError([error, rollbackError], `${this.name} ${operation} failed and rollback also failed`)
  284. }
  285. throw error
  286. }
  287. private incrementRevision(id: SessionId): void {
  288. const updated = this.db.prepare(sql('update-session-revision'))
  289. .run(id)
  290. /* v8 ignore next -- materialized writes follow coordinator create(); other writes upsert in this transaction. */
  291. if (Number(updated.changes) !== 1) throw new Error(`session ${id} metadata row is missing`)
  292. }
  293. private tailRows(id: SessionId): EventRow[] {
  294. const tail = this.db.prepare(sql('select-tail-events')).all(id, 2).map(decodeEventRow).reverse()
  295. if (tail.length === 0) return []
  296. return this.physicalSpanFrom(id, (tail[0] as EventRow).seq).eventRows
  297. }
  298. /** Select the bounded physical span that may represent `fromSeq`. */
  299. private physicalSpanFrom(
  300. id: SessionId,
  301. fromSeq: number,
  302. ): { readonly base: number; readonly eventRows: EventRow[] } {
  303. const packedFloor = Math.max(0, fromSeq - MAX_PACKED_ROW_MEMBERS + 1)
  304. const packedPredecessors = this.db.prepare(sql('select-packed-predecessors'))
  305. .all(id, packedFloor, fromSeq)
  306. .map(decodeEventRow)
  307. let base = fromSeq
  308. for (const predecessor of packedPredecessors) {
  309. try {
  310. const last = decodeRow(predecessor).at(-1)
  311. if (last !== undefined && last.seq >= fromSeq) base = Math.min(base, predecessor.seq)
  312. } catch {
  313. // A malformed bounded predecessor may cover fromSeq; include it so the scanner fails closed.
  314. base = Math.min(base, predecessor.seq)
  315. }
  316. }
  317. const eventRows = this.db.prepare(sql('select-events-from')).all(id, base).map(decodeEventRow)
  318. return { base, eventRows }
  319. }
  320. private logicalLastEvent(id: SessionId, tailRows: readonly EventRow[]): SessionEvent | undefined {
  321. if (tailRows.length === 0) return undefined
  322. const { preserved, tornFrom } = scanRows(tailRows, (tailRows[0] as EventRow).seq)
  323. if (tornFrom !== undefined) throw new Error(`session ${id} has an invalid physical tail at seq ${tornFrom}`)
  324. return preserved.at(-1)
  325. }
  326. private insertStatement(): StatementSync {
  327. return this.db.prepare(sql('insert-event'))
  328. }
  329. private insertRecord(insert: StatementSync, id: SessionId, record: BoundRecord): void {
  330. insert.run(
  331. id,
  332. record.seq,
  333. record.type,
  334. record.time,
  335. record.data,
  336. record.sourceEventSeqs,
  337. record.surfaceOp,
  338. record.ignorable,
  339. )
  340. }
  341. private writeRow(meta: SessionHeader): void {
  342. this.db.prepare(sql('upsert-session')).run(
  343. meta.id,
  344. meta.version,
  345. meta.createdAt,
  346. meta.cwd ?? null,
  347. meta.parentSession ?? null,
  348. meta.seedLength ?? null,
  349. meta.origin ?? null,
  350. meta.delegationDepth ?? null,
  351. meta.agentPreset ?? null,
  352. randomUUID(),
  353. )
  354. }
  355. }
  356. function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision {
  357. return SessionPersistenceRevision(
  358. `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
  359. )
  360. }
  361. async function createDatabaseFile(path: string): Promise<void> {
  362. try {
  363. const handle = await open(path, 'wx', 0o600)
  364. await handle.close()
  365. } catch (error: unknown) {
  366. if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
  367. }
  368. }
  369. async function validateParentDirectory(path: string): Promise<void> {
  370. const parent = await lstat(path)
  371. if (parent.isSymbolicLink() || !parent.isDirectory()) {
  372. throw new Error(`session database parent "${path}" must be a real directory`)
  373. }
  374. const uid = process.getuid?.()
  375. /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful
  376. * uid/mode bits; POSIX tests cover owner and mode rejection. */
  377. if (uid !== undefined && (parent.uid !== uid || (parent.mode & 0o022) !== 0)) {
  378. throw new Error(`session database parent "${path}" must be owned by the current user and not group/world-writable`)
  379. }
  380. /* v8 ignore stop */
  381. }
  382. async function validateDatabaseFile(path: string): Promise<void> {
  383. const file = await lstat(path)
  384. if (file.isSymbolicLink() || !file.isFile()) {
  385. throw new Error(`session database "${path}" must be a regular file, not a symbolic link`)
  386. }
  387. const uid = process.getuid?.()
  388. /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful
  389. * uid/mode bits; POSIX tests cover owner and mode rejection. */
  390. if (uid !== undefined && (file.uid !== uid || (file.mode & 0o077) !== 0)) {
  391. throw new Error(`session database "${path}" must be owned by the current user and accessible only by that user`)
  392. }
  393. /* v8 ignore stop */
  394. }
  395. async function validateDatabaseFileIfPresent(path: string): Promise<void> {
  396. try {
  397. await validateDatabaseFile(path)
  398. } catch (error: unknown) {
  399. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  400. }
  401. }
  402. let nodeSqlite: Promise<typeof import('node:sqlite')> | undefined
  403. /** Load Node SQLite once so concurrent stores share one warning-filter lifetime. */
  404. function loadNodeSqlite(): Promise<typeof import('node:sqlite')> {
  405. nodeSqlite ??= importNodeSqlite()
  406. return nodeSqlite
  407. }
  408. /** Import Node 22's SQLite dependency without its process-wide experimental warning. */
  409. async function importNodeSqlite(): Promise<typeof import('node:sqlite')> {
  410. const emitWarning = Reflect.get(process, 'emitWarning')
  411. /* v8 ignore start -- Node 22 alone emits this warning; primary coverage runs on Node 24. */
  412. const filteredEmitWarning = (warning: string | Error, ...args: unknown[]): void => {
  413. const message = warning instanceof Error ? warning.message : warning
  414. const first = args[0]
  415. const type = warning instanceof Error
  416. ? warning.name
  417. : typeof first === 'string'
  418. ? first
  419. : typeof first === 'object' && first !== null && 'type' in first
  420. ? first.type
  421. : undefined
  422. if (message === 'SQLite is an experimental feature and might change at any time'
  423. && type === 'ExperimentalWarning') return
  424. Reflect.apply(emitWarning, process, [warning, ...args])
  425. }
  426. Reflect.set(process, 'emitWarning', filteredEmitWarning)
  427. try {
  428. return await import('node:sqlite')
  429. } finally {
  430. Reflect.set(process, 'emitWarning', emitWarning)
  431. }
  432. /* v8 ignore stop */
  433. }