schema.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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 = 6
  19. /**
  20. * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
  21. * The row's EXISTENCE is the materialization signal: it is written only by the
  22. * first `append` (lazy materialization), so a created-but-never-appended
  23. * session has no row and is absent from `list`, mirroring the JSONL
  24. * backend's "no file until first append".
  25. */
  26. export interface SessionRow {
  27. id: string
  28. version: number
  29. created_at: number
  30. cwd: string | null
  31. parent_session: string | null
  32. seed_length: number | null
  33. /** Monotonic log-change token incremented in each mutating transaction. */
  34. revision: number
  35. }
  36. /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
  37. export interface EventRow {
  38. seq: number
  39. type: string
  40. time: number
  41. data: string
  42. /** JSON-encoded `number[]` — the event's sourceEventSeqs, or null. */
  43. source_event_seqs: string | null
  44. /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */
  45. surface_op: string | null
  46. }
  47. /**
  48. * Journal modes the backend will run under. `wal` is the default and the
  49. * durability model the persistence ADR records; the rollback-journal modes
  50. * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
  51. * shared-memory files do not work (network mounts). `memory`/`off` are
  52. * excluded: dropping journal durability silently contradicts what this
  53. * backend promises.
  54. */
  55. export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
  56. /**
  57. * Open the database at `path` and apply the schema + pragmas. `foreign_keys`
  58. * makes `ON DELETE CASCADE` drop a session's events with its row; the
  59. * `journal_mode` pragma is set from the plugin's `journalMode` config (`wal`
  60. * default — the durability model the ADR records; the row shape maps 1:1
  61. * onto `SessionEvent`; opencode runs this exact shape on SQLite/WAL).
  62. *
  63. * The table-layout version is persisted in SQLite's `PRAGMA user_version` and
  64. * checked on open: a fresh database (user_version 0) is stamped with the
  65. * current {@link SCHEMA_VERSION}; an existing database whose version is NOT the
  66. * current one (written by a different, incompatible build — older or newer) is
  67. * REJECTED rather than opened against a layout this build does not understand.
  68. * There are no migrations: an incompatible layout is rejected. The current
  69. * persistence-state row carries an immutable random store id, the sessions row
  70. * carries every header field plus its monotonic snapshot revision, and the
  71. * events row carries the complete surface metadata.
  72. * @param path - the SQLite database file to open (created when absent).
  73. * @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config.
  74. * @returns the open handle with pragmas applied and all three tables ensured.
  75. */
  76. export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
  77. const db = new DatabaseSync(path)
  78. try {
  79. configureDatabase(db, path, journalMode)
  80. return db
  81. } catch (error: unknown) {
  82. db.close()
  83. throw error
  84. }
  85. }
  86. function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {
  87. db.exec('PRAGMA foreign_keys = ON')
  88. // journalMode is a closed in-code union (validated by the plugin Config), not
  89. // user-controlled SQL — safe to interpolate (PRAGMA takes no bound params).
  90. db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
  91. // `PRAGMA user_version` always returns exactly one row { user_version }.
  92. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
  93. if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
  94. throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
  95. }
  96. if (onDisk === 0) {
  97. // Fresh (or pre-versioning) database: stamp the current layout version.
  98. // PRAGMA does not accept bound parameters, so interpolate the integer
  99. // constant (SCHEMA_VERSION is a trusted in-code number, not user input).
  100. db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
  101. }
  102. db.exec(`
  103. CREATE TABLE IF NOT EXISTS persistence_state (
  104. singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
  105. store_id TEXT NOT NULL
  106. ) STRICT
  107. `)
  108. db.prepare(
  109. 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
  110. ).run(randomUUID())
  111. db.exec(`
  112. CREATE TABLE IF NOT EXISTS sessions (
  113. id TEXT PRIMARY KEY,
  114. version INTEGER NOT NULL,
  115. created_at INTEGER NOT NULL,
  116. cwd TEXT,
  117. parent_session TEXT,
  118. seed_length INTEGER,
  119. revision INTEGER NOT NULL
  120. ) STRICT
  121. `)
  122. db.exec(`
  123. CREATE TABLE IF NOT EXISTS events (
  124. session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  125. seq INTEGER NOT NULL,
  126. type TEXT NOT NULL,
  127. time INTEGER NOT NULL,
  128. data TEXT NOT NULL,
  129. source_event_seqs TEXT,
  130. surface_op TEXT,
  131. PRIMARY KEY (session_id, seq)
  132. ) STRICT
  133. `)
  134. }
  135. /**
  136. * Reconstruct the {@link SessionHeader} from a `sessions` row.
  137. * @param row - the `sessions` table row.
  138. * @returns the header, `NULL` columns mapped to omitted optional fields.
  139. */
  140. export function rowToMeta(row: SessionRow): SessionHeader {
  141. return {
  142. version: row.version,
  143. id: row.id as SessionId,
  144. createdAt: row.created_at,
  145. ...row.cwd !== null ? { cwd: row.cwd } : {},
  146. ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
  147. ...row.seed_length !== null ? { seedLength: row.seed_length } : {},
  148. }
  149. }
  150. /**
  151. * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`).
  152. * @param row - the `events` table row; `data` and the surface columns hold JSON text.
  153. * @returns the reconstructed event; throws when a JSON column fails to parse
  154. * ({@link scanRows} treats that as a hole, not corruption, in the tail).
  155. */
  156. export function rowToEvent(row: EventRow): SessionEvent {
  157. // Surface-metadata fields are conditional on the event type in the type
  158. // system; spread them so each variant gets only the fields it declares.
  159. const surfaceFields = {
  160. ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {},
  161. ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {},
  162. }
  163. return {
  164. type: row.type as SessionEvent['type'],
  165. seq: row.seq,
  166. time: row.time,
  167. data: JSON.parse(row.data) as SessionEvent['data'],
  168. ...surfaceFields,
  169. } as SessionEvent
  170. }
  171. /**
  172. * The preserved prefix of an ordered event-row list (mirrors the JSONL
  173. * backend's `scanLog`): the longest prefix of complete, seq-contiguous,
  174. * parseable rows, PLUS the seq from which a never-committed torn tail must be
  175. * deleted (or `undefined` if the whole list is intact).
  176. *
  177. * A crash can leave a durable log whose final turn never closed: real,
  178. * fully-written rows sit after the last `turn/end`. Those are PRESERVED — a
  179. * single turn can be huge in a long-horizon task, so truncating it would
  180. * destroy real work; the backend closes the orphaned open turn with a synthetic
  181. * `turn/end {kind:'interrupted'}` on load (the session-persistence RFC). The ONLY thing excluded is
  182. * a torn trailing fragment — a row whose `data` never parses, or a seq gap —
  183. * AFTER the last committed `turn/end`; that bounds the preserved region and its
  184. * seq is returned as `tornFrom` so `load` can physically delete it.
  185. *
  186. * The last `turn/end` is computed from the `type` COLUMN (never parsing tail
  187. * `data`), so a malformed `data` in an uncommitted tail row is discarded rather
  188. * than making the session unloadable. A parse error or seq gap AT OR BEFORE the
  189. * last committed `turn/end` is committed-data corruption and throws.
  190. *
  191. * This relies on the session-log invariant that every event lives inside a turn
  192. * (`Session.append` enforces it): only the final turn can be open, so the
  193. * preserved tail is at most one unclosed turn.
  194. * @param rows - one session's event rows, ordered by seq ascending.
  195. * @returns the preserved event prefix, plus `tornFrom` — the seq the physical
  196. * delete starts at — when a torn tail exists.
  197. */
  198. export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } {
  199. // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
  200. // (The seq/type COLUMNS are always present even when `data` is corrupt.)
  201. interface Parsed { ok: boolean; event?: SessionEvent }
  202. const parsed: Parsed[] = rows.map((row) => {
  203. try {
  204. return { ok: true, event: rowToEvent(row) }
  205. } catch {
  206. return { ok: false }
  207. }
  208. })
  209. // The last index that is a valid `turn/end` — the last fully-committed
  210. // boundary (the loop flushes only at turn/end).
  211. let lastTurnEnd = -1
  212. for (let i = parsed.length - 1; i >= 0; i--) {
  213. if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
  214. }
  215. // Walk the longest PREFIX of complete, seq-contiguous, parseable rows
  216. // (row i has seq === i). This includes the fully-written rows of an
  217. // interrupted final turn AFTER the last turn/end — real work, never
  218. // truncated. The walk stops at the first hole:
  219. // - at or before the last committed turn/end → committed corruption (throw);
  220. // - after it (or no committed turn/end) → tolerated torn tail (stop).
  221. const preserved: SessionEvent[] = []
  222. for (let i = 0; i < rows.length; i++) {
  223. const p = parsed[i]
  224. if (!p?.ok || p.event === undefined) {
  225. if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`)
  226. break // torn tail fragment after the last turn/end — stop, tolerate
  227. }
  228. if (p.event.seq !== i) {
  229. if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${i}, got ${p.event.seq})`)
  230. break // gap after the last turn/end — torn tail, stop
  231. }
  232. preserved.push(p.event)
  233. }
  234. // Any rows past the preserved prefix are a never-committed torn tail; their
  235. // first seq is the deletion point for load's physical repair.
  236. return preserved.length < rows.length ? { preserved, tornFrom: preserved.length } : { preserved }
  237. }