/** * 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' /* jscpd:ignore-start -- deliberately mirrors the session-persistence-sqlite / session-query-sqlite open sequence; this group is the third user, and the shared medium helper is deferred to the log-facet migration so the session packages stay untouched this phase (see the domain KV storage Agent Note's reuse audit). */ /** * 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})`, ) } /* jscpd:ignore-end */ 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 `) if (onDisk === 0) { // Stamp fresh databases LAST: the stamp asserts the layout is complete, // so a failure above must leave the medium unstamped (a re-open after // the obstruction is cleared retries materialization from scratch). db.exec(`PRAGMA user_version = ${STORAGE_SQLITE_SCHEMA_VERSION}`) } } /** * 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}` }