migrations.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  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 = 9;
  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. version: 8,
  120. description:
  121. 'Track attempted-but-unresolvable refs as status=failed so sync can retry them when a changed file adds a matching symbol (#1240)',
  122. up: (db) => {
  123. // DDL only — instant on any size database. No backfill needed: rows are
  124. // only ever queried by name_tail once they carry status='failed', and
  125. // both fields are written together by markReferencesFailed. Legacy rows
  126. // (all 'pending' after this migration) are orphans from interrupted runs
  127. // that the #1187 sweep grinds down on the next sync, marking survivors
  128. // failed with their tails as it goes. The tail index is partial: on a
  129. // healthy index the pending set is empty and the failed set is the only
  130. // population worth indexing. Keep the definitions in lockstep with
  131. // schema.sql. ALTER TABLE has no IF NOT EXISTS, so guard each column for
  132. // idempotency — a database created from current schema.sql already has
  133. // both (matters when migrations are re-run from an older recorded
  134. // version, as the v6 regression test does).
  135. const cols = db.prepare('PRAGMA table_info(unresolved_refs)').all() as Array<{ name: string }>;
  136. const hasColumn = (name: string) => cols.some((c) => c.name === name);
  137. if (!hasColumn('status')) {
  138. db.exec("ALTER TABLE unresolved_refs ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'");
  139. }
  140. if (!hasColumn('name_tail')) {
  141. db.exec("ALTER TABLE unresolved_refs ADD COLUMN name_tail TEXT NOT NULL DEFAULT ''");
  142. }
  143. db.exec(`
  144. CREATE INDEX IF NOT EXISTS idx_unresolved_status ON unresolved_refs(status);
  145. CREATE INDEX IF NOT EXISTS idx_unresolved_failed_tail ON unresolved_refs(name_tail) WHERE status = 'failed';
  146. `);
  147. },
  148. },
  149. {
  150. version: 9,
  151. description:
  152. 'Add files.generated — index-time content-header generated-file detection for ranking (#1500)',
  153. up: (db) => {
  154. // DDL only — instant on any size database, and NO backfill: the flag is
  155. // derived from file CONTENT, which this migration has no access to (the
  156. // files table stores a hash, not the bytes). Migrated rows therefore stay
  157. // 0 until the next full index re-extracts them, and every reader unions
  158. // the flag with the path-only check, so an un-backfilled database keeps
  159. // exactly the pre-#1500 behavior instead of regressing. `sync` heals it
  160. // file-by-file as files change. This is why the CHANGELOG entry says a
  161. // re-index is required to pick up the new detection.
  162. //
  163. // ALTER TABLE has no IF NOT EXISTS, so guard for idempotency — a database
  164. // created from current schema.sql already has the column (matters when
  165. // migrations are re-run from an older recorded version, as the v6
  166. // regression test does). Keep in lockstep with schema.sql.
  167. const cols = db.prepare('PRAGMA table_info(files)').all() as Array<{ name: string }>;
  168. if (!cols.some((c) => c.name === 'generated')) {
  169. db.exec('ALTER TABLE files ADD COLUMN generated INTEGER NOT NULL DEFAULT 0');
  170. }
  171. db.exec(
  172. 'CREATE INDEX IF NOT EXISTS idx_files_generated ON files(path) WHERE generated = 1'
  173. );
  174. },
  175. },
  176. ];
  177. /**
  178. * Get the current schema version from the database
  179. */
  180. export function getCurrentVersion(db: SqliteDatabase): number {
  181. try {
  182. const row = db
  183. .prepare('SELECT MAX(version) as version FROM schema_versions')
  184. .get() as { version: number | null } | undefined;
  185. return row?.version ?? 0;
  186. } catch {
  187. // Table doesn't exist yet
  188. return 0;
  189. }
  190. }
  191. /**
  192. * Record a migration as applied
  193. */
  194. function recordMigration(db: SqliteDatabase, version: number, description: string): void {
  195. db.prepare(
  196. 'INSERT INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
  197. ).run(version, Date.now(), description);
  198. }
  199. /**
  200. * Run all pending migrations
  201. */
  202. export function runMigrations(db: SqliteDatabase, fromVersion: number): void {
  203. const pending = migrations.filter((m) => m.version > fromVersion);
  204. if (pending.length === 0) {
  205. return;
  206. }
  207. // Sort by version
  208. pending.sort((a, b) => a.version - b.version);
  209. // Run each migration in a transaction
  210. for (const migration of pending) {
  211. db.transaction(() => {
  212. migration.up(db);
  213. recordMigration(db, migration.version, migration.description);
  214. })();
  215. }
  216. }
  217. /**
  218. * Check if the database needs migration
  219. */
  220. export function needsMigration(db: SqliteDatabase): boolean {
  221. const current = getCurrentVersion(db);
  222. return current < CURRENT_SCHEMA_VERSION;
  223. }
  224. /**
  225. * Get list of pending migrations
  226. */
  227. export function getPendingMigrations(db: SqliteDatabase): Migration[] {
  228. const current = getCurrentVersion(db);
  229. return migrations
  230. .filter((m) => m.version > current)
  231. .sort((a, b) => a.version - b.version);
  232. }
  233. /**
  234. * Get migration history from database
  235. */
  236. export function getMigrationHistory(
  237. db: SqliteDatabase
  238. ): Array<{ version: number; appliedAt: number; description: string | null }> {
  239. const rows = db
  240. .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version')
  241. .all() as Array<{ version: number; applied_at: number; description: string | null }>;
  242. return rows.map((row) => ({
  243. version: row.version,
  244. appliedAt: row.applied_at,
  245. description: row.description,
  246. }));
  247. }