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:
@@ -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}`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user