migrations.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. /**
  2. * Database Migrations
  3. *
  4. * Schema versioning and migration support.
  5. */
  6. import { SqliteDatabase } from './sqlite-adapter';
  7. /**
  8. * Current schema version
  9. */
  10. export const CURRENT_SCHEMA_VERSION = 7;
  11. /**
  12. * Migration definition
  13. */
  14. interface Migration {
  15. version: number;
  16. description: string;
  17. up: (db: SqliteDatabase) => void;
  18. }
  19. /**
  20. * All migrations in order
  21. *
  22. * Note: Version 1 is the initial schema, handled by schema.sql
  23. * Future migrations go here.
  24. */
  25. const migrations: Migration[] = [
  26. {
  27. version: 2,
  28. description: 'Add project metadata, provenance tracking, and unresolved ref context',
  29. up: (db) => {
  30. db.exec(`
  31. CREATE TABLE IF NOT EXISTS project_metadata (
  32. key TEXT PRIMARY KEY,
  33. value TEXT NOT NULL,
  34. updated_at INTEGER NOT NULL
  35. );
  36. ALTER TABLE unresolved_refs ADD COLUMN file_path TEXT NOT NULL DEFAULT '';
  37. ALTER TABLE unresolved_refs ADD COLUMN language TEXT NOT NULL DEFAULT 'unknown';
  38. ALTER TABLE edges ADD COLUMN provenance TEXT DEFAULT NULL;
  39. CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path);
  40. CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance);
  41. `);
  42. },
  43. },
  44. {
  45. version: 3,
  46. description: 'Add lower(name) expression index for memory-efficient case-insensitive lookups',
  47. up: (db) => {
  48. db.exec(`
  49. CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name));
  50. `);
  51. },
  52. },
  53. {
  54. version: 4,
  55. description:
  56. 'Drop redundant idx_edges_source / idx_edges_target (covered by source_kind / target_kind composites)',
  57. up: (db) => {
  58. db.exec(`
  59. DROP INDEX IF EXISTS idx_edges_source;
  60. DROP INDEX IF EXISTS idx_edges_target;
  61. `);
  62. },
  63. },
  64. {
  65. version: 5,
  66. description:
  67. 'Add nodes.return_type — normalized return/result type for receiver-type inference (C++ singletons/factories, #645)',
  68. up: (db) => {
  69. db.exec(`
  70. ALTER TABLE nodes ADD COLUMN return_type TEXT;
  71. `);
  72. },
  73. },
  74. {
  75. version: 6,
  76. description:
  77. 'Dedup duplicate edge rows and add a UNIQUE identity index so INSERT OR IGNORE actually dedups (#1034)',
  78. up: (db) => {
  79. // `insertEdge` has always used `INSERT OR IGNORE`, but the edges table had
  80. // no UNIQUE constraint, so nothing conflicted and byte-identical rows
  81. // accumulated whenever two passes emitted the same edge. Collapse each
  82. // identity group to its lowest id, then add the constraint that makes
  83. // `OR IGNORE` keep its promise. IFNULL folds nullable line/col so
  84. // coordinate-less edges dedup too (SQLite treats each NULL as distinct) —
  85. // and it MUST match the GROUP BY exactly, or the index creation would
  86. // fail on a pair the DELETE left behind. Idempotent: the index is
  87. // `IF NOT EXISTS` and the DELETE is a no-op once the table is unique.
  88. db.exec(`
  89. DELETE FROM edges
  90. WHERE id NOT IN (
  91. SELECT MIN(id) FROM edges
  92. GROUP BY source, target, kind, IFNULL(line, -1), IFNULL(col, -1)
  93. );
  94. CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_identity
  95. ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1));
  96. `);
  97. },
  98. },
  99. {
  100. version: 7,
  101. description:
  102. 'Add name_segment_vocab — prose-word → symbol-name lookup for the prompt hook’s graph-derived gate',
  103. up: (db) => {
  104. // DDL only — instant on any size database (the row-churn hazards of #1067
  105. // don't apply). The table starts EMPTY on migrated databases; `sync`
  106. // detects that over a populated graph and backfills batched+yielding
  107. // (CodeGraph.rebuildNameSegmentVocab), and any full index rebuilds it
  108. // from scratch. Keep the definition in lockstep with schema.sql.
  109. db.exec(`
  110. CREATE TABLE IF NOT EXISTS name_segment_vocab (
  111. segment TEXT NOT NULL,
  112. name TEXT NOT NULL,
  113. PRIMARY KEY (segment, name)
  114. ) WITHOUT ROWID;
  115. `);
  116. },
  117. },
  118. ];
  119. /**
  120. * Get the current schema version from the database
  121. */
  122. export function getCurrentVersion(db: SqliteDatabase): number {
  123. try {
  124. const row = db
  125. .prepare('SELECT MAX(version) as version FROM schema_versions')
  126. .get() as { version: number | null } | undefined;
  127. return row?.version ?? 0;
  128. } catch {
  129. // Table doesn't exist yet
  130. return 0;
  131. }
  132. }
  133. /**
  134. * Record a migration as applied
  135. */
  136. function recordMigration(db: SqliteDatabase, version: number, description: string): void {
  137. db.prepare(
  138. 'INSERT INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
  139. ).run(version, Date.now(), description);
  140. }
  141. /**
  142. * Run all pending migrations
  143. */
  144. export function runMigrations(db: SqliteDatabase, fromVersion: number): void {
  145. const pending = migrations.filter((m) => m.version > fromVersion);
  146. if (pending.length === 0) {
  147. return;
  148. }
  149. // Sort by version
  150. pending.sort((a, b) => a.version - b.version);
  151. // Run each migration in a transaction
  152. for (const migration of pending) {
  153. db.transaction(() => {
  154. migration.up(db);
  155. recordMigration(db, migration.version, migration.description);
  156. })();
  157. }
  158. }
  159. /**
  160. * Check if the database needs migration
  161. */
  162. export function needsMigration(db: SqliteDatabase): boolean {
  163. const current = getCurrentVersion(db);
  164. return current < CURRENT_SCHEMA_VERSION;
  165. }
  166. /**
  167. * Get list of pending migrations
  168. */
  169. export function getPendingMigrations(db: SqliteDatabase): Migration[] {
  170. const current = getCurrentVersion(db);
  171. return migrations
  172. .filter((m) => m.version > current)
  173. .sort((a, b) => a.version - b.version);
  174. }
  175. /**
  176. * Get migration history from database
  177. */
  178. export function getMigrationHistory(
  179. db: SqliteDatabase
  180. ): Array<{ version: number; appliedAt: number; description: string | null }> {
  181. const rows = db
  182. .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version')
  183. .all() as Array<{ version: number; applied_at: number; description: string | null }>;
  184. return rows.map((row) => ({
  185. version: row.version,
  186. appliedAt: row.applied_at,
  187. description: row.description,
  188. }));
  189. }