import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage' import { runKvBackendContract } from '../../storage/tests/contract.ts' import * as StorageSqlite from '../src/index.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 { const backend = new SqliteStorageBackend(new Config({ path })) backends.push(backend) return backend } const backends: SqliteStorageBackend[] = [] const dirs: string[] = [] afterEach(async () => { for (const backend of backends.splice(0)) await backend.close() 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, } /** Exercise either a record write or a batch delete on the opened unit. */ function mutateUnit(unit: KvUnit, operation: 'putRecord' | 'deleteRecords'): Promise { return operation === 'putRecord' ? unit.putRecord('records', 'new', { n: 2 }) : unit.deleteRecords('records', ['saved', 'missing', 'saved']) } 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.each(['ABORT', 'ROLLBACK'])('restores a batch delete after SQLite %s', async (action) => { const path = await freshDbPath() const backend = backendAt(path) const unit = await backend.kv.open(DESCRIPTOR) await unit.putRecord('records', 'a', { n: 1 }) await unit.putRecord('records', 'b', { n: 2 }) const db = new DatabaseSync(path) try { db.exec('CREATE TRIGGER block_b BEFORE DELETE ON "u_specimen_records"' + ` WHEN old.key = 'b' BEGIN SELECT RAISE(${action}, 'blocked'); END`) await expect(unit.deleteRecords('records', ['a', 'b'])).rejects.toThrow(/blocked/) // The transaction rolled back, so the key deleted before the failing row is back. expect((await unit.loadAll()).tables['records']).toEqual({ a: { n: 1 }, b: { n: 2 } }) db.exec('DROP TRIGGER block_b') await unit.deleteRecords('records', ['a', 'b']) expect((await unit.loadAll()).tables['records']).toEqual({}) } finally { await backend.close() db.close() } }) it('reports unknown-state when a failed batch cannot roll back', async () => { const path = await freshDbPath() const backend = backendAt(path) const unit = await backend.kv.open(DESCRIPTOR) await unit.putRecord('records', 'a', { n: 1 }) await unit.putRecord('records', 'b', { n: 2 }) const db = new DatabaseSync(path) db.exec('CREATE TRIGGER block_b BEFORE DELETE ON "u_specimen_records"' + " WHEN old.key = 'b' BEGIN SELECT RAISE(ABORT, 'blocked'); END") // oxlint-disable-next-line typescript/unbound-method -- Delegated through Reflect.apply with the spy's receiver. const original = DatabaseSync.prototype.exec const execSpy = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, sql: string) { if (sql === 'ROLLBACK') throw new Error('rollback denied') Reflect.apply(original, this, [sql]) }) try { await expect(unit.deleteRecords('records', ['a', 'b'])).rejects.toMatchObject({ code: 'unknown-state' }) // Later statements would run inside the still-open transaction. await expect(unit.putRecord('records', 'c', { n: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) await expect(unit.loadAll()).rejects.toMatchObject({ code: 'unknown-state' }) } finally { execSpy.mockRestore() db.close() } // The denied rollback leaves the unit's connection in a transaction; the // backend close is what releases it, and this test's file is discarded by // afterEach. await expect(unit.close()).resolves.toBeUndefined() await backend.close() }) it('leaves a batch unapplied when BEGIN fails, then retries on the same unit', async () => { const path = await freshDbPath() const backend = backendAt(path) const unit = await backend.kv.open(DESCRIPTOR) await unit.putRecord('records', 'a', { n: 1 }) // oxlint-disable-next-line typescript/unbound-method -- Delegated through Reflect.apply with the spy's receiver. const original = DatabaseSync.prototype.exec const beginError = new Error('begin denied') const execSpy = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, sql: string) { if (sql.startsWith('BEGIN')) throw beginError Reflect.apply(original, this, [sql]) }) try { await expect(unit.deleteRecords('records', ['a'])).rejects.toBe(beginError) expect((await unit.loadAll()).tables['records']).toEqual({ a: { n: 1 } }) } finally { execSpy.mockRestore() } await unit.deleteRecords('records', ['a']) expect((await unit.loadAll()).tables['records']).toEqual({}) await expect(unit.close()).resolves.toBeUndefined() 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.each([ ['single', 'putRecord'], ['single', 'deleteRecords'], ['per-record', 'putRecord'], ['per-record', 'deleteRecords'], ] as const)('preserves the unit stamp when BEGIN is busy with layout %s and %s, then retries', async (layout, operation) => { const path = await freshDbPath() const first = backendAt(path) await (await first.kv.open(DESCRIPTOR)).putRecord('records', 'saved', { n: 1 }) await first.close() const observer = new DatabaseSync(path) const backend = backendAt(path) try { const unit = await backend.kv.open({ ...DESCRIPTOR, version: 2, compatibleVersions: [1], layout }) await unit.deleteRecord('records', 'missing') expect(observer.prepare('SELECT version FROM units WHERE name = ?').get(DESCRIPTOR.name)).toEqual({ version: 1 }) observer.exec('BEGIN IMMEDIATE') await expect(mutateUnit(unit, operation)).rejects.toMatchObject({ errcode: 5 }) expect(observer.isTransaction).toBe(true) expect(observer.prepare('SELECT version FROM units WHERE name = ?').get(DESCRIPTOR.name)).toEqual({ version: 1 }) expect((await unit.loadAll()).tables['records']).toEqual({ saved: { n: 1 } }) observer.exec('ROLLBACK') await mutateUnit(unit, operation) expect(observer.prepare('SELECT version FROM units WHERE name = ?').get(DESCRIPTOR.name)).toEqual({ version: 2 }) expect((await unit.loadAll()).tables['records']).toEqual(operation === 'putRecord' ? { saved: { n: 1 }, new: { n: 2 } } : {}) await backend.close() const oldReader = backendAt(path) await expect(oldReader.kv.open({ ...DESCRIPTOR, layout })).rejects.toMatchObject({ code: 'version-mismatch' }) await oldReader.close() } finally { if (observer.isTransaction) observer.exec('ROLLBACK') await backend.close() observer.close() } }) it.each([ ['ABORT', 'putRecord'], ['ABORT', 'deleteRecords'], ['ROLLBACK', 'putRecord'], ['ROLLBACK', 'deleteRecords'], ] as const)('preserves data and stamp on %s during the first %s version update, then retries', async (action, operation) => { const path = await freshDbPath() const first = backendAt(path) await (await first.kv.open(DESCRIPTOR)).putRecord('records', 'saved', { n: 1 }) await first.close() const observer = new DatabaseSync(path) const backend = backendAt(path) try { const physicalVersion = observer.prepare('PRAGMA user_version').get() observer.exec(`CREATE TRIGGER reject_unit_version BEFORE UPDATE ON units BEGIN SELECT RAISE(${action}, 'version write failed'); END`) const unit = await backend.kv.open({ ...DESCRIPTOR, version: 2, compatibleVersions: [1] }) const savedVersion = () => observer.prepare('SELECT version FROM units WHERE name = ?').get(DESCRIPTOR.name) expect(savedVersion()).toEqual({ version: 1 }) await expect(mutateUnit(unit, operation)).rejects.toThrow('version write failed') expect(savedVersion()).toEqual({ version: 1 }) expect((await unit.loadAll()).tables['records']).toEqual({ saved: { n: 1 } }) observer.exec('DROP TRIGGER reject_unit_version') await mutateUnit(unit, operation) expect(savedVersion()).toEqual({ version: 2 }) expect(observer.prepare('PRAGMA user_version').get()).toEqual(physicalVersion) expect((await unit.loadAll()).tables['records']).toEqual(operation === 'putRecord' ? { saved: { n: 1 }, new: { n: 2 } } : {}) } finally { await backend.close() observer.close() } }) it.each(['putRecord', 'deleteRecords'] as const)('rejects all backend use after a failed %s rollback and preserves both errors', async (operation) => { const path = await freshDbPath() const siblingDescriptor = { ...DESCRIPTOR, name: 'sibling' } const first = backendAt(path) await (await first.kv.open(DESCRIPTOR)).putRecord('records', 'saved', { n: 1 }) await (await first.kv.open(siblingDescriptor)).close() await first.close() const observer = new DatabaseSync(path) const backend = backendAt(path) try { observer.exec("CREATE TRIGGER reject_unit_version BEFORE UPDATE ON units BEGIN SELECT RAISE(ABORT, 'version write failed'); END") const unit = await backend.kv.open({ ...DESCRIPTOR, version: 2, compatibleVersions: [1] }) const sibling = await backend.kv.open(siblingDescriptor) // oxlint-disable-next-line typescript/unbound-method -- replay the original method with the intercepted connection as this const originalExec = DatabaseSync.prototype.exec const rollbackError = new Error('rollback failed') const rollback = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, sql) { if (sql === 'ROLLBACK') throw rollbackError originalExec.call(this, sql) }) try { const pending = backend.kv.open({ ...DESCRIPTOR, name: 'pending' }) const failure: unknown = await mutateUnit(unit, operation).catch((error: unknown) => error) expect(failure).toMatchObject({ name: 'StorageError', code: 'unknown-state', cause: { name: 'AggregateError', cause: { message: 'version write failed' }, errors: [{ message: 'version write failed' }, rollbackError], }, }) await expect(sibling.putRecord('records', 'acknowledged', { n: 3 })).rejects.toBe(failure) await expect(sibling.loadAll()).rejects.toBe(failure) await expect(unit.loadAll()).rejects.toBe(failure) await expect(pending).rejects.toBe(failure) await expect(backend.kv.open({ ...DESCRIPTOR, name: 'later' })).rejects.toBe(failure) expect(observer.prepare('SELECT * FROM u_sibling_records').all()).toEqual([]) } finally { rollback.mockRestore() } await backend.close() expect(observer.prepare('SELECT version FROM units WHERE name = ?').get(DESCRIPTOR.name)).toEqual({ version: 1 }) expect(observer.prepare('SELECT key FROM u_specimen_records').all()).toEqual([{ key: 'saved' }]) expect(observer.prepare('SELECT * FROM u_sibling_records').all()).toEqual([]) } finally { await backend.close() observer.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' }) }) it('round-trips prototype-polluting keys as own properties', async () => { const backend = backendAt(':memory:') const unit = await backend.kv.open(DESCRIPTOR) await unit.putRecord('records', '__proto__', { evil: true }) await unit.putRecord('records', 'constructor', { n: 1 }) const { tables } = await unit.loadAll() const records = tables['records']! expect(Object.hasOwn(records, '__proto__')).toBe(true) expect(records['__proto__']).toEqual({ evil: true }) expect(records['constructor']).toEqual({ n: 1 }) expect(Object.getPrototypeOf({})).not.toHaveProperty('evil') await backend.close() }) // Repeated durable DDL across four connections needs an instrumented I/O budget. it('leaves a failed materialization unstamped so a repaired medium reopens', async () => { const path = await freshDbPath() // Obstruct table creation: an index squatting on the unit_globals name // makes CREATE TABLE IF NOT EXISTS throw AFTER the units table exists. const setup = new DatabaseSync(path) try { setup.exec('CREATE TABLE squatter (x TEXT)') setup.exec('CREATE INDEX unit_globals ON squatter(x)') } finally { setup.close() } const broken = backendAt(path) try { await expect(broken.kv.open(DESCRIPTOR)).rejects.toThrow(/already an index/) } finally { await broken.close() } // Clear the obstruction; the medium must still be version 0, not a // half-materialized database stamped as current. const repair = new DatabaseSync(path) try { expect((repair.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(0) expect(repair.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'units'").get()) .toEqual({ name: 'units' }) repair.exec('DROP INDEX unit_globals') expect(repair.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'unit_globals'").get()) .toBeUndefined() } finally { repair.close() } const backend = backendAt(path) try { const unit = await backend.kv.open(DESCRIPTOR) await unit.putRecord('records', 'k', { n: 1 }) expect((await unit.loadAll()).tables['records']).toEqual({ k: { n: 1 } }) } finally { await backend.close() } }, 90_000) it('rejects unparsable stored JSON with malformed-medium', async () => { const path = await freshDbPath() const backend = backendAt(path) const unit = await backend.kv.open(DESCRIPTOR) await unit.putRecord('records', 'good', { n: 1 }) await unit.setGlobal({ g: 1 }) await backend.close() const db = new DatabaseSync(path) db.prepare('UPDATE u_specimen_records SET value = ? WHERE key = ?').run('{not json', 'good') db.close() const reopened = backendAt(path) const damaged = await reopened.kv.open(DESCRIPTOR) await expect(damaged.loadAll()).rejects.toMatchObject({ name: 'StorageError', code: 'malformed-medium', }) await reopened.close() }) it('wraps a non-Error toJSON throw into an Error rejection', async () => { const backend = backendAt(':memory:') const unit = await backend.kv.open(DESCRIPTOR) // JSON.stringify propagates a value's own toJSON throw verbatim; the unit // must still reject with an Error instance. const hostile = { toJSON: () => { throw 'not an error' } } await expect(unit.putRecord('records', 'k', hostile)).rejects.toThrow('not an error') await expect(unit.putRecord('records', 'k', hostile)).rejects.toBeInstanceOf(Error) await backend.close() }) it('rejects setGlobal on a unit without a global slot and writes to undeclared tables', async () => { const backend = backendAt(':memory:') const unit = await backend.kv.open({ ...DESCRIPTOR, hasGlobal: false }) await expect(unit.setGlobal({ g: 1 })).rejects.toThrow(/declared no global slot/) await expect(unit.putRecord('undeclared', 'k', 1)).rejects.toThrow(/declared no table/) expect((await unit.loadAll()).global).toBeNull() await backend.close() }) it('drains a still-pending failed open during close', async () => { const path = await freshDbPath() const first = backendAt(path) await (await first.kv.open(DESCRIPTOR)).close() await first.close() const backend = backendAt(path) // Do not await: close() must tolerate an in-flight open that will reject // (version mismatch) while its name is still reserved in the unit table. const pending = backend.kv.open({ ...DESCRIPTOR, version: 99 }) const closed = backend.close() await expect(pending).rejects.toMatchObject({ code: 'version-mismatch' }) await closed }) it('propagates filesystem errors other than an existing database file', async () => { if (process.platform === 'win32') return const dir = await mkdtemp(join(tmpdir(), 'dsh-storage-sqlite-')) dirs.push(dir) await chmod(dir, 0o500) const backend = backendAt(join(dir, 'storage.db')) await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'EACCES' }) await backend.close() await chmod(dir, 0o700) }) it('propagates an invalid database filename before opening SQLite', async () => { const path = await freshDbPath() const backend = backendAt(`${path}\0invalid`) await expect(backend.kv.open(DESCRIPTOR)).rejects.toThrow(/null bytes/i) await backend.close() }) it('preserves the mode of an existing database file', async () => { if (process.platform === 'win32') return const path = await freshDbPath() await writeFile(path, '', { mode: 0o644 }) await chmod(path, 0o644) const backend = backendAt(path) const unit = await backend.kv.open(DESCRIPTOR) await unit.putRecord('records', 'k', 1) await backend.close() }) it('registers on the storage hub as backend sqlite and closes on dispose', async () => { const ctx = new Context() await ctx.plugin(Storage) const fiber = await ctx.plugin(StorageSqlite, { path: ':memory:' }) const backend = ctx.storage.backend.get('sqlite') expect(ctx.get(storageBackendServiceKey('sqlite'))).toBe(backend) const unit = await backend.kv!.open(DESCRIPTOR) await unit.putRecord('records', 'k', { n: 1 }) await fiber.dispose() expect(ctx.storage.backend.names()).toEqual([]) expect(ctx.get(storageBackendServiceKey('sqlite'))).toBeUndefined() await expect(backend.kv!.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' }) }) it('rejects an unparsable global slot with malformed-medium', async () => { const path = await freshDbPath() const backend = backendAt(path) const unit = await backend.kv.open(DESCRIPTOR) await unit.setGlobal({ g: 1 }) await backend.close() const db = new DatabaseSync(path) db.prepare('UPDATE unit_globals SET value = ? WHERE unit = ?').run('][', 'specimen') db.close() const reopened = backendAt(path) const damaged = await reopened.kv.open(DESCRIPTOR) await expect(damaged.loadAll()).rejects.toMatchObject({ name: 'StorageError', code: 'malformed-medium', }) await reopened.close() }) })