schema.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. /**
  2. * SQLite schema ownership and durable-row validation.
  3. * @module @deepseek-ai/dsh-session-persistence-sqlite/schema
  4. */
  5. import { randomUUID } from 'node:crypto'
  6. import { isAbsolute } from 'node:path'
  7. import { performance } from 'node:perf_hooks'
  8. import type { DatabaseSync } from 'node:sqlite'
  9. import { setTimeout as delay } from 'node:timers/promises'
  10. import {
  11. SessionId,
  12. type SessionHeader,
  13. } from '@deepseek-ai/dsh-session'
  14. import { sql } from './sql.ts'
  15. /** Current physical-record schema with packed and compressed event rows. */
  16. export const SCHEMA_VERSION = 19
  17. /** Application id reserved for DeepSeek Harness SQLite session databases. */
  18. export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
  19. /** A materialized session's metadata and monotonic revision. */
  20. export interface SessionRow {
  21. readonly id: string
  22. readonly version: number
  23. readonly created_at: number
  24. readonly cwd: string | null
  25. readonly parent_session: string | null
  26. readonly seed_length: number | null
  27. readonly origin: 'subagent' | null
  28. readonly incarnation: string
  29. readonly revision: number
  30. readonly delegation_depth: number | null
  31. readonly agent_preset: string | null
  32. }
  33. /** One physical event row; packed rows may represent multiple logical events. */
  34. export interface EventRow {
  35. readonly seq: number
  36. readonly type: string
  37. readonly time: number
  38. readonly data: string | Uint8Array
  39. readonly source_event_seqs: Uint8Array | null
  40. readonly surface_op: string | null
  41. readonly is_packed: 0 | 1
  42. }
  43. /** Durable journal modes accepted by the backend. */
  44. export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
  45. interface SchemaObjectRow {
  46. readonly type: string
  47. readonly name: string
  48. readonly tbl_name: string
  49. readonly sql: string
  50. }
  51. const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
  52. const JOURNAL_BUSY_RETRY_INTERVAL_MS = 10
  53. type DatabaseSyncConstructor = typeof import('node:sqlite')['DatabaseSync']
  54. /**
  55. * Open and validate a SQLite session database.
  56. * @param Database - lazily imported Node SQLite constructor.
  57. * @param path - SQLite path, including `:memory:`.
  58. * @param journalMode - validated journal pragma.
  59. * @param busyTimeoutMs - validated maximum wait for a competing SQLite lock.
  60. * @returns the configured database handle.
  61. * @throws when connection settings, schema ownership, or SQLite setup cannot be validated.
  62. */
  63. export async function openDatabase(
  64. Database: DatabaseSyncConstructor,
  65. path: string,
  66. journalMode: JournalMode,
  67. busyTimeoutMs: number,
  68. ): Promise<DatabaseSync> {
  69. const deadline = performance.now() + busyTimeoutMs
  70. const db = new Database(path, { timeout: busyTimeoutMs })
  71. try {
  72. configureConnectionSecurity(db, path)
  73. configureDatabase(Database, db, path)
  74. await selectJournalMode(db, path, journalMode, deadline)
  75. configureDurability(db, path)
  76. return db
  77. } catch (error: unknown) {
  78. db.close()
  79. throw error
  80. }
  81. }
  82. function configureConnectionSecurity(db: DatabaseSync, path: string): void {
  83. db.exec(sql('trusted-schema-off'))
  84. const trustedSchema = integerField(db.prepare(sql('select-trusted-schema')).get(), 'trusted_schema')
  85. /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */
  86. if (trustedSchema !== 0) {
  87. throw new Error(`session database at "${path}" retained trusted_schema=${trustedSchema}, expected 0`)
  88. }
  89. db.exec(sql('mmap-off'))
  90. if (path === ':memory:') return
  91. const mmapSize = integerField(db.prepare(sql('select-mmap-size')).get(), 'mmap_size')
  92. /* v8 ignore next 3 -- supported file-backed SQLite connections return the fixed setting. */
  93. if (mmapSize !== 0) {
  94. throw new Error(`session database at "${path}" retained mmap_size=${mmapSize}, expected 0`)
  95. }
  96. }
  97. function configureDatabase(
  98. Database: DatabaseSyncConstructor,
  99. db: DatabaseSync,
  100. path: string,
  101. ): void {
  102. db.exec(sql('page-size'))
  103. db.exec(sql('foreign-keys-on'))
  104. let began = false
  105. try {
  106. db.exec(sql('begin-immediate'))
  107. began = true
  108. const onDisk = integerField(db.prepare(sql('select-user-version')).get(), 'user_version')
  109. const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id')
  110. const userObjectCount = integerField(db.prepare(sql('select-user-object-count')).get(), 'count')
  111. if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) {
  112. throw new Error(`session database at "${path}" has an unversioned schema or application identity`)
  113. }
  114. if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
  115. throw new Error(
  116. `session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`,
  117. )
  118. }
  119. if (onDisk !== 0 && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
  120. throw new Error(
  121. `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
  122. )
  123. }
  124. if (onDisk === 0) initializeDatabase(db)
  125. validateRequiredSchema(Database, db, path)
  126. db.exec(sql('commit'))
  127. began = false
  128. } catch (error: unknown) {
  129. /* v8 ignore else -- a failed begin leaves no transaction to roll back. */
  130. if (began) {
  131. /* v8 ignore next 5 -- retain the original ownership failure if rollback fails too. */
  132. try {
  133. db.exec(sql('rollback'))
  134. } catch {
  135. // The original database-ownership failure remains actionable.
  136. }
  137. }
  138. throw error
  139. }
  140. }
  141. async function selectJournalMode(
  142. db: DatabaseSync,
  143. path: string,
  144. journalMode: JournalMode,
  145. deadline: number,
  146. ): Promise<void> {
  147. let result: unknown
  148. while (true) {
  149. try {
  150. result = db.prepare(sql(journalResource(journalMode))).get()
  151. break
  152. } catch (error: unknown) {
  153. const remainingMs = Math.max(0, Math.ceil(deadline - performance.now()))
  154. if (!isSqliteBusy(error) || remainingMs === 0) throw error
  155. await delay(Math.min(JOURNAL_BUSY_RETRY_INTERVAL_MS, remainingMs))
  156. if (performance.now() >= deadline) throw error
  157. }
  158. }
  159. const selected = stringField(result, 'journal_mode').toLowerCase()
  160. const expected = path === ':memory:' ? 'memory' : journalMode
  161. /* v8 ignore next 3 -- SQLite returns the selected mode from these fixed, valid pragmas. */
  162. if (selected !== expected) {
  163. throw new Error(`session database at "${path}" selected journal mode ${selected}, expected ${expected}`)
  164. }
  165. }
  166. function configureDurability(db: DatabaseSync, path: string): void {
  167. db.exec(sql('synchronous-full'))
  168. const synchronous = integerField(db.prepare(sql('select-synchronous')).get(), 'synchronous')
  169. /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */
  170. if (synchronous !== 2) {
  171. throw new Error(`session database at "${path}" retained synchronous=${synchronous}, expected FULL (2)`)
  172. }
  173. }
  174. function isSqliteBusy(error: unknown): boolean {
  175. return typeof error === 'object'
  176. && error !== null
  177. && Reflect.get(error, 'errcode') === 5
  178. }
  179. function journalResource(mode: JournalMode):
  180. | 'journal-mode-wal'
  181. | 'journal-mode-delete'
  182. | 'journal-mode-truncate'
  183. | 'journal-mode-persist' {
  184. switch (mode) {
  185. case 'wal': return 'journal-mode-wal'
  186. case 'delete': return 'journal-mode-delete'
  187. case 'truncate': return 'journal-mode-truncate'
  188. case 'persist': return 'journal-mode-persist'
  189. }
  190. }
  191. function initializeDatabase(db: DatabaseSync): void {
  192. db.exec(sql('schema'))
  193. db.prepare(sql('insert-persistence-state')).run(randomUUID())
  194. db.exec(sql('set-application-id'))
  195. db.exec(sql('set-user-version-19'))
  196. }
  197. let canonicalSchema: readonly SchemaObjectRow[] | undefined
  198. function expectedSchema(Database: DatabaseSyncConstructor): readonly SchemaObjectRow[] {
  199. if (canonicalSchema !== undefined) return canonicalSchema
  200. const reference = new Database(':memory:')
  201. try {
  202. reference.exec(sql('foreign-keys-on'))
  203. reference.exec(sql('schema'))
  204. canonicalSchema = schemaObjects(reference)
  205. return canonicalSchema
  206. } finally {
  207. reference.close()
  208. }
  209. }
  210. function schemaObjects(db: DatabaseSync): SchemaObjectRow[] {
  211. return db.prepare(sql('select-schema-objects')).all().map((value) => {
  212. const row = record(value, 'schema object')
  213. return {
  214. type: stringField(row, 'type'),
  215. name: stringField(row, 'name'),
  216. tbl_name: stringField(row, 'tbl_name'),
  217. sql: normalizeSql(stringField(row, 'sql')),
  218. }
  219. })
  220. }
  221. function normalizeSql(value: string): string {
  222. return value.replaceAll(/\s+/gu, ' ').trim()
  223. }
  224. function validateRequiredSchema(
  225. Database: DatabaseSyncConstructor,
  226. db: DatabaseSync,
  227. path: string,
  228. ): void {
  229. if (JSON.stringify(schemaObjects(db)) !== JSON.stringify(expectedSchema(Database))) {
  230. throw new Error(`session database at "${path}" does not contain the required schema objects`)
  231. }
  232. }
  233. /**
  234. * Recheck schema ownership inside the caller's mutation transaction.
  235. * @param Database - constructor used to validate the canonical schema.
  236. * @param db - open owned database with an active immediate transaction.
  237. * @param path - database location used in ownership diagnostics.
  238. * @throws when another writer changed the application identity, schema, or version.
  239. */
  240. export function validateSchemaForMutation(
  241. Database: DatabaseSyncConstructor,
  242. db: DatabaseSync,
  243. path: string,
  244. ): void {
  245. const version = integerField(db.prepare(sql('select-user-version')).get(), 'user_version')
  246. const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id')
  247. if (applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
  248. throw new Error(
  249. `session database application id changed before mutation (expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}, got ${applicationId})`,
  250. )
  251. }
  252. validateRequiredSchema(Database, db, path)
  253. if (version !== SCHEMA_VERSION) {
  254. throw new Error(`session database schema changed before mutation (expected ${SCHEMA_VERSION}, got ${version})`)
  255. }
  256. }
  257. /**
  258. * Decode and validate one durable session row.
  259. * @param value - value returned by SQLite.
  260. * @returns a validated session row.
  261. */
  262. export function decodeSessionRow(value: unknown): SessionRow {
  263. const row = record(value, 'stored session metadata')
  264. const id = nonemptyStringField(row, 'id')
  265. const version = safeIntegerField(row, 'version')
  266. const cwd = nullableStringField(row, 'cwd')
  267. if (cwd !== null && !isAbsolute(cwd)) throw new Error('stored session cwd must be absolute')
  268. const parent = nullableStringField(row, 'parent_session')
  269. const origin = nullableStringField(row, 'origin')
  270. if (origin !== null && origin !== 'subagent') throw new Error('stored session origin must be subagent or null')
  271. const incarnation = nonemptyStringField(row, 'incarnation')
  272. if (!UUID.test(incarnation)) throw new Error('stored session incarnation must be a UUID')
  273. return {
  274. id,
  275. version,
  276. created_at: nonnegativeSafeIntegerField(row, 'created_at'),
  277. cwd,
  278. parent_session: parent,
  279. seed_length: nullableNonnegativeSafeIntegerField(row, 'seed_length'),
  280. origin,
  281. delegation_depth: nullableNonnegativeSafeIntegerField(row, 'delegation_depth'),
  282. agent_preset: nullableStringField(row, 'agent_preset'),
  283. incarnation,
  284. revision: nonnegativeSafeIntegerField(row, 'revision'),
  285. }
  286. }
  287. /**
  288. * Decode and validate one durable event row before JSON interpretation.
  289. * @param value - value returned by SQLite.
  290. * @returns a validated physical event row.
  291. */
  292. export function decodeEventRow(value: unknown): EventRow {
  293. const row = record(value, 'stored event')
  294. const isPacked = safeIntegerField(row, 'is_packed')
  295. if (isPacked !== 0 && isPacked !== 1) {
  296. throw new Error('stored event is_packed must be 0 or 1')
  297. }
  298. return {
  299. seq: nonnegativeSafeIntegerField(row, 'seq'),
  300. type: nonemptyStringField(row, 'type'),
  301. time: safeIntegerField(row, 'time'),
  302. data: stringOrBlobField(row, 'data'),
  303. source_event_seqs: nullableBlobField(row, 'source_event_seqs'),
  304. surface_op: nullableStringField(row, 'surface_op'),
  305. is_packed: isPacked,
  306. }
  307. }
  308. /**
  309. * Validate the singleton identity read from durable storage.
  310. * @param value - value returned by SQLite.
  311. * @returns the UUID store identity.
  312. */
  313. export function decodeStoreIdentity(value: unknown): string {
  314. const identity = nonemptyStringField(value, 'store_id')
  315. if (!UUID.test(identity)) throw new Error('stored store_id must be a UUID')
  316. return identity
  317. }
  318. /**
  319. * Reconstruct an immutable session header from a validated metadata row.
  320. * @param row - validated stored metadata row.
  321. * @returns the session header.
  322. */
  323. export function rowToMeta(row: SessionRow): SessionHeader {
  324. return {
  325. version: row.version,
  326. id: SessionId(row.id),
  327. createdAt: row.created_at,
  328. ...row.cwd === null ? {} : { cwd: row.cwd },
  329. ...row.parent_session === null ? {} : { parentSession: SessionId(row.parent_session) },
  330. ...row.seed_length === null ? {} : { seedLength: row.seed_length },
  331. ...row.origin === null ? {} : { origin: row.origin },
  332. ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth },
  333. ...row.agent_preset === null ? {} : { agentPreset: row.agent_preset },
  334. }
  335. }
  336. function record(value: unknown, label: string): Record<string, unknown> {
  337. if (typeof value !== 'object' || value === null) throw new Error(`${label} must be an object`)
  338. return value as Record<string, unknown>
  339. }
  340. function stringField(value: unknown, key: string): string {
  341. const field = record(value, 'SQLite row')[key]
  342. if (typeof field !== 'string') throw new Error(`stored ${key} must be a string`)
  343. return field
  344. }
  345. function nonemptyStringField(value: unknown, key: string): string {
  346. const field = stringField(value, key)
  347. if (field.length === 0) throw new Error(`stored ${key} must not be empty`)
  348. return field
  349. }
  350. function nullableStringField(value: unknown, key: string): string | null {
  351. const field = record(value, 'SQLite row')[key]
  352. if (field === null) return null
  353. if (typeof field !== 'string') throw new Error(`stored ${key} must be a string or null`)
  354. return field
  355. }
  356. function stringOrBlobField(value: unknown, key: string): string | Uint8Array {
  357. const field = record(value, 'SQLite row')[key]
  358. if (typeof field === 'string' || field instanceof Uint8Array) return field
  359. throw new Error(`stored ${key} must be a string or blob`)
  360. }
  361. function nullableBlobField(value: unknown, key: string): Uint8Array | null {
  362. const field = record(value, 'SQLite row')[key]
  363. if (field === null || field instanceof Uint8Array) return field
  364. throw new Error(`stored ${key} must be a blob or null`)
  365. }
  366. function integerField(value: unknown, key: string): number {
  367. const field = record(value, 'SQLite row')[key]
  368. if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer`)
  369. return field as number
  370. }
  371. function safeIntegerField(value: unknown, key: string): number {
  372. return integerField(value, key)
  373. }
  374. function nonnegativeSafeIntegerField(value: unknown, key: string): number {
  375. const field = integerField(value, key)
  376. if (field < 0) throw new Error(`stored ${key} must be non-negative`)
  377. return field
  378. }
  379. function nullableSafeIntegerField(value: unknown, key: string): number | null {
  380. const field = record(value, 'SQLite row')[key]
  381. if (field === null) return null
  382. if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer or null`)
  383. return field as number
  384. }
  385. function nullableNonnegativeSafeIntegerField(value: unknown, key: string): number | null {
  386. const field = nullableSafeIntegerField(value, key)
  387. if (field !== null && field < 0) throw new Error(`stored ${key} must be non-negative or null`)
  388. return field
  389. }