schema.ts 4.8 KB

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