diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md new file mode 100644 index 0000000000..b76515da3f --- /dev/null +++ b/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__" (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). diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json new file mode 100644 index 0000000000..dc792fe350 --- /dev/null +++ b/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" + } +} diff --git a/packages/storage/storage-sqlite/src/index.ts b/packages/storage/storage-sqlite/src/index.ts new file mode 100644 index 0000000000..72fff382bc --- /dev/null +++ b/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 = 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 + /** Open (or still-opening) units by name; presence is the double-open guard. */ + private readonly units = new Map>() + private closing: Promise | undefined + + /** + * @param config - Validated plugin configuration. + */ + constructor(config: Config) { + this.ready = openDatabase(config.path, (config as Required).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 { + 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 { + 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 { + this.closing ??= this.doClose() + return this.closing + } + + private async doClose(): Promise { + 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') +} diff --git a/packages/storage/storage-sqlite/src/invariant.ts b/packages/storage/storage-sqlite/src/invariant.ts new file mode 100644 index 0000000000..cbfadc8442 --- /dev/null +++ b/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 */ diff --git a/packages/storage/storage-sqlite/src/schema.ts b/packages/storage/storage-sqlite/src/schema.ts new file mode 100644 index 0000000000..8aee216774 --- /dev/null +++ b/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 { + 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 { + 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__
` identifier. + */ +export function recordTableName(unit: string, table: string): string { + return `u_${unit}_${table}` +} diff --git a/packages/storage/storage-sqlite/src/unit.ts b/packages/storage/storage-sqlite/src/unit.ts new file mode 100644 index 0000000000..ea7291f303 --- /dev/null +++ b/packages/storage/storage-sqlite/src/unit.ts @@ -0,0 +1,120 @@ +/** + * One opened SQLite KV unit: prepared per-table statements over the + * `u__
` 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() + 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>; global: unknown | null }> { + this.ensureOpen() + const tables: Record> = {} + for (const [name, statements] of this.tables) { + const records: Record = {} + 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 { + this.ensureOpen() + this.statementsFor(table).upsert.run(key, JSON.stringify(value)) + } + + async deleteRecord(table: string, key: string): Promise { + this.ensureOpen() + this.statementsFor(table).remove.run(key) + } + + async setGlobal(value: unknown): Promise { + 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 { + 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 + } +} diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts new file mode 100644 index 0000000000..b7c46bfe89 --- /dev/null +++ b/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 { + 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' }) + }) +}) diff --git a/packages/storage/storage-sqlite/tsconfig.json b/packages/storage/storage-sqlite/tsconfig.json new file mode 100644 index 0000000000..5a13b64de8 --- /dev/null +++ b/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" + } + ] +}