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,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}`
|
||||
}
|
||||
Reference in New Issue
Block a user