| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- /**
- * Database Migrations
- *
- * Schema versioning and migration support.
- */
- import { SqliteDatabase } from './sqlite-adapter';
- /**
- * Current schema version
- */
- export const CURRENT_SCHEMA_VERSION = 7;
- /**
- * Migration definition
- */
- interface Migration {
- version: number;
- description: string;
- up: (db: SqliteDatabase) => void;
- }
- /**
- * All migrations in order
- *
- * Note: Version 1 is the initial schema, handled by schema.sql
- * Future migrations go here.
- */
- const migrations: Migration[] = [
- {
- version: 2,
- description: 'Add project metadata, provenance tracking, and unresolved ref context',
- up: (db) => {
- db.exec(`
- CREATE TABLE IF NOT EXISTS project_metadata (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL,
- updated_at INTEGER NOT NULL
- );
- ALTER TABLE unresolved_refs ADD COLUMN file_path TEXT NOT NULL DEFAULT '';
- ALTER TABLE unresolved_refs ADD COLUMN language TEXT NOT NULL DEFAULT 'unknown';
- ALTER TABLE edges ADD COLUMN provenance TEXT DEFAULT NULL;
- CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path);
- CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance);
- `);
- },
- },
- {
- version: 3,
- description: 'Add lower(name) expression index for memory-efficient case-insensitive lookups',
- up: (db) => {
- db.exec(`
- CREATE INDEX IF NOT EXISTS idx_nodes_lower_name ON nodes(lower(name));
- `);
- },
- },
- {
- version: 4,
- description:
- 'Drop redundant idx_edges_source / idx_edges_target (covered by source_kind / target_kind composites)',
- up: (db) => {
- db.exec(`
- DROP INDEX IF EXISTS idx_edges_source;
- DROP INDEX IF EXISTS idx_edges_target;
- `);
- },
- },
- {
- version: 5,
- description:
- 'Add nodes.return_type — normalized return/result type for receiver-type inference (C++ singletons/factories, #645)',
- up: (db) => {
- db.exec(`
- ALTER TABLE nodes ADD COLUMN return_type TEXT;
- `);
- },
- },
- {
- version: 6,
- description:
- 'Dedup duplicate edge rows and add a UNIQUE identity index so INSERT OR IGNORE actually dedups (#1034)',
- up: (db) => {
- // `insertEdge` has always used `INSERT OR IGNORE`, but the edges table had
- // no UNIQUE constraint, so nothing conflicted and byte-identical rows
- // accumulated whenever two passes emitted the same edge. Collapse each
- // identity group to its lowest id, then add the constraint that makes
- // `OR IGNORE` keep its promise. IFNULL folds nullable line/col so
- // coordinate-less edges dedup too (SQLite treats each NULL as distinct) —
- // and it MUST match the GROUP BY exactly, or the index creation would
- // fail on a pair the DELETE left behind. Idempotent: the index is
- // `IF NOT EXISTS` and the DELETE is a no-op once the table is unique.
- db.exec(`
- DELETE FROM edges
- WHERE id NOT IN (
- SELECT MIN(id) FROM edges
- GROUP BY source, target, kind, IFNULL(line, -1), IFNULL(col, -1)
- );
- CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_identity
- ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1));
- `);
- },
- },
- {
- version: 7,
- description:
- 'Add name_segment_vocab — prose-word → symbol-name lookup for the prompt hook’s graph-derived gate',
- up: (db) => {
- // DDL only — instant on any size database (the row-churn hazards of #1067
- // don't apply). The table starts EMPTY on migrated databases; `sync`
- // detects that over a populated graph and backfills batched+yielding
- // (CodeGraph.rebuildNameSegmentVocab), and any full index rebuilds it
- // from scratch. Keep the definition in lockstep with schema.sql.
- db.exec(`
- CREATE TABLE IF NOT EXISTS name_segment_vocab (
- segment TEXT NOT NULL,
- name TEXT NOT NULL,
- PRIMARY KEY (segment, name)
- ) WITHOUT ROWID;
- `);
- },
- },
- ];
- /**
- * Get the current schema version from the database
- */
- export function getCurrentVersion(db: SqliteDatabase): number {
- try {
- const row = db
- .prepare('SELECT MAX(version) as version FROM schema_versions')
- .get() as { version: number | null } | undefined;
- return row?.version ?? 0;
- } catch {
- // Table doesn't exist yet
- return 0;
- }
- }
- /**
- * Record a migration as applied
- */
- function recordMigration(db: SqliteDatabase, version: number, description: string): void {
- db.prepare(
- 'INSERT INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)'
- ).run(version, Date.now(), description);
- }
- /**
- * Run all pending migrations
- */
- export function runMigrations(db: SqliteDatabase, fromVersion: number): void {
- const pending = migrations.filter((m) => m.version > fromVersion);
- if (pending.length === 0) {
- return;
- }
- // Sort by version
- pending.sort((a, b) => a.version - b.version);
- // Run each migration in a transaction
- for (const migration of pending) {
- db.transaction(() => {
- migration.up(db);
- recordMigration(db, migration.version, migration.description);
- })();
- }
- }
- /**
- * Check if the database needs migration
- */
- export function needsMigration(db: SqliteDatabase): boolean {
- const current = getCurrentVersion(db);
- return current < CURRENT_SCHEMA_VERSION;
- }
- /**
- * Get list of pending migrations
- */
- export function getPendingMigrations(db: SqliteDatabase): Migration[] {
- const current = getCurrentVersion(db);
- return migrations
- .filter((m) => m.version > current)
- .sort((a, b) => a.version - b.version);
- }
- /**
- * Get migration history from database
- */
- export function getMigrationHistory(
- db: SqliteDatabase
- ): Array<{ version: number; appliedAt: number; description: string | null }> {
- const rows = db
- .prepare('SELECT version, applied_at, description FROM schema_versions ORDER BY version')
- .all() as Array<{ version: number; applied_at: number; description: string | null }>;
- return rows.map((row) => ({
- version: row.version,
- appliedAt: row.applied_at,
- description: row.description,
- }));
- }
|