1
/**2
* Schema + open-time helpers for the SQLite storage backend: the physical3
* layout version, the database open/configure sequence (permissions, pragmas,4
* version stamp/reject), and the unit metadata tables. Unit record tables are5
* created per descriptor in `unit.ts`.6
* @module @deepseek-ai/dsh-storage-sqlite/schema7
*/9
import { DatabaseSync } from 'node:sqlite'10
import { mkdir, open } from 'node:fs/promises'11
import { dirname, resolve } from 'node:path'12
import { StorageError } from '@deepseek-ai/dsh-storage'14
/**15
* The on-disk physical layout version, stored in `PRAGMA user_version`.16
* Orthogonal to each unit's own `version` (stamped per unit in the `units`17
* row). Bumped only on a breaking change to the table layout; any other18
* stamped version rejects — this unreleased format has no migrations.19
*/20
export const STORAGE_SQLITE_SCHEMA_VERSION = 122
/**23
* Journal modes the backend will run under. `wal` is the default; the24
* rollback-journal modes (`delete`/`truncate`/`persist`) exist for25
* filesystems where WAL's shared-memory files do not work (network mounts).26
* `memory`/`off` are excluded: dropping journal durability silently27
* contradicts the durability clause of the KV backend contract.28
*/29
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'31
/* jscpd:ignore-start -- deliberately mirrors the session-query-sqlite open32
sequence. Each package owns a distinct database identity and schema, so a33
shared helper would couple otherwise independent storage providers (see the34
domain KV storage Agent Note's reuse audit). */35
/**36
* Exclusively create a missing database file with owner-only permissions.37
* Existing files retain their modes, and errors other than `EEXIST` propagate.38
* `DatabaseSync` reopens by path, so this does not protect confidentiality or39
* integrity when another principal can replace the database entry in its40
* parent directory.41
*/42
async function createDatabaseFile(path: string): Promise<void> {43
try {44
const handle = await open(path, 'wx', 0o600)45
await handle.close()46
} catch (error) {47
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error48
}49
}51
/**52
* Open the database and apply its schema and pragmas. Missing directories and53
* database files are created owner-only (`:memory:` skips filesystem setup).54
* A zero `user_version` is stamped with {@link STORAGE_SQLITE_SCHEMA_VERSION};55
* every other non-current version rejects rather than being migrated in place.56
* @param path - the SQLite database file to open, or `:memory:`.57
* @param journalMode - validated journal pragma.58
* @returns the open handle with pragmas applied and the unit metadata tables ensured.59
*/60
export async function openDatabase(path: string, journalMode: JournalMode): Promise<DatabaseSync> {61
const actual = path === ':memory:' ? path : resolve(path)62
if (actual !== ':memory:') {63
await mkdir(dirname(actual), { recursive: true, mode: 0o700 })64
await createDatabaseFile(actual)65
}66
const db = new DatabaseSync(actual)67
try {68
configureDatabase(db, actual, journalMode)69
return db70
} catch (error: unknown) {71
db.close()72
throw error73
}74
}76
function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {77
db.exec('PRAGMA foreign_keys = ON')78
// The validated union is safe to interpolate into a non-bindable PRAGMA.79
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)80
// `PRAGMA user_version` always returns exactly one row { user_version }.81
const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }82
if (onDisk !== 0 && onDisk !== STORAGE_SQLITE_SCHEMA_VERSION) {83
throw new StorageError(84
'version-mismatch',85
`storage database at "${path}" has schema version ${onDisk}, incompatible with this build (${STORAGE_SQLITE_SCHEMA_VERSION})`,86
)87
}88
/* jscpd:ignore-end */89
db.exec(`90
CREATE TABLE IF NOT EXISTS units (91
name TEXT PRIMARY KEY,92
version INTEGER NOT NULL93
) STRICT94
`)95
db.exec(`96
CREATE TABLE IF NOT EXISTS unit_globals (97
unit TEXT PRIMARY KEY REFERENCES units(name),98
value TEXT NOT NULL99
) STRICT100
`)101
if (onDisk === 0) {102
// Stamp fresh databases LAST: the stamp asserts the layout is complete,103
// so a failure above must leave the medium unstamped (a re-open after104
// the obstruction is cleared retries materialization from scratch).105
db.exec(`PRAGMA user_version = ${STORAGE_SQLITE_SCHEMA_VERSION}`)106
}107
}109
/**110
* Physical table name for one unit table. Both segments are validated against111
* `UNIT_NAME_RE` before reaching this, so the result is safe to interpolate112
* into DDL and prepared-statement text.113
* @param unit - Validated unit name.114
* @param table - Validated table name.115
* @returns the `u_<unit>_<table>` identifier.116
*/117
export function recordTableName(unit: string, table: string): string {118
return `u_${unit}_${table}`119
}