schema.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. /**
  2. * Schema + load-time helpers for the SQLite session-persistence backend: the
  3. * DDL (a store-identity row, `sessions` metadata, and a 1:1 `events` row per
  4. * `SessionEvent`), the database open/configure step, and the last-`turn/end`
  5. * cut that gives the SQLite backend the SAME crash-tail-on-load semantics as
  6. * the JSONL backend.
  7. *
  8. * @module dsh-session-persistence-sqlite/schema
  9. */
  10. import { randomUUID } from 'node:crypto'
  11. import { DatabaseSync } from 'node:sqlite'
  12. import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session'
  13. /**
  14. * The on-disk schema version. Bumped only on a breaking change to the table
  15. * layout; orthogonal to a session's own `version` (which versions the EVENT
  16. * vocabulary, stored per session in the `sessions` row).
  17. */
  18. export const SCHEMA_VERSION = 12
  19. /** SQLite application id protecting unrelated databases from persistence writes. */
  20. export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
  21. /**
  22. * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
  23. * The row's EXISTENCE is the materialization signal: it is written only by the
  24. * first `append` (lazy materialization), so a created-but-never-appended
  25. * session has no row and is absent from `list`, mirroring the JSONL
  26. * backend's "no file until first append".
  27. */
  28. export interface SessionRow {
  29. id: string
  30. version: number
  31. created_at: number
  32. cwd: string | null
  33. parent_session: string | null
  34. seed_length: number | null
  35. /** Stable identity assigned when this log is materialized. */
  36. incarnation: string
  37. /** Monotonic log-change token incremented in each mutating transaction. */
  38. revision: number
  39. delegation_depth: number | null
  40. }
  41. /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
  42. export interface EventRow {
  43. seq: number
  44. type: string
  45. time: number
  46. data: string
  47. /** JSON-encoded `number[]` — the event's sourceEventSeqs, or null. */
  48. source_event_seqs: string | null
  49. /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */
  50. surface_op: string | null
  51. }
  52. /**
  53. * Journal modes the backend will run under. `wal` is the default and the
  54. * durability model the persistence ADR records; the rollback-journal modes
  55. * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
  56. * shared-memory files do not work (network mounts). `memory`/`off` are
  57. * excluded: dropping journal durability silently contradicts what this
  58. * backend promises.
  59. */
  60. export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
  61. /**
  62. * Open the database and apply its schema and pragmas. An empty database with a
  63. * zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty
  64. * unversioned database and every other non-current version reject rather than
  65. * being migrated in place.
  66. * @param path - the SQLite database file to open (created when absent).
  67. * @param journalMode - validated journal pragma.
  68. * @returns the open handle with pragmas applied and all three tables ensured.
  69. */
  70. export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
  71. const db = new DatabaseSync(path)
  72. try {
  73. configureDatabase(db, path, journalMode)
  74. return db
  75. } catch (error: unknown) {
  76. db.close()
  77. throw error
  78. }
  79. }
  80. function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {
  81. db.exec('PRAGMA foreign_keys = ON')
  82. let began = false
  83. try {
  84. db.exec('BEGIN IMMEDIATE')
  85. began = true
  86. // Validate while holding the write lock so no other connection can change
  87. // schema ownership between inspection and initialization.
  88. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
  89. const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
  90. const { count: userObjectCount } = db.prepare(
  91. "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'",
  92. ).get() as { count: number }
  93. if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) {
  94. throw new Error(`session database at "${path}" has an unversioned schema or application identity`)
  95. }
  96. if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
  97. throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
  98. }
  99. if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
  100. throw new Error(
  101. `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
  102. )
  103. }
  104. db.exec(`
  105. CREATE TABLE IF NOT EXISTS persistence_state (
  106. singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
  107. store_id TEXT NOT NULL
  108. ) STRICT;
  109. CREATE TABLE IF NOT EXISTS sessions (
  110. id TEXT PRIMARY KEY,
  111. version INTEGER NOT NULL,
  112. created_at INTEGER NOT NULL,
  113. cwd TEXT,
  114. parent_session TEXT,
  115. seed_length INTEGER,
  116. delegation_depth INTEGER,
  117. incarnation TEXT NOT NULL,
  118. revision INTEGER NOT NULL
  119. ) STRICT;
  120. CREATE TABLE IF NOT EXISTS events (
  121. session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  122. seq INTEGER NOT NULL,
  123. type TEXT NOT NULL,
  124. time INTEGER NOT NULL,
  125. data TEXT NOT NULL,
  126. source_event_seqs TEXT,
  127. surface_op TEXT,
  128. PRIMARY KEY (session_id, seq)
  129. ) STRICT
  130. `)
  131. db.prepare(
  132. 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
  133. ).run(randomUUID())
  134. if (onDisk === 0) {
  135. db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
  136. db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
  137. }
  138. db.exec('COMMIT')
  139. began = false
  140. } catch (error: unknown) {
  141. /* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */
  142. if (began) {
  143. /* v8 ignore next 5 -- preserve the original schema failure if SQLite also refuses rollback. */
  144. try {
  145. db.exec('ROLLBACK')
  146. } catch {
  147. // The original SQLite failure remains the actionable cause.
  148. }
  149. }
  150. throw error
  151. }
  152. // The validated union is safe to interpolate into a non-bindable PRAGMA.
  153. // Apply it only after ownership validation and initialization commit.
  154. db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
  155. }
  156. /**
  157. * Reconstruct the {@link SessionHeader} from a `sessions` row.
  158. * @param row - the `sessions` table row.
  159. * @returns the header, `NULL` columns mapped to omitted optional fields.
  160. */
  161. export function rowToMeta(row: SessionRow): SessionHeader {
  162. if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) {
  163. throw new Error('stored session createdAt must be a non-negative safe integer')
  164. }
  165. return {
  166. version: row.version,
  167. id: row.id as SessionId,
  168. createdAt: row.created_at,
  169. ...row.cwd !== null ? { cwd: row.cwd } : {},
  170. ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
  171. ...row.seed_length !== null ? { seedLength: row.seed_length } : {},
  172. ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
  173. }
  174. }
  175. /**
  176. * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`).
  177. * @param row - the `events` table row; `data` and the surface columns hold JSON text.
  178. * @returns the reconstructed event; throws when a JSON column fails to parse
  179. * ({@link scanRows} treats that as a hole, not corruption, in the tail).
  180. */
  181. export function rowToEvent(row: EventRow): SessionEvent {
  182. // Surface-metadata fields are conditional on the event type in the type
  183. // system; spread them so each variant gets only the fields it declares.
  184. const surfaceFields = {
  185. ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {},
  186. ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {},
  187. }
  188. return {
  189. type: row.type as SessionEvent['type'],
  190. seq: row.seq,
  191. time: row.time,
  192. data: JSON.parse(row.data) as SessionEvent['data'],
  193. ...surfaceFields,
  194. } as SessionEvent
  195. }
  196. /**
  197. * Find the preserved prefix of ordered event rows. Fully written rows in an
  198. * interrupted final turn remain in the prefix. The first unparsable row or seq
  199. * gap after the last `turn/end` marks a tolerated torn tail; the same hole in
  200. * the committed region rejects.
  201. *
  202. * @param rows - one session's event rows, ordered by seq ascending.
  203. * @param base - the seq the first row is expected to carry; `0` for a whole
  204. * log, the requested `fromSeq` for a suffix read (`loadStoredFrom`).
  205. * @returns the preserved event prefix, plus `tornFrom` — the seq the physical
  206. * delete starts at — when a torn tail exists.
  207. */
  208. export function scanRows(rows: readonly EventRow[], base = 0): { preserved: SessionEvent[]; tornFrom?: number } {
  209. // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
  210. // (The seq/type COLUMNS are always present even when `data` is corrupt.)
  211. interface Parsed { ok: boolean; event?: SessionEvent }
  212. const parsed: Parsed[] = rows.map((row) => {
  213. try {
  214. return { ok: true, event: rowToEvent(row) }
  215. } catch {
  216. return { ok: false }
  217. }
  218. })
  219. // The last index that is a valid `turn/end` — holes through a closed turn
  220. // are always committed corruption.
  221. let lastTurnEnd = -1
  222. for (let i = parsed.length - 1; i >= 0; i--) {
  223. if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
  224. }
  225. // Preserve the contiguous prefix, including a complete interrupted turn;
  226. // holes through the last committed boundary throw, while later holes stop.
  227. const preserved: SessionEvent[] = []
  228. for (let i = 0; i < rows.length; i++) {
  229. const p = parsed[i]
  230. if (!p?.ok || p.event === undefined) {
  231. if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`)
  232. break // torn tail fragment after the last turn/end — stop, tolerate
  233. }
  234. if (p.event.seq !== base + i) {
  235. if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`)
  236. break // gap after the last turn/end — torn tail, stop
  237. }
  238. preserved.push(p.event)
  239. }
  240. // Any rows past the preserved prefix are a never-committed torn tail; their
  241. // first seq is the deletion point for load's physical repair.
  242. return preserved.length < rows.length ? { preserved, tornFrom: base + preserved.length } : { preserved }
  243. }