feat(ui): read-only JSON API over the index for the viewer (CG-42)

Six endpoints under `/api/`, one per screen, each answering in a single
round-trip in the spirit of `codegraph_explore` — the viewer should never
have to ask a follow-up question to finish drawing a pane:

    /api/stats                     index state, graph counts, frameworks
    /api/search?q=                 ranked, kind-grouped symbol search
    /api/node/<id>                 rails, members, tests, blast radius
    /api/source?file=&from=&to=    verbatim source + a drift verdict
    /api/file/<path>               outline and import rails
    /api/routes                    URL -> handler, when there is one

It is a reader of the existing schema: no extraction or resolution changes.
It mounts on the `api` seam `startUiServer` already exposed, so it sits
behind the CG-41 loopback boundary — Host allowlist, no CORS headers,
GET/HEAD only — and every read out of the repository goes through
`resolveProjectFile`, ahead of the index lookup so a traversal is refused
as a traversal rather than reported as "not indexed".

Three properties the endpoints are built around:

- No N+1. The engine's busiest symbol has 545 incoming edges; resolving
  those one `getNode` at a time is 545 queries. Every edge list is
  resolved with one batched lookup, which needed four additive read-only
  query methods (`getNodesByIds`/`getFanIn`/`getFanOut` on `CodeGraph`,
  plus batched outgoing/incoming edge fetches and unresolved-reference
  reads). `/api/node` on `LRUCache.get` answers in ~10 ms.

- Capped lists, honest totals. 545 callers cannot all be rows, so caller
  groups cap at 300 — but `total` is always the real number, and the
  ordering puts the useful end first (same file, then production code,
  then tests). Every count in the payload is the length of a list the
  same payload returns, so a badge and its rail cannot disagree.

- Nothing overclaims. Source that drifted on disk since the last index
  sync is omitted rather than sliced at line ranges that may now point at
  a different symbol; calls that leave the index are counted instead of
  silently shortening the callee rail; imports that never resolved are
  named; and a test-coverage claim reports whether its search actually
  finished. `/api/routes` says a project simply is not routed, and
  refuses a `limit` below three because the engine's manifest would
  answer that question wrongly.

Tests: 45 against a real indexed fixture over a real loopback server,
covering every endpoint's shape, the drift verdict in all three places it
surfaces, search ranking and the filter grammar, the refusals, and the
capping/latency behaviour at 500 callers. The issue's own acceptance case
— `lru-cache.ts` `get` under 100 ms — runs against this repo's index when
one is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-26 23:33:30 -05:00
co-authored by Claude Opus 5
parent 41a90c6ba4
commit 951ba3678a
15 changed files with 3300 additions and 5 deletions
+128
View File
@@ -245,6 +245,7 @@ export class QueryBuilder {
deleteEdgesByTarget?: SqliteStatement;
getEdgesBySource?: SqliteStatement;
getEdgesByTarget?: SqliteStatement;
getUnresolvedFromNode?: SqliteStatement;
insertFile?: SqliteStatement;
updateFile?: SqliteStatement;
deleteFile?: SqliteStatement;
@@ -1862,6 +1863,133 @@ export class QueryBuilder {
return rows.map(rowToEdge);
}
/**
* Outgoing edges for MANY source nodes in one query.
*
* The batch form of {@link getOutgoingEdges}. Building a nested outline needs
* the `contains` edges of every container in a file at once; doing that one
* source at a time is a query per symbol on files that have hundreds.
*/
getOutgoingEdgesFrom(sourceIds: readonly string[], kinds?: EdgeKind[]): Edge[] {
if (sourceIds.length === 0) return [];
const unique = [...new Set(sourceIds)];
const out: Edge[] = [];
for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
let sql = `SELECT * FROM edges WHERE source IN (${placeholders})`;
const params: string[] = [...chunk];
if (kinds && kinds.length > 0) {
sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
params.push(...kinds);
}
const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
for (const row of rows) out.push(rowToEdge(row));
}
return out;
}
/**
* Fan-in (total incoming edge count) for MANY nodes in one query.
*
* The per-node alternative — `getIncomingEdges(id).length` — is an indexed
* lookup each, but a symbol screen rendering a couple of hundred callees
* would issue a couple of hundred of them. Ids with no incoming edges are
* absent from the map rather than present as 0, so callers can tell "no
* edges" from "not asked about".
*/
countIncomingEdges(ids: readonly string[]): Map<string, number> {
const out = new Map<string, number>();
if (ids.length === 0) return out;
const unique = [...new Set(ids)];
for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT target, COUNT(*) AS count FROM edges WHERE target IN (${placeholders}) GROUP BY target`
)
.all(...chunk) as Array<{ target: string; count: number }>;
for (const row of rows) out.set(row.target, row.count);
}
return out;
}
/**
* Incoming edges for MANY target nodes in one query — the mirror of
* {@link getOutgoingEdgesFrom}. Needed wherever a whole file's inbound edges
* are wanted at once ("which files import anything in this one?").
*/
getIncomingEdgesTo(targetIds: readonly string[], kinds?: EdgeKind[]): Edge[] {
if (targetIds.length === 0) return [];
const unique = [...new Set(targetIds)];
const out: Edge[] = [];
for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
let sql = `SELECT * FROM edges WHERE target IN (${placeholders})`;
const params: string[] = [...chunk];
if (kinds && kinds.length > 0) {
sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`;
params.push(...kinds);
}
const rows = this.db.prepare(sql).all(...params) as EdgeRow[];
for (const row of rows) out.push(rowToEdge(row));
}
return out;
}
/**
* Fan-out (total outgoing edge count) for MANY nodes in one query — the
* mirror of {@link countIncomingEdges}. Ids with no outgoing edges are absent
* from the map rather than present as 0.
*/
countOutgoingEdges(ids: readonly string[]): Map<string, number> {
const out = new Map<string, number>();
if (ids.length === 0) return out;
const unique = [...new Set(ids)];
for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT source, COUNT(*) AS count FROM edges WHERE source IN (${placeholders}) GROUP BY source`
)
.all(...chunk) as Array<{ source: string; count: number }>;
for (const row of rows) out.set(row.source, row.count);
}
return out;
}
/**
* References recorded against a symbol that never resolved to a node — the
* calls and type mentions that leave the index (a third-party package, a
* runtime builtin, a language construct extraction doesn't model).
*
* Read-only. It exists so a reader can say "N calls into symbols outside the
* index" instead of silently showing a callee list shorter than the body's
* call sites, which reads as "nothing else happens here".
*/
getUnresolvedReferencesFrom(fromNodeId: string): UnresolvedReference[] {
if (!this.stmts.getUnresolvedFromNode) {
this.stmts.getUnresolvedFromNode = this.db.prepare(
'SELECT * FROM unresolved_refs WHERE from_node_id = ?'
);
}
const rows = this.stmts.getUnresolvedFromNode.all(fromNodeId) as UnresolvedRefRow[];
return rows.map((row) => ({
fromNodeId: row.from_node_id,
referenceName: row.reference_name,
referenceKind: row.reference_kind as EdgeKind,
line: row.line,
column: row.col,
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
rowId: row.id,
}));
}
/**
* Find all edges where both source and target are in the given node set.
* Useful for recovering inter-node connectivity after BFS.