graph-indexer 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +228 -350
  3. package/SECURITY.md +68 -0
  4. package/TRADEMARK.md +31 -0
  5. package/cli-ui.mjs +78 -0
  6. package/config.mjs +269 -0
  7. package/daemon-ctl.mjs +175 -0
  8. package/daemon-lock.mjs +69 -0
  9. package/embedders/python/mlx_embed_server.py +61 -0
  10. package/embedders/python/requirements-mlx.txt +5 -0
  11. package/embedders/setup-mlx.mjs +110 -0
  12. package/embeddings.mjs +489 -0
  13. package/engine/binary.mjs +399 -0
  14. package/{core-engine.mjs → engine/memory.mjs} +359 -249
  15. package/engine/sqlite.mjs +863 -0
  16. package/enrichment.mjs +426 -0
  17. package/git-signals.mjs +248 -0
  18. package/indexer.mjs +216 -77
  19. package/init.mjs +1604 -170
  20. package/layout.mjs +123 -0
  21. package/mcp/format.mjs +61 -0
  22. package/mcp/tools.mjs +1157 -0
  23. package/mcp/topology.mjs +551 -0
  24. package/mcp-server.mjs +118 -573
  25. package/package.json +56 -16
  26. package/parse/extractor.mjs +617 -0
  27. package/parse/imports.mjs +365 -0
  28. package/parse/languages.mjs +89 -0
  29. package/parse/metadata.mjs +655 -0
  30. package/parse/routes.mjs +327 -0
  31. package/prompts/CORE.md +86 -0
  32. package/prompts/DOMAIN_TEMPLATE.md +46 -0
  33. package/prompts/INTEGRATION.md +123 -0
  34. package/prompts/frameworks/ANDROID.md +17 -0
  35. package/prompts/frameworks/ASPNET_CORE.md +19 -0
  36. package/prompts/frameworks/FASTAPI_DJANGO.md +19 -0
  37. package/prompts/frameworks/LARAVEL_SYMFONY.md +22 -0
  38. package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +18 -0
  39. package/prompts/frameworks/RAILS.md +20 -0
  40. package/prompts/frameworks/REACT.md +20 -0
  41. package/prompts/frameworks/SPRING_BOOT.md +19 -0
  42. package/prompts/languages/BASH.md +22 -0
  43. package/prompts/languages/C.md +21 -0
  44. package/prompts/languages/CSHARP.md +24 -0
  45. package/prompts/languages/CSS_SCSS.md +22 -0
  46. package/prompts/languages/GO.md +23 -0
  47. package/prompts/languages/JAVA.md +23 -0
  48. package/prompts/languages/JAVASCRIPT_TYPESCRIPT.md +24 -0
  49. package/prompts/languages/KOTLIN.md +24 -0
  50. package/prompts/languages/PHP.md +23 -0
  51. package/prompts/languages/PYTHON.md +23 -0
  52. package/prompts/languages/RUBY.md +23 -0
  53. package/prompts/languages/RUST.md +21 -0
  54. package/prompts/languages/SWIFT.md +21 -0
  55. package/search-core.mjs +627 -0
  56. package/storage.mjs +68 -0
  57. package/watch-daemon.mjs +204 -69
  58. package/PROMPT.md +0 -143
  59. package/parser-utils.mjs +0 -861
