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:
109
packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts
Normal file
109
packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts
Normal file
@@ -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' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user