| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120 |
- /**
- * 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<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})`,
- )
- }
- /* 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_<unit>_<table>` identifier.
- */
- export function recordTableName(unit: string, table: string): string {
- return `u_${unit}_${table}`
- }
|