Files
codegraph/src/db/migrations.ts
T
9d0cd3a7d1 fix(sync): resolve cross-file refs when an edit adds or removes the satisfying symbol (#1240) (#1249)
* chore: ignore .kommandr/ directory

* fix(sync): resolve cross-file refs when an edit adds or removes the satisfying symbol (#1240)

Incremental sync scoped reference resolution to the changed files' own
refs, and a completed pass deleted every ref it failed to resolve — so
a symbol change in one file could never repair references in UNCHANGED
files, in either direction, until a full re-index:

- New-export case: a.ts imports/calls `greet` before b.ts defines it.
  The failed refs were deleted at index time; when b.ts later gained
  `greet`, nothing revisited a.ts — the calls/imports edges stayed
  missing while status reported a clean index.
- Removal case: when a re-index (or file deletion) dropped a symbol,
  the incoming edges cascade-deleted and the callers — whose resolved
  refs had been consumed — never got a chance to rebind to an
  alternative definition or reconnect when the symbol returned.

Fix, sharing one lifecycle:

- Schema v8: unresolved_refs gains status ('pending'/'failed') and
  name_tail (last dotted segment, so `h.greet` is findable by `greet`).
  Both resolver persist paths now park unresolvable refs as failed
  instead of deleting them. All pending-work readers (batched drain,
  non-progress guard, #1187 orphan sweep, status pendingRefs) filter to
  pending, preserving their invariants and keeping status honest.
- Sync retry: after scoped resolution, failed refs whose name tail
  matches a symbol name now present in the changed files are re-resolved
  through a per-ref-yielding path (watchdog-safe, #1091 class). Names
  matching >500 failed refs are skipped as external/builtin noise (#999
  rationale).
- Removal side: createEdges stamps each resolution edge with its
  originating reference (metadata.refName, + refKind when kind promotion
  rewrote it). When the #899 restore misses a target or sync deletes a
  file, the dropped edge is resurrected as exactly that ref — re-resolved
  in the same sync (rebinding to an alternative definition) or parked
  failed until the symbol reappears. Edges without the stamp (pre-upgrade,
  synthesized) still drop silently: reconstructing from the target's plain
  name would strip receiver context and risk a rebind a full re-index
  would never make.
- Pure-removal syncs clear resolver caches so a long-lived daemon can't
  resolve resurrected refs against the pre-removal graph.

Validated: issue repro now yields a graph byte-identical to a full
re-index; move/remove-readd/file-deletion scenarios all rebind or heal;
baseline-vs-new A/B on express and gin shows identical node/edge counts
and no timing regression (DB grows ~25% from the parked ref rows — pure
cache, reset by any full re-index). 8 regression tests added.

Fixes #1240

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:19:08 -05:00

235 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Database Migrations
*
* Schema versioning and migration support.
*/
import { SqliteDatabase } from './sqlite-adapter';
/**
* Current schema version
*/
export const CURRENT_SCHEMA_VERSION = 8;
/**
* 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 hooks 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;
`);
},
},
{
version: 8,
description:
'Track attempted-but-unresolvable refs as status=failed so sync can retry them when a changed file adds a matching symbol (#1240)',
up: (db) => {
// DDL only — instant on any size database. No backfill needed: rows are
// only ever queried by name_tail once they carry status='failed', and
// both fields are written together by markReferencesFailed. Legacy rows
// (all 'pending' after this migration) are orphans from interrupted runs
// that the #1187 sweep grinds down on the next sync, marking survivors
// failed with their tails as it goes. The tail index is partial: on a
// healthy index the pending set is empty and the failed set is the only
// population worth indexing. Keep the definitions in lockstep with
// schema.sql. ALTER TABLE has no IF NOT EXISTS, so guard each column for
// idempotency — a database created from current schema.sql already has
// both (matters when migrations are re-run from an older recorded
// version, as the v6 regression test does).
const cols = db.prepare('PRAGMA table_info(unresolved_refs)').all() as Array<{ name: string }>;
const hasColumn = (name: string) => cols.some((c) => c.name === name);
if (!hasColumn('status')) {
db.exec("ALTER TABLE unresolved_refs ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'");
}
if (!hasColumn('name_tail')) {
db.exec("ALTER TABLE unresolved_refs ADD COLUMN name_tail TEXT NOT NULL DEFAULT ''");
}
db.exec(`
CREATE INDEX IF NOT EXISTS idx_unresolved_status ON unresolved_refs(status);
CREATE INDEX IF NOT EXISTS idx_unresolved_failed_tail ON unresolved_refs(name_tail) WHERE status = 'failed';
`);
},
},
];
/**
* 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,
}));
}