schema.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. /** SQLite schema for the disposable session full-text read model. */
  2. import { DatabaseSync } from 'node:sqlite'
  3. import { mkdir, open } from 'node:fs/promises'
  4. import { dirname, resolve } from 'node:path'
  5. /** Current derived-index schema version. Incompatible versions reset in place. */
  6. export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5
  7. /** SQLite application id protecting unrelated databases from derived resets. */
  8. export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
  9. /** Supported SQLite journal modes. */
  10. export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
  11. const DERIVED_USER_TABLES = new Set([
  12. 'search_state',
  13. 'persisted_sessions',
  14. 'persisted_docs',
  15. 'persisted_docs_data',
  16. 'persisted_docs_idx',
  17. 'persisted_docs_content',
  18. 'persisted_docs_docsize',
  19. 'persisted_docs_config',
  20. ])
  21. /**
  22. * Exclusively create a missing database file with owner-only permissions.
  23. * Existing files retain their modes, and errors other than `EEXIST` propagate.
  24. */
  25. async function createDatabaseFile(path: string): Promise<void> {
  26. try {
  27. const handle = await open(path, 'wx', 0o600)
  28. await handle.close()
  29. } catch (error) {
  30. if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
  31. }
  32. }
  33. /**
  34. * Open, validate, and initialize persistent and connection-local schemas.
  35. * @param path - dedicated derived-index path or `:memory:`; missing filesystem paths are created owner-only.
  36. * @param journalMode - validated SQLite journal mode.
  37. * @returns initialized database handle owned by the search service.
  38. */
  39. export async function openSearchDatabase(path: string, journalMode: JournalMode): Promise<DatabaseSync> {
  40. const actual = path === ':memory:' ? path : resolve(path)
  41. if (actual !== ':memory:') {
  42. await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
  43. await createDatabaseFile(actual)
  44. }
  45. const db = new DatabaseSync(actual)
  46. try {
  47. const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
  48. const { user_version: version } = db.prepare('PRAGMA user_version').get() as { user_version: number }
  49. const userTables = listUserTables(db)
  50. if (applicationId !== 0 && applicationId !== SESSION_QUERY_SQLITE_APPLICATION_ID) {
  51. throw new Error(`session-search database at "${actual}" belongs to another application`)
  52. }
  53. if (applicationId === 0 && userTables.length > 0) {
  54. throw new Error(`session-search database at "${actual}" is not an empty or recognized derived index`)
  55. }
  56. if (applicationId === SESSION_QUERY_SQLITE_APPLICATION_ID) {
  57. assertDerivedUserTables(actual, userTables)
  58. if (version !== SESSION_QUERY_SQLITE_SCHEMA_VERSION) resetDerivedSchema(db, userTables)
  59. }
  60. // Apply mutating pragmas only after refusing foreign or canonical files.
  61. // journalMode is a validated closed union, not caller-controlled SQL.
  62. db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
  63. ensurePersistentSchema(db)
  64. ensureTemporarySchema(db)
  65. return db
  66. } catch (error: unknown) {
  67. db.close()
  68. throw error
  69. }
  70. }
  71. function listUserTables(db: DatabaseSync): string[] {
  72. const rows = db.prepare(
  73. "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
  74. ).all() as Array<{ name: string }>
  75. return rows.map(row => row.name)
  76. }
  77. function assertDerivedUserTables(path: string, userTables: readonly string[]): void {
  78. const unknownTables = userTables.filter(name => !DERIVED_USER_TABLES.has(name))
  79. if (unknownTables.length > 0) {
  80. throw new Error(
  81. `session-search database at "${path}" has unrecognized user tables: ${unknownTables.join(', ')}`,
  82. )
  83. }
  84. }
  85. function resetDerivedSchema(db: DatabaseSync, userTables: readonly string[]): void {
  86. for (const name of userTables) {
  87. db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(name)}`)
  88. }
  89. db.exec('PRAGMA user_version = 0')
  90. }
  91. function ensurePersistentSchema(db: DatabaseSync): void {
  92. db.exec(`PRAGMA application_id = ${SESSION_QUERY_SQLITE_APPLICATION_ID}`)
  93. db.exec(`
  94. CREATE TABLE IF NOT EXISTS search_state (
  95. singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
  96. global_generation INTEGER NOT NULL
  97. ) STRICT
  98. `)
  99. db.exec('INSERT OR IGNORE INTO search_state (singleton, global_generation) VALUES (1, 0)')
  100. db.exec(`
  101. CREATE TABLE IF NOT EXISTS persisted_sessions (
  102. id TEXT PRIMARY KEY,
  103. version INTEGER NOT NULL,
  104. created_at INTEGER NOT NULL,
  105. cwd TEXT,
  106. parent_session TEXT,
  107. seed_length INTEGER,
  108. delegation_depth INTEGER,
  109. revision TEXT NOT NULL,
  110. generation INTEGER NOT NULL
  111. ) STRICT
  112. `)
  113. db.exec(`
  114. CREATE VIRTUAL TABLE IF NOT EXISTS persisted_docs USING fts5(
  115. text,
  116. session_id UNINDEXED,
  117. seq UNINDEXED,
  118. type UNINDEXED,
  119. time UNINDEXED,
  120. surface UNINDEXED,
  121. codepoint_length UNINDEXED,
  122. tokenize = 'unicode61'
  123. )
  124. `)
  125. db.exec(`PRAGMA user_version = ${SESSION_QUERY_SQLITE_SCHEMA_VERSION}`)
  126. }
  127. function ensureTemporarySchema(db: DatabaseSync): void {
  128. db.exec(`
  129. CREATE TEMP TABLE IF NOT EXISTS live_sessions (
  130. id TEXT PRIMARY KEY,
  131. version INTEGER NOT NULL,
  132. created_at INTEGER NOT NULL,
  133. cwd TEXT,
  134. parent_session TEXT,
  135. seed_length INTEGER,
  136. delegation_depth INTEGER,
  137. fingerprint TEXT NOT NULL,
  138. persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)),
  139. generation INTEGER NOT NULL
  140. ) STRICT
  141. `)
  142. db.exec(`
  143. CREATE VIRTUAL TABLE IF NOT EXISTS temp.live_docs USING fts5(
  144. text,
  145. session_id UNINDEXED,
  146. seq UNINDEXED,
  147. type UNINDEXED,
  148. time UNINDEXED,
  149. surface UNINDEXED,
  150. codepoint_length UNINDEXED,
  151. tokenize = 'unicode61'
  152. )
  153. `)
  154. }
  155. function quoteIdentifier(value: string): string {
  156. return `"${value.replaceAll('"', '""')}"`
  157. }