Add a SQLite SessionPersistence backend (node:sqlite), a SECOND implementation built to prove the abstract seam + the shared runPersistenceContract suite are genuinely backend-agnostic. Each SessionEvent maps 1:1 onto an events row (session_id, seq, type, time, data); append is an INSERT inside a transaction asserting the contiguous-seq contract; the mutable SessionSummary lives in the sessions metadata row. It satisfies the SAME contract semantics as the JSONL backend, expressed over rows instead of file bytes: - Lazy materialization: create() records intent in memory; no row until the first append (a never-appended session is absent from has()/list() via a materialized flag set inside the first append transaction). - Crash-tail-on-load: load() returns events only through the last complete turn/end and deletes the uncommitted tail; a seq gap in the committed region makes the session unloadable. - Transactional append: a mid-batch failure (a UNIQUE seq collision from a concurrent writer) rolls back entirely, keeping the cursor truthful. Like the JSONL backend it is also the write-path plugin (session/event → buffer → session/flush drain, onCreated seed/adopt/collision handling, HMR seeding, dispose-to-quiescence). The package runs the shared runPersistenceContract suite plus SQLite-specific tests (transaction rollback, crash-tail cut, schema version, HMR adoption). Docs flip every "SQLite is future/deferred" reference (ADR 0016, architecture.md, the persistence module doc + README) to "implemented; the contract holds both backends to identical semantics".
2.9 KiB
@deepseek-ai/dsh-session-persistence-sqlite
A SQLite durable session-persistence backend — a second SessionPersistence implementation (ADR 0016), 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, crash-tail-on-load), expressed over node:sqlite rows instead of file bytes.
Storage model
Each SessionEvent maps 1:1 onto a row in an events table (session_id, seq, type, time, data) — data is the event payload as JSON text, so the row shape is the event verbatim (including assistant/chunk, keeping seq contiguous). Out-of-log metadata (SessionMeta) lives in a sessions row, including the mutable SessionSummary fields (updatedAt, title, firstPrompt) that update() rewrites without touching the event log.
node:sqlite requires Node ≥ 22.5 (this repo runs Node ≥ 24); the database opens with foreign_keys = ON (so ON DELETE CASCADE drops a session's events with its row) and journal_mode = WAL.
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. - Lazy materialization.
create()records intent in memory only — no row is written until the firstappend. A created-but-never-appended session is absent fromhas()/list()(amaterializedflag on the row, set inside the first append transaction;has/listfilter to materialized rows). - Crash-tail-on-load.
load()reads every stored event ordered byseqand returns only the prefix through the last completeturn/end(theSessionPersistence.loadcontract). A batch that landed without its closingturn/end(a process killed mid-turn) is an uncommitted tail and is deleted on load; aseqgap inside the committed region makes the session unloadable.
Configuration (schemastery)
interface Config {
path: string // SQLite database file path, or ':memory:' for an in-process DB
}
Write path
Like the JSONL backend, the plugin also installs the session/event → buffer → session/flush drain: it snapshots each event when buffered (the live session.events object is mutable), 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.