schema.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  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`) plus
  19. * the `materialized` flag that implements lazy materialization (a created-but-
  20. * never-appended session has `materialized = 0` and is excluded from
  21. * `has`/`list`, mirroring the JSONL backend's "no file until first append").
  22. */
  23. export interface SessionRow {
  24. id: string
  25. version: number
  26. created_at: number
  27. cwd: string | null
  28. parent_session: string | null
  29. updated_at: number
  30. title: string | null
  31. first_prompt: string | null
  32. materialized: number
  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. export function openDatabase(path: string): DatabaseSync {
  48. const db = new DatabaseSync(path)
  49. db.exec('PRAGMA foreign_keys = ON')
  50. db.exec('PRAGMA journal_mode = WAL')
  51. db.exec(`
  52. CREATE TABLE IF NOT EXISTS sessions (
  53. id TEXT PRIMARY KEY,
  54. version INTEGER NOT NULL,
  55. created_at INTEGER NOT NULL,
  56. cwd TEXT,
  57. parent_session TEXT,
  58. updated_at INTEGER NOT NULL,
  59. title TEXT,
  60. first_prompt TEXT,
  61. materialized INTEGER NOT NULL DEFAULT 0
  62. ) STRICT
  63. `)
  64. db.exec(`
  65. CREATE TABLE IF NOT EXISTS events (
  66. session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  67. seq INTEGER NOT NULL,
  68. type TEXT NOT NULL,
  69. time INTEGER NOT NULL,
  70. data TEXT NOT NULL,
  71. PRIMARY KEY (session_id, seq)
  72. ) STRICT
  73. `)
  74. return db
  75. }
  76. /** Reconstruct the full {@link SessionMeta} from a `sessions` row. */
  77. export function rowToMeta(row: SessionRow): SessionMeta {
  78. return {
  79. version: row.version,
  80. id: row.id as SessionId,
  81. createdAt: row.created_at,
  82. updatedAt: row.updated_at,
  83. ...row.cwd !== null ? { cwd: row.cwd } : {},
  84. ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
  85. ...row.title !== null ? { title: row.title } : {},
  86. ...row.first_prompt !== null ? { firstPrompt: row.first_prompt } : {},
  87. }
  88. }
  89. /** Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). */
  90. export function rowToEvent(row: EventRow): SessionEvent {
  91. return {
  92. type: row.type,
  93. seq: row.seq,
  94. time: row.time,
  95. data: JSON.parse(row.data) as SessionEvent['data'],
  96. } as SessionEvent
  97. }
  98. /**
  99. * The committed prefix of an ordered event list: everything up to and including
  100. * the LAST `turn/end`, plus whether a crash tail (events after it) was cut.
  101. *
  102. * The loop only flushes at `turn/end`, so the last `turn/end` is the last
  103. * durable boundary; anything after it is a never-committed crash tail (a batch
  104. * that landed without its closing `turn/end`, e.g. a process killed mid-turn).
  105. * This is the SQLite analogue of the JSONL backend's `scanLog` truncation point
  106. * — the SAME contract (`SessionPersistence.load`), expressed over rows rather
  107. * than file bytes. The committed region MUST be contiguous (`events[i].seq ===
  108. * i`); a gap there means committed data was lost and the session is unloadable.
  109. */
  110. export function cutAtLastTurnEnd(events: readonly SessionEvent[]): { committed: SessionEvent[]; cutTail: boolean } {
  111. let lastTurnEnd = -1
  112. events.forEach((event, i) => {
  113. if (event.type === 'turn/end') lastTurnEnd = i
  114. })
  115. // No committed turn/end anywhere: the whole list is an uncommitted first-turn
  116. // tail. Nothing is committed (mirrors scanLog returning zero events).
  117. if (lastTurnEnd < 0) {
  118. return { committed: [], cutTail: events.length > 0 }
  119. }
  120. const committed = events.slice(0, lastTurnEnd + 1)
  121. committed.forEach((event, i) => {
  122. if (event.seq !== i) {
  123. throw new Error(`corrupt session log: seq gap in committed region at index ${i} (expected ${i}, got ${event.seq})`)
  124. }
  125. })
  126. return { committed, cutTail: lastTurnEnd < events.length - 1 }
  127. }