Răsfoiți Sursa

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.
imccyu 2 luni în urmă
părinte
comite
9dee9e1a71

+ 39 - 0
packages/storage/storage-sqlite/README.md

@@ -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).

+ 42 - 0
packages/storage/storage-sqlite/package.json

@@ -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"
+  }
+}

+ 167 - 0
packages/storage/storage-sqlite/src/index.ts

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

+ 32 - 0
packages/storage/storage-sqlite/src/invariant.ts

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

+ 112 - 0
packages/storage/storage-sqlite/src/schema.ts

@@ -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 - 0
packages/storage/storage-sqlite/src/unit.ts

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

+ 109 - 0
packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts

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

+ 27 - 0
packages/storage/storage-sqlite/tsconfig.json

@@ -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"
+    }
+  ]
+}