graph-indexer 1.0.3 → 1.2.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.
@@ -0,0 +1,768 @@
1
+ /**
2
+ * @file sqlite-store.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
+ SUMMARY_VEC_SUFFIX, LEXICAL_FUSION_CAP, VECTOR_SCAN_RAW_N, finalizeVectorCandidates,
32
+ } from './search-core.mjs';
33
+ import {
34
+ writeEmbeddingBinary, appendEmbeddingBinary, scanEmbeddingBinary,
35
+ updateVectorSketch, searchVectorSketch,
36
+ } from './core-engine.mjs';
37
+
38
+ // node:sqlite is loaded lazily so the default in-memory path never requires it.
39
+ let DatabaseSync = null;
40
+ try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* reported in ctor */ }
41
+
42
+ const SKETCH_THRESHOLD = 10000; // build the binary sketch above this many vectors.
43
+ // Below it the exact streaming scan stays under ~20 ms
44
+ // AND both backends rank identically (the sketch is
45
+ // approximate in the candidate tail); above it the
46
+ // sketch caps latency at ~5–15 ms regardless of size.
47
+
48
+ const SCHEMA_TABLES = `
49
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
50
+ CREATE TABLE IF NOT EXISTS chunks (
51
+ id TEXT PRIMARY KEY, file_path TEXT NOT NULL, node_type TEXT,
52
+ name TEXT, name_lower TEXT, docstring TEXT, code_snippet TEXT, content_hash TEXT,
53
+ start_line INTEGER, end_line INTEGER,
54
+ calls TEXT, params TEXT, return_type TEXT, class_context TEXT,
55
+ type_refs TEXT, decorators TEXT, extends_ TEXT, hyde TEXT, summary TEXT, concepts TEXT,
56
+ doc_len INTEGER, path_tokens TEXT, vec_key TEXT, vec_offset INTEGER, vec_dim INTEGER
57
+ );
58
+ CREATE TABLE IF NOT EXISTS postings (term TEXT, chunk_id TEXT, tf INTEGER);
59
+ CREATE TABLE IF NOT EXISTS terms (term TEXT PRIMARY KEY, df INTEGER);
60
+ CREATE TABLE IF NOT EXISTS call_edges (callee TEXT, chunk_id TEXT);
61
+ CREATE TABLE IF NOT EXISTS deps (file TEXT, dep TEXT);
62
+ `;
63
+
64
+ const SCHEMA_INDEXES = `
65
+ CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_path);
66
+ CREATE INDEX IF NOT EXISTS idx_chunks_name ON chunks(name_lower);
67
+ CREATE INDEX IF NOT EXISTS idx_chunks_veckey ON chunks(vec_key);
68
+ CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(content_hash);
69
+ CREATE INDEX IF NOT EXISTS idx_postings_term ON postings(term);
70
+ CREATE INDEX IF NOT EXISTS idx_postings_chunk ON postings(chunk_id);
71
+ CREATE INDEX IF NOT EXISTS idx_calledges_callee ON call_edges(callee);
72
+ CREATE INDEX IF NOT EXISTS idx_deps_file ON deps(file);
73
+ CREATE INDEX IF NOT EXISTS idx_deps_dep ON deps(dep);
74
+ `;
75
+
76
+ const CHUNK_COLS = [
77
+ 'id', 'file_path', 'node_type', 'name', 'name_lower', 'docstring', 'code_snippet',
78
+ 'content_hash', 'start_line', 'end_line', 'calls', 'params', 'return_type',
79
+ 'class_context', 'type_refs', 'decorators', 'extends_', 'hyde', 'summary', 'concepts',
80
+ 'doc_len', 'path_tokens', 'vec_key', 'vec_offset', 'vec_dim',
81
+ ];
82
+
83
+ function jsonArr(v) { return JSON.stringify(Array.isArray(v) ? v : []); }
84
+ function parseArr(s) {
85
+ if (!s) return [];
86
+ try { const v = JSON.parse(s); return Array.isArray(v) ? v : []; } catch { return []; }
87
+ }
88
+ function nameLowerOf(name) {
89
+ return (name && name !== 'anonymous') ? String(name).toLowerCase() : null;
90
+ }
91
+
92
+ export class SqliteGraphStore {
93
+ constructor(dbPath, { embeddingPath, rrfK = 60 } = {}) {
94
+ if (!DatabaseSync) {
95
+ throw new Error(
96
+ 'node:sqlite is unavailable — the --use-sqlite backend requires Node >= 22.5. '
97
+ + 'Use the default in-memory engine, or upgrade Node.'
98
+ );
99
+ }
100
+ this.dbPath = dbPath;
101
+ this._embeddingPath = embeddingPath || dbPath.replace(/\.db$/, '.embeddings.bin');
102
+ this.db = null;
103
+ this._vecFd = -1;
104
+ this.rrfK = rrfK;
105
+
106
+ // File-level dependency graph — small (per file, not per chunk); held in RAM
107
+ // because PageRank needs the whole thing. This is the only corpus-scaling
108
+ // structure kept resident, and it is orders of magnitude smaller than chunks.
109
+ this._graph = { dependencies: {}, importedBy: {} };
110
+ this._docCount = 0;
111
+ this._totalDocLen = 0;
112
+ this._avgdl = 1;
113
+ this._dim = 0;
114
+ this._dataVersion = -1;
115
+ this._writeReady = false;
116
+ this._sketch = null; // binary-quantized vector sketch (built above SKETCH_THRESHOLD)
117
+ }
118
+
119
+ get backend() { return 'sqlite'; }
120
+
121
+ /** Live view of the file dependency graph (refreshed when another process commits). */
122
+ get graph() {
123
+ this._maybeRefresh();
124
+ return this._graph;
125
+ }
126
+
127
+ // ─── Open / load ───────────────────────────────────────────────────────────
128
+
129
+ _open() {
130
+ this.db = new DatabaseSync(this.dbPath);
131
+ // WAL: one writer (daemon) + many readers (MCP server) across processes.
132
+ // busy_timeout absorbs brief writer/checkpoint locks instead of throwing.
133
+ this.db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 3000;');
134
+ }
135
+
136
+ load() {
137
+ const existed = fs.existsSync(this.dbPath);
138
+ this._open();
139
+ this.db.exec(SCHEMA_TABLES);
140
+ if (existed) {
141
+ // Forward migrations for indexes built by older versions.
142
+ try { this.db.exec('ALTER TABLE chunks ADD COLUMN concepts TEXT DEFAULT NULL'); } catch { /* exists */ }
143
+ try {
144
+ this.db.exec('ALTER TABLE chunks ADD COLUMN vec_key TEXT DEFAULT NULL');
145
+ // Pre-enrichment-key indexes stored vectors under the plain content hash.
146
+ this.db.exec('UPDATE chunks SET vec_key = content_hash WHERE vec_key IS NULL');
147
+ } catch { /* exists */ }
148
+ }
149
+ this.db.exec(SCHEMA_INDEXES);
150
+ this._reloadMeta();
151
+ this._loadGraph();
152
+ this._openVecFd();
153
+ this._refreshSketch();
154
+ this._prepare();
155
+ this._dataVersion = this._readDataVersion();
156
+ return this;
157
+ }
158
+
159
+ _openVecFd() {
160
+ if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
161
+ if (fs.existsSync(this._embeddingPath)) {
162
+ try { this._vecFd = fs.openSync(this._embeddingPath, 'r'); } catch { this._vecFd = -1; }
163
+ }
164
+ }
165
+
166
+ /** Read the bin's entry count + first entry key (cheap, two small preads). */
167
+ _binFingerprint() {
168
+ if (this._vecFd < 0) return null;
169
+ try {
170
+ const hdr = Buffer.allocUnsafe(8);
171
+ if (fs.readSync(this._vecFd, hdr, 0, 8, 0) < 8) return null;
172
+ const count = hdr.readUInt32LE(0);
173
+ const keyLen = hdr.readUInt32LE(4);
174
+ if (keyLen <= 0 || keyLen > 4096) return { count, firstKey: null };
175
+ const kb = Buffer.allocUnsafe(keyLen);
176
+ if (fs.readSync(this._vecFd, kb, 0, keyLen, 8) < keyLen) return { count, firstKey: null };
177
+ return { count, firstKey: kb.toString('utf8') };
178
+ } catch { return null; }
179
+ }
180
+
181
+ /**
182
+ * Build / extend / rebuild the binary vector sketch to match the bin on disk.
183
+ * Appends (the daemon's write path) extend the sketch by scanning only the
184
+ * unseen tail; a replaced bin (full re-index) is detected via the entry count
185
+ * + first-key fingerprint and triggers a rebuild.
186
+ */
187
+ _refreshSketch() {
188
+ if (this._vecFd < 0) { this._sketch = null; return; }
189
+ const fp = this._binFingerprint();
190
+ if (!fp || fp.count < SKETCH_THRESHOLD) { this._sketch = null; return; }
191
+ try {
192
+ const size = fs.fstatSync(this._vecFd).size;
193
+ if (this._sketch) {
194
+ const replaced = fp.count < this._sketch.n
195
+ || size < this._sketch.consumed
196
+ || (this._sketch.firstKey !== null && fp.firstKey !== this._sketch.firstKey);
197
+ if (replaced) this._sketch = null;
198
+ }
199
+ this._sketch = updateVectorSketch(this._sketch, { fd: this._vecFd });
200
+ } catch { this._sketch = null; }
201
+ }
202
+
203
+ _reloadMeta() {
204
+ const meta = {};
205
+ for (const r of this.db.prepare('SELECT key, value FROM meta').all()) meta[r.key] = r.value;
206
+ this._docCount = Number(meta.doc_count || 0);
207
+ this._totalDocLen = Number(meta.total_doc_len || 0);
208
+ this._avgdl = this._docCount > 0 ? this._totalDocLen / this._docCount : 1;
209
+ this._dim = Number(meta.dim || 0);
210
+ }
211
+
212
+ _loadGraph() {
213
+ const dependencies = {};
214
+ const importedBy = {};
215
+ for (const { file, dep } of this.db.prepare('SELECT file, dep FROM deps').all()) {
216
+ (dependencies[file] ||= []);
217
+ if (dep != null) {
218
+ dependencies[file].push(dep);
219
+ (importedBy[dep] ||= []).push(file);
220
+ }
221
+ }
222
+ // Ensure every file that has chunks is a graph node (PageRank node set parity).
223
+ for (const { f } of this.db.prepare('SELECT DISTINCT file_path AS f FROM chunks').all()) {
224
+ dependencies[f] ||= [];
225
+ }
226
+ this._graph = { dependencies, importedBy };
227
+ }
228
+
229
+ _readDataVersion() {
230
+ try { return this.db.prepare('PRAGMA data_version').get()?.data_version ?? -1; }
231
+ catch { return -1; }
232
+ }
233
+
234
+ /**
235
+ * Detect commits made by OTHER processes (the watch daemon, a re-run of the
236
+ * indexer) via PRAGMA data_version — a single cheap pragma per call — and
237
+ * refresh the cached meta, dependency graph and embeddings fd when the
238
+ * underlying database changed. This is what keeps a long-running MCP server
239
+ * consistent with the daemon without restarts or full re-indexes.
240
+ */
241
+ _maybeRefresh() {
242
+ if (!this.db) return;
243
+ try {
244
+ const v = this._readDataVersion();
245
+ if (v === this._dataVersion) return;
246
+ this._dataVersion = v;
247
+ this._reloadMeta();
248
+ this._loadGraph();
249
+ this._openVecFd();
250
+ this._refreshSketch();
251
+ } catch { /* keep previous snapshot on transient errors */ }
252
+ }
253
+
254
+ _prepare() {
255
+ this._stmtChunk = this.db.prepare('SELECT * FROM chunks WHERE id = ?');
256
+ this._stmtByFile = this.db.prepare('SELECT * FROM chunks WHERE file_path = ?');
257
+ this._stmtByName = this.db.prepare('SELECT * FROM chunks WHERE name_lower = ?');
258
+ this._stmtIdByName = this.db.prepare('SELECT id FROM chunks WHERE name_lower = ?');
259
+ this._stmtCallers = this.db.prepare(
260
+ 'SELECT c.* FROM chunks c JOIN call_edges e ON e.chunk_id = c.id WHERE e.callee = ?'
261
+ );
262
+ this._stmtPosting = this.db.prepare(
263
+ 'SELECT p.chunk_id AS id, p.tf AS tf, c.doc_len AS doc_len '
264
+ + 'FROM postings p JOIN chunks c ON c.id = p.chunk_id WHERE p.term = ?'
265
+ );
266
+ this._stmtDf = this.db.prepare('SELECT df FROM terms WHERE term = ?');
267
+ this._stmtVec = this.db.prepare('SELECT vec_offset, vec_dim FROM chunks WHERE id = ?');
268
+ // ORDER BY id: duplicate-content chunks share one vector key; emitting their
269
+ // ids in a deterministic order keeps both backends rank-identical on ties.
270
+ this._stmtIdsByKey = this.db.prepare('SELECT id FROM chunks WHERE vec_key = ? ORDER BY id');
271
+ this._stmtIdsByHash = this.db.prepare('SELECT id FROM chunks WHERE content_hash = ? ORDER BY id');
272
+ }
273
+
274
+ _prepareWrite() {
275
+ if (this._writeReady) return;
276
+ this._stmtInsChunk = this.db.prepare(
277
+ `INSERT OR REPLACE INTO chunks (${CHUNK_COLS.join(', ')}) `
278
+ + `VALUES (${CHUNK_COLS.map(() => '?').join(', ')})`
279
+ );
280
+ this._stmtInsPost = this.db.prepare('INSERT INTO postings (term, chunk_id, tf) VALUES (?, ?, ?)');
281
+ this._stmtInsEdge = this.db.prepare('INSERT INTO call_edges (callee, chunk_id) VALUES (?, ?)');
282
+ this._stmtInsDep = this.db.prepare('INSERT INTO deps (file, dep) VALUES (?, ?)');
283
+ this._stmtDelDeps = this.db.prepare('DELETE FROM deps WHERE file = ?');
284
+ this._stmtUpsertTerm = this.db.prepare(
285
+ 'INSERT INTO terms (term, df) VALUES (?, 1) ON CONFLICT(term) DO UPDATE SET df = df + 1'
286
+ );
287
+ this._stmtDecTerm = this.db.prepare('UPDATE terms SET df = df - 1 WHERE term = ?');
288
+ this._stmtPruneTerms = this.db.prepare('DELETE FROM terms WHERE df <= 0');
289
+ this._stmtPostByChunk = this.db.prepare('SELECT term FROM postings WHERE chunk_id = ?');
290
+ this._stmtDelPostings = this.db.prepare('DELETE FROM postings WHERE chunk_id = ?');
291
+ this._stmtDelEdges = this.db.prepare('DELETE FROM call_edges WHERE chunk_id = ?');
292
+ this._stmtDelChunk = this.db.prepare('DELETE FROM chunks WHERE id = ?');
293
+ this._stmtInsMeta = this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)');
294
+ this._stmtVecByKey = this.db.prepare(
295
+ 'SELECT vec_offset, vec_dim FROM chunks WHERE vec_key = ? AND vec_offset >= 0 LIMIT 1'
296
+ );
297
+ this._stmtVecByHash = this.db.prepare(
298
+ 'SELECT vec_offset, vec_dim FROM chunks WHERE content_hash = ? AND vec_offset >= 0 LIMIT 1'
299
+ );
300
+ this._writeReady = true;
301
+ }
302
+
303
+ // ─── Row → chunk ───────────────────────────────────────────────────────────
304
+
305
+ _rowToChunk(row) {
306
+ if (!row) return null;
307
+ return {
308
+ id: row.id, file_path: row.file_path, node_type: row.node_type, name: row.name,
309
+ docstring: row.docstring || '', code_snippet: row.code_snippet || '',
310
+ content_hash: row.content_hash, start_line: row.start_line, end_line: row.end_line,
311
+ calls: parseArr(row.calls), params: parseArr(row.params),
312
+ return_type: row.return_type || '', class_context: row.class_context || '',
313
+ type_refs: parseArr(row.type_refs), decorators: parseArr(row.decorators),
314
+ extends: parseArr(row.extends_), hyde: row.hyde || '', summary: row.summary || '',
315
+ concepts: parseArr(row.concepts),
316
+ };
317
+ }
318
+
319
+ // ─── Contract: point reads ─────────────────────────────────────────────────
320
+
321
+ getChunk(id) { return this._rowToChunk(this._stmtChunk.get(id)); }
322
+
323
+ getChunksByFile(filePath) { return this._stmtByFile.all(filePath).map(r => this._rowToChunk(r)); }
324
+
325
+ resolveSymbol(name) {
326
+ const key = nameLowerOf(name);
327
+ if (!key) return [];
328
+ return this._stmtByName.all(key).map(r => this._rowToChunk(r));
329
+ }
330
+
331
+ findCallers(funcName) { return this._stmtCallers.all(funcName).map(r => this._rowToChunk(r)); }
332
+
333
+ *iterateChunks() {
334
+ // Stream rows via a cursor so repo-map / call-graph passes never materialise
335
+ // the whole table. Falls back to paging if .iterate() is unavailable.
336
+ const stmt = this.db.prepare('SELECT * FROM chunks');
337
+ if (typeof stmt.iterate === 'function') {
338
+ for (const row of stmt.iterate()) yield this._rowToChunk(row);
339
+ return;
340
+ }
341
+ const page = this.db.prepare('SELECT * FROM chunks LIMIT ? OFFSET ?');
342
+ let offset = 0;
343
+ for (;;) {
344
+ const rows = page.all(1000, offset);
345
+ if (rows.length === 0) break;
346
+ for (const row of rows) yield this._rowToChunk(row);
347
+ offset += rows.length;
348
+ }
349
+ }
350
+
351
+ getDependencies(filePath) { return this.graph.dependencies[filePath] || []; }
352
+ getImportedBy(filePath) { return this.graph.importedBy[filePath] || []; }
353
+
354
+ chunkCount() { return this.db.prepare('SELECT COUNT(*) AS n FROM chunks').get().n; }
355
+ symbolCount() { return this.db.prepare('SELECT COUNT(DISTINCT name_lower) AS n FROM chunks WHERE name_lower IS NOT NULL').get().n; }
356
+ fileCount() { return this.db.prepare('SELECT COUNT(DISTINCT file_path) AS n FROM chunks').get().n; }
357
+ vectorCount() { return this.db.prepare('SELECT COUNT(*) AS n FROM chunks WHERE vec_offset >= 0').get().n; }
358
+
359
+ stats() {
360
+ this._maybeRefresh();
361
+ // Extension tally, streamed via a cursor so it never materialises all rows.
362
+ const extCounts = new Map();
363
+ const stmt = this.db.prepare('SELECT file_path FROM chunks');
364
+ const rows = typeof stmt.iterate === 'function' ? stmt.iterate() : stmt.all();
365
+ for (const r of rows) {
366
+ const ext = r.file_path.split('.').pop() || 'unknown';
367
+ extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
368
+ }
369
+ const vectors = this.vectorCount();
370
+ return {
371
+ backend: 'sqlite',
372
+ chunks: this.chunkCount(),
373
+ files: this.fileCount(),
374
+ symbols: this.symbolCount(),
375
+ vectors,
376
+ hasVectors: vectors > 0,
377
+ lazyMode: true,
378
+ vectorSource: this._vecFd >= 0 ? 'sqlite + disk-backed .bin' : 'sqlite (no vectors)',
379
+ extCounts,
380
+ };
381
+ }
382
+
383
+ // ─── Vector access (pread from shared .embeddings.bin) ─────────────────────
384
+
385
+ _readVector(id) {
386
+ if (this._vecFd < 0) return null;
387
+ const row = this._stmtVec.get(id);
388
+ if (!row || row.vec_offset == null || row.vec_offset < 0 || !row.vec_dim) return null;
389
+ const byteLen = row.vec_dim * 4;
390
+ const raw = Buffer.allocUnsafe(byteLen);
391
+ try {
392
+ const read = fs.readSync(this._vecFd, raw, 0, byteLen, row.vec_offset);
393
+ if (read < byteLen) return null;
394
+ const vec = new Float32Array(row.vec_dim);
395
+ for (let d = 0; d < row.vec_dim; d++) vec[d] = raw.readFloatLE(d * 4);
396
+ return vec;
397
+ } catch { return null; }
398
+ }
399
+
400
+ /** Whether a vector for this embedding key is already stored (skip re-embedding). */
401
+ hasEmbedding(key) {
402
+ this._prepareWrite();
403
+ return Boolean(this._stmtVecByKey.get(key));
404
+ }
405
+
406
+ // ─── Hybrid search ─────────────────────────────────────────────────────────
407
+
408
+ searchHybrid(queryText, queryVector, topK = 5, minScore = 0.3, exactBoostName = null) {
409
+ this._maybeRefresh();
410
+
411
+ // 1. Lexical BM25 over posting lists (one indexed lookup per query term).
412
+ const qTokens = tokenize(queryText);
413
+ const occ = new Map();
414
+ for (const t of qTokens) occ.set(t, (occ.get(t) || 0) + 1);
415
+
416
+ const lexScores = new Map();
417
+ for (const [token, n] of occ) {
418
+ const dfRow = this._stmtDf.get(token);
419
+ if (!dfRow || dfRow.df <= 0) continue;
420
+ const idf = okapiIdf(this._docCount, dfRow.df);
421
+ for (const row of this._stmtPosting.all(token)) {
422
+ const dl = row.doc_len ?? this._avgdl;
423
+ const s = bm25Score(idf, row.tf, dl, this._avgdl) * n;
424
+ lexScores.set(row.id, (lexScores.get(row.id) || 0) + s);
425
+ }
426
+ }
427
+ const lexicalResults = Array.from(lexScores.entries())
428
+ .sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1)) // id tie-break: backend parity
429
+ .slice(0, LEXICAL_FUSION_CAP)
430
+ .map(([id, score], i) => ({ id, score, rank: i + 1 }));
431
+
432
+ // 2. Vector channel: stream the FULL embeddings bin (bounded buffers) so a
433
+ // conceptual query that shares no tokens with the code can still match.
434
+ // The old design scored only lexical candidates, which silently turned
435
+ // semantic search off exactly where it was needed.
436
+ let vectorResults = [];
437
+ if (queryVector && this._vecFd >= 0) {
438
+ // Binary sketch above SKETCH_THRESHOLD (Hamming prefilter + exact
439
+ // rescore, O(candidates) disk reads); exact streaming scan below it.
440
+ const hits = this._sketch
441
+ ? searchVectorSketch(this._sketch, { fd: this._vecFd }, queryVector, {
442
+ topN: VECTOR_SCAN_RAW_N, minScore,
443
+ })
444
+ : scanEmbeddingBinary({ fd: this._vecFd }, queryVector, {
445
+ topN: VECTOR_SCAN_RAW_N, minScore,
446
+ });
447
+ const entries = [];
448
+ for (const { key, score } of hits) {
449
+ // Summary-only vectors live under `<key>|s` — fold onto the chunk.
450
+ const baseKey = key.endsWith(SUMMARY_VEC_SUFFIX)
451
+ ? key.slice(0, -SUMMARY_VEC_SUFFIX.length) : key;
452
+ let rows = this._stmtIdsByKey.all(baseKey);
453
+ if (rows.length === 0) rows = this._stmtIdsByHash.all(baseKey); // legacy bins
454
+ for (const { id } of rows) entries.push({ id, score });
455
+ }
456
+ vectorResults = finalizeVectorCandidates(entries);
457
+ }
458
+
459
+ // 3. Fetch candidate rows ONCE (transient, bounded by the caps above), then
460
+ // fuse + boost with the shared ranker. getChunk lazy-fetches stragglers
461
+ // (e.g. exact-boost ids) and caches them for the duration of the call.
462
+ const rowCache = new Map();
463
+ const fetchRow = (id) => {
464
+ if (rowCache.has(id)) return rowCache.get(id);
465
+ const row = this._stmtChunk.get(id);
466
+ rowCache.set(id, row);
467
+ return row;
468
+ };
469
+ for (const r of lexicalResults) fetchRow(r.id);
470
+ for (const r of vectorResults) fetchRow(r.id);
471
+
472
+ const chunkCache = new Map();
473
+ const getChunk = (id) => {
474
+ if (chunkCache.has(id)) return chunkCache.get(id);
475
+ const c = this._rowToChunk(fetchRow(id));
476
+ chunkCache.set(id, c);
477
+ return c;
478
+ };
479
+ const pathCache = new Map();
480
+ const getPathTokens = (id) => {
481
+ if (pathCache.has(id)) return pathCache.get(id);
482
+ const row = fetchRow(id);
483
+ const set = row ? new Set(parseArr(row.path_tokens)) : undefined;
484
+ pathCache.set(id, set);
485
+ return set;
486
+ };
487
+ const dfCache = new Map();
488
+ const getDf = (t) => {
489
+ if (dfCache.has(t)) return dfCache.get(t);
490
+ const row = this._stmtDf.get(t);
491
+ const df = row ? row.df : 0;
492
+ dfCache.set(t, df);
493
+ return df;
494
+ };
495
+
496
+ return fuseAndRank({
497
+ lexicalResults, vectorResults, getChunk, getPathTokens, getDf,
498
+ docCount: this._docCount, rrfK: this.rrfK, topK, queryText, exactBoostName,
499
+ resolveExact: (term) => this._stmtIdByName.all(term).map(r => r.id),
500
+ });
501
+ }
502
+
503
+ close() {
504
+ if (this._vecFd >= 0) { try { fs.closeSync(this._vecFd); } catch {} this._vecFd = -1; }
505
+ if (this.db) { try { this.db.close(); } catch {} this.db = null; }
506
+ }
507
+
508
+ // ─── Incremental writes (used by the watch daemon) ─────────────────────────
509
+
510
+ /** Internal: index one chunk's lexical document + row + call edges. */
511
+ _insertChunk(chunk, imports, vecEntry) {
512
+ const tokens = tokenize(buildLexicalDocument(chunk, imports));
513
+ const docLen = tokens.length;
514
+
515
+ const termCounts = new Map();
516
+ for (const t of tokens) termCounts.set(t, (termCounts.get(t) || 0) + 1);
517
+ for (const [term, cnt] of termCounts) {
518
+ this._stmtUpsertTerm.run(term);
519
+ this._stmtInsPost.run(term, chunk.id, cnt);
520
+ }
521
+
522
+ const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '))));
523
+ this._stmtInsChunk.run(
524
+ chunk.id, chunk.file_path, chunk.node_type ?? null, chunk.name ?? null,
525
+ nameLowerOf(chunk.name), chunk.docstring ?? '', chunk.code_snippet ?? '',
526
+ chunk.content_hash ?? null, chunk.start_line ?? null, chunk.end_line ?? null,
527
+ jsonArr(chunk.calls), jsonArr(chunk.params), chunk.return_type ?? '',
528
+ chunk.class_context ?? '', jsonArr(chunk.type_refs), jsonArr(chunk.decorators),
529
+ jsonArr(chunk.extends), chunk.hyde ?? null, chunk.summary ?? null, jsonArr(chunk.concepts),
530
+ docLen, JSON.stringify(pathTokens),
531
+ chunk.content_hash ? embeddingKeyFor(chunk) : null,
532
+ vecEntry ? vecEntry.offset : -1, vecEntry ? vecEntry.dim : 0
533
+ );
534
+ for (const callee of (chunk.calls || [])) this._stmtInsEdge.run(callee, chunk.id);
535
+ return docLen;
536
+ }
537
+
538
+ /**
539
+ * Atomically replace every chunk of one file — the incremental-update entry
540
+ * point used by the watch daemon. Mirrors MemoryGraphIndex.applyFileUpdate.
541
+ *
542
+ * New vectors are APPENDED to the shared .embeddings.bin (O(changed chunks),
543
+ * never a full rewrite); unchanged chunks reuse their existing bin offsets.
544
+ * All SQL runs in one WAL transaction, so concurrent readers see either the
545
+ * old or the new file state, never a partial one. BM25 document-frequency
546
+ * and length accounting is decremented from the chunk's actual posting rows,
547
+ * so incremental state matches what a full rebuild would produce.
548
+ *
549
+ * @param {string} filePath
550
+ * @param {object} p
551
+ * @param {object[]} [p.chunks]
552
+ * @param {string[]} [p.imports]
553
+ * @param {Map<string, Float32Array|number[]>} [p.embeddings] New vectors keyed by embeddingKeyFor(chunk).
554
+ */
555
+ applyFileUpdate(filePath, { chunks = [], imports = [], embeddings = null } = {}) {
556
+ if (!this.db) this.load();
557
+ this._prepareWrite();
558
+ this._maybeRefresh();
559
+
560
+ // 1. Append new vectors first — orphan bytes on a failed commit are
561
+ // harmless and compacted away by the next full index run.
562
+ let appended = new Map();
563
+ const embCount = embeddings ? (embeddings instanceof Map ? embeddings.size : Object.keys(embeddings).length) : 0;
564
+ if (embCount > 0) {
565
+ appended = appendEmbeddingBinary(this._embeddingPath, embeddings);
566
+ if (this._vecFd < 0) this._openVecFd();
567
+ if (!this._dim && appended.size > 0) this._dim = appended.values().next().value.dim;
568
+ // Keep our own sketch current (tail-only scan of what we just wrote).
569
+ if (this._sketch) {
570
+ try { this._sketch = updateVectorSketch(this._sketch, { fd: this._vecFd }); }
571
+ catch { this._sketch = null; }
572
+ }
573
+ }
574
+
575
+ this.db.exec('BEGIN');
576
+ try {
577
+ // 2. Capture reusable vector offsets for incoming chunks BEFORE the
578
+ // file's old rows (which may hold those offsets) are deleted.
579
+ const reuse = new Map();
580
+ for (const chunk of chunks) {
581
+ if (!chunk.content_hash) continue;
582
+ const key = embeddingKeyFor(chunk);
583
+ if (appended.has(key) || reuse.has(key)) continue;
584
+ const row = this._stmtVecByKey.get(key) ?? this._stmtVecByHash.get(chunk.content_hash);
585
+ if (row) reuse.set(key, { offset: row.vec_offset, dim: row.vec_dim });
586
+ }
587
+
588
+ // 3. Remove the file's old rows with exact BM25 bookkeeping.
589
+ let docCount = this._docCount;
590
+ let totalDocLen = this._totalDocLen;
591
+ for (const row of this._stmtByFile.all(filePath)) {
592
+ for (const { term } of this._stmtPostByChunk.all(row.id)) {
593
+ this._stmtDecTerm.run(term);
594
+ }
595
+ this._stmtDelPostings.run(row.id);
596
+ this._stmtDelEdges.run(row.id);
597
+ this._stmtDelChunk.run(row.id);
598
+ totalDocLen -= (row.doc_len || 0);
599
+ docCount--;
600
+ }
601
+
602
+ // 4. Insert the new chunks.
603
+ for (const chunk of chunks) {
604
+ const key = chunk.content_hash ? embeddingKeyFor(chunk) : null;
605
+ const vecEntry = key ? (appended.get(key) ?? reuse.get(key) ?? null) : null;
606
+ totalDocLen += this._insertChunk(chunk, imports, vecEntry);
607
+ docCount++;
608
+ }
609
+ this._stmtPruneTerms.run();
610
+
611
+ // 5. Dependency edges + meta.
612
+ this._stmtDelDeps.run(filePath);
613
+ if (!imports || imports.length === 0) this._stmtInsDep.run(filePath, null);
614
+ else for (const d of imports) this._stmtInsDep.run(filePath, d);
615
+
616
+ docCount = Math.max(0, docCount);
617
+ totalDocLen = Math.max(0, totalDocLen);
618
+ this._stmtInsMeta.run('doc_count', String(docCount));
619
+ this._stmtInsMeta.run('total_doc_len', String(totalDocLen));
620
+ this._stmtInsMeta.run('dim', String(this._dim));
621
+ this._stmtInsMeta.run('built_at', String(Date.now()));
622
+ this.db.exec('COMMIT');
623
+
624
+ this._docCount = docCount;
625
+ this._totalDocLen = totalDocLen;
626
+ this._avgdl = docCount > 0 ? totalDocLen / docCount : 1;
627
+ } catch (err) {
628
+ try { this.db.exec('ROLLBACK'); } catch { /* not in txn */ }
629
+ throw err;
630
+ }
631
+
632
+ // 6. Mirror the in-RAM dependency graph (same shape as updateFileGraph).
633
+ const oldDeps = this._graph.dependencies[filePath] || [];
634
+ for (const oldDep of oldDeps) {
635
+ if (this._graph.importedBy[oldDep]) {
636
+ this._graph.importedBy[oldDep] = this._graph.importedBy[oldDep].filter(f => f !== filePath);
637
+ }
638
+ }
639
+ this._graph.dependencies[filePath] = imports || [];
640
+ for (const dep of (imports || [])) {
641
+ (this._graph.importedBy[dep] ||= []);
642
+ if (!this._graph.importedBy[dep].includes(filePath)) this._graph.importedBy[dep].push(filePath);
643
+ }
644
+ // Our own commit bumps data_version for other connections, not this one —
645
+ // re-read so the next _maybeRefresh() doesn't see a spurious change.
646
+ this._dataVersion = this._readDataVersion();
647
+ }
648
+
649
+ /** Remove a deleted file's chunks, postings, edges and graph node. */
650
+ removeFile(filePath) {
651
+ this.applyFileUpdate(filePath, { chunks: [], imports: [] });
652
+ // applyFileUpdate leaves a (file → null) deps row so the file stays a graph
653
+ // node; a deleted file should disappear from the graph entirely.
654
+ this._stmtDelDeps.run(filePath);
655
+ delete this._graph.dependencies[filePath];
656
+ this._dataVersion = this._readDataVersion();
657
+ }
658
+
659
+ // ─── Build (write path, used by the indexer) ───────────────────────────────
660
+
661
+ /**
662
+ * (Re)build the entire database from an in-memory index payload. Runs as a
663
+ * single transaction (concurrent readers see the old index until the commit);
664
+ * mirrors exactly how MemoryGraphIndex derives its BM25 posting lists,
665
+ * document lengths, symbol and call indexes, so the two backends stay
666
+ * rank-consistent.
667
+ *
668
+ * @param {object} payload
669
+ * @param {object[]} payload.chunks
670
+ * @param {{dependencies:Object<string,string[]>}} payload.graph
671
+ * @param {Map<string,Float32Array>|object} [payload.embeddingCache] Keyed by embeddingKeyFor(chunk).
672
+ */
673
+ buildFrom({ chunks, graph, embeddingCache }) {
674
+ this._open();
675
+ this.db.exec('PRAGMA synchronous = OFF;'); // fast bulk load; rebuilt artifact
676
+
677
+ // Write the embedding binary and derive each key's float offset.
678
+ const offsets = new Map();
679
+ let dim = 0;
680
+ const cacheSize = embeddingCache instanceof Map ? embeddingCache.size : Object.keys(embeddingCache || {}).length;
681
+ if (cacheSize > 0) {
682
+ const buf = writeEmbeddingBinary(embeddingCache);
683
+ fs.writeFileSync(this._embeddingPath, buf);
684
+ let off = 0;
685
+ const count = buf.readUInt32LE(off); off += 4;
686
+ for (let i = 0; i < count; i++) {
687
+ const hl = buf.readUInt32LE(off); off += 4;
688
+ const key = buf.subarray(off, off + hl).toString('utf8'); off += hl;
689
+ const d = buf.readUInt32LE(off); off += 4;
690
+ offsets.set(key, { offset: off, dim: d });
691
+ if (!dim) dim = d;
692
+ off += d * 4;
693
+ }
694
+ }
695
+
696
+ const df = new Map();
697
+ let totalDocLen = 0, docCount = 0;
698
+
699
+ this.db.exec('BEGIN');
700
+ // Fresh build — drop any prior content (inside the txn: readers keep the
701
+ // old index until COMMIT, instead of briefly seeing empty tables).
702
+ this.db.exec(`
703
+ DROP TABLE IF EXISTS chunks; DROP TABLE IF EXISTS postings;
704
+ DROP TABLE IF EXISTS terms; DROP TABLE IF EXISTS call_edges;
705
+ DROP TABLE IF EXISTS deps; DROP TABLE IF EXISTS meta;
706
+ `);
707
+ this.db.exec(SCHEMA_TABLES);
708
+ this.db.exec(SCHEMA_INDEXES);
709
+
710
+ const insChunk = this.db.prepare(
711
+ `INSERT OR REPLACE INTO chunks (${CHUNK_COLS.join(', ')}) `
712
+ + `VALUES (${CHUNK_COLS.map(() => '?').join(', ')})`
713
+ );
714
+ const insPost = this.db.prepare('INSERT INTO postings (term, chunk_id, tf) VALUES (?, ?, ?)');
715
+ const insEdge = this.db.prepare('INSERT INTO call_edges (callee, chunk_id) VALUES (?, ?)');
716
+
717
+ for (const chunk of chunks) {
718
+ const deps = (graph?.dependencies?.[chunk.file_path]) || [];
719
+ const tokens = tokenize(buildLexicalDocument(chunk, deps));
720
+ const docLen = tokens.length;
721
+ totalDocLen += docLen; docCount++;
722
+
723
+ const termCounts = new Map();
724
+ for (const t of tokens) termCounts.set(t, (termCounts.get(t) || 0) + 1);
725
+ for (const [term, cnt] of termCounts) {
726
+ df.set(term, (df.get(term) || 0) + 1);
727
+ insPost.run(term, chunk.id, cnt);
728
+ }
729
+
730
+ const pathTokens = Array.from(new Set(tokenize(chunk.file_path.replace(/[/\-_.]/g, ' '))));
731
+ const vecKey = chunk.content_hash ? embeddingKeyFor(chunk) : null;
732
+ const vec = vecKey ? (offsets.get(vecKey) ?? offsets.get(chunk.content_hash)) : null;
733
+
734
+ insChunk.run(
735
+ chunk.id, chunk.file_path, chunk.node_type ?? null, chunk.name ?? null,
736
+ nameLowerOf(chunk.name), chunk.docstring ?? '', chunk.code_snippet ?? '',
737
+ chunk.content_hash ?? null, chunk.start_line ?? null, chunk.end_line ?? null,
738
+ jsonArr(chunk.calls), jsonArr(chunk.params), chunk.return_type ?? '',
739
+ chunk.class_context ?? '', jsonArr(chunk.type_refs), jsonArr(chunk.decorators),
740
+ jsonArr(chunk.extends), chunk.hyde ?? null, chunk.summary ?? null, jsonArr(chunk.concepts),
741
+ docLen, JSON.stringify(pathTokens), vecKey,
742
+ vec ? vec.offset : -1, vec ? vec.dim : 0
743
+ );
744
+ for (const callee of (chunk.calls || [])) insEdge.run(callee, chunk.id);
745
+ }
746
+
747
+ const insTerm = this.db.prepare('INSERT OR REPLACE INTO terms (term, df) VALUES (?, ?)');
748
+ for (const [term, d] of df) insTerm.run(term, d);
749
+
750
+ const insDep = this.db.prepare('INSERT INTO deps (file, dep) VALUES (?, ?)');
751
+ for (const [file, list] of Object.entries(graph?.dependencies || {})) {
752
+ if (!list || list.length === 0) insDep.run(file, null);
753
+ else for (const d of list) insDep.run(file, d);
754
+ }
755
+
756
+ const insMeta = this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)');
757
+ insMeta.run('doc_count', String(docCount));
758
+ insMeta.run('total_doc_len', String(totalDocLen));
759
+ insMeta.run('dim', String(dim));
760
+ insMeta.run('built_at', String(Date.now()));
761
+ this.db.exec('COMMIT');
762
+
763
+ this.db.close();
764
+ this.db = null;
765
+ this._writeReady = false;
766
+ return { chunks: docCount, terms: df.size, dim };
767
+ }
768
+ }