fix(session-query): harden SQLite search reconciliation

This commit is contained in:
Hypatia May
2026-07-15 12:10:24 +08:00
parent ecf90ff382
commit f88ca85ffd
40 changed files with 1315 additions and 227 deletions

View File

@@ -6,7 +6,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
## 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](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). 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.
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](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). 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).
@@ -14,6 +14,7 @@ The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matchi
- **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must 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, so `append` never has to repair a crash tail.)
- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (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 by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/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 error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ 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 next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`.
## Configuration (schemastery)

View File

@@ -11,7 +11,7 @@
* Like the JSONL backend it supplies ONLY the storage primitives (the
* {@link PersistenceBackend} hooks below — INSERT/DELETE/SELECT inside
* transactions); all the write-path orchestration lives in the backend-agnostic
* {@link PersistenceCoordinator} this class composes. The four public
* {@link PersistenceCoordinator} this class composes. The stateful public
* {@link SessionPersistence} methods delegate to the coordinator.
*
* @module @deepseek-ai/dsh-session-persistence-sqlite
@@ -23,8 +23,8 @@ import { DatabaseSync } from 'node:sqlite'
import { mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
SessionPersistence, PersistenceCoordinator,
type PersistenceBackend, type StoredPrefix,
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import {
@@ -181,6 +181,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
const [surfaceSeqs, surfaceOp] = surfaceBindings(event)
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
}
this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id)
this.db.exec('COMMIT')
} catch (error) {
this.db.exec('ROLLBACK')
@@ -209,6 +210,9 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
}
}
if (tornMarker !== undefined || closers.length > 0) {
this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id)
}
this.db.exec('COMMIT')
} catch (error) {
// The DELETE+INSERT cannot collide (a row at a closer's seq is preserved or
@@ -230,6 +234,16 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return rows.map(rowToMeta)
}
/** List metadata with an append-only event-count revision per session. */
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
await this.ready
const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[]
return rows.map(row => ({
header: rowToMeta(row),
revision: SessionPersistenceRevision(`revision:${row.revision}`),
}))
}
/** Close the database handle (awaited by the coordinator's dispose, post-drain). */
async close(): Promise<void> {
await this.ready
@@ -250,8 +264,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
*/
private writeRow(meta: SessionHeader): void {
this.db.prepare(`
INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, revision)
VALUES (?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
created_at = excluded.created_at,

View File

@@ -15,7 +15,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
export const SCHEMA_VERSION = 4
export const SCHEMA_VERSION = 5
/**
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
@@ -31,6 +31,8 @@ export interface SessionRow {
cwd: string | null
parent_session: string | null
seed_length: number | null
/** Monotonic log-change token incremented in each mutating transaction. */
revision: number
}
/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
@@ -67,15 +69,9 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
* current {@link SCHEMA_VERSION}; an existing database whose version is NOT the
* current one (written by a different, incompatible build — older or newer) is
* REJECTED rather than opened against a layout this build does not understand.
* There are no migrations: an earlier layout is not upgraded in place — it is
* rejected. v1 had a different `sessions` shape; v2 lacked all of
* `seed_length`/`source_event_seqs`/`surface_op`. v3 is SKIPPED: two unmerged
* branches each shipped a DISTINCT v3 (one adding only `seed_length`, the other
* adding only the surface columns), so an on-disk v3 is ambiguous — it could be
* either sibling layout, neither of which has all of this build's columns. v4
* is the merged layout carrying every column; bumping past the collided v3
* makes the version check reject both sibling v3 databases instead of opening
* one against columns it does not have.
* There are no migrations: an incompatible layout is rejected. The current
* sessions row carries every header field plus its monotonic snapshot revision;
* the events row carries the complete surface metadata.
* @param path - the SQLite database file to open (created when absent).
* @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config.
* @returns the open handle with pragmas applied and both tables ensured.
@@ -105,7 +101,8 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy
created_at INTEGER NOT NULL,
cwd TEXT,
parent_session TEXT,
seed_length INTEGER
seed_length INTEGER,
revision INTEGER NOT NULL
) STRICT
`)
db.exec(`

View File

@@ -258,11 +258,11 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
// Two unmerged branches each shipped a DISTINCT layout under user_version 3
// (one added only `seed_length`, the other only the surface columns). The
// merged build is v4; an on-disk v3 is ambiguous and is missing at least one
// the current build rejects every older layout; an on-disk v3 is ambiguous and is missing at least one
// of this build's columns, so it MUST be rejected, not opened. Stamp a v3
// database and confirm the version check refuses it.
const path = await freshDbPath()
openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4)
openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION
const db = openDatabase(path, 'wal')
db.exec('PRAGMA user_version = 3')
db.close()
@@ -338,7 +338,18 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(4)
expect(SCHEMA_VERSION).toBe(5)
})
it('keeps the revision stable for an empty repair hook', async () => {
const b = await backend()
const m = meta('empty-repair')
await b.ctx.sessionPersistence.create(m)
await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
const before = await b.ctx.sessionPersistence.listSnapshots()
await (b.ctx.sessionPersistence as SessionPersistenceSqlite).commitRepair(m, undefined, [])
expect(await b.ctx.sessionPersistence.listSnapshots()).toEqual(before)
await b.dispose()
})
})