schema.ts 6.0 KB

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