feature(session): session surface

This commit is contained in:
Hypatia May
2026-06-17 19:25:29 +08:00
parent dbaef018cc
commit c5a1c494e7
17 changed files with 1047 additions and 78 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)``data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). Out-of-log metadata (`SessionMeta`) lives in a `sessions` row, including the mutable `SessionSummary` fields (`updatedAt`, `title`, `firstPrompt`) that `update()` rewrites without touching the event log. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`has`/`list` report 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 [ADR 0019](../../docs/adr/0019-session-surface.md)). The schema migrates from v1 to v2 via `ALTER TABLE ADD COLUMN` — existing rows get NULL for both columns, which is correct for events written before surface support. Out-of-log metadata (`SessionMeta`) lives in a `sessions` row, including the mutable `SessionSummary` fields (`updatedAt`, `title`, `firstPrompt`) that `update()` rewrites without touching the event log. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`has`/`list` report 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 a newer, incompatible build (higher `user_version`) is rejected rather than opened against an unknown layout.

View File

@@ -33,6 +33,18 @@ import {
export { SCHEMA_VERSION } from './schema.ts'
/**
* Serialize an event's surface-metadata fields for SQL binding. Both fields are
* nullable TEXT columns — null when the event has no surface metadata (non-surface
* events, events written before surface support).
*/
function surfaceBindings(event: SessionEvent): [string | null, string | null] {
return [
event.sourceEventSeqs ? JSON.stringify(event.sourceEventSeqs) : null,
event.surfaceOp !== undefined ? JSON.stringify(event.surfaceOp) : null,
]
}
/** Plugin configuration. */
export interface Config {
/**
@@ -180,13 +192,14 @@ export class SessionPersistenceSqlite extends SessionPersistence {
// durably closes the interrupted turn before returning, so by the time any
// append runs the stored log is balanced and contiguous.)
const insertEvent = this.db.prepare(
'INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)',
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
)
this.db.exec('BEGIN')
try {
if (!state.materialized) this.writeRow(state.meta)
for (const event of events) {
insertEvent.run(id, event.seq, event.type, event.time, JSON.stringify(event.data))
const [surfaceSeqs, surfaceOp] = surfaceBindings(event)
insertEvent.run(id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
}
// Bump updatedAt on every append (the mutable summary lives in the row).
const updatedAt = Date.now()
@@ -220,7 +233,7 @@ export class SessionPersistenceSqlite extends SessionPersistence {
// discarded (not unloadable); only a parse error / seq gap in the COMMITTED
// region (at or before the last turn/end) throws (genuine corruption).
const eventRows = this.db
.prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq')
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
.all(id) as unknown as EventRow[]
const { preserved, tornFrom } = scanRows(eventRows)
@@ -248,9 +261,12 @@ export class SessionPersistenceSqlite extends SessionPersistence {
this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(id, tornFrom)
}
if (closers.length > 0) {
const insertEvent = this.db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
const insertEvent = this.db.prepare(
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
)
for (const event of closers) {
insertEvent.run(id, event.seq, event.type, event.time, JSON.stringify(event.data))
const [surfaceSeqs, surfaceOp] = surfaceBindings(event)
insertEvent.run(id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp)
}
}
this.db.exec('COMMIT')
@@ -497,7 +513,7 @@ export class SessionPersistenceSqlite extends SessionPersistence {
/** The preserved events for a session id (torn tail excluded, turn NOT yet closed). */
private eventsFor(id: SessionId): SessionEvent[] {
const rows = this.db
.prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq')
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
.all(id) as unknown as EventRow[]
// Scan on seq+type columns, parsing `data` only for the preserved prefix (a
// malformed torn tail must not throw here — same as loadCore). Returns the

View File

@@ -8,14 +8,14 @@
*/
import { DatabaseSync } from 'node:sqlite'
import type { SessionEvent, SessionId, SessionMeta } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId, SessionMeta, 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 = 1
export const SCHEMA_VERSION = 2
/**
* A row of the `sessions` table — the out-of-log metadata (`SessionMeta`). The
@@ -41,6 +41,10 @@ export interface EventRow {
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
}
/**
@@ -72,6 +76,13 @@ export function openDatabase(path: string): DatabaseSync {
// constant (SCHEMA_VERSION is a trusted in-code number, not user input).
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
}
if (onDisk === 1) {
// Migrate from v1 to v2: add surface-metadata columns (nullable — existing
// rows get NULL, which is correct for events written before surface existed).
db.exec('ALTER TABLE events ADD COLUMN source_event_seqs TEXT')
db.exec('ALTER TABLE events ADD COLUMN surface_op TEXT')
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
}
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
@@ -86,11 +97,13 @@ export function openDatabase(path: string): DatabaseSync {
`)
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,
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
`)
@@ -113,12 +126,19 @@ export function rowToMeta(row: SessionRow): SessionMeta {
/** Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). */
export function rowToEvent(row: EventRow): SessionEvent {
return {
type: row.type,
const event = {
type: row.type as SessionEvent['type'],
seq: row.seq,
time: row.time,
data: JSON.parse(row.data) as SessionEvent['data'],
} as SessionEvent
if (row.source_event_seqs !== null) {
event.sourceEventSeqs = JSON.parse(row.source_event_seqs) as number[]
}
if (row.surface_op !== null) {
event.surfaceOp = JSON.parse(row.surface_op) as SurfaceOp
}
return event
}
/**

View File

@@ -1,12 +1,13 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { DatabaseSync } from 'node:sqlite'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session'
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
import { openDatabase, scanRows, type EventRow } from '../src/schema.ts'
import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
const dirs: string[] = []
@@ -42,7 +43,7 @@ describe('scanRows', () => {
// scanRows works off EventRows (data is a JSON string column); build them from
// SessionEvents so the unit tests read in terms of the event vocabulary.
const rows = (events: SessionEvent[]): EventRow[] =>
events.map(e => ({ seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data) }))
events.map(e => ({ seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data), source_event_seqs: null, surface_op: null }))
it('preserves the full log when it ends exactly on a turn/end (no torn tail)', () => {
const { preserved, tornFrom } = scanRows(rows(oneTurnLog()))
@@ -91,8 +92,8 @@ describe('scanRows', () => {
it('throws on an unparsable row inside the committed region', () => {
const withCorruptCommitted: EventRow[] = [
{ seq: 0, type: 'turn/start', time: 1, data: '{not json' }, // corrupt, sits before a turn/end
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }) },
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
]
expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
})
@@ -100,7 +101,7 @@ describe('scanRows', () => {
it('tolerates an unparsable torn-tail row after the last turn/end', () => {
const withCorruptTail: EventRow[] = [
...rows(oneTurnLog()),
{ seq: 6, type: 'turn/start', time: 7, data: '{not json' }, // torn fragment, no committed turn/end after
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after
]
const { preserved, tornFrom } = scanRows(withCorruptTail)
expect(preserved).toEqual(oneTurnLog())
@@ -343,7 +344,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(1)
expect(SCHEMA_VERSION).toBe(2)
})
})
@@ -749,4 +750,121 @@ describe('SessionPersistenceSqlite: edge cases', () => {
await expect(ctx.parallel('session/flush', session)).rejects.toThrow(/id collision/)
await ctx.fiber.dispose()
})
it('migrates a v1 database to v2 (adds surface columns)', async () => {
const path = await freshDbPath()
// Manually create a v1 database with the OLD schema (no surface columns).
const db = new DatabaseSync(path)
db.exec('PRAGMA user_version = 1')
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
cwd TEXT,
parent_session TEXT,
updated_at INTEGER NOT NULL,
title TEXT,
first_prompt TEXT
) STRICT
`)
db.exec(`
CREATE TABLE 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,
PRIMARY KEY (session_id, seq)
) STRICT
`)
db.close()
// Re-open with v2 code: migration adds the surface columns and stamps v2.
const db2 = openDatabase(path)
const version = (db2.prepare('PRAGMA user_version').get() as { user_version: number }).user_version
expect(version).toBe(2)
const info = db2.prepare("PRAGMA table_info('events')").all() as Array<{ name: string }>
const names = info.map(c => c.name)
expect(names).toContain('source_event_seqs')
expect(names).toContain('surface_op')
db2.close()
})
})
describe('surface field round-trip', () => {
it('rowToEvent parses surface fields from EventRow columns', () => {
const row: EventRow = {
seq: 0, type: 'assistant/message', time: 1,
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
source_event_seqs: JSON.stringify([3, 5]),
surface_op: JSON.stringify('append'),
}
const event = rowToEvent(row)
expect(event.sourceEventSeqs).toEqual([3, 5])
expect(event.surfaceOp).toBe('append')
})
it('rowToEvent handles replace surfaceOp object', () => {
const row: EventRow = {
seq: 0, type: 'assistant/message', time: 1,
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
source_event_seqs: JSON.stringify([0, 1]),
surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }),
}
const event = rowToEvent(row)
expect(event.sourceEventSeqs).toEqual([0, 1])
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 1 })
})
it('scanRows with surface columns reconstructs events with surface fields', () => {
const rows: EventRow[] = [
{ seq: 0, type: 'user/message', time: 1,
data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' },
{ seq: 1, type: 'turn/end', time: 2,
data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
source_event_seqs: null, surface_op: null },
]
const { preserved } = scanRows(rows)
expect(preserved).toHaveLength(2)
expect(preserved[0]!.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
expect(preserved[0]!.sourceEventSeqs).toBeUndefined()
expect(preserved[1]!.surfaceOp).toBeUndefined()
})
it('append and load round-trips surface fields through SQLite', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const session = ctx.sessions.create('roundtrip-surface')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [0] })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
expect(loaded.events).toHaveLength(4)
const um = loaded.events[1]!
expect(um.surfaceOp).toBe('append')
expect(um.sourceEventSeqs).toBeUndefined()
const am = loaded.events[2]!
expect(am.surfaceOp).toBe('append')
expect(am.sourceEventSeqs).toEqual([0])
await fiber.dispose()
})
it('persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const session = ctx.sessions.create('surface-noseq')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('steering/message', { turn: 1, content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq'))
expect(loaded.events[1]!.surfaceOp).toBe('append')
expect(loaded.events[1]!.sourceEventSeqs).toBeUndefined()
await fiber.dispose()
})
})