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:
co-authored by
Claude Opus 5
parent
41a90c6ba4
commit
951ba3678a
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user