@deepseek-ai/dsh-session-persistence-sqlite
A SQLite durable session-persistence backend — a second SessionPersistence implementation (session persistence), built to validate that the abstract seam and the shared runPersistenceContract suite are genuinely backend-agnostic. It satisfies the SAME contract as dsh-session-persistence-jsonl (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over node:sqlite rows instead of file bytes.
TODO: this backend talks to
node:sqlitedirectly. If a cordis database service (cordis/db/ a@cordisjsSQL driver plugin) is adopted, route through that instead of holding a rawDatabaseSynchere — the contract surface (SessionPersistence) would not change, only the storage driver.
Storage model
Each SessionEvent maps 1:1 onto a row in an events table (session_id, seq, type, time, data, source_event_seqs, surface_op) — data is the event payload as JSON text, so the row shape is the event verbatim (including assistant/chunk, keeping seq contiguous). The two TEXT columns source_event_seqs and surface_op are nullable; they store the event's optional surface-metadata fields (see session surface). Out-of-log metadata (SessionHeader) and a monotonic per-log revision live in a sessions row. A sessions row is written only by the first append — its existence is the lazy-materialization signal (list reports exactly the sessions that have a row).
The repo's engines.node is ^22.19.0 || >=24.0.0 (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; node:sqlite itself ships without the --experimental-sqlite flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with foreign_keys = ON (so ON DELETE CASCADE drops a session's events with its row) and the configured journal_mode (default wal; pick a rollback-journal mode like delete on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in PRAGMA user_version and checked on open: a fresh database is stamped with the current SCHEMA_VERSION; a database written by any other, incompatible build (a non-current user_version, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software).
Contract semantics over rows
- Append = a transaction.
appendrunsBEGIN/COMMITaround the batch: it materializes thesessionsrow (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event'sseqmust equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (load()already balanced the stored log, soappendnever has to repair a crash tail.) - Lazy materialization.
create()records intent in memory only — no row is written until the firstappend. A created-but-never-appended session has nosessionsrow, so it is absent fromlist()(which reports exactly the sessions that have a row). - Lightweight revisions.
listSnapshots()reads the monotonic revision stored beside each session header. Append and mutating load repair increment it in the same transaction as their event changes, so unchanged observations are stable and no full-log count or parse is required. - Interrupted-turn close on load.
load()reads every stored event ordered byseqand finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the lastturn/end(the loop only flushes atturn/end, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are preserved, never truncated:load()CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an errortool/resultfor every assistant tool call left unanswered, astep/endif a step was open, then aturn/endcarrying{ kind: 'interrupted' }), inside one transaction that also DELETEs any never-fully-written torn tail row.load()is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the nextappendcontinues cleanly. The boundary (lastturn/end, torn-tail detection) is computed from theseq/typecolumns so a malformeddatain a torn tail row is never parsed (discarded, not unloadable). A parse error orseqgap inside the committed region (at or before the last realturn/end) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present inlist()— the same as the JSONL backend, whose file likewise survives a first append that never reachedturn/end.
Configuration (schemastery)
interface Config {
path: string // SQLite database file path, or ':memory:' for an in-process DB
journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
}
Write path
Like the JSONL backend, the plugin also installs the session/event → buffer → session/flush drain: it copies each already-frozen event into a persistence-owned buffer, persists a fork's seed once on session/created, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay session/created). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown.