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-24 19:07:07 +08:00
parent 1529be6fd4
commit 9dee9e1a71
8 changed files with 648 additions and 0 deletions

View File

@@ -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')
}

View File

@@ -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 */

View File

@@ -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}`
}

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
}
}