refactor(session-persistence-sqlite): drop the materialized column; use row existence as the signal (review #35)

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).
This commit is contained in:
Tianyi Cui
2026-06-16 20:35:01 +08:00
parent c27b1bba6e
commit 3bef6b38c7
4 changed files with 41 additions and 44 deletions

View File

@@ -245,18 +245,12 @@ export class SessionPersistenceSqlite extends SessionPersistence {
// returns the committed prefix; the next append performs the one-time
// physical repair). Record the repair point so the next appendCore DELETEs
// the orphaned tail inside its own transaction before inserting.
const materialized = committed.length > 0
if (committed.length === 0 && row.materialized === 1) {
// All-tail discard: the only committed events were a crash tail, so the
// session now has NO committed events. The metadata row, however, still
// reads materialized = 1 from the prior append — which would make has()
// and list() report a session that load() just emptied. Correct the
// materialized FLAG (metadata, not the event log) so has()/list() are
// immediately consistent. The orphaned tail rows are still removed by the
// deferred repair on the next append.
this.db.prepare('UPDATE sessions SET materialized = 0 WHERE id = ?').run(id)
}
//
// The metadata row stays as-is even when committed.length === 0 (an all-tail
// crash): the session WAS materialized by the partial append, so its row
// exists and has()/list() report it present — the same as the JSONL backend,
// whose file likewise survives a first append that never reached turn/end.
//
// Record state so a later append continues at the committed length and runs
// the deferred tail repair. The state keeps its OWN copy of the meta; the
// returned value is separate so a consumer mutating loaded.meta cannot
@@ -264,7 +258,7 @@ export class SessionPersistenceSqlite extends SessionPersistence {
this.states.set(id, {
meta: { ...meta },
cursor: committed.length,
materialized,
materialized: true,
...cutTail ? { repairFrom: committed.length } : {},
})
return { meta, events }
@@ -272,11 +266,11 @@ export class SessionPersistenceSqlite extends SessionPersistence {
async list(): Promise<SessionMeta[]> {
await this.ready
// Materialized rows only: a created-but-never-appended (lazy) session has no
// row at all, and a load that cut every event back to zero leaves
// materialized = 0. Both are excluded, matching has().
// Every metadata row is a materialized session: the row is written only by
// the first append (a created-but-never-appended session has no row), so
// listing all rows is exactly the materialized set.
const rows = this.db
.prepare('SELECT * FROM sessions WHERE materialized = 1')
.prepare('SELECT * FROM sessions')
.all() as unknown as SessionRow[]
return rows.map(rowToMeta)
}
@@ -285,8 +279,8 @@ export class SessionPersistenceSqlite extends SessionPersistence {
await this.ready
const state = this.states.get(id)
if (state?.materialized) return true
const row = this.rowFor(id)
return row !== undefined && row.materialized === 1
// A metadata row exists iff the session was materialized by a first append.
return this.rowFor(id) !== undefined
}
delete(id: SessionId): Promise<void> {
@@ -326,15 +320,15 @@ export class SessionPersistenceSqlite extends SessionPersistence {
}
/**
* Insert-or-replace a session's metadata row, marked materialized. The only
* callers are the first materializing `append` and a post-materialization
* `update` — a row is written only once a session has durable events, so
* `materialized` is always 1 (a never-appended session has no row at all).
* Insert-or-replace a session's metadata row. The only callers are the first
* materializing `append` and a post-materialization `update`, so writing the
* row IS the materialization (its existence is the signal `has`/`list` read);
* a never-appended session has no row at all.
*/
private writeRow(meta: SessionMeta): void {
this.db.prepare(`
INSERT INTO sessions (id, version, created_at, cwd, parent_session, updated_at, title, first_prompt, materialized)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
INSERT INTO sessions (id, version, created_at, cwd, parent_session, updated_at, title, first_prompt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
created_at = excluded.created_at,
@@ -342,8 +336,7 @@ export class SessionPersistenceSqlite extends SessionPersistence {
parent_session = excluded.parent_session,
updated_at = excluded.updated_at,
title = excluded.title,
first_prompt = excluded.first_prompt,
materialized = excluded.materialized
first_prompt = excluded.first_prompt
`).run(
meta.id,
meta.version,
@@ -466,7 +459,7 @@ export class SessionPersistenceSqlite extends SessionPersistence {
}
const row = this.rowFor(id)
if (row !== undefined && row.materialized === 1) {
if (row !== undefined) {
const stored = this.eventsFor(id)
if (!seedCoversPrefix(seed, stored)) {
throw new Error(`session "${id}" already has a persisted log that does not match this live session (id collision)`)

View File

@@ -18,10 +18,11 @@ import type { SessionEvent, SessionId, SessionMeta } from '@deepseek-ai/dsh-sess
export const SCHEMA_VERSION = 1
/**
* A row of the `sessions` table — the out-of-log metadata (`SessionMeta`) plus
* the `materialized` flag that implements lazy materialization (a created-but-
* never-appended session has `materialized = 0` and is excluded from
* `has`/`list`, mirroring the JSONL backend's "no file until first append").
* A row of the `sessions` table — the out-of-log metadata (`SessionMeta`). 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 `has`/`list`, mirroring the JSONL
* backend's "no file until first append".
*/
export interface SessionRow {
id: string
@@ -32,7 +33,6 @@ export interface SessionRow {
updated_at: number
title: string | null
first_prompt: string | null
materialized: number
}
/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
@@ -81,8 +81,7 @@ export function openDatabase(path: string): DatabaseSync {
parent_session TEXT,
updated_at INTEGER NOT NULL,
title TEXT,
first_prompt TEXT,
materialized INTEGER NOT NULL DEFAULT 0
first_prompt TEXT
) STRICT
`)
db.exec(`