Files
codegraph/src/db/migrations.ts
T
Colby MchenryandClaude Fable 5 e699ee9686 feat(prompt-hook): graph-derived gate tier + confidence-tiered injection + gate telemetry (#1136)
The keyword gate (#1126) can never know a repo's domain nouns. This adds
the graph-derived tier the design discussion converged on: symbol names
are split into prose segments at index time (name_segment_vocab, riding
the insertNode write path), and the hook verifies a prompt's plain words
against them — "the state machine des commandes" → OrderStateMachine, in
any language whose technical nouns are Latin script.

Confidence now decides HOW MUCH to inject, not just whether:
- HIGH (keyword, or index-verified code token): full explore injection,
  unchanged — the validated adoption lever.
- MEDIUM (segment matches only): a ~500-byte pointer naming the matching
  symbols; the AGENT writes the explore query. Never runs explore, so a
  fuzzy match can't inject 16KB of wrong-feature context.
- Silent otherwise, as before.

Precision is derived from the repo's own naming statistics plus measured
FP fixes: co-occurrence (≥2 words on one name) always qualifies; a single
word must be ≥5 chars, cluster across 2–25 names (singletons are prose
coincidence: "deploy to production" → matchesNonProductionDir), match a
multi-segment name, and not be an English function/filler word (the one
place a word list is honest: identifiers are English, so only English
prose collides). Every candidate is re-verified against nodes before
being surfaced — vocab rows are proposals, deletions leave orphans by
design, a full index rebuilds from scratch, and sync heals pre-upgrade
databases (batched + yielding; emptiness captured at sync ENTRY so the
sync's own writes can't mask the backfill).

Schema v7 migration is DDL-only (instant; none of the #1067 row-churn
hazards). Gate outcomes roll up as anonymous usage counters
(prompt-hook-gate-<outcome>, names only, never content) through the
existing telemetry pipeline — recall becomes measurable, and the counters
are the agreed kill-criterion data for ever revisiting a local classifier.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:35:38 -05:00

204 lines
6.1 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 = 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,
}));
}