feat(storage): sqlite backend — one database hosting all routed units

node:sqlite DatabaseSync with the session-persistence-sqlite open
sequence (0o700 dir, exclusive 0o600 create, foreign_keys, configurable
journal mode, user_version stamp-or-reject). STRICT tables throughout:
units/unit_globals meta tables plus one document-per-row table per
declared unit table, keeping per-key durable updates precise.
This commit is contained in:
imccyu
2026-07-25 11:08:04 +08:00
parent 1529be6fd4
commit 9dee9e1a71
8 changed files with 648 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# @deepseek-ai/dsh-storage-sqlite
SQLite backend for the [storage hub](../storage/README.md): registers as backend `sqlite`, serving the `kv` facet over one `node:sqlite` database file (or `:memory:`). Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
## Storage model
Document-per-row: each unit table becomes a physical `"u_<unit>_<table>" (key TEXT PRIMARY KEY, value TEXT)` STRICT table whose `value` is the record's JSON text, so one key updates one row (the reason to route a high-churn domain here instead of the JSON backend). Unit identity lives in two metadata tables — `units` stamps each unit's format version at first open and rejects a differing descriptor with `version-mismatch`; `unit_globals` holds each unit's global singleton row. The physical layout version lives in `PRAGMA user_version`; any other stamped value rejects (unreleased format, no migrations). Unit and table names are validated against the hub's `UNIT_NAME_RE` before they reach DDL, so no external input is ever interpolated into SQL identifiers.
Every write primitive is a single prepared statement — SQLite's per-statement atomicity satisfies the KV contract without explicit transactions, and write ordering stays the caller's responsibility (the domain layer's write chain). Missing directories and database files are created owner-only (`0o700`/`0o600`), matching the session-persistence SQLite backend, whose open sequence this package copies verbatim until the planned media-layer extraction.
## Configuration (schemastery)
```ts
interface Config {
path: string // SQLite database file path, or ':memory:' for an in-process DB
journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
}
```
## Model Experience
### What the model sees
Nothing. This backend contributes no prompt, tool, or schema; it persists non-session domain data for host-side consumers.
### Token effect
Zero live-request tokens.
### KV Cache effect
None — no live request prefixes are touched.
## Known Limitations and Deferred Work
- **`DatabaseSync` is synchronous** — each write blocks the event loop for its (single-statement) duration; acceptable at domain-data scale.
- **No busy-wait or retry policy** — another connection holding a write transaction rejects the operation immediately; multi-process write protection is on the design's future-work list.
- **Only the current `STORAGE_SQLITE_SCHEMA_VERSION` opens** — any other stamped version is rejected rather than migrated (pre-release stance).
- **`openDatabase` duplicates the session-persistence SQLite open sequence** — extraction into a shared media layer is deferred to the planned session-backend migration (see the Agent Note's reuse audit).
@@ -0,0 +1,42 @@
{
"name": "@deepseek-ai/dsh-storage-sqlite",
"description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-storage": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,167 @@
/**
* SQLite storage backend for the storage hub: one database file hosts every
* routed unit, document-per-row (`key TEXT` / `value TEXT` JSON). Registers
* as backend `sqlite`; the disposer unregisters first, then closes the medium.
* @module @deepseek-ai/dsh-storage-sqlite
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { DatabaseSync } from 'node:sqlite'
import { StorageError, UNIT_NAME_RE } from '@deepseek-ai/dsh-storage'
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
import { openDatabase, recordTableName, type JournalMode } from './schema.ts'
import { SqliteKvUnit } from './unit.ts'
export { STORAGE_SQLITE_SCHEMA_VERSION, type JournalMode } from './schema.ts'
/** Cordis plugin name. */
export const name = 'storage-sqlite'
/** The backend registers on the storage hub. */
export const inject = ['storage']
/** Plugin configuration. */
export interface Config {
/**
* Filesystem path to the SQLite database file. The special value `:memory:`
* opens an in-process database (tests). On filesystems with POSIX modes,
* missing directories and databases are created owner-only; existing path
* modes are preserved. Filesystem setup errors other than an existing
* database fail the open. The backend does not protect confidentiality or
* integrity when another principal can replace the database entry in its
* parent directory.
*/
path: string
/**
* SQLite `journal_mode` pragma. `wal` (the default) suits local disks; pick
* a rollback-journal mode (`delete`/`truncate`/`persist`) on filesystems
* where WAL's shared-memory files do not work (network mounts). See
* {@link JournalMode}.
*/
journalMode?: JournalMode
}
/** Schemastery validator for {@link Config}. */
export const Config: z<Config> = z.object({
path: z.string().required(),
journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'),
})
/**
* The SQLite {@link StorageBackend}. Owns one `DatabaseSync` connection and
* the open-unit table; `kv.open` validates names, enforces the per-unit
* version stamp in `units`, and ensures the unit's record tables.
*/
export class SqliteStorageBackend implements StorageBackend {
/** The key-value facet; the only shape this backend serves. */
readonly kv: KvFacet = { open: descriptor => this.openUnit(descriptor) }
private readonly ready: Promise<DatabaseSync>
/** Open (or still-opening) units by name; presence is the double-open guard. */
private readonly units = new Map<string, Promise<SqliteKvUnit>>()
private closing: Promise<void> | undefined
/**
* @param config - Validated plugin configuration.
*/
constructor(config: Config) {
this.ready = openDatabase(config.path, (config as Required<Config>).journalMode)
// Mark the rejection handled: every primitive re-awaits `ready`, so an
// open failure still surfaces to each caller; this guard only prevents an
// unhandled-rejection crash when the failure precedes the first use.
this.ready.catch(() => {})
}
private openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
if (this.closing !== undefined) {
return Promise.reject(new StorageError('closed', 'sqlite storage backend is closed'))
}
if (!UNIT_NAME_RE.test(descriptor.name)) {
return Promise.reject(new Error(`kv unit name '${descriptor.name}' violates ${UNIT_NAME_RE}`))
}
for (const table of descriptor.tables) {
if (!UNIT_NAME_RE.test(table)) {
return Promise.reject(new Error(`kv table name '${table}' in unit '${descriptor.name}' violates ${UNIT_NAME_RE}`))
}
}
if (this.units.has(descriptor.name)) {
return Promise.reject(new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`))
}
// Reserve the name synchronously so a concurrent second open of the same
// name rejects instead of racing past the guard during the awaits below.
const pending = this.materializeUnit(descriptor)
this.units.set(descriptor.name, pending)
pending.catch(() => this.units.delete(descriptor.name))
return pending
}
private async materializeUnit(descriptor: KvUnitDescriptor): Promise<SqliteKvUnit> {
const db = await this.ready
const row = db.prepare('SELECT version FROM units WHERE name = ?').get(descriptor.name) as
| { version: number }
| undefined
if (row === undefined) {
db.prepare('INSERT INTO units (name, version) VALUES (?, ?)').run(descriptor.name, descriptor.version)
} else if (row.version !== descriptor.version) {
throw new StorageError(
'version-mismatch',
`kv unit '${descriptor.name}' is stamped version ${row.version} on the medium, incompatible with descriptor version ${descriptor.version}`,
)
}
for (const table of descriptor.tables) {
// Both segments passed UNIT_NAME_RE, so the identifier is safe in DDL.
db.exec(`
CREATE TABLE IF NOT EXISTS "${recordTableName(descriptor.name, table)}" (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT
`)
}
return new SqliteKvUnit(db, descriptor, () => {
this.units.delete(descriptor.name)
})
}
/**
* Close every open unit and release the database. Idempotent; concurrent
* and repeated calls resolve once teardown finishes.
* @returns resolution after the medium is released.
*/
close(): Promise<void> {
this.closing ??= this.doClose()
return this.closing
}
private async doClose(): Promise<void> {
let db: DatabaseSync
try {
db = await this.ready
} catch {
// The medium never opened; that failure already rejected the opener and
// every unit call, so there is nothing left to release here.
return
}
for (const pending of [...this.units.values()]) {
const unit = await pending.catch(() => undefined)
await unit?.close()
}
db.close()
}
}
/**
* Register the SQLite backend as `sqlite` on the storage hub. The disposer
* unregisters the name first, then closes the backend.
* @param ctx - Plugin context (must inject `storage`).
* @param config - Validated plugin configuration.
*/
export function apply(ctx: Context, config: Config) {
const backend = new SqliteStorageBackend(config)
ctx.effect(() => {
const dispose = ctx.storage.backend.register('sqlite', backend)
return async () => {
dispose()
await backend.close()
}
}, 'storage-sqlite.registerBackend')
}
@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-storage-sqlite`.
* @module @deepseek-ai/dsh-storage-sqlite/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-storage-sqlite'
/** Cordis companion plugin name. */
export const name = 'storage-sqlite-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: schema-version and unit-version consistency are
* open-time checks that reject before a unit exists, and durability needs the
* backend round-trip tests in the shared KV conformance suite; this package
* exposes no continuously observable in-process relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,112 @@
/**
* Schema + open-time helpers for the SQLite storage backend: the physical
* layout version, the database open/configure sequence (permissions, pragmas,
* version stamp/reject), and the unit metadata tables. Unit record tables are
* created per descriptor in `unit.ts`.
* @module @deepseek-ai/dsh-storage-sqlite/schema
*/
import { DatabaseSync } from 'node:sqlite'
import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { StorageError } from '@deepseek-ai/dsh-storage'
/**
* The on-disk physical layout version, stored in `PRAGMA user_version`.
* Orthogonal to each unit's own `version` (stamped per unit in the `units`
* row). Bumped only on a breaking change to the table layout; any other
* stamped version rejects — this unreleased format has no migrations.
*/
export const STORAGE_SQLITE_SCHEMA_VERSION = 1
/**
* Journal modes the backend will run under. `wal` is the default; the
* rollback-journal modes (`delete`/`truncate`/`persist`) exist for
* filesystems where WAL's shared-memory files do not work (network mounts).
* `memory`/`off` are excluded: dropping journal durability silently
* contradicts the durability clause of the KV backend contract.
*/
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
/**
* Exclusively create a missing database file with owner-only permissions.
* Existing files retain their modes, and errors other than `EEXIST` propagate.
* `DatabaseSync` reopens by path, so this does not protect confidentiality or
* integrity when another principal can replace the database entry in its
* parent directory.
*/
async function createDatabaseFile(path: string): Promise<void> {
try {
const handle = await open(path, 'wx', 0o600)
await handle.close()
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
}
/**
* Open the database and apply its schema and pragmas. Missing directories and
* database files are created owner-only (`:memory:` skips filesystem setup).
* A zero `user_version` is stamped with {@link STORAGE_SQLITE_SCHEMA_VERSION};
* every other non-current version rejects rather than being migrated in place.
* @param path - the SQLite database file to open, or `:memory:`.
* @param journalMode - validated journal pragma.
* @returns the open handle with pragmas applied and the unit metadata tables ensured.
*/
export async function openDatabase(path: string, journalMode: JournalMode): Promise<DatabaseSync> {
const actual = path === ':memory:' ? path : resolve(path)
if (actual !== ':memory:') {
await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
await createDatabaseFile(actual)
}
const db = new DatabaseSync(actual)
try {
configureDatabase(db, actual, journalMode)
return db
} catch (error: unknown) {
db.close()
throw error
}
}
function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {
db.exec('PRAGMA foreign_keys = ON')
// The validated union is safe to interpolate into a non-bindable PRAGMA.
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
// `PRAGMA user_version` always returns exactly one row { user_version }.
const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
if (onDisk !== 0 && onDisk !== STORAGE_SQLITE_SCHEMA_VERSION) {
throw new StorageError(
'version-mismatch',
`storage database at "${path}" has schema version ${onDisk}, incompatible with this build (${STORAGE_SQLITE_SCHEMA_VERSION})`,
)
}
if (onDisk === 0) {
// Stamp fresh databases.
db.exec(`PRAGMA user_version = ${STORAGE_SQLITE_SCHEMA_VERSION}`)
}
db.exec(`
CREATE TABLE IF NOT EXISTS units (
name TEXT PRIMARY KEY,
version INTEGER NOT NULL
) STRICT
`)
db.exec(`
CREATE TABLE IF NOT EXISTS unit_globals (
unit TEXT PRIMARY KEY REFERENCES units(name),
value TEXT NOT NULL
) STRICT
`)
}
/**
* Physical table name for one unit table. Both segments are validated against
* `UNIT_NAME_RE` before reaching this, so the result is safe to interpolate
* into DDL and prepared-statement text.
* @param unit - Validated unit name.
* @param table - Validated table name.
* @returns the `u_<unit>_<table>` identifier.
*/
export function recordTableName(unit: string, table: string): string {
return `u_${unit}_${table}`
}
+120
View File
@@ -0,0 +1,120 @@
/**
* One opened SQLite KV unit: prepared per-table statements over the
* `u_<unit>_<table>` record tables plus this unit's row in the shared
* `unit_globals` table. Each primitive is a single statement, so atomicity
* comes from SQLite itself — no explicit transactions, and no write queue
* (write ordering is the caller's responsibility per the KV contract).
* @module @deepseek-ai/dsh-storage-sqlite/unit
*/
import type { DatabaseSync, StatementSync } from 'node:sqlite'
import { StorageError } from '@deepseek-ai/dsh-storage'
import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
import { recordTableName } from './schema.ts'
/** Prepared statements for one declared table. */
interface TableStatements {
upsert: StatementSync
remove: StatementSync
selectAll: StatementSync
}
/**
* The SQLite {@link KvUnit}. Constructed by the backend AFTER the unit's
* record tables exist; statements are prepared once here and reused for every
* primitive. Values are stored as JSON text in the `value` column.
*/
export class SqliteKvUnit implements KvUnit {
private readonly tables = new Map<string, TableStatements>()
private readonly globalUpsert: StatementSync | undefined
private readonly globalSelect: StatementSync | undefined
private closed = false
/**
* @param db - Open database handle owned by the backend (never closed here).
* @param descriptor - Validated descriptor whose record tables already exist.
* @param onClose - Backend callback releasing this unit's open-name slot.
*/
constructor(
db: DatabaseSync,
private readonly descriptor: KvUnitDescriptor,
private readonly onClose: () => void,
) {
for (const table of descriptor.tables) {
// Both name segments are validated against UNIT_NAME_RE by the backend,
// so the physical identifier is safe to interpolate into statement text.
const physical = recordTableName(descriptor.name, table)
this.tables.set(table, {
upsert: db.prepare(
`INSERT INTO "${physical}" (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
),
remove: db.prepare(`DELETE FROM "${physical}" WHERE key = ?`),
selectAll: db.prepare(`SELECT key, value FROM "${physical}"`),
})
}
this.globalUpsert = descriptor.hasGlobal
? db.prepare(
'INSERT INTO unit_globals (unit, value) VALUES (?, ?) ON CONFLICT(unit) DO UPDATE SET value = excluded.value',
)
: undefined
this.globalSelect = descriptor.hasGlobal
? db.prepare('SELECT value FROM unit_globals WHERE unit = ?')
: undefined
}
async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown | null }> {
this.ensureOpen()
const tables: Record<string, Record<string, unknown>> = {}
for (const [name, statements] of this.tables) {
const records: Record<string, unknown> = {}
for (const row of statements.selectAll.all() as unknown as Array<{ key: string; value: string }>) {
records[row.key] = JSON.parse(row.value)
}
tables[name] = records
}
let global: unknown = null
if (this.globalSelect !== undefined) {
const row = this.globalSelect.get(this.descriptor.name) as { value: string } | undefined
if (row !== undefined) global = JSON.parse(row.value)
}
return { tables, global }
}
async putRecord(table: string, key: string, value: unknown): Promise<void> {
this.ensureOpen()
this.statementsFor(table).upsert.run(key, JSON.stringify(value))
}
async deleteRecord(table: string, key: string): Promise<void> {
this.ensureOpen()
this.statementsFor(table).remove.run(key)
}
async setGlobal(value: unknown): Promise<void> {
this.ensureOpen()
if (this.globalUpsert === undefined) {
throw new Error(`kv unit '${this.descriptor.name}' declared no global slot`)
}
this.globalUpsert.run(this.descriptor.name, JSON.stringify(value))
}
async close(): Promise<void> {
if (this.closed) return
this.closed = true
this.onClose()
}
private ensureOpen(): void {
if (this.closed) {
throw new StorageError('closed', `kv unit '${this.descriptor.name}' is closed`)
}
}
private statementsFor(table: string): TableStatements {
const statements = this.tables.get(table)
if (statements === undefined) {
throw new Error(`kv unit '${this.descriptor.name}' declared no table '${table}'`)
}
return statements
}
}
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
import { runKvBackendContract } from '../../storage/tests/contract.ts'
import { Config, SqliteStorageBackend, STORAGE_SQLITE_SCHEMA_VERSION } from '../src/index.ts'
/** Mirror the loader: resolve schemastery defaults before construction. */
function backendAt(path: string): SqliteStorageBackend {
return new SqliteStorageBackend(new Config({ path }))
}
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
async function freshDbPath(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-storage-sqlite-'))
dirs.push(dir)
return join(dir, 'storage.db')
}
// The contract suite's reopen() needs a surviving medium, so the harness binds
// a real file; :memory: gets its own cases below.
runKvBackendContract('sqlite', async () => {
const path = await freshDbPath()
return {
backend: backendAt(path),
reopen: async () => backendAt(path),
}
})
const DESCRIPTOR: KvUnitDescriptor = {
name: 'specimen',
version: 1,
tables: ['records'],
hasGlobal: true,
}
describe('sqlite backend specifics', () => {
it('opens an in-memory database', async () => {
const backend = backendAt(':memory:')
const unit = await backend.kv.open(DESCRIPTOR)
await unit.putRecord('records', 'k', { n: 1 })
expect((await unit.loadAll()).tables['records']).toEqual({ k: { n: 1 } })
await backend.close()
})
it('materializes STRICT record tables and stamps the schema version', async () => {
const path = await freshDbPath()
const backend = backendAt(path)
const unit = await backend.kv.open(DESCRIPTOR)
await unit.putRecord('records', 'k', { n: 1 })
await backend.close()
const db = new DatabaseSync(path)
try {
const { user_version: version } = db.prepare('PRAGMA user_version').get() as { user_version: number }
expect(version).toBe(STORAGE_SQLITE_SCHEMA_VERSION)
const table = db.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'u_specimen_records'",
).get() as { sql: string } | undefined
expect(table?.sql).toContain('STRICT')
const unitRow = db.prepare('SELECT version FROM units WHERE name = ?').get('specimen') as { version: number }
expect(unitRow.version).toBe(DESCRIPTOR.version)
} finally {
db.close()
}
})
it('rejects a mismatched database schema version', async () => {
const path = await freshDbPath()
const db = new DatabaseSync(path)
db.exec('PRAGMA user_version = 999')
db.close()
const backend = backendAt(path)
await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({
name: 'StorageError',
code: 'version-mismatch',
})
await backend.close()
})
it('rejects invalid unit and table names before touching the medium', async () => {
const backend = backendAt(':memory:')
await expect(backend.kv.open({ ...DESCRIPTOR, name: 'Bad-Name' })).rejects.toThrow(/violates/)
await expect(backend.kv.open({ ...DESCRIPTOR, tables: ['ok', '1bad'] })).rejects.toThrow(/violates/)
await backend.close()
})
it('rejects a second open of the same unit name', async () => {
const backend = backendAt(':memory:')
await backend.kv.open(DESCRIPTOR)
await expect(backend.kv.open(DESCRIPTOR)).rejects.toThrow(/already open/)
await backend.close()
})
it('allows re-open after unit close, and rejects open on a closed backend', async () => {
const backend = backendAt(':memory:')
const unit = await backend.kv.open(DESCRIPTOR)
await unit.close()
const again = await backend.kv.open(DESCRIPTOR)
await again.putRecord('records', 'k', 1)
await backend.close()
await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' })
})
})
@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../storage"
},
{
"path": "../../support/invariants"
}
]
}