Files
codegraph/src/db/migrations.ts
T
Colby McHenryandClaude Opus 5 16e17495f4 feat(extraction): content-based generated-file detection (CG-5, #1500)
`isGeneratedFile` was path-only, but Go's own convention is a CONTENT
marker (`// Code generated by <tool>. DO NOT EDIT.`), not a filename one.
A Go monorepo with generated CRUD in ordinarily-named files sitting beside
hand-written use-cases was therefore invisible to every generated-file
down-rank in the codebase — that is #1500.

Measured on kubernetes/client-go (2,453 Go files): the canonical banner
appears in 2,001 of them, the path check flags 0, the new content check
flags exactly those 2,001 — no false positives, no misses.

Design: decide at INDEX time (content is already in memory for parsing),
persist on `files.generated`, read from the DB. Explore never reads file
headers per request.

- `hasGeneratedHeader(content)` recognizes the standard banners — Go's,
  protoc's, `@generated`, `<auto-generated>`, Thrift, OpenAPI Generator,
  FlatBuffers, bindgen, ANTLR. Precision-first and fenced three ways: an
  8KB/60-line header window, a comment-line requirement (leader or open
  block comment), and markers tight enough that prose can't trip them. A
  generator's own source, holding the banner as a string constant in its
  body, is not flagged; neither is this module itself (pinned by test).
- `isGeneratedFile(path)` is unchanged — cheap, sync, still the fallback.
- Schema v9 adds `files.generated` + a PARTIAL index. DDL only, no
  backfill: the flag derives from content the migration cannot see, so
  rows stay 0 until a re-index and every reader unions the flag with the
  path check — an un-migrated index keeps pre-#1500 behavior rather than
  regressing. Re-index required; noted in the CHANGELOG.
- `generatedPredicateFor(paths)` gives ranking a bounded probe + O(1)
  lookups. Bounded, not cached: no invalidation, so a ranking call can
  never serve a verdict the last sync already replaced. Wired into explore
  ranking, findSymbolMatches, findAllSymbols, search (MCP + CLI), the
  context formatter, and the dominant-file/route-file hygiene filters.

Cost (acceptance bar was no measurable index-time regression): a single
unanchored `/generat/i` test over the header rejects ~every hand-written
file before any line splitting. 4.6 µs/file on client-go (worst case —
82% generated). End-to-end `codegraph init` on client-go, n=3 alternating
arms: 5.73s median with detection vs 5.76s path-only baseline; the arms
cross over between runs, so the difference is inside run-to-run noise.

Scope note: generated status remains a stable TIEBREAK at equal score,
exactly where it was. Making it a strong negative signal is CG-10, which
this unblocks by making the signal correct and available.

Two pre-existing tests hard-coded schema version 8; both now track
CURRENT_SCHEMA_VERSION (or the migration table) so future migrations
don't require editing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:13:59 -05:00

262 lines
9.2 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 = 9;
/**
* 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;
`);
},
},
{
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';
`);
},
},
{
version: 9,
description:
'Add files.generated — index-time content-header generated-file detection for ranking (#1500)',
up: (db) => {
// DDL only — instant on any size database, and NO backfill: the flag is
// derived from file CONTENT, which this migration has no access to (the
// files table stores a hash, not the bytes). Migrated rows therefore stay
// 0 until the next full index re-extracts them, and every reader unions
// the flag with the path-only check, so an un-backfilled database keeps
// exactly the pre-#1500 behavior instead of regressing. `sync` heals it
// file-by-file as files change. This is why the CHANGELOG entry says a
// re-index is required to pick up the new detection.
//
// ALTER TABLE has no IF NOT EXISTS, so guard for idempotency — a database
// created from current schema.sql already has the column (matters when
// migrations are re-run from an older recorded version, as the v6
// regression test does). Keep in lockstep with schema.sql.
const cols = db.prepare('PRAGMA table_info(files)').all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'generated')) {
db.exec('ALTER TABLE files ADD COLUMN generated INTEGER NOT NULL DEFAULT 0');
}
db.exec(
'CREATE INDEX IF NOT EXISTS idx_files_generated ON files(path) WHERE generated = 1'
);
},
},
];
/**
* 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,
}));
}