schema.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  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 = 3
  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 && version !== SESSION_QUERY_SQLITE_SCHEMA_VERSION) {
  57. resetDerivedSchema(db, actual, userTables)
  58. }
  59. // Apply mutating pragmas only after refusing foreign or canonical files.
  60. // journalMode is a validated closed union, not caller-controlled SQL.
  61. db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
  62. ensurePersistentSchema(db)
  63. ensureTemporarySchema(db)
  64. return db
  65. } catch (error: unknown) {
  66. db.close()
  67. throw error
  68. }
  69. }
  70. function listUserTables(db: DatabaseSync): string[] {
  71. const rows = db.prepare(
  72. "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
  73. ).all() as Array<{ name: string }>
  74. return rows.map(row => row.name)
  75. }
  76. function resetDerivedSchema(db: DatabaseSync, path: string, userTables: readonly string[]): void {
  77. const unknownTables = userTables.filter(name => !DERIVED_USER_TABLES.has(name))
  78. if (unknownTables.length > 0) {
  79. throw new Error(
  80. `session-search database at "${path}" has unrecognized user tables: ${unknownTables.join(', ')}`,
  81. )
  82. }
  83. for (const name of userTables) {
  84. db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(name)}`)
  85. }
  86. db.exec('PRAGMA user_version = 0')
  87. }
  88. function ensurePersistentSchema(db: DatabaseSync): void {
  89. db.exec(`PRAGMA application_id = ${SESSION_QUERY_SQLITE_APPLICATION_ID}`)
  90. db.exec(`
  91. CREATE TABLE IF NOT EXISTS search_state (
  92. singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
  93. global_generation INTEGER NOT NULL
  94. ) STRICT
  95. `)
  96. db.exec('INSERT OR IGNORE INTO search_state (singleton, global_generation) VALUES (1, 0)')
  97. db.exec(`
  98. CREATE TABLE IF NOT EXISTS persisted_sessions (
  99. id TEXT PRIMARY KEY,
  100. version INTEGER NOT NULL,
  101. created_at INTEGER NOT NULL,
  102. cwd TEXT,
  103. parent_session TEXT,
  104. seed_length INTEGER,
  105. delegation_depth INTEGER,
  106. revision TEXT NOT NULL,
  107. generation INTEGER NOT NULL
  108. ) STRICT
  109. `)
  110. db.exec(`
  111. CREATE VIRTUAL TABLE IF NOT EXISTS persisted_docs USING fts5(
  112. text,
  113. session_id UNINDEXED,
  114. seq UNINDEXED,
  115. type UNINDEXED,
  116. time UNINDEXED,
  117. surface UNINDEXED,
  118. codepoint_length UNINDEXED,
  119. tokenize = 'unicode61'
  120. )
  121. `)
  122. db.exec(`PRAGMA user_version = ${SESSION_QUERY_SQLITE_SCHEMA_VERSION}`)
  123. }
  124. function ensureTemporarySchema(db: DatabaseSync): void {
  125. db.exec(`
  126. CREATE TEMP TABLE IF NOT EXISTS live_sessions (
  127. id TEXT PRIMARY KEY,
  128. version INTEGER NOT NULL,
  129. created_at INTEGER NOT NULL,
  130. cwd TEXT,
  131. parent_session TEXT,
  132. seed_length INTEGER,
  133. delegation_depth INTEGER,
  134. fingerprint TEXT NOT NULL,
  135. persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)),
  136. generation INTEGER NOT NULL
  137. ) STRICT
  138. `)
  139. db.exec(`
  140. CREATE VIRTUAL TABLE IF NOT EXISTS temp.live_docs USING fts5(
  141. text,
  142. session_id UNINDEXED,
  143. seq UNINDEXED,
  144. type UNINDEXED,
  145. time UNINDEXED,
  146. surface UNINDEXED,
  147. codepoint_length UNINDEXED,
  148. tokenize = 'unicode61'
  149. )
  150. `)
  151. }
  152. function quoteIdentifier(value: string): string {
  153. return `"${value.replaceAll('"', '""')}"`
  154. }