P1: both merge parents shipped SCHEMA_VERSION=3 for different layouts (surface columns vs seed_length), so an on-disk 3 was ambiguous and wrongly accepted. Bump to 4 (merged layout) so the version check rejects both sibling v3s. P2: a surface-eligible event with no surfaceOp lands in the log but vanishes from deriveMessages() (surface is the sole derivation path). The typed append overload enforces the marker only when the type arg is a literal; it collapses to optional when widened to the union (a caller iterating raw events). Guard at runtime in both append() and the seed constructor — no backward-compat for surface-less logs. Shared seed fixtures carry surfaceOp explicitly and the appendLog helper forwards it verbatim (no synthesized default). Exports isSurfaceEligibleType. Regression tests for all three, each verified to fail on the unfixed code. Gates: typecheck, test (1115), snapshot (14), doc-sync, lint, build, hygiene green.
@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) lives 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), so no separate column is needed.
The repo targets Node ≥ 24 (the root engines field), which includes the stable node:sqlite module. The database opens with foreign_keys = ON (so ON DELETE CASCADE drops a session's events with its row) and journal_mode = WAL. 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). - 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
}
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.