schema.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /**
  2. * Schema + load-time helpers for the SQLite session-persistence backend: the
  3. * DDL (a `sessions` metadata table and a 1:1 `events` row per `SessionEvent`),
  4. * the database open/configure step, and the last-`turn/end` cut that gives the
  5. * SQLite backend the SAME crash-tail-on-load semantics as the JSONL backend.
  6. *
  7. * @module dsh-session-persistence-sqlite/schema
  8. */
  9. import { DatabaseSync } from 'node:sqlite'
  10. import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session'
  11. /**
  12. * The on-disk schema version. Bumped only on a breaking change to the table
  13. * layout; orthogonal to a session's own `version` (which versions the EVENT
  14. * vocabulary, stored per session in the `sessions` row).
  15. */
  16. export const SCHEMA_VERSION = 5
  17. /**
  18. * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
  19. * The row's EXISTENCE is the materialization signal: it is written only by the
  20. * first `append` (lazy materialization), so a created-but-never-appended
  21. * session has no row and is absent from `list`, mirroring the JSONL
  22. * backend's "no file until first append".
  23. */
  24. export interface SessionRow {
  25. id: string
  26. version: number
  27. created_at: number
  28. cwd: string | null
  29. parent_session: string | null
  30. seed_length: number | null
  31. delegation_depth: number | null
  32. }
  33. /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
  34. export interface EventRow {
  35. seq: number
  36. type: string
  37. time: number
  38. data: string
  39. /** JSON-encoded `number[]` — the event's sourceEventSeqs, or null. */
  40. source_event_seqs: string | null
  41. /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */
  42. surface_op: string | null
  43. }
  44. /**
  45. * Journal modes the backend will run under. `wal` is the default and the
  46. * durability model the persistence ADR records; the rollback-journal modes
  47. * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
  48. * shared-memory files do not work (network mounts). `memory`/`off` are
  49. * excluded: dropping journal durability silently contradicts what this
  50. * backend promises.
  51. */
  52. export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
  53. /**
  54. * Open the database and apply its schema and pragmas. A zero `user_version` is
  55. * stamped with {@link SCHEMA_VERSION}; every other non-current version rejects
  56. * rather than being migrated in place.
  57. * @param path - the SQLite database file to open (created when absent).
  58. * @param journalMode - validated journal pragma.
  59. * @returns the open handle with pragmas applied and both tables ensured.
  60. */
  61. export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
  62. const db = new DatabaseSync(path)
  63. db.exec('PRAGMA foreign_keys = ON')
  64. // The validated union is safe to interpolate into a non-bindable PRAGMA.
  65. db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
  66. // `PRAGMA user_version` always returns exactly one row { user_version }.
  67. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
  68. if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
  69. db.close()
  70. throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
  71. }
  72. if (onDisk === 0) {
  73. // Stamp fresh or pre-versioning databases.
  74. db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
  75. }
  76. db.exec(`
  77. CREATE TABLE IF NOT EXISTS sessions (
  78. id TEXT PRIMARY KEY,
  79. version INTEGER NOT NULL,
  80. created_at INTEGER NOT NULL,
  81. cwd TEXT,
  82. parent_session TEXT,
  83. seed_length INTEGER,
  84. delegation_depth INTEGER
  85. ) STRICT
  86. `)
  87. db.exec(`
  88. CREATE TABLE IF NOT EXISTS events (
  89. session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  90. seq INTEGER NOT NULL,
  91. type TEXT NOT NULL,
  92. time INTEGER NOT NULL,
  93. data TEXT NOT NULL,
  94. source_event_seqs TEXT,
  95. surface_op TEXT,
  96. PRIMARY KEY (session_id, seq)
  97. ) STRICT
  98. `)
  99. return db
  100. }
  101. /**
  102. * Reconstruct the {@link SessionHeader} from a `sessions` row.
  103. * @param row - the `sessions` table row.
  104. * @returns the header, `NULL` columns mapped to omitted optional fields.
  105. */
  106. export function rowToMeta(row: SessionRow): SessionHeader {
  107. return {
  108. version: row.version,
  109. id: row.id as SessionId,
  110. createdAt: row.created_at,
  111. ...row.cwd !== null ? { cwd: row.cwd } : {},
  112. ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
  113. ...row.seed_length !== null ? { seedLength: row.seed_length } : {},
  114. ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
  115. }
  116. }
  117. /**
  118. * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`).
  119. * @param row - the `events` table row; `data` and the surface columns hold JSON text.
  120. * @returns the reconstructed event; throws when a JSON column fails to parse
  121. * ({@link scanRows} treats that as a hole, not corruption, in the tail).
  122. */
  123. export function rowToEvent(row: EventRow): SessionEvent {
  124. // Surface-metadata fields are conditional on the event type in the type
  125. // system; spread them so each variant gets only the fields it declares.
  126. const surfaceFields = {
  127. ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {},
  128. ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {},
  129. }
  130. return {
  131. type: row.type as SessionEvent['type'],
  132. seq: row.seq,
  133. time: row.time,
  134. data: JSON.parse(row.data) as SessionEvent['data'],
  135. ...surfaceFields,
  136. } as SessionEvent
  137. }
  138. /**
  139. * Find the preserved prefix of ordered event rows. Fully written rows in an
  140. * interrupted final turn remain in the prefix. The first unparsable row or seq
  141. * gap after the last `turn/end` marks a tolerated torn tail; the same hole in
  142. * the committed region rejects.
  143. *
  144. * @param rows - one session's event rows, ordered by seq ascending.
  145. * @returns the preserved event prefix, plus `tornFrom` — the seq the physical
  146. * delete starts at — when a torn tail exists.
  147. */
  148. export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } {
  149. // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
  150. // (The seq/type COLUMNS are always present even when `data` is corrupt.)
  151. interface Parsed { ok: boolean; event?: SessionEvent }
  152. const parsed: Parsed[] = rows.map((row) => {
  153. try {
  154. return { ok: true, event: rowToEvent(row) }
  155. } catch {
  156. return { ok: false }
  157. }
  158. })
  159. // The last index that is a valid `turn/end` — holes through a closed turn
  160. // are always committed corruption.
  161. let lastTurnEnd = -1
  162. for (let i = parsed.length - 1; i >= 0; i--) {
  163. if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
  164. }
  165. // Preserve the contiguous prefix, including a complete interrupted turn;
  166. // holes through the last committed boundary throw, while later holes stop.
  167. const preserved: SessionEvent[] = []
  168. for (let i = 0; i < rows.length; i++) {
  169. const p = parsed[i]
  170. if (!p?.ok || p.event === undefined) {
  171. if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`)
  172. break // torn tail fragment after the last turn/end — stop, tolerate
  173. }
  174. if (p.event.seq !== i) {
  175. if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${i}, got ${p.event.seq})`)
  176. break // gap after the last turn/end — torn tail, stop
  177. }
  178. preserved.push(p.event)
  179. }
  180. // Any rows past the preserved prefix are a never-committed torn tail; their
  181. // first seq is the deletion point for load's physical repair.
  182. return preserved.length < rows.length ? { preserved, tornFrom: preserved.length } : { preserved }
  183. }