2026-08-18 17:40:26 +08:00
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
2026-08-18 15:56:54 +08:00
|
|
|
import { spawn } from 'node:child_process'
|
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
2026-08-18 15:56:54 +08:00
|
|
|
import { once } from 'node:events'
|
|
|
|
|
import { chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
import { tmpdir } from 'node:os'
|
2026-08-18 15:56:54 +08:00
|
|
|
import { join } from 'node:path'
|
|
|
|
|
import { performance } from 'node:perf_hooks'
|
|
|
|
|
import { pathToFileURL } from 'node:url'
|
2026-07-23 22:08:58 +08:00
|
|
|
import { DatabaseSync } from 'node:sqlite'
|
2026-08-18 15:56:54 +08:00
|
|
|
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
|
|
|
|
import Include from '@deepseek-ai/cordis-plugin-include'
|
|
|
|
|
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import SessionPersistenceSqlite, {
|
|
|
|
|
DEFAULT_BUSY_TIMEOUT_MS,
|
|
|
|
|
SCHEMA_VERSION,
|
|
|
|
|
} from '@deepseek-ai/dsh-session-persistence-sqlite'
|
2026-08-18 17:40:26 +08:00
|
|
|
import { SessionPersistenceRevisionConflictError } from '@deepseek-ai/dsh-session-persistence'
|
2026-07-24 10:35:09 +08:00
|
|
|
import {
|
2026-08-18 15:56:54 +08:00
|
|
|
runCoordinatorContract,
|
|
|
|
|
type CoordinatorFixture,
|
|
|
|
|
} from '../../session-persistence/tests/coordinator-contract.ts'
|
|
|
|
|
import {
|
|
|
|
|
meta,
|
2026-08-19 21:08:03 +08:00
|
|
|
oneTurnLog,
|
2026-08-18 15:56:54 +08:00
|
|
|
runPersistenceContract,
|
|
|
|
|
} from '../../session-persistence/tests/contract.ts'
|
|
|
|
|
import { MAX_PACKED_DATA_BYTES } from '../src/codec.ts'
|
|
|
|
|
import {
|
|
|
|
|
decodeEventRow,
|
|
|
|
|
decodeSessionRow,
|
|
|
|
|
decodeStoreIdentity,
|
2026-07-24 10:35:09 +08:00
|
|
|
openDatabase,
|
2026-08-18 15:56:54 +08:00
|
|
|
validateSchemaForMutation,
|
2026-07-24 10:35:09 +08:00
|
|
|
rowToMeta,
|
|
|
|
|
SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
|
2026-08-18 15:56:54 +08:00
|
|
|
type SessionRow,
|
2026-07-24 10:35:09 +08:00
|
|
|
} from '../src/schema.ts'
|
2026-08-18 15:56:54 +08:00
|
|
|
import { SqliteStore } from '../src/store.ts'
|
|
|
|
|
import { sql } from '../src/sql.ts'
|
|
|
|
|
import { testSql } from './test-sql.ts'
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
|
|
|
|
|
const dirs: string[] = []
|
2026-08-18 15:56:54 +08:00
|
|
|
afterEach(async () => {
|
|
|
|
|
for (const directory of dirs.splice(0)) await rm(directory, { recursive: true, force: true })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function freshDbPath(prefix = 'dsh-sqlite-'): Promise<string> {
|
|
|
|
|
const directory = await mkdtemp(join(tmpdir(), prefix))
|
|
|
|
|
dirs.push(directory)
|
|
|
|
|
return join(directory, 'sessions.db')
|
|
|
|
|
}
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
async function backendFailure(path: string): Promise<unknown> {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
2026-07-15 11:31:55 +08:00
|
|
|
try {
|
2026-08-18 15:56:54 +08:00
|
|
|
await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
await ctx.sessionPersistence.list()
|
|
|
|
|
return undefined
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
return error
|
|
|
|
|
} finally {
|
|
|
|
|
await ctx.fiber.dispose()
|
2026-07-15 11:31:55 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
function errorMessage(error: unknown): string {
|
|
|
|
|
return error instanceof Error ? error.message : String(error)
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
function databaseWithJournalFailure(
|
|
|
|
|
nextFailure: () => Error | undefined,
|
|
|
|
|
): typeof DatabaseSync {
|
|
|
|
|
return class JournalFailureDatabase extends DatabaseSync {
|
|
|
|
|
override prepare(source: string) {
|
|
|
|
|
if (source !== sql('journal-mode-wal')) return super.prepare(source)
|
|
|
|
|
const statement = super.prepare(sql('journal-mode-wal'))
|
|
|
|
|
const get = statement.get.bind(statement)
|
|
|
|
|
Object.defineProperty(statement, 'get', {
|
|
|
|
|
value: () => {
|
|
|
|
|
const failure = nextFailure()
|
|
|
|
|
if (failure !== undefined) throw failure
|
|
|
|
|
return get()
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
return statement
|
|
|
|
|
}
|
|
|
|
|
}
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
function chunk(seq: number, text = `token-${seq}`): SessionEvent {
|
|
|
|
|
return {
|
|
|
|
|
type: 'assistant/chunk',
|
|
|
|
|
seq,
|
|
|
|
|
time: 1_000 + seq,
|
|
|
|
|
data: {
|
|
|
|
|
turn: 1,
|
|
|
|
|
step: 1,
|
|
|
|
|
chunk: { type: 'text-delta', index: 0, text },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chunkLog(count: number): SessionEvent[] {
|
|
|
|
|
return [
|
|
|
|
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
|
|
|
|
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
|
|
|
|
...Array.from({ length: count }, (_, index) => chunk(index + 2)),
|
|
|
|
|
{ type: 'step/end', seq: count + 2, time: count + 3, data: { turn: 1, step: 1 } },
|
|
|
|
|
{
|
|
|
|
|
type: 'turn/end',
|
|
|
|
|
seq: count + 3,
|
|
|
|
|
time: count + 4,
|
|
|
|
|
data: { turn: 1, reason: { kind: 'completed' } },
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function measureWriteTraffic(
|
|
|
|
|
path: string,
|
|
|
|
|
events: readonly SessionEvent[],
|
|
|
|
|
): Promise<{
|
|
|
|
|
readonly walBytes: number
|
|
|
|
|
readonly idleWalBytes: number
|
|
|
|
|
readonly rows: number
|
|
|
|
|
readonly largest: number
|
|
|
|
|
readonly inserted: number
|
|
|
|
|
readonly changed: number
|
|
|
|
|
readonly removed: number
|
|
|
|
|
}> {
|
|
|
|
|
interface PhysicalRow {
|
|
|
|
|
readonly rowid: number
|
|
|
|
|
readonly seq: number
|
|
|
|
|
readonly type: string
|
|
|
|
|
readonly time: number
|
|
|
|
|
readonly data: string | Uint8Array
|
|
|
|
|
readonly source_event_seqs: Uint8Array | null
|
|
|
|
|
readonly surface_op: string | null
|
|
|
|
|
readonly ignorable: number | null
|
|
|
|
|
}
|
|
|
|
|
const sameValue = (left: string | Uint8Array | null, right: string | Uint8Array | null): boolean => (
|
|
|
|
|
typeof left === 'string' || left === null
|
|
|
|
|
? left === right
|
|
|
|
|
: right instanceof Uint8Array && Buffer.from(left).equals(Buffer.from(right))
|
|
|
|
|
)
|
|
|
|
|
const sameRow = (left: PhysicalRow, right: PhysicalRow): boolean => (
|
|
|
|
|
left.rowid === right.rowid
|
|
|
|
|
&& left.seq === right.seq
|
|
|
|
|
&& left.type === right.type
|
|
|
|
|
&& left.time === right.time
|
|
|
|
|
&& sameValue(left.data, right.data)
|
|
|
|
|
&& sameValue(left.source_event_seqs, right.source_event_seqs)
|
|
|
|
|
&& left.surface_op === right.surface_op
|
|
|
|
|
&& left.ignorable === right.ignorable
|
|
|
|
|
)
|
2026-06-15 22:23:36 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
2026-08-18 15:56:54 +08:00
|
|
|
await ctx.plugin(SessionPersistenceSqlite, { path, writeBatchMaxDelayMs: 200 })
|
|
|
|
|
try {
|
|
|
|
|
const header = meta('traffic')
|
|
|
|
|
await ctx.sessionPersistence.create(header)
|
|
|
|
|
let previous = new Map<number, PhysicalRow>()
|
|
|
|
|
let inserted = 0
|
|
|
|
|
let changed = 0
|
|
|
|
|
let removed = 0
|
|
|
|
|
const probe = new DatabaseSync(path, { readOnly: true })
|
|
|
|
|
try {
|
|
|
|
|
const selectRows = probe.prepare(testSql('select-event-rows'))
|
|
|
|
|
for (let offset = 0; offset < events.length; offset += 40) {
|
|
|
|
|
await ctx.sessionPersistence.append(header.id, events.slice(offset, offset + 40))
|
|
|
|
|
const current = new Map((selectRows.all(header.id) as unknown as PhysicalRow[])
|
|
|
|
|
.map(row => [row.seq, row]))
|
|
|
|
|
for (const [seq, row] of current) {
|
|
|
|
|
const old = previous.get(seq)
|
|
|
|
|
if (old === undefined) inserted += 1
|
|
|
|
|
else if (!sameRow(old, row)) changed += 1
|
|
|
|
|
}
|
|
|
|
|
for (const seq of previous.keys()) if (!current.has(seq)) removed += 1
|
|
|
|
|
previous = current
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
probe.close()
|
|
|
|
|
}
|
|
|
|
|
const db = new DatabaseSync(path, { readOnly: true })
|
|
|
|
|
const measured = db.prepare(testSql('measure-write-traffic')).get() as { rows: number; largest: number }
|
|
|
|
|
db.close()
|
|
|
|
|
const walBytes = (await stat(`${path}-wal`)).size
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 250))
|
|
|
|
|
return {
|
|
|
|
|
walBytes,
|
|
|
|
|
idleWalBytes: (await stat(`${path}-wal`)).size,
|
|
|
|
|
rows: measured.rows,
|
|
|
|
|
largest: measured.largest,
|
|
|
|
|
inserted,
|
|
|
|
|
changed,
|
|
|
|
|
removed,
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
}
|
2026-06-15 22:23:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-19 21:08:03 +08:00
|
|
|
/** Yield immutable event copies as one replacement stream. */
|
2026-08-18 17:40:26 +08:00
|
|
|
async function* replacementEvents(events: readonly SessionEvent[]): AsyncIterable<SessionEvent> {
|
|
|
|
|
for (const event of events) yield structuredClone(event)
|
|
|
|
|
}
|
|
|
|
|
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
runPersistenceContract('sqlite', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
2026-08-18 15:56:54 +08:00
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
return {
|
|
|
|
|
persistence: ctx.sessionPersistence,
|
|
|
|
|
dispose: async () => { await fiber.dispose() },
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
refactor(session-persistence): extract a shared write coordinator
The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL
of their write-path orchestration — the four maps (states/buffers/chains/inits),
installWritePath, initFor, onCreated's four adoption cases, flush, drain,
serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/
has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows)
differed, so every fix landed twice.
Extract that orchestration into a PersistenceCoordinator in the seam package.
Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a
small PersistenceBackend hook interface (loadStored, loadLive, appendBatch,
commitRepair, deleteStored, list, optional close), and delegates its six public
service methods to it. Composition, not inheritance — a backend exposes only the
hooks, can't reach the coordinator's private state, and the public
SessionPersistence API is unchanged so a third-party backend may still implement
it directly.
The crash-repair torn-tail token is OPAQUE: the coordinator computes the
synthetic closers (it owns interruptedTurnClosers) but only tests
`tornMarker !== undefined` and round-trips it to commitRepair, never inspecting
it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so
HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not
a resume). appendBatch carries meta so lazy-materialize + first-batch commit
atomically (no separate materialize hook).
Tests: the duplicated orchestration tests (adoption, HMR, collision,
dispose-drain, crash-tail) move into one runCoordinatorContract suite run once
per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep
only storage mechanics. A through-coordinator torn-tail test per real backend
keeps the commitRepair-with-marker branch covered under the 100% gate.
Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100%
coverage; backends shrank ~1200 lines of duplicated churn. Migrates the
write-coordinator RFC proposed -> implemented.
2026-06-20 03:47:28 +08:00
|
|
|
runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
|
2026-08-18 15:56:54 +08:00
|
|
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
|
|
|
|
|
const path = join(directory, 'sessions.db')
|
refactor(session-persistence): extract a shared write coordinator
The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL
of their write-path orchestration — the four maps (states/buffers/chains/inits),
installWritePath, initFor, onCreated's four adoption cases, flush, drain,
serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/
has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows)
differed, so every fix landed twice.
Extract that orchestration into a PersistenceCoordinator in the seam package.
Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a
small PersistenceBackend hook interface (loadStored, loadLive, appendBatch,
commitRepair, deleteStored, list, optional close), and delegates its six public
service methods to it. Composition, not inheritance — a backend exposes only the
hooks, can't reach the coordinator's private state, and the public
SessionPersistence API is unchanged so a third-party backend may still implement
it directly.
The crash-repair torn-tail token is OPAQUE: the coordinator computes the
synthetic closers (it owns interruptedTurnClosers) but only tests
`tornMarker !== undefined` and round-trips it to commitRepair, never inspecting
it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so
HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not
a resume). appendBatch carries meta so lazy-materialize + first-batch commit
atomically (no separate materialize hook).
Tests: the duplicated orchestration tests (adoption, HMR, collision,
dispose-drain, crash-tail) move into one runCoordinatorContract suite run once
per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep
only storage mechanics. A through-coordinator torn-tail test per real backend
keeps the commitRepair-with-marker branch covered under the 100% gate.
Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100%
coverage; backends shrank ~1200 lines of duplicated churn. Migrates the
write-coordinator RFC proposed -> implemented.
2026-06-20 03:47:28 +08:00
|
|
|
return {
|
2026-08-18 15:56:54 +08:00
|
|
|
mount: async ctx => ctx.plugin(SessionPersistenceSqlite, { path }),
|
refactor(session-persistence): extract a shared write coordinator
The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL
of their write-path orchestration — the four maps (states/buffers/chains/inits),
installWritePath, initFor, onCreated's four adoption cases, flush, drain,
serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/
has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows)
differed, so every fix landed twice.
Extract that orchestration into a PersistenceCoordinator in the seam package.
Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a
small PersistenceBackend hook interface (loadStored, loadLive, appendBatch,
commitRepair, deleteStored, list, optional close), and delegates its six public
service methods to it. Composition, not inheritance — a backend exposes only the
hooks, can't reach the coordinator's private state, and the public
SessionPersistence API is unchanged so a third-party backend may still implement
it directly.
The crash-repair torn-tail token is OPAQUE: the coordinator computes the
synthetic closers (it owns interruptedTurnClosers) but only tests
`tornMarker !== undefined` and round-trips it to commitRepair, never inspecting
it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so
HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not
a resume). appendBatch carries meta so lazy-materialize + first-batch commit
atomically (no separate materialize hook).
Tests: the duplicated orchestration tests (adoption, HMR, collision,
dispose-drain, crash-tail) move into one runCoordinatorContract suite run once
per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep
only storage mechanics. A through-coordinator torn-tail test per real backend
keeps the commitRepair-with-marker branch covered under the 100% gate.
Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100%
coverage; backends shrank ~1200 lines of duplicated churn. Migrates the
write-coordinator RFC proposed -> implemented.
2026-06-20 03:47:28 +08:00
|
|
|
corruptTail: async (id) => {
|
2026-08-18 15:56:54 +08:00
|
|
|
const db = new DatabaseSync(path)
|
|
|
|
|
const last = db.prepare(testSql('select-last-event'))
|
|
|
|
|
.get(id) as { seq: number; type: string; data: string }
|
|
|
|
|
const logicalLength = last.type === 'text-chunks'
|
|
|
|
|
? (JSON.parse(last.data) as { texts: string[] }).texts.length
|
|
|
|
|
: 1
|
|
|
|
|
const next = last.seq + logicalLength
|
|
|
|
|
db.prepare(testSql('insert-corrupt-event'))
|
|
|
|
|
.run(id, next, 'assistant/chunk', 99, '{not valid json', null)
|
refactor(session-persistence): extract a shared write coordinator
The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL
of their write-path orchestration — the four maps (states/buffers/chains/inits),
installWritePath, initFor, onCreated's four adoption cases, flush, drain,
serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/
has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows)
differed, so every fix landed twice.
Extract that orchestration into a PersistenceCoordinator in the seam package.
Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a
small PersistenceBackend hook interface (loadStored, loadLive, appendBatch,
commitRepair, deleteStored, list, optional close), and delegates its six public
service methods to it. Composition, not inheritance — a backend exposes only the
hooks, can't reach the coordinator's private state, and the public
SessionPersistence API is unchanged so a third-party backend may still implement
it directly.
The crash-repair torn-tail token is OPAQUE: the coordinator computes the
synthetic closers (it owns interruptedTurnClosers) but only tests
`tornMarker !== undefined` and round-trips it to commitRepair, never inspecting
it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so
HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not
a resume). appendBatch carries meta so lazy-materialize + first-batch commit
atomically (no separate materialize hook).
Tests: the duplicated orchestration tests (adoption, HMR, collision,
dispose-drain, crash-tail) move into one runCoordinatorContract suite run once
per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep
only storage mechanics. A through-coordinator torn-tail test per real backend
keeps the commitRepair-with-marker branch covered under the 100% gate.
Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100%
coverage; backends shrank ~1200 lines of duplicated churn. Migrates the
write-coordinator RFC proposed -> implemented.
2026-06-20 03:47:28 +08:00
|
|
|
db.close()
|
|
|
|
|
},
|
2026-08-18 15:56:54 +08:00
|
|
|
cleanup: async () => { await rm(directory, { recursive: true, force: true }) },
|
refactor(session-persistence): extract a shared write coordinator
The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL
of their write-path orchestration — the four maps (states/buffers/chains/inits),
installWritePath, initFor, onCreated's four adoption cases, flush, drain,
serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/
has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows)
differed, so every fix landed twice.
Extract that orchestration into a PersistenceCoordinator in the seam package.
Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a
small PersistenceBackend hook interface (loadStored, loadLive, appendBatch,
commitRepair, deleteStored, list, optional close), and delegates its six public
service methods to it. Composition, not inheritance — a backend exposes only the
hooks, can't reach the coordinator's private state, and the public
SessionPersistence API is unchanged so a third-party backend may still implement
it directly.
The crash-repair torn-tail token is OPAQUE: the coordinator computes the
synthetic closers (it owns interruptedTurnClosers) but only tests
`tornMarker !== undefined` and round-trips it to commitRepair, never inspecting
it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so
HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not
a resume). appendBatch carries meta so lazy-materialize + first-batch commit
atomically (no separate materialize hook).
Tests: the duplicated orchestration tests (adoption, HMR, collision,
dispose-drain, crash-tail) move into one runCoordinatorContract suite run once
per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep
only storage mechanics. A through-coordinator torn-tail test per real backend
keeps the commitRepair-with-marker branch covered under the 100% gate.
Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100%
coverage; backends shrank ~1200 lines of duplicated churn. Migrates the
write-coordinator RFC proposed -> implemented.
2026-06-20 03:47:28 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
describe('SessionPersistenceSqlite physical packing', () => {
|
|
|
|
|
it('loads from cordis.yml and packs through the assembled service', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-loader-')
|
|
|
|
|
const configPath = join(path, '..', 'cordis.yml')
|
|
|
|
|
await writeFile(configPath, [
|
|
|
|
|
"- name: '@deepseek-ai/dsh-session'",
|
|
|
|
|
"- name: '@deepseek-ai/dsh-session-persistence-sqlite'",
|
|
|
|
|
' config:',
|
|
|
|
|
` path: ${JSON.stringify(path)}`,
|
|
|
|
|
'',
|
|
|
|
|
].join('\n'))
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
ctx.baseUrl = pathToFileURL(join(path, '..')).href + '/'
|
|
|
|
|
await ctx.plugin(Loader)
|
|
|
|
|
ctx.loader.builtins.include = Include
|
|
|
|
|
ctx.loader.internal = {
|
|
|
|
|
version: 'sqlite',
|
|
|
|
|
async import(specifier: string) {
|
|
|
|
|
if (specifier === '@deepseek-ai/dsh-session') return SessionStore
|
|
|
|
|
if (specifier === '@deepseek-ai/dsh-session-persistence-sqlite') {
|
|
|
|
|
return SessionPersistenceSqlite
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`unexpected Loader import: ${specifier}`)
|
|
|
|
|
},
|
|
|
|
|
} as unknown as NonNullable<typeof ctx.loader.internal>
|
|
|
|
|
await ctx.loader.create({
|
|
|
|
|
name: 'cordis:include',
|
|
|
|
|
config: { path: pathToFileURL(configPath).href },
|
2026-06-24 17:45:48 +08:00
|
|
|
})
|
2026-08-18 15:56:54 +08:00
|
|
|
await ctx.loader.await()
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
const header = meta('loader')
|
|
|
|
|
const events = chunkLog(4)
|
|
|
|
|
await ctx.sessionPersistence.create(header)
|
|
|
|
|
await ctx.sessionPersistence.append(header.id, events)
|
|
|
|
|
expect((await ctx.sessionPersistence.inspect(header.id)).events).toEqual(events)
|
|
|
|
|
await ctx.fiber.dispose()
|
2026-06-16 22:56:17 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
const db = new DatabaseSync(path)
|
|
|
|
|
expect(db.prepare(testSql('count-packed-events')).get())
|
|
|
|
|
.toEqual({ count: 1 })
|
|
|
|
|
db.close()
|
2026-06-16 22:56:17 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('packs each append once without rewriting earlier rows and seeks inside packed rows', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
const header = meta('packed')
|
|
|
|
|
const events = chunkLog(100)
|
|
|
|
|
await ctx.sessionPersistence.create(header)
|
|
|
|
|
await ctx.sessionPersistence.append(header.id, events.slice(0, 3))
|
|
|
|
|
await ctx.sessionPersistence.append(header.id, events.slice(3, 4))
|
|
|
|
|
const before = new DatabaseSync(path, { readOnly: true })
|
|
|
|
|
const originalRows = before.prepare(testSql('select-event-rowids')).all()
|
|
|
|
|
before.close()
|
|
|
|
|
await ctx.sessionPersistence.append(header.id, events.slice(4))
|
|
|
|
|
|
|
|
|
|
const inspected = await ctx.sessionPersistence.inspect(header.id)
|
|
|
|
|
expect(inspected.events).toEqual(events)
|
|
|
|
|
for (const fromSeq of [0, 2, 25, 101, 104, 105]) {
|
|
|
|
|
expect((await ctx.sessionPersistence.readFrom(header.id, fromSeq)).events)
|
|
|
|
|
.toEqual(events.filter(event => event.seq >= fromSeq))
|
|
|
|
|
}
|
|
|
|
|
await fiber.dispose()
|
2026-06-16 22:56:17 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
const db = new DatabaseSync(path)
|
|
|
|
|
expect(db.prepare(testSql('select-user-version')).get()).toEqual({ user_version: SCHEMA_VERSION })
|
|
|
|
|
expect(db.prepare(testSql('count-events')).get()).toEqual({ count: 7 })
|
|
|
|
|
expect(db.prepare(testSql('count-packed-events')).get())
|
|
|
|
|
.toEqual({ count: 1 })
|
|
|
|
|
expect(db.prepare(testSql('select-event-rowids')).all().slice(0, originalRows.length))
|
|
|
|
|
.toEqual(originalRows)
|
|
|
|
|
db.close()
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it.runIf(process.platform !== 'win32')('bounds paced-stream WAL extent without rewriting committed rows', async () => {
|
|
|
|
|
const events = chunkLog(1_000)
|
|
|
|
|
const measured = await measureWriteTraffic(await freshDbPath('dsh-sqlite-traffic-'), events)
|
2026-08-03 12:25:33 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
expect(measured).toMatchObject({ rows: 31, inserted: 31, changed: 0, removed: 0 })
|
|
|
|
|
expect(measured.inserted).toBe(measured.rows)
|
|
|
|
|
expect(measured.largest).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES)
|
|
|
|
|
expect(measured.idleWalBytes).toBe(measured.walBytes)
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('includes a packed predecessor when an overlapping scalar tail hides it', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-overlap-')
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
const header = meta('overlap')
|
|
|
|
|
await store.appendBatch(header, [chunk(0), chunk(1), chunk(2)], false)
|
2026-07-24 10:35:09 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
const db = new DatabaseSync(path)
|
|
|
|
|
db.prepare(testSql('insert-corrupt-event'))
|
|
|
|
|
.run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), null)
|
2026-07-13 23:56:10 +08:00
|
|
|
db.close()
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([chunk(2)])
|
|
|
|
|
|
|
|
|
|
const malformed = new DatabaseSync(path)
|
|
|
|
|
malformed.prepare(testSql('delete-session-events')).run(header.id)
|
|
|
|
|
malformed.prepare(testSql('insert-corrupt-event'))
|
|
|
|
|
.run(header.id, 0, 'text-chunks', 1, '{not json', 0)
|
|
|
|
|
malformed.close()
|
|
|
|
|
expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([])
|
|
|
|
|
await store.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('waits for a competing process within the configured busy timeout', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-busy-')
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: 1_000 })
|
|
|
|
|
const header = meta('busy')
|
|
|
|
|
await store.appendBatch(header, [chunk(0)], false)
|
|
|
|
|
|
|
|
|
|
const holder = spawn(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
|
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
|
|
|
const db = new DatabaseSync(process.argv[1]);
|
|
|
|
|
db.exec('BEGIN IMMEDIATE');
|
|
|
|
|
process.stdout.write('locked\n');
|
|
|
|
|
setTimeout(() => { db.exec('COMMIT'); db.close(); }, 100);
|
|
|
|
|
`, path], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
|
|
|
const exited = new Promise<number | null>((resolve, reject) => {
|
|
|
|
|
holder.once('error', reject)
|
|
|
|
|
holder.once('exit', resolve)
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
await once(holder.stdout, 'data')
|
|
|
|
|
await expect(store.appendBatch(header, [chunk(1)], true)).resolves.toBeUndefined()
|
|
|
|
|
const code = await exited
|
|
|
|
|
expect(code).toBe(0)
|
|
|
|
|
expect((await store.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)])
|
|
|
|
|
} finally {
|
|
|
|
|
if (holder.exitCode === null) holder.kill()
|
|
|
|
|
await store.close()
|
|
|
|
|
}
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('rejects an older SQLite physical schema', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-old-schema-')
|
|
|
|
|
const seed = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
|
|
|
|
|
seed.exec(testSql('set-user-version-16'))
|
|
|
|
|
seed.close()
|
|
|
|
|
await chmod(path, 0o600)
|
|
|
|
|
await expect(openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS))
|
|
|
|
|
.rejects.toThrow(/schema version 16.*incompatible/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a stale physical append without replacing the winning tail', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-stale-')
|
|
|
|
|
const first = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
const second = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
const header = meta(SessionId('stale'))
|
|
|
|
|
await first.appendBatch(header, [chunk(0)], false)
|
|
|
|
|
await second.appendBatch(header, [chunk(1)], true)
|
|
|
|
|
await expect(first.appendBatch(header, [chunk(1)], true)).rejects.toThrow(/stored next seq is 2/)
|
|
|
|
|
expect((await first.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)])
|
|
|
|
|
await first.close()
|
|
|
|
|
await second.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a stale repair without deleting a newer winning tail', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-stale-repair-')
|
|
|
|
|
const stale = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
const winner = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
const header = meta(SessionId('stale-repair'))
|
|
|
|
|
await stale.appendBatch(header, [chunk(0)], false)
|
|
|
|
|
const db = new DatabaseSync(path)
|
|
|
|
|
db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
|
2026-07-14 12:32:44 +08:00
|
|
|
db.close()
|
2026-08-18 15:56:54 +08:00
|
|
|
expect((await stale.loadStored(header.id))?.tornMarker).toBe(1)
|
|
|
|
|
await winner.commitRepair(header, 1, [])
|
|
|
|
|
await winner.appendBatch(header, [chunk(1), chunk(2)], true)
|
|
|
|
|
await expect(stale.commitRepair(header, 1, [])).rejects.toThrow(/repair is stale/)
|
|
|
|
|
expect((await stale.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1), chunk(2)])
|
|
|
|
|
await stale.close()
|
|
|
|
|
await winner.close()
|
2026-06-16 22:56:17 +08:00
|
|
|
})
|
2026-08-18 15:56:54 +08:00
|
|
|
})
|
2026-06-16 22:56:17 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
describe('SessionPersistenceSqlite schema ownership', () => {
|
|
|
|
|
it('accepts every configured journal mode and SQLite memory mode result', async () => {
|
|
|
|
|
const resources = {
|
|
|
|
|
wal: 'journal-mode-wal',
|
|
|
|
|
delete: 'journal-mode-delete',
|
|
|
|
|
truncate: 'journal-mode-truncate',
|
|
|
|
|
persist: 'journal-mode-persist',
|
|
|
|
|
} as const
|
|
|
|
|
for (const mode of ['wal', 'delete', 'truncate', 'persist'] as const) {
|
|
|
|
|
;(await openDatabase(DatabaseSync, ':memory:', mode, DEFAULT_BUSY_TIMEOUT_MS)).close()
|
|
|
|
|
const path = await freshDbPath(`dsh-sqlite-journal-${mode}-`)
|
|
|
|
|
const db = await openDatabase(DatabaseSync, path, mode, DEFAULT_BUSY_TIMEOUT_MS)
|
|
|
|
|
expect(db.prepare(sql(resources[mode])).get()).toEqual({ journal_mode: mode })
|
|
|
|
|
expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 })
|
|
|
|
|
expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 })
|
|
|
|
|
expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 })
|
|
|
|
|
db.close()
|
|
|
|
|
}
|
2026-06-16 22:56:17 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('retries a busy journal-mode transition within its retry budget', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-journal-busy-')
|
|
|
|
|
let attempts = 0
|
|
|
|
|
const BusyOnceDatabase = databaseWithJournalFailure(() => {
|
|
|
|
|
attempts += 1
|
|
|
|
|
return attempts === 1
|
|
|
|
|
? Object.assign(new Error('database is locked'), {
|
|
|
|
|
code: 'ERR_SQLITE_ERROR',
|
|
|
|
|
errcode: 5,
|
|
|
|
|
errstr: 'database is locked',
|
|
|
|
|
})
|
|
|
|
|
: undefined
|
|
|
|
|
})
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
const db = await openDatabase(BusyOnceDatabase, path, 'wal', 100)
|
|
|
|
|
expect(attempts).toBe(2)
|
|
|
|
|
expect(db.prepare(sql('journal-mode-wal')).get()).toEqual({ journal_mode: 'wal' })
|
|
|
|
|
expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 })
|
|
|
|
|
expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 })
|
|
|
|
|
expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 })
|
2026-06-16 00:05:25 +08:00
|
|
|
db.close()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('does not retry journal failures outside the available busy budget', async () => {
|
|
|
|
|
for (const { errcode, timeout } of [
|
|
|
|
|
{ errcode: 5, timeout: 0 },
|
|
|
|
|
{ errcode: 6, timeout: 100 },
|
|
|
|
|
]) {
|
|
|
|
|
let attempts = 0
|
|
|
|
|
const FailingDatabase = databaseWithJournalFailure(() => {
|
|
|
|
|
attempts += 1
|
|
|
|
|
return Object.assign(new Error(`SQLite error ${errcode}`), { errcode })
|
|
|
|
|
})
|
|
|
|
|
await expect(openDatabase(
|
|
|
|
|
FailingDatabase,
|
|
|
|
|
await freshDbPath(`dsh-sqlite-journal-failure-${errcode}-`),
|
|
|
|
|
'wal',
|
|
|
|
|
timeout,
|
|
|
|
|
)).rejects.toThrow(`SQLite error ${errcode}`)
|
|
|
|
|
expect(attempts).toBe(1)
|
|
|
|
|
}
|
2026-07-24 11:11:45 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('starts no journal retry after its open-relative cutoff', async () => {
|
|
|
|
|
let attempts = 0
|
|
|
|
|
const BusyDatabase = databaseWithJournalFailure(() => {
|
|
|
|
|
attempts += 1
|
|
|
|
|
return Object.assign(new Error('database is locked'), { errcode: 5 })
|
|
|
|
|
})
|
|
|
|
|
const clock = vi.spyOn(performance, 'now')
|
|
|
|
|
.mockReturnValueOnce(0)
|
|
|
|
|
.mockReturnValueOnce(50)
|
|
|
|
|
.mockReturnValueOnce(100)
|
|
|
|
|
try {
|
|
|
|
|
await expect(openDatabase(
|
|
|
|
|
BusyDatabase,
|
|
|
|
|
await freshDbPath('dsh-sqlite-journal-cutoff-'),
|
|
|
|
|
'wal',
|
|
|
|
|
100,
|
|
|
|
|
)).rejects.toThrow('database is locked')
|
|
|
|
|
} finally {
|
|
|
|
|
clock.mockRestore()
|
|
|
|
|
}
|
|
|
|
|
expect(attempts).toBe(1)
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('paces repeated busy journal-mode attempts', async () => {
|
2026-08-24 23:06:05 +08:00
|
|
|
const attemptedAt: number[] = []
|
|
|
|
|
const BusyTwiceDatabase = databaseWithJournalFailure(() => {
|
|
|
|
|
attemptedAt.push(performance.now())
|
|
|
|
|
return attemptedAt.length <= 2
|
|
|
|
|
? Object.assign(new Error('database is locked'), { errcode: 5 })
|
|
|
|
|
: undefined
|
2026-08-18 15:56:54 +08:00
|
|
|
})
|
2026-08-24 23:06:05 +08:00
|
|
|
const db = await openDatabase(
|
|
|
|
|
BusyTwiceDatabase,
|
2026-08-18 15:56:54 +08:00
|
|
|
await freshDbPath('dsh-sqlite-journal-paced-'),
|
|
|
|
|
'wal',
|
2026-08-24 23:06:05 +08:00
|
|
|
DEFAULT_BUSY_TIMEOUT_MS,
|
|
|
|
|
)
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
expect(attemptedAt).toHaveLength(3)
|
|
|
|
|
for (let index = 1; index < attemptedAt.length; index += 1) {
|
|
|
|
|
const previous = attemptedAt[index - 1]
|
|
|
|
|
const current = attemptedAt[index]
|
|
|
|
|
if (previous === undefined || current === undefined) throw new Error('missing journal attempt timestamp')
|
|
|
|
|
expect(current - previous).toBeGreaterThanOrEqual(5)
|
|
|
|
|
}
|
2026-08-18 15:56:54 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects unversioned, incompatible, and foreign-application databases', async () => {
|
|
|
|
|
const unversionedPath = await freshDbPath('dsh-sqlite-unversioned-')
|
|
|
|
|
const unversioned = new DatabaseSync(unversionedPath)
|
|
|
|
|
unversioned.exec(testSql('create-unrelated-table'))
|
|
|
|
|
unversioned.close()
|
|
|
|
|
await expect(openDatabase(DatabaseSync, unversionedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/unversioned schema/)
|
|
|
|
|
|
|
|
|
|
const incompatiblePath = await freshDbPath('dsh-sqlite-incompatible-')
|
|
|
|
|
const incompatible = new DatabaseSync(incompatiblePath)
|
|
|
|
|
incompatible.exec(testSql('set-user-version-16'))
|
|
|
|
|
incompatible.close()
|
|
|
|
|
await expect(openDatabase(DatabaseSync, incompatiblePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/incompatible with this build/)
|
|
|
|
|
|
|
|
|
|
const foreignPath = await freshDbPath('dsh-sqlite-foreign-')
|
|
|
|
|
const foreign = new DatabaseSync(foreignPath)
|
|
|
|
|
foreign.exec(testSql('set-user-version-17'))
|
|
|
|
|
foreign.exec(testSql('set-application-id-12345'))
|
2026-07-24 10:35:09 +08:00
|
|
|
foreign.close()
|
2026-08-18 15:56:54 +08:00
|
|
|
await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects changed columns and non-strict owned tables', async () => {
|
|
|
|
|
const changedPath = await freshDbPath('dsh-sqlite-columns-')
|
|
|
|
|
;(await openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close()
|
|
|
|
|
const changed = new DatabaseSync(changedPath)
|
|
|
|
|
changed.exec(testSql('add-unexpected-column'))
|
|
|
|
|
changed.close()
|
|
|
|
|
await expect(openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
|
|
|
|
|
|
|
|
|
|
const nonStrictPath = await freshDbPath('dsh-sqlite-nonstrict-')
|
|
|
|
|
;(await openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close()
|
|
|
|
|
const nonStrict = new DatabaseSync(nonStrictPath)
|
|
|
|
|
nonStrict.exec(testSql('replace-events-with-nonstrict-table'))
|
|
|
|
|
nonStrict.close()
|
|
|
|
|
await expect(openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
|
|
|
|
|
|
|
|
|
|
const loosePath = await freshDbPath('dsh-sqlite-loose-')
|
|
|
|
|
const loose = new DatabaseSync(loosePath)
|
|
|
|
|
loose.exec(testSql('create-loose-schema'))
|
|
|
|
|
loose.close()
|
|
|
|
|
await expect(openDatabase(DatabaseSync, loosePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects schema ownership changes observed at mutation time', async () => {
|
|
|
|
|
const changedVersion = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
|
|
|
|
|
changedVersion.exec(testSql('set-user-version-16'))
|
|
|
|
|
expect(() => { validateSchemaForMutation(DatabaseSync, changedVersion, ':memory:') })
|
|
|
|
|
.toThrow(/schema changed before mutation/)
|
|
|
|
|
changedVersion.close()
|
|
|
|
|
|
|
|
|
|
const changedApplication = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
|
|
|
|
|
changedApplication.exec(testSql('set-application-id-12345'))
|
|
|
|
|
expect(() => { validateSchemaForMutation(DatabaseSync, changedApplication, ':memory:') })
|
|
|
|
|
.toThrow(/application id changed before mutation/)
|
|
|
|
|
changedApplication.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('validates creation time and restores every optional header field', () => {
|
|
|
|
|
const base: SessionRow = {
|
|
|
|
|
id: 'stored-header',
|
|
|
|
|
version: 0,
|
2026-08-18 17:40:26 +08:00
|
|
|
created_at: 1,
|
2026-08-18 15:56:54 +08:00
|
|
|
cwd: '/project',
|
2026-08-18 17:40:26 +08:00
|
|
|
parent_session: 'parent',
|
2026-08-18 15:56:54 +08:00
|
|
|
seed_length: 4,
|
|
|
|
|
origin: 'subagent',
|
|
|
|
|
incarnation: '00000000-0000-4000-8000-000000000000',
|
2026-08-18 17:40:26 +08:00
|
|
|
revision: 1,
|
|
|
|
|
delegation_depth: 2,
|
|
|
|
|
agent_preset: 'minimal',
|
|
|
|
|
}
|
2026-08-18 15:56:54 +08:00
|
|
|
expect(rowToMeta(decodeSessionRow(base))).toMatchObject({
|
|
|
|
|
cwd: '/project',
|
2026-08-18 17:40:26 +08:00
|
|
|
parentSession: 'parent',
|
2026-08-18 15:56:54 +08:00
|
|
|
seedLength: 4,
|
2026-08-18 17:40:26 +08:00
|
|
|
origin: 'subagent',
|
|
|
|
|
delegationDepth: 2,
|
|
|
|
|
agentPreset: 'minimal',
|
|
|
|
|
})
|
2026-08-18 15:56:54 +08:00
|
|
|
expect(() => decodeSessionRow({ ...base, created_at: -1 })).toThrow(/created_at/)
|
|
|
|
|
expect(() => decodeSessionRow({ ...base, origin: 'external' })).toThrow(/origin/)
|
|
|
|
|
expect(() => decodeSessionRow({ ...base, delegation_depth: -1 })).toThrow(/delegation_depth/)
|
2026-08-18 17:40:26 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('rejects malformed SQLite row primitives generically', () => {
|
|
|
|
|
const base: SessionRow = {
|
|
|
|
|
id: 'stored-header',
|
2026-08-03 12:25:33 +08:00
|
|
|
version: 0,
|
|
|
|
|
created_at: 1,
|
2026-08-18 15:56:54 +08:00
|
|
|
cwd: '/project',
|
2026-07-24 10:35:09 +08:00
|
|
|
parent_session: null,
|
|
|
|
|
seed_length: null,
|
2026-08-01 09:41:52 +08:00
|
|
|
origin: null,
|
2026-08-18 15:56:54 +08:00
|
|
|
incarnation: '00000000-0000-4000-8000-000000000000',
|
2026-07-24 10:35:09 +08:00
|
|
|
revision: 1,
|
|
|
|
|
delegation_depth: null,
|
feat(web): choose the agent preset on the new-session screen
The composer seat spent nearly all its life disabled: a session's
composition is fixed once a turn has run. Move the choice to the
new-session screen beside the workspace picker, where it still works,
and let the session header report what a running session runs.
The hero pick is staged rather than applied — that screen precedes the
session it belongs to. It lands when a session becomes current and is
still blank, which covers both the session a workspace connect creates
and the blank one it reuses; riding `sessions.create` would miss the
second. It is spent on first use, matching the workspace picker.
Fix the durability the header field claimed but never had: `agentPreset`
was declared on `SessionHeader` and dropped by the JSONL header line, the
SQLite sessions row, the derived query index, and the cold list
projection, so every resumed session came back composed from nothing.
Add the web e2e lane that would have caught it — the one lane that mounts
the shipped roster, which needed `cordis:group` in the scaffold's Loader
builtins, as `mountRootInclude` already registers.
2026-08-05 18:51:11 +08:00
|
|
|
agent_preset: null,
|
2026-08-18 15:56:54 +08:00
|
|
|
}
|
|
|
|
|
for (const [value, message] of [
|
|
|
|
|
[null, /object/],
|
|
|
|
|
[{ ...base, id: 1 }, /id.*string/],
|
|
|
|
|
[{ ...base, id: '' }, /id.*empty/],
|
|
|
|
|
[{ ...base, version: '0' }, /version.*safe integer/],
|
|
|
|
|
[{ ...base, cwd: 'relative' }, /cwd.*absolute/],
|
|
|
|
|
[{ ...base, cwd: 1 }, /cwd.*string or null/],
|
|
|
|
|
[{ ...base, incarnation: 'invalid' }, /incarnation.*UUID/],
|
|
|
|
|
[{ ...base, seed_length: '1' }, /seed_length.*safe integer or null/],
|
|
|
|
|
[{ ...base, agent_preset: 1 }, /agent_preset.*string or null/],
|
|
|
|
|
] as const) {
|
|
|
|
|
expect(() => decodeSessionRow(value)).toThrow(message)
|
|
|
|
|
}
|
2026-07-10 20:52:27 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
const eventRow = {
|
|
|
|
|
seq: 0, type: 'turn/start', time: 1, data: '{}',
|
|
|
|
|
source_event_seqs: null, surface_op: null, ignorable: null,
|
|
|
|
|
}
|
|
|
|
|
for (const [value, message] of [
|
|
|
|
|
[null, /object/],
|
|
|
|
|
[{ ...eventRow, seq: '0' }, /seq.*safe integer/],
|
|
|
|
|
[{ ...eventRow, type: '' }, /type.*empty/],
|
|
|
|
|
[{ ...eventRow, time: '1' }, /time.*safe integer/],
|
|
|
|
|
[{ ...eventRow, data: 1 }, /data.*string or blob/],
|
|
|
|
|
[{ ...eventRow, source_event_seqs: 1 }, /source_event_seqs.*blob or null/],
|
|
|
|
|
[{ ...eventRow, ignorable: 2 }, /ignorable.*0, 1, or null/],
|
|
|
|
|
] as const) {
|
|
|
|
|
expect(() => decodeEventRow(value)).toThrow(message)
|
|
|
|
|
}
|
|
|
|
|
expect(() => decodeStoreIdentity({ store_id: 'invalid' })).toThrow(/store_id.*UUID/)
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('rejects invalid durable metadata before exposing a session header', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-metadata-')
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
const header = meta('invalid-metadata')
|
|
|
|
|
await store.appendBatch(header, [chunk(0)], false)
|
|
|
|
|
const db = new DatabaseSync(path)
|
|
|
|
|
db.prepare(testSql('update-invalid-session-metadata')).run(header.id)
|
2026-06-16 00:05:25 +08:00
|
|
|
db.close()
|
2026-08-18 15:56:54 +08:00
|
|
|
await expect(store.list()).rejects.toThrow(/seed_length|origin|delegation_depth/)
|
|
|
|
|
await expect(store.loadStored(header.id)).rejects.toThrow(/seed_length|origin|delegation_depth/)
|
|
|
|
|
await store.close()
|
2026-06-16 00:05:25 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('uses the shared persistence application identity', () => {
|
|
|
|
|
expect(SESSION_PERSISTENCE_SQLITE_APPLICATION_ID).toBe(0x44534850)
|
2026-06-16 00:05:25 +08:00
|
|
|
})
|
2026-08-18 15:56:54 +08:00
|
|
|
})
|
2026-06-16 00:05:25 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
describe('SessionPersistenceSqlite edge behavior', () => {
|
2026-08-22 18:52:27 +08:00
|
|
|
it('materializes an explicitly durable empty live session', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-empty-')
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
const session = ctx.sessions.create(SessionId('empty'), { meta: { cwd: '/workspace' } })
|
|
|
|
|
|
|
|
|
|
await ctx.sessionPersistence.ensureMaterialized(session)
|
|
|
|
|
|
|
|
|
|
await expect(ctx.sessionPersistence.list()).resolves.toEqual([session.header])
|
|
|
|
|
await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ meta: session.header, events: [] })
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('keeps a fresh database unopened until the first persistence operation', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-lazy-')
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
2026-08-18 15:56:54 +08:00
|
|
|
await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
|
|
|
const emitWarning = Reflect.get(process, 'emitWarning')
|
|
|
|
|
expect(await ctx.sessionPersistence.list()).toEqual([])
|
|
|
|
|
expect(Reflect.get(process, 'emitWarning')).toBe(emitWarning)
|
|
|
|
|
expect(typeof (await stat(path)).size).toBe('number')
|
|
|
|
|
await ctx.fiber.dispose()
|
2026-07-23 22:08:58 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('disposes after path validation without opening the database', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-unused-')
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
2026-07-24 11:11:45 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
const untouchedPath = await freshDbPath('dsh-sqlite-never-validated-')
|
|
|
|
|
const untouched = new SqliteStore({
|
|
|
|
|
path: untouchedPath,
|
|
|
|
|
journalMode: 'wal',
|
|
|
|
|
busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS,
|
|
|
|
|
})
|
|
|
|
|
await untouched.close()
|
|
|
|
|
await expect(stat(untouchedPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
2026-07-24 10:35:09 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('uses constructor defaults and exposes locate and prepare directly', async () => {
|
2026-08-06 04:09:16 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
2026-08-18 15:56:54 +08:00
|
|
|
let persistence!: SessionPersistenceSqlite
|
2026-08-06 04:09:16 +08:00
|
|
|
await ctx.plugin(Object.assign((inner: Context) => {
|
2026-08-18 15:56:54 +08:00
|
|
|
persistence = new SessionPersistenceSqlite(inner, { path: ':memory:' })
|
2026-08-06 04:09:16 +08:00
|
|
|
}, { inject: ['sessions'] }))
|
2026-07-24 10:35:09 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
const header = meta('direct-provider')
|
|
|
|
|
const events = chunkLog(3)
|
|
|
|
|
expect(persistence.locate(header)).toBeUndefined()
|
|
|
|
|
await persistence.create(header)
|
|
|
|
|
await persistence.append(header.id, events)
|
|
|
|
|
const preparation = await persistence.prepare(header.id)
|
|
|
|
|
expect(preparation.session.header).toEqual(header)
|
|
|
|
|
preparation[Symbol.dispose]()
|
2026-08-06 04:09:16 +08:00
|
|
|
await ctx.fiber.dispose()
|
2026-07-24 10:35:09 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('keeps empty mutations inert and rolls back a repair without metadata', async () => {
|
|
|
|
|
const store = new SqliteStore({
|
2026-08-06 01:12:17 +08:00
|
|
|
path: ':memory:',
|
2026-08-18 15:56:54 +08:00
|
|
|
journalMode: 'wal',
|
|
|
|
|
busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS,
|
2026-08-06 01:12:17 +08:00
|
|
|
})
|
2026-08-18 15:56:54 +08:00
|
|
|
const header = meta('empty-store')
|
|
|
|
|
await store.appendBatch(header, [], false)
|
|
|
|
|
await store.commitRepair(header, undefined, [])
|
|
|
|
|
expect(await store.readStoredRevision(header.id)).toBeUndefined()
|
|
|
|
|
await expect(store.commitRepair(header, 0, [])).rejects.toThrow(/metadata row is missing/)
|
|
|
|
|
await store.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects omitted torn markers and stale closer positions', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-repair-validation-')
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
const header = meta('repair-validation')
|
|
|
|
|
await store.appendBatch(header, [chunk(0)], false)
|
2026-07-24 10:35:09 +08:00
|
|
|
const db = new DatabaseSync(path)
|
2026-08-18 15:56:54 +08:00
|
|
|
db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
|
2026-07-24 10:35:09 +08:00
|
|
|
db.close()
|
2026-08-18 15:56:54 +08:00
|
|
|
await expect(store.commitRepair(header, undefined, [chunk(1)])).rejects.toThrow(/omitted current torn tail/)
|
|
|
|
|
await store.commitRepair(header, 1, [])
|
|
|
|
|
await expect(store.commitRepair(header, undefined, [chunk(2)])).rejects.toThrow(/closer starts at seq 2/)
|
|
|
|
|
|
|
|
|
|
const cleared = new DatabaseSync(path)
|
|
|
|
|
cleared.prepare(testSql('delete-session-events')).run(header.id)
|
|
|
|
|
cleared.close()
|
|
|
|
|
await store.commitRepair(header, undefined, [chunk(0)])
|
|
|
|
|
expect((await store.loadStored(header.id))?.events).toEqual([chunk(0)])
|
|
|
|
|
await store.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects malformed physical tail rows before appending', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-tail-')
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
const header = meta('invalid-tail')
|
|
|
|
|
await store.appendBatch(header, [chunk(0)], false)
|
|
|
|
|
const db = new DatabaseSync(path)
|
|
|
|
|
db.prepare(testSql('insert-corrupt-event'))
|
|
|
|
|
.run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
|
2026-06-24 17:45:48 +08:00
|
|
|
db.close()
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
await expect(store.appendBatch(header, [chunk(2)], true)).rejects.toThrow(/invalid physical tail/)
|
|
|
|
|
await store.close()
|
2026-06-24 17:45:48 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('rejects missing and empty store identities', async () => {
|
|
|
|
|
for (const mode of ['missing', 'empty'] as const) {
|
|
|
|
|
const path = await freshDbPath(`dsh-sqlite-identity-${mode}-`)
|
|
|
|
|
const db = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
|
|
|
|
|
if (mode === 'missing') db.exec(testSql('delete-persistence-state'))
|
|
|
|
|
else db.exec(testSql('empty-store-id'))
|
|
|
|
|
db.close()
|
|
|
|
|
await chmod(path, 0o600)
|
2026-06-15 22:23:36 +08:00
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
expect(errorMessage(await backendFailure(path))).toMatch(/no valid store identity/)
|
2026-06-22 10:35:59 +08:00
|
|
|
}
|
2026-06-15 22:23:36 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('rejects invalid paths during service initialization', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-invalid-path-')
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
2026-08-18 15:56:54 +08:00
|
|
|
await expect(ctx.plugin(SessionPersistenceSqlite, { path: `${path}\0` })).rejects.toMatchObject({
|
|
|
|
|
code: 'ERR_INVALID_ARG_VALUE',
|
|
|
|
|
})
|
|
|
|
|
await ctx.fiber.dispose()
|
feat(session-persistence-sqlite): second backend validating the abstraction
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".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-08-18 15:56:54 +08:00
|
|
|
it('rejects non-files and symbolic links', async () => {
|
|
|
|
|
const directoryPath = await freshDbPath('dsh-sqlite-directory-')
|
|
|
|
|
await mkdir(directoryPath)
|
|
|
|
|
expect(errorMessage(await backendFailure(directoryPath)))
|
|
|
|
|
.toMatch(/must be a regular file/)
|
|
|
|
|
|
|
|
|
|
const linkPath = await freshDbPath('dsh-sqlite-link-')
|
|
|
|
|
const target = join(linkPath, '..', 'target.db')
|
|
|
|
|
await writeFile(target, '')
|
|
|
|
|
await symlink(target, linkPath)
|
|
|
|
|
expect(errorMessage(await backendFailure(linkPath)))
|
|
|
|
|
.toMatch(/not a symbolic link/)
|
|
|
|
|
|
|
|
|
|
const parentLinkPath = await freshDbPath('dsh-sqlite-parent-link-')
|
|
|
|
|
const realParent = join(parentLinkPath, '..', 'real-parent')
|
|
|
|
|
const linkedParent = join(parentLinkPath, '..', 'linked-parent')
|
|
|
|
|
await mkdir(realParent, { mode: 0o700 })
|
|
|
|
|
await symlink(realParent, linkedParent)
|
|
|
|
|
expect(errorMessage(await backendFailure(join(linkedParent, 'sessions.db'))))
|
|
|
|
|
.toMatch(/must be a real directory/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.runIf(
|
|
|
|
|
process.getuid !== undefined && process.getuid() !== 0,
|
|
|
|
|
)('rejects permissive files and writable parents', async () => {
|
|
|
|
|
const permissivePath = await freshDbPath('dsh-sqlite-permissive-')
|
|
|
|
|
await writeFile(permissivePath, '')
|
|
|
|
|
await chmod(permissivePath, 0o644)
|
|
|
|
|
expect(errorMessage(await backendFailure(permissivePath)))
|
|
|
|
|
.toMatch(/accessible only by that user/)
|
|
|
|
|
|
|
|
|
|
const writableParentPath = await freshDbPath('dsh-sqlite-parent-')
|
|
|
|
|
await chmod(join(writableParentPath, '..'), 0o770)
|
|
|
|
|
expect(errorMessage(await backendFailure(writableParentPath)))
|
|
|
|
|
.toMatch(/not group\/world-writable/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('surfaces database creation failures after path validation', async () => {
|
|
|
|
|
const path = await freshDbPath('dsh-sqlite-create-failure-')
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
await store.validatePath()
|
|
|
|
|
const parent = join(path, '..')
|
|
|
|
|
await rm(parent, { recursive: true })
|
|
|
|
|
await writeFile(parent, 'not a directory')
|
|
|
|
|
await expect(store.open()).rejects.toThrow(/ENOENT|ENOTDIR/)
|
|
|
|
|
await store.close()
|
2026-07-15 12:32:18 +08:00
|
|
|
})
|
2026-06-22 10:35:59 +08:00
|
|
|
})
|
2026-07-15 12:32:18 +08:00
|
|
|
|
2026-08-19 21:08:03 +08:00
|
|
|
describe('SessionPersistenceSqlite stored-source and replacement primitives', () => {
|
2026-08-18 17:40:26 +08:00
|
|
|
it('binds a stored source to the same revision as a lightweight read', async () => {
|
2026-08-19 21:08:03 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
2026-08-06 03:45:22 +08:00
|
|
|
const m = meta('stored-prefix-revision')
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.appendBatch(m, oneTurnLog(), false)
|
|
|
|
|
|
|
|
|
|
const stored = await store.openStored(m.id)
|
|
|
|
|
expect(stored?.revision).toBe(await store.readStoredRevision(m.id))
|
|
|
|
|
expect(await store.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
|
|
|
|
|
await store.close()
|
2026-08-18 17:40:26 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects revision-bound full and suffix readers after the row changes or disappears', async () => {
|
2026-08-19 21:08:03 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
2026-08-18 17:40:26 +08:00
|
|
|
const m = meta('stored-reader-conflict')
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.appendBatch(m, oneTurnLog(), false)
|
|
|
|
|
const changed = await store.openStored(m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
if (changed === undefined) throw new Error('test session must be materialized')
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.appendBatch(m, [
|
2026-08-18 17:40:26 +08:00
|
|
|
{ type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } },
|
2026-08-19 21:08:03 +08:00
|
|
|
], true)
|
2026-08-18 17:40:26 +08:00
|
|
|
const changedRead = changed.readEvents()
|
|
|
|
|
const changedCompletion = changedRead.completed.catch((error: unknown) => error)
|
|
|
|
|
await expect((async () => { for await (const _event of changedRead.events) { /* consume */ } })())
|
|
|
|
|
.rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
|
|
|
|
|
await expect(changedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError)
|
|
|
|
|
|
2026-08-19 21:08:03 +08:00
|
|
|
const removed = await store.openStored(m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
if (removed === undefined) throw new Error('test session must remain materialized')
|
2026-08-19 21:08:03 +08:00
|
|
|
const db = (store as unknown as { db: DatabaseSync }).db
|
2026-08-20 14:21:47 +08:00
|
|
|
db.prepare(testSql('delete-session-by-id')).run(m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
const removedRead = removed.readEvents({ fromSeq: 1 })
|
|
|
|
|
const removedCompletion = removedRead.completed.catch((error: unknown) => error)
|
|
|
|
|
await expect((async () => { for await (const _event of removedRead.events) { /* consume */ } })())
|
|
|
|
|
.rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
|
|
|
|
|
await expect(removedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError)
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.close()
|
2026-08-18 17:40:26 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rolls back a suffix snapshot when its SQL read fails and reports absent direct snapshots', async () => {
|
2026-08-19 21:08:03 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
|
|
|
|
expect(await store.loadStored(SessionId('missing-prefix'))).toBeUndefined()
|
|
|
|
|
expect(await store.loadStoredFrom(SessionId('missing-suffix'), 1)).toBeUndefined()
|
2026-08-18 17:40:26 +08:00
|
|
|
|
|
|
|
|
const m = meta('suffix-rollback')
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.appendBatch(m, oneTurnLog(), false)
|
|
|
|
|
const db = (store as unknown as { db: DatabaseSync }).db
|
|
|
|
|
const prepare = db.prepare.bind(db)
|
|
|
|
|
const spy = vi.spyOn(db, 'prepare').mockImplementation((source) => {
|
|
|
|
|
if (source.includes('seq >= ?')) throw new Error('simulated suffix SELECT failure')
|
|
|
|
|
return prepare(source)
|
2026-08-18 17:40:26 +08:00
|
|
|
})
|
2026-08-19 21:08:03 +08:00
|
|
|
await expect(store.loadStoredFrom(m.id, 1)).rejects.toThrow('simulated suffix SELECT failure')
|
2026-08-18 17:40:26 +08:00
|
|
|
spy.mockRestore()
|
2026-08-20 14:21:47 +08:00
|
|
|
expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n)
|
2026-08-18 17:40:26 +08:00
|
|
|
.toBe(oneTurnLog().length)
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.close()
|
2026-08-18 17:40:26 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('atomically replaces one exact revision and rejects a stale replacement', async () => {
|
2026-08-19 21:08:03 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
2026-08-18 17:40:26 +08:00
|
|
|
const m = meta('format-replace')
|
|
|
|
|
const original = [
|
|
|
|
|
...oneTurnLog(),
|
2026-08-19 21:08:03 +08:00
|
|
|
{ type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } },
|
|
|
|
|
{ type: 'turn/end', seq: oneTurnLog().length + 1, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
2026-08-18 17:40:26 +08:00
|
|
|
] as SessionEvent[]
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.appendBatch(m, original, false)
|
|
|
|
|
const source = await store.openStored(m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
if (source === undefined) throw new Error('test session must be materialized')
|
|
|
|
|
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.replaceStored(source.revision, m, replacementEvents(oneTurnLog()))
|
|
|
|
|
const replaced = await store.openStored(m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
if (replaced === undefined) throw new Error('replacement must preserve the session')
|
|
|
|
|
expect(replaced.revision).not.toBe(source.revision)
|
2026-08-19 21:08:03 +08:00
|
|
|
expect((await store.loadStored(m.id))?.events).toEqual(oneTurnLog())
|
2026-08-18 17:40:26 +08:00
|
|
|
|
|
|
|
|
await expect(
|
2026-08-19 21:08:03 +08:00
|
|
|
store.replaceStored(source.revision, m, replacementEvents(original)),
|
2026-08-18 17:40:26 +08:00
|
|
|
).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
|
2026-08-19 21:08:03 +08:00
|
|
|
expect((await store.loadStored(m.id))?.events).toEqual(oneTurnLog())
|
|
|
|
|
await store.close()
|
2026-08-18 17:40:26 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects replacement identity changes before and during the transaction', async () => {
|
2026-08-19 21:08:03 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
2026-08-18 17:40:26 +08:00
|
|
|
const m = meta('format-replace-identity', '/work')
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.appendBatch(m, oneTurnLog(), false)
|
|
|
|
|
const source = await store.openStored(m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
if (source === undefined) throw new Error('test session must be materialized')
|
|
|
|
|
|
2026-08-19 21:08:03 +08:00
|
|
|
await expect(store.replaceStored(
|
2026-08-18 17:40:26 +08:00
|
|
|
source.revision,
|
|
|
|
|
{ ...m, cwd: '/other' },
|
|
|
|
|
replacementEvents(oneTurnLog()),
|
|
|
|
|
)).rejects.toThrow(/changes its stored identity/)
|
|
|
|
|
|
2026-08-19 21:08:03 +08:00
|
|
|
const db = (store as unknown as { db: DatabaseSync }).db
|
2026-08-18 17:40:26 +08:00
|
|
|
const changesDuringStaging = (async function* (): AsyncIterable<SessionEvent> {
|
|
|
|
|
yield* oneTurnLog()
|
2026-08-20 14:21:47 +08:00
|
|
|
db.prepare(testSql('update-session-cwd')).run('/raced', m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
})()
|
2026-08-19 21:08:03 +08:00
|
|
|
await expect(store.replaceStored(source.revision, m, changesDuringStaging))
|
2026-08-18 17:40:26 +08:00
|
|
|
.rejects.toThrow(/changes its stored identity/)
|
2026-08-20 14:21:47 +08:00
|
|
|
expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n)
|
2026-08-18 17:40:26 +08:00
|
|
|
.toBe(oneTurnLog().length)
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.close()
|
2026-08-18 17:40:26 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a revision change that occurs while replacement events are staged', async () => {
|
2026-08-19 21:08:03 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
2026-08-18 17:40:26 +08:00
|
|
|
const m = meta('format-replace-staging-race', '/work')
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.appendBatch(m, oneTurnLog(), false)
|
|
|
|
|
const source = await store.openStored(m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
if (source === undefined) throw new Error('test session must be materialized')
|
2026-08-19 21:08:03 +08:00
|
|
|
const db = (store as unknown as { db: DatabaseSync }).db
|
2026-08-18 17:40:26 +08:00
|
|
|
const changesDuringStaging = (async function* (): AsyncIterable<SessionEvent> {
|
|
|
|
|
yield* oneTurnLog()
|
2026-08-20 14:21:47 +08:00
|
|
|
db.prepare(testSql('update-session-revision')).run(m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
})()
|
|
|
|
|
|
2026-08-19 21:08:03 +08:00
|
|
|
await expect(store.replaceStored(source.revision, m, changesDuringStaging))
|
2026-08-18 17:40:26 +08:00
|
|
|
.rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
|
2026-08-20 14:21:47 +08:00
|
|
|
expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n)
|
2026-08-18 17:40:26 +08:00
|
|
|
.toBe(oneTurnLog().length)
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.close()
|
2026-08-18 17:40:26 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rolls back the complete replacement when the transaction fails after it begins', async () => {
|
2026-08-19 21:08:03 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
|
2026-08-18 17:40:26 +08:00
|
|
|
const m = meta('format-replace-rollback')
|
2026-08-19 21:08:03 +08:00
|
|
|
await store.appendBatch(m, oneTurnLog(), false)
|
|
|
|
|
const source = await store.openStored(m.id)
|
2026-08-18 17:40:26 +08:00
|
|
|
if (source === undefined) throw new Error('test session must be materialized')
|
2026-08-19 21:08:03 +08:00
|
|
|
const db = (store as unknown as { db: DatabaseSync }).db
|
2026-08-20 14:21:47 +08:00
|
|
|
db.exec(testSql('create-temp-replace-trigger'))
|
2026-08-18 17:40:26 +08:00
|
|
|
|
|
|
|
|
await expect(
|
2026-08-19 21:08:03 +08:00
|
|
|
store.replaceStored(source.revision, m, replacementEvents([])),
|
2026-08-18 17:40:26 +08:00
|
|
|
).rejects.toThrow(/simulated format replacement failure/)
|
2026-08-20 14:21:47 +08:00
|
|
|
db.exec(testSql('drop-temp-replace-trigger'))
|
2026-08-18 17:40:26 +08:00
|
|
|
|
2026-08-19 21:08:03 +08:00
|
|
|
expect((await store.loadStored(m.id))?.events).toEqual(oneTurnLog())
|
|
|
|
|
await store.close()
|
2026-06-22 10:35:59 +08:00
|
|
|
})
|
|
|
|
|
})
|