Mirror the JSONL backend's crash-recovery contract change: load() now PRESERVES
the real events of an interrupted final turn (a turn can be huge — truncating it
would destroy work) and durably CLOSES the orphaned turn with synthetic boundary
events (step/end if open, then turn/end {interrupted}) inside one transaction
that also deletes any torn tail row. load() is therefore mutating; the deferred
truncation-repair on the next append is gone. Replaces cutAtLastTurnEnd with
scanRows (longest preserved prefix + torn-tail offset). Updates the sqlite tests
and README to the preserve-and-close semantics; the shared runPersistenceContract
suite now holds both backends to identical interrupted-turn behavior.
The `materialized` INTEGER column was redundant: create()/update() already
keep a lazy session in memory and write no row, so a `sessions` row is
written only by the first append. Its EXISTENCE is the materialization
signal — has()/list() now report exactly the sessions that have a row,
matching the JSONL backend's "file exists ⇔ materialized".
The column only existed to force has()/list() to FALSE for an all-tail
crash (a partial first turn, zero committed events). That actually
DIVERGED from the JSONL backend, whose file (and thus has()=true) survives
a first append that never reached turn/end. Removing the column drops that
special case: an all-tail session keeps its row and stays present, the
same as JSONL. The orphaned tail rows are still removed by the deferred
truncation-repair on the next append, and load() stays non-mutating.
Also: add a TODO to route through a cordis db service if one is adopted,
and correct the README's Node-version framing to the repo's engines (>=24).
- load() no longer DELETEs the crash tail — it stays non-mutating w.r.t.
the event log and records a repair point (repairFrom). The next append
runs the DELETE inside its own transaction before inserting. This makes
the SQLite backend honor the SAME public contract as JSONL (load returns
the committed prefix; the subsequent append performs the one-time
physical truncation-repair), instead of mutating during load.
- All-tail load: when the discarded crash tail was the session's only
committed content (committed.length === 0), the metadata row still read
materialized = 1 from the prior append, so has()/list() reported a
session load() had just emptied. load() now flips the row's materialized
flag to 0 (metadata only — the orphaned event rows are still removed by
the deferred repair), so has()/list() are immediately consistent.
- Schema version: openDatabase now stores SCHEMA_VERSION in PRAGMA
user_version on a fresh database and rejects opening one whose
user_version is newer than this build supports, protecting against a
future incompatible layout.
Regression tests: load is non-mutating (tail rows survive until the next
append), all-tail load makes has()/list() false, and a newer-schema
database is rejected on open.
Two parity gaps with the JSONL backend found in review:
- append() now validates serializability and structuredClones the batch
synchronously at call time, BEFORE waiting behind the per-session
chain. A caller that mutates the passed array (or an event inside it)
after the call can no longer corrupt the persisted copy or advance the
cursor past what was written. Matches the JSONL backend.
- load() now computes the last-turn/end cut from the seq+type COLUMNS
only (cutAtLastTurnEnd is generic over {seq,type}); event `data` is
JSON-parsed only for the committed prefix, never for the uncommitted
tail. A malformed `data` in a crash tail is discarded, not treated as
unloadable — only a parse error/gap in the COMMITTED region is
unloadable (the SessionPersistence.load contract). Matches scanLog.
Regression tests for both.
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".