Files
deepseek-harness/packages/session-persistence/session-persistence-sqlite/src/schema.ts
Hypatia May 0a0f19cf57 Merge remote-tracking branch 'origin/master' into session-query-search
# Conflicts:
#	.agents/notes/implemented/feature/2026-07-10-session-query-service.md
#	.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md
#	.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.md
#	docs/architecture.md
#	docs/capability-seams.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/persistence.md
#	docs/core-data-structures/session-query.md
#	docs/module-graph.md
#	docs/rfc/INDEX.md
#	packages/README.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/hooks/hooks-claude/tests/coverage.spec.ts
#	packages/session-persistence/session-persistence-jsonl/src/index.ts
#	packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
#	packages/session-persistence/session-persistence-sqlite/README.md
#	packages/session-persistence/session-persistence-sqlite/src/index.ts
#	packages/session-persistence/session-persistence-sqlite/src/schema.ts
#	packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
#	packages/session-persistence/session-persistence/README.md
#	packages/session-persistence/session-persistence/package.json
#	packages/session-query/README.md
#	packages/session-query/session-query/README.md
#	packages/session-query/session-query/package.json
#	packages/session-query/session-query/src/config.ts
#	packages/session-query/session-query/src/index.ts
#	packages/session-query/session-query/src/types.ts
#	pnpm-lock.yaml
#	scripts/gen-doc-graphs.ts
#	scripts/type-equiv.manifest.json
#	tsconfig.host.json
#	tsconfig.json
2026-07-23 13:56:56 +08:00

221 lines
8.6 KiB
TypeScript

/**
* Schema + load-time helpers for the SQLite session-persistence backend: the
* DDL (a store-identity row, `sessions` metadata, and a 1:1 `events` row per
* `SessionEvent`), the database open/configure step, and the last-`turn/end`
* cut that gives the SQLite backend the SAME crash-tail-on-load semantics as
* the JSONL backend.
*
* @module dsh-session-persistence-sqlite/schema
*/
import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session'
/**
* The on-disk schema version. Bumped only on a breaking change to the table
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
export const SCHEMA_VERSION = 8
/**
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
* 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 `list`, mirroring the JSONL
* backend's "no file until first append".
*/
export interface SessionRow {
id: string
version: number
created_at: number
cwd: string | null
parent_session: string | null
seed_length: number | null
/** Stable identity assigned when this log is materialized. */
incarnation: string
/** Monotonic log-change token incremented in each mutating transaction. */
revision: number
delegation_depth: number | null
}
/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
export interface EventRow {
seq: number
type: string
time: number
data: string
/** JSON-encoded `number[]` — the event's sourceEventSeqs, or null. */
source_event_seqs: string | null
/** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */
surface_op: string | null
}
/**
* Journal modes the backend will run under. `wal` is the default and the
* durability model the persistence ADR records; the rollback-journal modes
* (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
* shared-memory files do not work (network mounts). `memory`/`off` are
* excluded: dropping journal durability silently contradicts what this
* backend promises.
*/
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
/**
* Open the database and apply its schema and pragmas. A zero `user_version` is
* stamped with {@link SCHEMA_VERSION}; every other non-current version rejects
* rather than being migrated in place.
* @param path - the SQLite database file to open (created when absent).
* @param journalMode - validated journal pragma.
* @returns the open handle with pragmas applied and all three tables ensured.
*/
export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
const db = new DatabaseSync(path)
try {
configureDatabase(db, path, journalMode)
return db
} catch (error: unknown) {
db.close()
throw error
}
}
function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {
db.exec('PRAGMA foreign_keys = ON')
// The validated union is safe to interpolate into a non-bindable PRAGMA.
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
// `PRAGMA user_version` always returns exactly one row { user_version }.
const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
}
if (onDisk === 0) {
// Stamp fresh or pre-versioning databases.
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
}
db.exec(`
CREATE TABLE IF NOT EXISTS persistence_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
store_id TEXT NOT NULL
) STRICT
`)
db.prepare(
'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
).run(randomUUID())
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
cwd TEXT,
parent_session TEXT,
seed_length INTEGER,
delegation_depth INTEGER,
incarnation TEXT NOT NULL,
revision INTEGER NOT NULL
) STRICT
`)
db.exec(`
CREATE TABLE IF NOT EXISTS events (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
time INTEGER NOT NULL,
data TEXT NOT NULL,
source_event_seqs TEXT,
surface_op TEXT,
PRIMARY KEY (session_id, seq)
) STRICT
`)
}
/**
* Reconstruct the {@link SessionHeader} from a `sessions` row.
* @param row - the `sessions` table row.
* @returns the header, `NULL` columns mapped to omitted optional fields.
*/
export function rowToMeta(row: SessionRow): SessionHeader {
return {
version: row.version,
id: row.id as SessionId,
createdAt: row.created_at,
...row.cwd !== null ? { cwd: row.cwd } : {},
...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
...row.seed_length !== null ? { seedLength: row.seed_length } : {},
...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
}
}
/**
* Reconstruct a {@link SessionEvent} from an `events` row (parses `data`).
* @param row - the `events` table row; `data` and the surface columns hold JSON text.
* @returns the reconstructed event; throws when a JSON column fails to parse
* ({@link scanRows} treats that as a hole, not corruption, in the tail).
*/
export function rowToEvent(row: EventRow): SessionEvent {
// Surface-metadata fields are conditional on the event type in the type
// system; spread them so each variant gets only the fields it declares.
const surfaceFields = {
...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {},
...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {},
}
return {
type: row.type as SessionEvent['type'],
seq: row.seq,
time: row.time,
data: JSON.parse(row.data) as SessionEvent['data'],
...surfaceFields,
} as SessionEvent
}
/**
* Find the preserved prefix of ordered event rows. Fully written rows in an
* interrupted final turn remain in the prefix. The first unparsable row or seq
* gap after the last `turn/end` marks a tolerated torn tail; the same hole in
* the committed region rejects.
*
* @param rows - one session's event rows, ordered by seq ascending.
* @returns the preserved event prefix, plus `tornFrom` — the seq the physical
* delete starts at — when a torn tail exists.
*/
export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } {
// Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
// (The seq/type COLUMNS are always present even when `data` is corrupt.)
interface Parsed { ok: boolean; event?: SessionEvent }
const parsed: Parsed[] = rows.map((row) => {
try {
return { ok: true, event: rowToEvent(row) }
} catch {
return { ok: false }
}
})
// The last index that is a valid `turn/end` — holes through a closed turn
// are always committed corruption.
let lastTurnEnd = -1
for (let i = parsed.length - 1; i >= 0; i--) {
if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
}
// Preserve the contiguous prefix, including a complete interrupted turn;
// holes through the last committed boundary throw, while later holes stop.
const preserved: SessionEvent[] = []
for (let i = 0; i < rows.length; i++) {
const p = parsed[i]
if (!p?.ok || p.event === undefined) {
if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`)
break // torn tail fragment after the last turn/end — stop, tolerate
}
if (p.event.seq !== i) {
if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${i}, got ${p.event.seq})`)
break // gap after the last turn/end — torn tail, stop
}
preserved.push(p.event)
}
// Any rows past the preserved prefix are a never-committed torn tail; their
// first seq is the deletion point for load's physical repair.
return preserved.length < rows.length ? { preserved, tornFrom: preserved.length } : { preserved }
}