schema.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  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, SessionMeta } 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 = 1
  17. /**
  18. * A row of the `sessions` table — the out-of-log metadata (`SessionMeta`). The
  19. * 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 `has`/`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. updated_at: number
  31. title: string | null
  32. first_prompt: string | null
  33. }
  34. /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
  35. export interface EventRow {
  36. seq: number
  37. type: string
  38. time: number
  39. data: string
  40. }
  41. /**
  42. * Open the database at `path` and apply the schema + pragmas. `foreign_keys`
  43. * makes `ON DELETE CASCADE` drop a session's events with its row; `journal_mode
  44. * = WAL` matches the durability model the ADR records (the row shape maps 1:1
  45. * onto `SessionEvent`; opencode runs this exact shape on SQLite/WAL).
  46. *
  47. * The table-layout version is persisted in SQLite's `PRAGMA user_version` and
  48. * checked on open: a fresh database (user_version 0) is stamped with the
  49. * current {@link SCHEMA_VERSION}; an existing database with a NEWER version
  50. * (written by a future, incompatible build) is rejected rather than opened
  51. * against a layout this build does not understand. (An older-but-compatible
  52. * version would be migrated here when migrations exist; v1 has none.)
  53. */
  54. export function openDatabase(path: string): DatabaseSync {
  55. const db = new DatabaseSync(path)
  56. db.exec('PRAGMA foreign_keys = ON')
  57. db.exec('PRAGMA journal_mode = WAL')
  58. // `PRAGMA user_version` always returns exactly one row { user_version }.
  59. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
  60. if (onDisk > SCHEMA_VERSION) {
  61. db.close()
  62. throw new Error(`session database at "${path}" has schema version ${onDisk}, newer than this build supports (${SCHEMA_VERSION})`)
  63. }
  64. if (onDisk === 0) {
  65. // Fresh (or pre-versioning) database: stamp the current layout version.
  66. // PRAGMA does not accept bound parameters, so interpolate the integer
  67. // constant (SCHEMA_VERSION is a trusted in-code number, not user input).
  68. db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
  69. }
  70. db.exec(`
  71. CREATE TABLE IF NOT EXISTS sessions (
  72. id TEXT PRIMARY KEY,
  73. version INTEGER NOT NULL,
  74. created_at INTEGER NOT NULL,
  75. cwd TEXT,
  76. parent_session TEXT,
  77. updated_at INTEGER NOT NULL,
  78. title TEXT,
  79. first_prompt TEXT
  80. ) STRICT
  81. `)
  82. db.exec(`
  83. CREATE TABLE IF NOT EXISTS events (
  84. session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  85. seq INTEGER NOT NULL,
  86. type TEXT NOT NULL,
  87. time INTEGER NOT NULL,
  88. data TEXT NOT NULL,
  89. PRIMARY KEY (session_id, seq)
  90. ) STRICT
  91. `)
  92. return db
  93. }
  94. /** Reconstruct the full {@link SessionMeta} from a `sessions` row. */
  95. export function rowToMeta(row: SessionRow): SessionMeta {
  96. return {
  97. version: row.version,
  98. id: row.id as SessionId,
  99. createdAt: row.created_at,
  100. updatedAt: row.updated_at,
  101. ...row.cwd !== null ? { cwd: row.cwd } : {},
  102. ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
  103. ...row.title !== null ? { title: row.title } : {},
  104. ...row.first_prompt !== null ? { firstPrompt: row.first_prompt } : {},
  105. }
  106. }
  107. /** Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). */
  108. export function rowToEvent(row: EventRow): SessionEvent {
  109. return {
  110. type: row.type,
  111. seq: row.seq,
  112. time: row.time,
  113. data: JSON.parse(row.data) as SessionEvent['data'],
  114. } as SessionEvent
  115. }
  116. /**
  117. * The preserved prefix of an ordered event-row list (mirrors the JSONL
  118. * backend's `scanLog`): the longest prefix of complete, seq-contiguous,
  119. * parseable rows, PLUS the seq from which a never-committed torn tail must be
  120. * deleted (or `undefined` if the whole list is intact).
  121. *
  122. * A crash can leave a durable log whose final turn never closed: real,
  123. * fully-written rows sit after the last `turn/end`. Those are PRESERVED — a
  124. * single turn can be huge in a long-horizon task, so truncating it would
  125. * destroy real work; the backend closes the orphaned open turn with a synthetic
  126. * `turn/end {kind:'interrupted'}` on load (the session-persistence RFC). The ONLY thing excluded is
  127. * a torn trailing fragment — a row whose `data` never parses, or a seq gap —
  128. * AFTER the last committed `turn/end`; that bounds the preserved region and its
  129. * seq is returned as `tornFrom` so `load` can physically delete it.
  130. *
  131. * The last `turn/end` is computed from the `type` COLUMN (never parsing tail
  132. * `data`), so a malformed `data` in an uncommitted tail row is discarded rather
  133. * than making the session unloadable. A parse error or seq gap AT OR BEFORE the
  134. * last committed `turn/end` is committed-data corruption and throws.
  135. *
  136. * This relies on the session-log invariant that every event lives inside a turn
  137. * (`Session.append` enforces it): only the final turn can be open, so the
  138. * preserved tail is at most one unclosed turn.
  139. */
  140. export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } {
  141. // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
  142. // (The seq/type COLUMNS are always present even when `data` is corrupt.)
  143. interface Parsed { ok: boolean; event?: SessionEvent }
  144. const parsed: Parsed[] = rows.map((row) => {
  145. try {
  146. return { ok: true, event: rowToEvent(row) }
  147. } catch {
  148. return { ok: false }
  149. }
  150. })
  151. // The last index that is a valid `turn/end` — the last fully-committed
  152. // boundary (the loop flushes only at turn/end).
  153. let lastTurnEnd = -1
  154. for (let i = parsed.length - 1; i >= 0; i--) {
  155. if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
  156. }
  157. // Walk the longest PREFIX of complete, seq-contiguous, parseable rows
  158. // (row i has seq === i). This includes the fully-written rows of an
  159. // interrupted final turn AFTER the last turn/end — real work, never
  160. // truncated. The walk stops at the first hole:
  161. // - at or before the last committed turn/end → committed corruption (throw);
  162. // - after it (or no committed turn/end) → tolerated torn tail (stop).
  163. const preserved: SessionEvent[] = []
  164. for (let i = 0; i < rows.length; i++) {
  165. const p = parsed[i]
  166. if (!p?.ok || p.event === undefined) {
  167. if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`)
  168. break // torn tail fragment after the last turn/end — stop, tolerate
  169. }
  170. if (p.event.seq !== i) {
  171. if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${i}, got ${p.event.seq})`)
  172. break // gap after the last turn/end — torn tail, stop
  173. }
  174. preserved.push(p.event)
  175. }
  176. // Any rows past the preserved prefix are a never-committed torn tail; their
  177. // first seq is the deletion point for load's physical repair.
  178. return preserved.length < rows.length ? { preserved, tornFrom: preserved.length } : { preserved }
  179. }