/** * Schema + load-time helpers for the SQLite session-persistence backend: the * DDL (a store-identity row, `sessions` metadata, and a 1:1 `events` row per * `SessionEvent`), the database open/configure step, and the last-`turn/end` * cut that gives the SQLite backend the SAME crash-tail-on-load semantics as * the JSONL backend. * * @module dsh-session-persistence-sqlite/schema */ import { randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session' /** * The on-disk schema version. Bumped only on a breaking change to the table * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ export const SCHEMA_VERSION = 14 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). * The row's EXISTENCE is the materialization signal: it is written only by the * first `append` (lazy materialization), so a created-but-never-appended * session has no row and is absent from `list`, mirroring the JSONL * backend's "no file until first append". */ export interface SessionRow { id: string version: number created_at: number cwd: string | null parent_session: string | null seed_length: number | null origin: 'subagent' | null /** Stable identity assigned when this log is materialized. */ incarnation: string /** Monotonic log-change token incremented in each mutating transaction. */ revision: number delegation_depth: number | null agent_preset: string | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ export interface EventRow { seq: number type: string time: number data: string /** JSON-encoded `number[]` — the event's sourceEventSeqs, or null. */ source_event_seqs: string | null /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */ surface_op: string | null } /** * Journal modes the backend will run under. `wal` is the default and the * durability model the persistence ADR records; the rollback-journal modes * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's * shared-memory files do not work (network mounts). `memory`/`off` are * excluded: dropping journal durability silently contradicts what this * backend promises. */ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' /** * Open the database and apply its schema and pragmas. An empty database with a * zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty * unversioned database and every other non-current version reject rather than * being migrated in place. * @param path - the SQLite database file to open (created when absent). * @param journalMode - validated journal pragma. * @returns the open handle with pragmas applied and all three tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) try { configureDatabase(db, path, journalMode) return db } catch (error: unknown) { db.close() throw error } } function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void { db.exec('PRAGMA foreign_keys = ON') let began = false try { db.exec('BEGIN IMMEDIATE') began = true // Validate while holding the write lock so no other connection can change // schema ownership between inspection and initialization. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } const { count: userObjectCount } = db.prepare( "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'", ).get() as { count: number } if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) { throw new Error(`session database at "${path}" has an unversioned schema or application identity`) } if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) } if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) { throw new Error( `session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`, ) } db.exec(` CREATE TABLE IF NOT EXISTS persistence_state ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), store_id TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, version INTEGER NOT NULL, created_at INTEGER NOT NULL, cwd TEXT, parent_session TEXT, seed_length INTEGER, origin TEXT, delegation_depth INTEGER, agent_preset TEXT, incarnation TEXT NOT NULL, revision INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS events ( session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, seq INTEGER NOT NULL, type TEXT NOT NULL, time INTEGER NOT NULL, data TEXT NOT NULL, source_event_seqs TEXT, surface_op TEXT, PRIMARY KEY (session_id, seq) ) STRICT `) db.prepare( 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)', ).run(randomUUID()) if (onDisk === 0) { db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`) db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) } db.exec('COMMIT') began = false } catch (error: unknown) { /* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */ if (began) { /* v8 ignore next 5 -- preserve the original schema failure if SQLite also refuses rollback. */ try { db.exec('ROLLBACK') } catch { // The original SQLite failure remains the actionable cause. } } throw error } // The validated union is safe to interpolate into a non-bindable PRAGMA. // Apply it only after ownership validation and initialization commit. db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) } /** * Reconstruct the {@link SessionHeader} from a `sessions` row. * @param row - the `sessions` table row. * @returns the header, `NULL` columns mapped to omitted optional fields. */ export function rowToMeta(row: SessionRow): SessionHeader { if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) { throw new Error('stored session createdAt must be a non-negative safe integer') } return { version: row.version, id: row.id as SessionId, createdAt: row.created_at, ...row.cwd !== null ? { cwd: row.cwd } : {}, ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, ...row.origin !== null ? { origin: row.origin } : {}, ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {}, } } /** * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). * @param row - the `events` table row; `data` and the surface columns hold JSON text. * @returns the reconstructed event; throws when a JSON column fails to parse * ({@link scanRows} treats that as a hole, not corruption, in the tail). */ export function rowToEvent(row: EventRow): SessionEvent { // Surface-metadata fields are conditional on the event type in the type // system; spread them so each variant gets only the fields it declares. const surfaceFields = { ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {}, ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {}, } return { type: row.type as SessionEvent['type'], seq: row.seq, time: row.time, data: JSON.parse(row.data) as SessionEvent['data'], ...surfaceFields, } as SessionEvent } /** * Find the preserved prefix of ordered event rows. Fully written rows in an * interrupted final turn remain in the prefix. The first unparsable row or seq * gap after the last `turn/end` marks a tolerated torn tail; the same hole in * the committed region rejects. * * @param rows - one session's event rows, ordered by seq ascending. * @param base - the seq the first row is expected to carry; `0` for a whole * log, the requested `fromSeq` for a suffix read (`loadStoredFrom`). * @returns the preserved event prefix, plus `tornFrom` — the seq the physical * delete starts at — when a torn tail exists. */ export function scanRows(rows: readonly EventRow[], base = 0): { preserved: SessionEvent[]; tornFrom?: number } { // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole. // (The seq/type COLUMNS are always present even when `data` is corrupt.) interface Parsed { ok: boolean; event?: SessionEvent } const parsed: Parsed[] = rows.map((row) => { try { return { ok: true, event: rowToEvent(row) } } catch { return { ok: false } } }) // The last index that is a valid `turn/end` — holes through a closed turn // are always committed corruption. let lastTurnEnd = -1 for (let i = parsed.length - 1; i >= 0; i--) { if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break } } // Preserve the contiguous prefix, including a complete interrupted turn; // holes through the last committed boundary throw, while later holes stop. const preserved: SessionEvent[] = [] for (let i = 0; i < rows.length; i++) { const p = parsed[i] if (!p?.ok || p.event === undefined) { if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`) break // torn tail fragment after the last turn/end — stop, tolerate } if (p.event.seq !== base + i) { if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`) break // gap after the last turn/end — torn tail, stop } preserved.push(p.event) } // Any rows past the preserved prefix are a never-committed torn tail; their // first seq is the deletion point for load's physical repair. return preserved.length < rows.length ? { preserved, tornFrom: base + preserved.length } : { preserved } }