@@ -0,0 +1,863 @@
1
+ /**
2
+ * @file engine/sqlite.mjs
3
+ * @description Disk-backed graph store for enterprise-scale monorepos. Implements
4
+ * the same read contract as MemoryGraphIndex (see storage.mjs) over
5
+ * `node:sqlite` — built into Node, so this adds ZERO dependencies.
6
+ *
7
+ * The whole point is bounded RAM: chunk payloads, the BM25 posting
8
+ * lists and the symbol/call indexes live in SQLite tables and are
9
+ * touched only for the candidates a query actually needs. Vectors
10
+ * live in the shared `.embeddings.bin`: point reads are pread on
11
+ * demand, and the semantic channel streams the whole bin in bounded
12
+ * buffers. Nothing scales with corpus size in the resident set
13
+ * except the small file-level dependency graph (needed wholesale by
14
+ * PageRank).
15
+ *
16
+ * The store is LIVE: the watch daemon applies per-file incremental
17
+ * updates through applyFileUpdate() (WAL mode, single transaction
18
+ * per file), and reader processes detect those commits via
19
+ * `PRAGMA data_version` and transparently refresh their cached
20
+ * meta/graph state — no re-indexing, no server restarts.
21
+ *
22
+ * Ranking is delegated to search-core.fuseAndRank, so results are
23
+ * consistent with the in-memory engine.
24
+ * @author MaquinaTech <https://github.com/MaquinaTech>
25
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
26
+ * @license MIT
27
+ */
28
+ import fs from 'fs';
29
+ import {
30
+ tokenize, okapiIdf, bm25Score, fuseAndRank, buildLexicalDocument, embeddingKeyFor,
31
+ LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
32
+ isNaturalLanguageQuery, baseEmbeddingKey,
33
+ } from '../search-core.mjs';
34
+ import {
35
+ writeEmbeddingBinary, appendEmbeddingBinary, scanEmbeddingBinary,
36
+ updateVectorSketch, searchVectorSketch, SKETCH_THRESHOLD,
37
+ } from './binary.mjs';
38
+
39
+ // node:sqlite is loaded lazily so the default in-memory path never requires it.
40
+ let DatabaseSync = null;
41
+ try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* reported in ctor */ }
42
+
43
+ // SKETCH_THRESHOLD (shared with the memory store via binary.mjs) — build the binary
44
+ // sketch above this many vectors. Below it the exact streaming scan stays under ~20 ms
45
+ // AND both backends rank identically (the sketch is
46
+ // approximate in the candidate tail); above it the
47
+ // sketch caps latency at ~5–15 ms regardless of size.
48
+
49
+ const SCHEMA_TABLES = `
50
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
51
+ CREATE TABLE IF NOT EXISTS chunks (
52
+ id TEXT PRIMARY KEY, file_path TEXT NOT NULL, node_type TEXT,
53
+ name TEXT, name_lower TEXT, docstring TEXT, code_snippet TEXT, content_hash TEXT,
54
+ start_line INTEGER, end_line INTEGER,
55
+ calls TEXT, params TEXT, return_type TEXT, class_context TEXT,
56
+ type_refs TEXT, decorators TEXT, extends_ TEXT, hyde TEXT, summary TEXT, concepts TEXT,
57
+ doc_len INTEGER, path_tokens TEXT, vec_key TEXT, vec_offset INTEGER, vec_dim INTEGER,
58
+ call_sites TEXT
59
+ );
60
+ CREATE TABLE IF NOT EXISTS postings (term TEXT, chunk_id TEXT, tf INTEGER);
61
+ CREATE TABLE IF NOT EXISTS terms (term TEXT PRIMARY KEY, df INTEGER);
62
+ CREATE TABLE IF NOT EXISTS call_edges (callee TEXT, chunk_id TEXT);
63
+ CREATE TABLE IF NOT EXISTS deps (file TEXT, dep TEXT);
64
+ CREATE TABLE IF NOT EXISTS routes (
65
+ id INTEGER PRIMARY KEY,
66
+ method TEXT NOT NULL,
67
+ path TEXT NOT NULL,
68
+ handler_name TEXT,
69
+ handler_chunk_id TEXT,
70
+ file_path TEXT NOT NULL,
71
+ line INTEGER,
72
+ framework TEXT
73
+ );
74
+ `;
75
+
76
+ const SCHEMA_INDEXES = `
77
+ CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_path);
78
+ CREATE INDEX IF NOT EXISTS idx_chunks_name ON chunks(name_lower);
79
+ CREATE INDEX IF NOT EXISTS idx_chunks_veckey ON chunks(vec_key);
80
+ CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(content_hash);
81
+ CREATE INDEX IF NOT EXISTS idx_postings_term ON postings(term);
82
+ CREATE INDEX IF NOT EXISTS idx_postings_chunk ON postings(chunk_id);
83
+ CREATE INDEX IF NOT EXISTS idx_calledges_callee ON call_edges(callee);
84
+ CREATE INDEX IF NOT EXISTS idx_deps_file ON deps(file);
85
+ CREATE INDEX IF NOT EXISTS idx_deps_dep ON deps(dep);
86
+ CREATE INDEX IF NOT EXISTS idx_routes_method ON routes(method);
87
+ CREATE INDEX IF NOT EXISTS idx_routes_path ON routes(path);
88
+ CREATE INDEX IF NOT EXISTS idx_routes_handler ON routes(handler_chunk_id);
89
+ `;
90
+
91
+ const CHUNK_COLS = [
92
+ 'id', 'file_path', 'node_type', 'name', 'name_lower', 'docstring', 'code_snippet',
93
+ 'content_hash', 'start_line', 'end_line', 'calls', 'params', 'return_type',
94
+ 'class_context', 'type_refs', 'decorators', 'extends_', 'hyde', 'summary', 'concepts',
95
+ 'doc_len', 'path_tokens', 'vec_key', 'vec_offset', 'vec_dim', 'call_sites',
96
+ ];
97
+
98
+ function jsonArr(v) { return JSON.stringify(Array.isArray(v) ? v : []); }
99
+ function parseArr(s) {
100
+ if (!s) return [];
101
+ try { const v = JSON.parse(s); return Array.isArray(v) ? v : []; } catch { return []; }
102
+ }
103
+ function nameLowerOf(name) {
104
+ return (name && name !== 'anonymous') ? String(name).toLowerCase() : null;
105
+ }
106
+
107
+ export class SqliteGraphStore {
108
+ constructor(dbPath, { embeddingPath, rrfK = 60 } = {}) {
109
+ if (!DatabaseSync) {
110
+ throw new Error(
111
+ 'node:sqlite is unavailable — the --use-sqlite backend requires Node >= 22.5. '
112
+ + 'Use the default in-memory engine, or upgrade Node.'
113
+ );
114
+ }
115
+ this.dbPath = dbPath;
116
+ this._embeddingPath = embeddingPath || dbPath.replace(/\.db$/, '.embeddings.bin');
117
+ this.db = null;
118
+ this._vecFd = -1;
119
+ this.rrfK = rrfK;
120
+
121
+ // File-level dependency graph — small (per file, not per chunk); held in RAM
122
+ // because PageRank needs the whole thing. This is the only corpus-scaling
123
+ // structure kept resident, and it is orders of magnitude smaller than chunks.
124
+ this._graph = { dependencies: {}, importedBy: {} };
125
+ this._docCount = 0;
126
+ this._totalDocLen = 0;
127
+ this._avgdl = 1;
128
+ this._dim = 0;
129
+ this._dataVersion = -1;
130
+ this._writeReady = false;
131
+ this._sketch = null; // binary-quantized vector sketch (built above SKETCH_THRESHOLD)
132
+ }
133
+
134
+ get backend() { return 'sqlite'; }
135
+
136
+ /** Live view of the file dependency graph (refreshed when another process commits). */
137
+ get graph() {
138
+ this._maybeRefresh();
139
+ return this._graph;
140
+ }
141
+
142
+ // ─── Open / load ───────────────────────────────────────────────────────────
143
+
144
+ _open() {
145
+ this.db = new DatabaseSync(this.dbPath);
146
+ // WAL: one writer (daemon) + many readers (MCP server) across processes.
147
+ // busy_timeout absorbs brief writer/checkpoint locks instead of throwing.
148
+ this.db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 3000;');
149
+ }
150
+
151
+ load() {
152
+ const existed = fs.existsSync(this.dbPath);
153
+ this._open();
154
+ this.db.exec(SCHEMA_TABLES);
155
+ if (existed) {
156
+ // Forward migrations for indexes built by older versions.
157
+ try { this.db.exec('ALTER TABLE chunks ADD COLUMN concepts TEXT DEFAULT NULL'); } catch { /* exists */ }
158
+ try {
159
+ this.db.exec('ALTER TABLE chunks ADD COLUMN vec_key TEXT DEFAULT NULL');
160
+ // Pre-enrichment-key indexes stored vectors under the plain content hash.
161
+ this.db.exec('UPDATE chunks SET vec_key = content_hash WHERE vec_key IS NULL');
162
+ } catch { /* exists */ }
163
+ // Receiver-aware call graph (precise get_call_graph) landed later; older
164
+ // indexes lack the column. NULL → callers fall back to name-only grouping.
165
+ try { this.db.exec('ALTER TABLE chunks ADD COLUMN call_sites TEXT DEFAULT NULL'); } catch { /* exists */ }
166
+ }
167
+ this.db.exec(SCHEMA_INDEXES);
168
+ this._reloadMeta();
169
+ // Does this corpus carry LLM enrichment? One cheap probe → drives the NL
170
+ // vector-channel weight in fuseAndRank (parity with the in-memory engine).
171
+ try {
172
+ this._corpusEnriched = Boolean(
173
+ this.db.prepare("SELECT 1 FROM chunks WHERE summary IS NOT NULL AND summary != '' LIMIT 1").get()
174
+ );
175
+ } catch { this._corpusEnriched = false; }
176
+ this._loadGraph();
177
+ this._openVecFd();
178
+ this._refreshSketch();
179
+ this._prepare();
180
+ this._dataVersion = this._readDataVersion();
181
+ return this;
182
+ }
183
+
184
+ _openVecFd() {
185
+ if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
186
+ if (fs.existsSync(this._embeddingPath)) {
187
+ try { this._vecFd = fs.openSync(this._embeddingPath, 'r'); } catch { this._vecFd = -1; }
188
+ }
189
+ }
190
+
191
+ /** Read the bin's entry count + first entry key (cheap, two small preads). */
192
+ _binFingerprint() {
193
+ if (this._vecFd < 0) return null;
194
+ try {
195
+ const hdr = Buffer.allocUnsafe(8);
196
+ if (fs.readSync(this._vecFd, hdr, 0, 8, 0) < 8) return null;
197
+ const count = hdr.readUInt32LE(0);
198
+ const keyLen = hdr.readUInt32LE(4);
199
+ if (keyLen <= 0 || keyLen > 4096) return { count, firstKey: null };
200
+ const kb = Buffer.allocUnsafe(keyLen);
201
+ if (fs.readSync(this._vecFd, kb, 0, keyLen, 8) < keyLen) return { count, firstKey: null };
202
+ return { count, firstKey: kb.toString('utf8') };
203
+ } catch { return null; }
204
+ }
205
+
206
+ /**
207
+ * Build / extend / rebuild the binary vector sketch to match the bin on disk.
208
+ * Appends (the daemon's write path) extend the sketch by scanning only the
209
+ * unseen tail; a replaced bin (full re-index) is detected via the entry count
210
+ * + first-key fingerprint and triggers a rebuild.
211
+ */
212
+ _refreshSketch() {
213
+ if (this._vecFd < 0) { this._sketch = null; return; }
214
+ const fp = this._binFingerprint();
215
+ if (!fp || fp.count < SKETCH_THRESHOLD) { this._sketch = null; return; }
216
+ try {
217
+ const size = fs.fstatSync(this._vecFd).size;
218
+ if (this._sketch) {
219
+ const replaced = fp.count < this._sketch.n
220
+ || size < this._sketch.consumed
221
+ || (this._sketch.firstKey !== null && fp.firstKey !== this._sketch.firstKey);
222
+ if (replaced) this._sketch = null;
223
+ }
224
+ this._sketch = updateVectorSketch(this._sketch, { fd: this._vecFd });
225
+ } catch { this._sketch = null; }
226
+ }
227
+
228
+ _reloadMeta() {
229
+ const meta = {};
230
+ for (const r of this.db.prepare('SELECT key, value FROM meta').all()) meta[r.key] = r.value;
231
+ this._docCount = Number(meta.doc_count || 0);
232
+ this._totalDocLen = Number(meta.total_doc_len || 0);
233
+ this._avgdl = this._docCount > 0 ? this._totalDocLen / this._docCount : 1;
234
+ this._dim = Number(meta.dim || 0);
235
+ }
236
+
237
+ _loadGraph() {
238
+ const dependencies = {};
239
+ const importedBy = {};
240
+ for (const { file, dep } of this.db.prepare('SELECT file, dep FROM deps').all()) {
241
+ (dependencies[file] ||= []);
242
+ if (dep != null) {
243
+ dependencies[file].push(dep);
244
+ (importedBy[dep] ||= []).push(file);
245
+ }
246
+ }
247
+ // Ensure every file that has chunks is a graph node (PageRank node set parity).
248
+ for (const { f } of this.db.prepare('SELECT DISTINCT file_path AS f FROM chunks').all()) {
249
+ dependencies[f] ||= [];
250
+ }
251
+ this._graph = { dependencies, importedBy };
252
+ }
253
+
254
+ _readDataVersion() {
255
+ try { return this.db.prepare('PRAGMA data_version').get()?.data_version ?? -1; }
256
+ catch { return -1; }
257
+ }
258
+
259
+ /**
260
+ * Detect commits made by OTHER processes (the watch daemon, a re-run of the
261
+ * indexer) via PRAGMA data_version — a single cheap pragma per call — and
262
+ * refresh the cached meta, dependency graph and embeddings fd when the
263
+ * underlying database changed. This is what keeps a long-running MCP server
264
+ * consistent with the daemon without restarts or full re-indexes.
265
+ */
266
+ _maybeRefresh() {
267
+ if (!this.db) return;
268
+ try {
269
+ const v = this._readDataVersion();
270
+ if (v === this._dataVersion) return;
271
+ this._dataVersion = v;
272
+ this._reloadMeta();
273
+ this._loadGraph();
274
+ this._openVecFd();
275
+ this._refreshSketch();
276
+ } catch { /* keep previous snapshot on transient errors */ }
277
+ }
278
+
279
+ _prepare() {
280
+ this._stmtChunk = this.db.prepare('SELECT * FROM chunks WHERE id = ?');
281
+ this._stmtByFile = this.db.prepare('SELECT * FROM chunks WHERE file_path = ?');
282
+ this._stmtByName = this.db.prepare('SELECT * FROM chunks WHERE name_lower = ?');
283
+ this._stmtIdByName = this.db.prepare('SELECT id FROM chunks WHERE name_lower = ?');
284
+ this._stmtCallers = this.db.prepare(
285
+ 'SELECT c.* FROM chunks c JOIN call_edges e ON e.chunk_id = c.id WHERE e.callee = ?'
286
+ );
287
+ // type_refs / extends_ are JSON arrays of names. LIKE is a cheap prefilter
288
+ // (ASCII case-insensitive in SQLite); findReferers() verifies exact, quoted
289
+ // membership in JS so e.g. "User" never matches "UserProfile".
290
+ this._stmtReferers = this.db.prepare(
291
+ 'SELECT * FROM chunks WHERE type_refs LIKE ? ESCAPE \'\\\' OR extends_ LIKE ? ESCAPE \'\\\''
292
+ );
293
+ this._stmtPosting = this.db.prepare(
294
+ 'SELECT p.chunk_id AS id, p.tf AS tf, c.doc_len AS doc_len '
295
+ + 'FROM postings p JOIN chunks c ON c.id = p.chunk_id WHERE p.term = ?'
296
+ );
297
+ this._stmtDf = this.db.prepare('SELECT df FROM terms WHERE term = ?');
298
+ this._stmtVec = this.db.prepare('SELECT vec_offset, vec_dim FROM chunks WHERE id = ?');
299
+ // ORDER BY id: duplicate-content chunks share one vector key; emitting their
300
+ // ids in a deterministic order keeps both backends rank-identical on ties.
301
+ this._stmtIdsByKey = this.db.prepare('SELECT id FROM chunks WHERE vec_key = ? ORDER BY id');
302
+ this._stmtIdsByHash = this.db.prepare('SELECT id FROM chunks WHERE content_hash = ? ORDER BY id');
303
+ // ORDER BY id (rowid = insertion order) keeps routes in index-build order,
304
+ // matching the in-memory graph.routes array order for cross-backend parity.
305
+ this._stmtRoutes = this.db.prepare(
306
+ 'SELECT method, path, handler_name, handler_chunk_id, file_path, line, framework '
307
+ + 'FROM routes ORDER BY id'
308
+ );
309
+ }
310
+
311
+ _prepareWrite() {
312
+ if (this._writeReady) return;
313
+ this._stmtInsChunk = this.db.prepare(
314
+ `INSERT OR REPLACE INTO chunks (${CHUNK_COLS.join(', ')}) `
315
+ + `VALUES (${CHUNK_COLS.map(() => '?').join(', ')})`
316
+ );
317
+ this._stmtInsPost = this.db.prepare('INSERT INTO postings (term, chunk_id, tf) VALUES (?, ?, ?)');
318
+ this._stmtInsEdge = this.db.prepare('INSERT INTO call_edges (callee, chunk_id) VALUES (?, ?)');
319
+ this._stmtInsDep = this.db.prepare('INSERT INTO deps (file, dep) VALUES (?, ?)');
320
+ this._stmtDelDeps = this.db.prepare('DELETE FROM deps WHERE file = ?');
321
+ this._stmtUpsertTerm = this.db.prepare(
322
+ 'INSERT INTO terms (term, df) VALUES (?, 1) ON CONFLICT(term) DO UPDATE SET df = df + 1'
323
+ );
324
+ this._stmtDecTerm = this.db.prepare('UPDATE terms SET df = df - 1 WHERE term = ?');
325
+ this._stmtPruneTerms = this.db.prepare('DELETE FROM terms WHERE df <= 0');
326
+ this._stmtPostByChunk = this.db.prepare('SELECT term FROM postings WHERE chunk_id = ?');
327
+ this._stmtDelPostings = this.db.prepare('DELETE FROM postings WHERE chunk_id = ?');
328
+ this._stmtDelEdges = this.db.prepare('DELETE FROM call_edges WHERE chunk_id = ?');
329
+ this._stmtDelChunk = this.db.prepare('DELETE FROM chunks WHERE id = ?');
330
+ this._stmtInsMeta = this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)');
331
+ this._stmtVecByKey = this.db.prepare(
332
+ 'SELECT vec_offset, vec_dim FROM chunks WHERE vec_key = ? AND vec_offset >= 0 LIMIT 1'
333
+ );
334
+ this._stmtVecByHash = this.db.prepare(
335
+ 'SELECT vec_offset, vec_dim FROM chunks WHERE content_hash = ? AND vec_offset >= 0 LIMIT 1'
336
+ );
337
+ this._writeReady = true;
338
+ }
339
+
340
+ // ─── Row → chunk ───────────────────────────────────────────────────────────
341
+
342
+ _rowToChunk(row) {
343
+ if (!row) return null;
344
+ return {
345
+ id: row.id, file_path: row.file_path, node_type: row.node_type, name: row.name,
346
+ docstring: row.docstring || '', code_snippet: row.code_snippet || '',
347
+ content_hash: row.content_hash, start_line: row.start_line, end_line: row.end_line,
348
+ calls: parseArr(row.calls), params: parseArr(row.params),
349
+ return_type: row.return_type || '', class_context: row.class_context || '',
350
+ type_refs: parseArr(row.type_refs), decorators: parseArr(row.decorators),
351
+ extends: parseArr(row.extends_), hyde: row.hyde || '', summary: row.summary || '',
352
+ concepts: parseArr(row.concepts), call_sites: parseArr(row.call_sites),
353
+ };
354
+ }
355
+
356
+ // ─── Contract: point reads ─────────────────────────────────────────────────
357
+
358
+ getChunk(id) { return this._rowToChunk(this._stmtChunk.get(id)); }
359
+
360
+ getChunksByFile(filePath) { return this._stmtByFile.all(filePath).map(r => this._rowToChunk(r)); }
361
+
362
+ resolveSymbol(name) {
363
+ const key = nameLowerOf(name);
364
+ if (!key) return [];
365
+ return this._stmtByName.all(key).map(r => this._rowToChunk(r));
366
+ }
367
+
368
+ findCallers(funcName) { return this._stmtCallers.all(funcName).map(r => this._rowToChunk(r)); }
369
+
370
+ findReferers(symbol) {
371
+ const key = String(symbol).toLowerCase().trim();
372
+ if (!key) return [];
373
+ // Escape LIKE metacharacters in the symbol, then bracket with JSON quotes so
374
+ // the prefilter only fires on a whole array element.
375
+ const esc = key.replace(/[\\%_]/g, m => '\\' + m);
376
+ const pat = `%"${esc}"%`;
377
+ const out = [];
378
+ for (const row of this._stmtReferers.all(pat, pat)) {
379
+ const c = this._rowToChunk(row);
380
+ // A class/type lists its own name in type_refs — that self-mention is a
381
+ // definition, not a reference; skip it.
382
+ if (c.name && c.name.toLowerCase() === key) continue;
383
+ const hit = c.type_refs.some(t => t.toLowerCase() === key)
384
+ || c.extends.some(t => t.toLowerCase() === key);
385
+ if (hit) out.push(c);
386
+ }
387
+ return out;
388
+ }
389
+
390
+ /**
391
+ * HTTP routes filtered by method and/or path prefix, each augmented with its
392
+ * resolved handler chunk (`chunk`, or null). Filtering logic is byte-identical
393
+ * to MemoryGraphIndex.findRoutes; rows arrive in index-build order (ORDER BY id)
394
+ * so both backends return identical records. An index built before this feature
395
+ * (empty routes table) yields [].
396
+ * @returns {object[]}
397
+ */
398
+ findRoutes({ method = null, pathPrefix = null } = {}) {
399
+ this._maybeRefresh();
400
+ const m = method ? String(method).toUpperCase().trim() : null;
401
+ const p = pathPrefix ? String(pathPrefix).toLowerCase() : null;
402
+ const out = [];
403
+ for (const r of this._stmtRoutes.all()) {
404
+ if (m && String(r.method || '').toUpperCase() !== m) continue;
405
+ if (p && !String(r.path || '').toLowerCase().startsWith(p)) continue;
406
+ const chunk = r.handler_chunk_id ? this.getChunk(r.handler_chunk_id) : null;
407
+ out.push({
408
+ method: r.method, path: r.path, handler_name: r.handler_name,
409
+ handler_chunk_id: r.handler_chunk_id, file_path: r.file_path,
410
+ line: r.line, framework: r.framework, chunk,
411
+ });
412
+ }
413
+ return out;
414
+ }
415
+
416
+ *iterateChunks() {
417
+ // Stream rows via a cursor so repo-map / call-graph passes never materialise
418
+ // the whole table. Falls back to paging if .iterate() is unavailable.
419
+ const stmt = this.db.prepare('SELECT * FROM chunks');
420
+ if (typeof stmt.iterate === 'function') {
421
+ for (const row of stmt.iterate()) yield this._rowToChunk(row);
422
+ return;
423
+ }
424
+ const page = this.db.prepare('SELECT * FROM chunks LIMIT ? OFFSET ?');
425
+ let offset = 0;
426
+ for (;;) {
427
+ const rows = page.all(1000, offset);
428
+ if (rows.length === 0) break;
429
+ for (const row of rows) yield this._rowToChunk(row);
430
+ offset += rows.length;
431
+ }
432
+ }
433
+
434
+ getDependencies(filePath) { return this.graph.dependencies[filePath] || []; }
435
+ getImportedBy(filePath) { return this.graph.importedBy[filePath] || []; }
436
+
437
+ chunkCount() { return this.db.prepare('SELECT COUNT(*) AS n FROM chunks').get().n; }
438
+ symbolCount() { return this.db.prepare('SELECT COUNT(DISTINCT name_lower) AS n FROM chunks WHERE name_lower IS NOT NULL').get().n; }
439
+ fileCount() { return this.db.prepare('SELECT COUNT(DISTINCT file_path) AS n FROM chunks').get().n; }
440
+ vectorCount() { return this.db.prepare('SELECT COUNT(*) AS n FROM chunks WHERE vec_offset >= 0').get().n; }
441
+
442
+ stats() {
443
+ this._maybeRefresh();
444
+ // Extension tally, streamed via a cursor so it never materialises all rows.
445
+ const extCounts = new Map();
446
+ const stmt = this.db.prepare('SELECT file_path FROM chunks');
447
+ const rows = typeof stmt.iterate === 'function' ? stmt.iterate() : stmt.all();
448
+ for (const r of rows) {
449
+ const ext = r.file_path.split('.').pop() || 'unknown';
450
+ extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
451
+ }
452
+ const vectors = this.vectorCount();
453
+ return {
454
+ backend: 'sqlite',
455
+ chunks: this.chunkCount(),
456
+ files: this.fileCount(),
457
+ symbols: this.symbolCount(),
458
+ vectors,
459
+ hasVectors: vectors > 0,
460
+ lazyMode: true,
461
+ vectorSource: this._vecFd >= 0 ? 'sqlite + disk-backed .bin' : 'sqlite (no vectors)',
462
+ extCounts,
463
+ };
464
+ }
465
+
466
+ // ─── Vector access (pread from shared .embeddings.bin) ─────────────────────
467
+
468
+ _readVector(id) {
469
+ if (this._vecFd < 0) return null;
470
+ const row = this._stmtVec.get(id);
471
+ if (!row || row.vec_offset == null || row.vec_offset < 0 || !row.vec_dim) return null;
472
+ const byteLen = row.vec_dim * 4;
473
+ const raw = Buffer.allocUnsafe(byteLen);
474
+ try {
475
+ const read = fs.readSync(this._vecFd, raw, 0, byteLen, row.vec_offset);
476
+ if (read < byteLen) return null;
477
+ const vec = new Float32Array(row.vec_dim);
478
+ for (let d = 0; d < row.vec_dim; d++) vec[d] = raw.readFloatLE(d * 4);
479
+ return vec;
480
+ } catch { return null; }
481
+ }
482
+
483
+ /** Whether a vector for this embedding key is already stored (skip re-embedding). */
484
+ hasEmbedding(key) {
485
+ this._prepareWrite();
486
+ return Boolean(this._stmtVecByKey.get(key));
487
+ }
488
+
489
+ // ─── Hybrid search ─────────────────────────────────────────────────────────
490
+
491
+ searchHybrid(queryText, queryVector, topK = 5, minScore = 0.3, exactBoostName = null) {
492
+ this._maybeRefresh();
493
+
494
+ // Asymmetric stemming: stem only for natural-language queries so symbolic
495
+ // lookups stay exact — mirrors the in-memory engine for parity.
496
+ const qTokens = tokenize(queryText, isNaturalLanguageQuery(queryText));
497
+ const occ = new Map();
498
+ for (const t of qTokens) occ.set(t, (occ.get(t) || 0) + 1);
499
+
500
+ const lexScores = new Map();
501
+ for (const [token, n] of occ) {
502
+ const dfRow = this._stmtDf.get(token);
503
+ if (!dfRow || dfRow.df <= 0) continue;
504
+ const idf = okapiIdf(this._docCount, dfRow.df);
505
+ for (const row of this._stmtPosting.all(token)) {
506
+ const dl = row.doc_len ?? this._avgdl;
507
+ const s = bm25Score(idf, row.tf, dl, this._avgdl) * n;
508
+ lexScores.set(row.id, (lexScores.get(row.id) || 0) + s);
509
+ }
510
+ }
511
+ const lexicalResults = Array.from(lexScores.entries())
512
+ .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1)) // id tie-break: backend parity
513
+ .slice(0, LEXICAL_FUSION_CAP)
514
+ .map(([id, score], i) => ({ id, score, rank: i + 1 }));
515
+
516
+ // Stream the FULL embeddings bin (bounded buffers) so conceptual queries
517
+ // that share no tokens with the code can still match — scoring only lexical
518
+ // candidates silently disabled semantic search where it mattered most.
519
+ let vectorResults = [];
520
+ if (queryVector && this._vecFd >= 0) {
521
+ // Binary sketch above SKETCH_THRESHOLD (Hamming prefilter + exact
522
+ // rescore, O(candidates) disk reads); exact streaming scan below it.
523
+ const hits = this._sketch
524
+ ? searchVectorSketch(this._sketch, { fd: this._vecFd }, queryVector, {
525
+ topN: VECTOR_SCAN_RAW_N, minScore,
526
+ })
527
+ : scanEmbeddingBinary({ fd: this._vecFd }, queryVector, {
528
+ topN: VECTOR_SCAN_RAW_N, minScore,
529
+ });
530
+ const entries = [];
531
+ for (const { key, score } of hits) {
532
+ // Summary-only (`<key>|s`) and per-window (`<key>|wN`) vectors fold onto
533
+ // the chunk; finalizeVectorCandidates keeps the max-sim score per chunk.
534
+ const baseKey = baseEmbeddingKey(key);
535
+ let rows = this._stmtIdsByKey.all(baseKey);
536
+ if (rows.length === 0) rows = this._stmtIdsByHash.all(baseKey); // legacy bins
537
+ for (const { id } of rows) entries.push({ id, score });
538
+ }
539
+ vectorResults = finalizeVectorCandidates(entries);
540
+ }
541
+
542
+ const rowCache = new Map();
543
+ const fetchRow = (id) => {
544
+ if (rowCache.has(id)) return rowCache.get(id);
545
+ const row = this._stmtChunk.get(id);
546
+ rowCache.set(id, row);
547
+ return row;
548
+ };
549
+ for (const r of lexicalResults) fetchRow(r.id);
550
+ for (const r of vectorResults) fetchRow(r.id);
551
+
552
+ const chunkCache = new Map();
553
+ const getChunk = (id) => {
554
+ if (chunkCache.has(id)) return chunkCache.get(id);
555
+ const c = this._rowToChunk(fetchRow(id));
556
+ chunkCache.set(id, c);
557
+ return c;
558
+ };
559
+ const pathCache = new Map();
560
+ const getPathTokens = (id) => {
561
+ if (pathCache.has(id)) return pathCache.get(id);
562
+ const row = fetchRow(id);
563
+ const set = row ? new Set(parseArr(row.path_tokens)) : undefined;
564
+ pathCache.set(id, set);
565
+ return set;
566
+ };
567
+ const dfCache = new Map();
568
+ const getDf = (t) => {
569
+ if (dfCache.has(t)) return dfCache.get(t);
570
+ const row = this._stmtDf.get(t);
571
+ const df = row ? row.df : 0;
572
+ dfCache.set(t, df);
573
+ return df;
574
+ };
575
+
576
+ return fuseAndRank({
577
+ lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
578
+ docCount: this._docCount, rrfK: this.rrfK, topK, queryText, exactBoostName,
579
+ corpusEnriched: this._corpusEnriched,
580
+ resolveExact: (term) => this._stmtIdByName.all(term).map(r => r.id),
581
+ });
582
+ }
583
+
584
+ close() {
585
+ if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
586
+ if (this.db) { try { this.db.close(); } catch {} this.db = null; }
587
+ }
588
+
589
+ // ─── Incremental writes (used by the watch daemon) ─────────────────────────
590
+
591
+ /** Internal: index one chunk's lexical document + row + call edges. */
592
+ _insertChunk(chunk, imports, vecEntry) {
593
+ const doc = buildLexicalDocument(chunk, imports);
594
+ const tokens = tokenize(doc); // raw + additive stems
595
+ const docLen = tokenize(doc, false).length; // raw-only length for BM25 avgdl (parity w/ memory engine)
596
+
597
+ const termCounts = new Map();
598
+ for (const t of tokens) termCounts.set(t, (termCounts.get(t) || 0) + 1);
599
+ for (const [term, cnt] of termCounts) {
600
+ this._stmtUpsertTerm.run(term);
601
+ this._stmtInsPost.run(term, chunk.id, cnt);
602
+ }
603
+
604
+ const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '), false)));
605
+ this._stmtInsChunk.run(
606
+ chunk.id, chunk.file_path, chunk.node_type ?? null, chunk.name ?? null,
607
+ nameLowerOf(chunk.name), chunk.docstring ?? '', chunk.code_snippet ?? '',
608
+ chunk.content_hash ?? null, chunk.start_line ?? null, chunk.end_line ?? null,
609
+ jsonArr(chunk.calls), jsonArr(chunk.params), chunk.return_type ?? '',
610
+ chunk.class_context ?? '', jsonArr(chunk.type_refs), jsonArr(chunk.decorators),
611
+ jsonArr(chunk.extends), chunk.hyde ?? null, chunk.summary ?? null, jsonArr(chunk.concepts),
612
+ docLen, JSON.stringify(pathTokens),
613
+ chunk.content_hash ? embeddingKeyFor(chunk) : null,
614
+ vecEntry ? vecEntry.offset : -1, vecEntry ? vecEntry.dim : 0,
615
+ jsonArr(chunk.call_sites)
616
+ );
617
+ for (const callee of (chunk.calls || [])) this._stmtInsEdge.run(callee, chunk.id);
618
+ return docLen;
619
+ }
620
+
621
+ /**
622
+ * Atomically replace every chunk of one file — the incremental-update entry
623
+ * point used by the watch daemon. Mirrors MemoryGraphIndex.applyFileUpdate.
624
+ *
625
+ * New vectors are APPENDED to the shared .embeddings.bin (O(changed chunks),
626
+ * never a full rewrite); unchanged chunks reuse their existing bin offsets.
627
+ * All SQL runs in one WAL transaction, so concurrent readers see either the
628
+ * old or the new file state, never a partial one. BM25 document-frequency
629
+ * and length accounting is decremented from the chunk's actual posting rows,
630
+ * so incremental state matches what a full rebuild would produce.
631
+ *
632
+ * @param {string} filePath
633
+ * @param {object} p
634
+ * @param {object[]} [p.chunks]
635
+ * @param {string[]} [p.imports]
636
+ * @param {Map<string, Float32Array|number[]>} [p.embeddings] New vectors keyed by embeddingKeyFor(chunk).
637
+ */
638
+ applyFileUpdate(filePath, { chunks = [], imports = [], embeddings = null } = {}) {
639
+ if (!this.db) this.load();
640
+ this._prepareWrite();
641
+ this._maybeRefresh();
642
+
643
+ // Append new vectors first — orphan bytes on a failed commit are harmless
644
+ // and compacted away by the next full index run.
645
+ let appended = new Map();
646
+ const embCount = embeddings ? (embeddings instanceof Map ? embeddings.size : Object.keys(embeddings).length) : 0;
647
+ if (embCount > 0) {
648
+ appended = appendEmbeddingBinary(this._embeddingPath, embeddings);
649
+ if (this._vecFd < 0) this._openVecFd();
650
+ if (!this._dim && appended.size > 0) this._dim = appended.values().next().value.dim;
651
+ // Keep our own sketch current (tail-only scan of what we just wrote).
652
+ if (this._sketch) {
653
+ try { this._sketch = updateVectorSketch(this._sketch, { fd: this._vecFd }); }
654
+ catch { this._sketch = null; }
655
+ }
656
+ }
657
+
658
+ this.db.exec('BEGIN');
659
+ try {
660
+ // Capture reusable vector offsets BEFORE deleting old rows, which may
661
+ // be the only rows that hold those offsets.
662
+ const reuse = new Map();
663
+ for (const chunk of chunks) {
664
+ if (!chunk.content_hash) continue;
665
+ const key = embeddingKeyFor(chunk);
666
+ if (appended.has(key) || reuse.has(key)) continue;
667
+ const row = this._stmtVecByKey.get(key) ?? this._stmtVecByHash.get(chunk.content_hash);
668
+ if (row) reuse.set(key, { offset: row.vec_offset, dim: row.vec_dim });
669
+ }
670
+
671
+ let docCount = this._docCount;
672
+ let totalDocLen = this._totalDocLen;
673
+ for (const row of this._stmtByFile.all(filePath)) {
674
+ for (const { term } of this._stmtPostByChunk.all(row.id)) {
675
+ this._stmtDecTerm.run(term);
676
+ }
677
+ this._stmtDelPostings.run(row.id);
678
+ this._stmtDelEdges.run(row.id);
679
+ this._stmtDelChunk.run(row.id);
680
+ totalDocLen -= (row.doc_len || 0);
681
+ docCount--;
682
+ }
683
+
684
+ for (const chunk of chunks) {
685
+ const key = chunk.content_hash ? embeddingKeyFor(chunk) : null;
686
+ const vecEntry = key ? (appended.get(key) ?? reuse.get(key) ?? null) : null;
687
+ totalDocLen += this._insertChunk(chunk, imports, vecEntry);
688
+ docCount++;
689
+ }
690
+ this._stmtPruneTerms.run();
691
+
692
+ this._stmtDelDeps.run(filePath);
693
+ if (!imports || imports.length === 0) this._stmtInsDep.run(filePath, null);
694
+ else for (const d of imports) this._stmtInsDep.run(filePath, d);
695
+
696
+ docCount = Math.max(0, docCount);
697
+ totalDocLen = Math.max(0, totalDocLen);
698
+ this._stmtInsMeta.run('doc_count', String(docCount));
699
+ this._stmtInsMeta.run('total_doc_len', String(totalDocLen));
700
+ this._stmtInsMeta.run('dim', String(this._dim));
701
+ this._stmtInsMeta.run('built_at', String(Date.now()));
702
+ this.db.exec('COMMIT');
703
+
704
+ this._docCount = docCount;
705
+ this._totalDocLen = totalDocLen;
706
+ this._avgdl = docCount > 0 ? totalDocLen / docCount : 1;
707
+ } catch (err) {
708
+ try { this.db.exec('ROLLBACK'); } catch { /* not in txn */ }
709
+ throw err;
710
+ }
711
+
712
+ const oldDeps = this._graph.dependencies[filePath] || [];
713
+ for (const oldDep of oldDeps) {
714
+ if (this._graph.importedBy[oldDep]) {
715
+ this._graph.importedBy[oldDep] = this._graph.importedBy[oldDep].filter(f => f !== filePath);
716
+ }
717
+ }
718
+ this._graph.dependencies[filePath] = imports || [];
719
+ for (const dep of (imports || [])) {
720
+ (this._graph.importedBy[dep] ||= []);
721
+ if (!this._graph.importedBy[dep].includes(filePath)) this._graph.importedBy[dep].push(filePath);
722
+ }
723
+ // Our own commit bumps data_version for other connections, not this one —
724
+ // re-read so the next _maybeRefresh() doesn't see a spurious change.
725
+ this._dataVersion = this._readDataVersion();
726
+ }
727
+
728
+ /** Remove a deleted file's chunks, postings, edges and graph node. */
729
+ removeFile(filePath) {
730
+ this.applyFileUpdate(filePath, { chunks: [], imports: [] });
731
+ // applyFileUpdate leaves a (file → null) deps row so the file stays a graph
732
+ // node; a deleted file should disappear from the graph entirely.
733
+ this._stmtDelDeps.run(filePath);
734
+ delete this._graph.dependencies[filePath];
735
+ this._dataVersion = this._readDataVersion();
736
+ }
737
+
738
+ // ─── Build (write path, used by the indexer) ───────────────────────────────
739
+
740
+ /**
741
+ * (Re)build the entire database from an in-memory index payload. Runs as a
742
+ * single transaction (concurrent readers see the old index until the commit);
743
+ * mirrors exactly how MemoryGraphIndex derives its BM25 posting lists,
744
+ * document lengths, symbol and call indexes, so the two backends stay
745
+ * rank-consistent.
746
+ *
747
+ * @param {object} payload
748
+ * @param {object[]} payload.chunks
749
+ * @param {{dependencies:Object<string,string[]>}} payload.graph
750
+ * @param {Map<string,Float32Array>|object} [payload.embeddingCache] Keyed by embeddingKeyFor(chunk).
751
+ */
752
+ buildFrom({ chunks, graph, embeddingCache }) {
753
+ this._open();
754
+ this.db.exec('PRAGMA synchronous = OFF;'); // safe only for a full rebuild; not used for incremental writes
755
+
756
+ const offsets = new Map();
757
+ let dim = 0;
758
+ const cacheSize = embeddingCache instanceof Map ? embeddingCache.size : Object.keys(embeddingCache || {}).length;
759
+ if (cacheSize > 0) {
760
+ const buf = writeEmbeddingBinary(embeddingCache);
761
+ fs.writeFileSync(this._embeddingPath, buf);
762
+ let off = 0;
763
+ const count = buf.readUInt32LE(off); off += 4;
764
+ for (let i = 0; i < count; i++) {
765
+ const hl = buf.readUInt32LE(off); off += 4;
766
+ const key = buf.subarray(off, off + hl).toString('utf8'); off += hl;
767
+ const d = buf.readUInt32LE(off); off += 4;
768
+ offsets.set(key, { offset: off, dim: d });
769
+ if (!dim) dim = d;
770
+ off += d * 4;
771
+ }
772
+ }
773
+
774
+ const df = new Map();
775
+ let totalDocLen = 0, docCount = 0;
776
+
777
+ this.db.exec('BEGIN');
778
+ // Drop inside the transaction so concurrent readers see the old index
779
+ // until COMMIT, never briefly-empty tables.
780
+ this.db.exec(`
781
+ DROP TABLE IF EXISTS chunks; DROP TABLE IF EXISTS postings;
782
+ DROP TABLE IF EXISTS terms; DROP TABLE IF EXISTS call_edges;
783
+ DROP TABLE IF EXISTS deps; DROP TABLE IF EXISTS meta;
784
+ DROP TABLE IF EXISTS routes;
785
+ `);
786
+ this.db.exec(SCHEMA_TABLES);
787
+ this.db.exec(SCHEMA_INDEXES);
788
+
789
+ const insChunk = this.db.prepare(
790
+ `INSERT OR REPLACE INTO chunks (${CHUNK_COLS.join(', ')}) `
791
+ + `VALUES (${CHUNK_COLS.map(() => '?').join(', ')})`
792
+ );
793
+ const insPost = this.db.prepare('INSERT INTO postings (term, chunk_id, tf) VALUES (?, ?, ?)');
794
+ const insEdge = this.db.prepare('INSERT INTO call_edges (callee, chunk_id) VALUES (?, ?)');
795
+
796
+ for (const chunk of chunks) {
797
+ const deps = (graph?.dependencies?.[chunk.file_path]) || [];
798
+ const doc = buildLexicalDocument(chunk, deps);
799
+ const tokens = tokenize(doc); // raw + additive stems
800
+ const docLen = tokenize(doc, false).length; // raw-only length for BM25 avgdl (parity w/ memory engine)
801
+ totalDocLen += docLen; docCount++;
802
+
803
+ const termCounts = new Map();
804
+ for (const t of tokens) termCounts.set(t, (termCounts.get(t) || 0) + 1);
805
+ for (const [term, cnt] of termCounts) {
806
+ df.set(term, (df.get(term) || 0) + 1);
807
+ insPost.run(term, chunk.id, cnt);
808
+ }
809
+
810
+ const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '), false)));
811
+ const vecKey = chunk.content_hash ? embeddingKeyFor(chunk) : null;
812
+ const vec = vecKey ? (offsets.get(vecKey) ?? offsets.get(chunk.content_hash)) : null;
813
+
814
+ insChunk.run(
815
+ chunk.id, chunk.file_path, chunk.node_type ?? null, chunk.name ?? null,
816
+ nameLowerOf(chunk.name), chunk.docstring ?? '', chunk.code_snippet ?? '',
817
+ chunk.content_hash ?? null, chunk.start_line ?? null, chunk.end_line ?? null,
818
+ jsonArr(chunk.calls), jsonArr(chunk.params), chunk.return_type ?? '',
819
+ chunk.class_context ?? '', jsonArr(chunk.type_refs), jsonArr(chunk.decorators),
820
+ jsonArr(chunk.extends), chunk.hyde ?? null, chunk.summary ?? null, jsonArr(chunk.concepts),
821
+ docLen, JSON.stringify(pathTokens), vecKey,
822
+ vec ? vec.offset : -1, vec ? vec.dim : 0,
823
+ jsonArr(chunk.call_sites)
824
+ );
825
+ for (const callee of (chunk.calls || [])) insEdge.run(callee, chunk.id);
826
+ }
827
+
828
+ const insTerm = this.db.prepare('INSERT OR REPLACE INTO terms (term, df) VALUES (?, ?)');
829
+ for (const [term, d] of df) insTerm.run(term, d);
830
+
831
+ const insDep = this.db.prepare('INSERT INTO deps (file, dep) VALUES (?, ?)');
832
+ for (const [file, list] of Object.entries(graph?.dependencies || {})) {
833
+ if (!list || list.length === 0) insDep.run(file, null);
834
+ else for (const d of list) insDep.run(file, d);
835
+ }
836
+
837
+ // Inserted in array order so the autoincrement id mirrors the in-memory
838
+ // graph.routes order, keeping both backends rank-identical.
839
+ const insRoute = this.db.prepare(
840
+ 'INSERT INTO routes (method, path, handler_name, handler_chunk_id, file_path, line, framework) '
841
+ + 'VALUES (?, ?, ?, ?, ?, ?, ?)'
842
+ );
843
+ for (const r of (graph?.routes || [])) {
844
+ insRoute.run(
845
+ String(r.method || ''), String(r.path ?? ''), r.handler_name ?? null,
846
+ r.handler_chunk_id ?? null, String(r.file_path || ''),
847
+ r.line ?? null, r.framework ?? null
848
+ );
849
+ }
850
+
851
+ const insMeta = this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)');
852
+ insMeta.run('doc_count', String(docCount));
853
+ insMeta.run('total_doc_len', String(totalDocLen));
854
+ insMeta.run('dim', String(dim));
855
+ insMeta.run('built_at', String(Date.now()));
856
+ this.db.exec('COMMIT');
857
+
858
+ this.db.close();
859
+ this.db = null;
860
+ this._writeReady = false;
861
+ return { chunks: docCount, terms: df.size, dim };
862
+ }
863
+ }