rag-memory-epf-mcp 3.5.2 → 3.6.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,59 @@
1
+ import type Database from 'better-sqlite3';
2
+ export type ReconState = 'pending' | 'running' | 'complete' | 'failed' | 'deferred' | 'n/a';
3
+ export interface CoverageSnapshot {
4
+ chunk: {
5
+ total: number;
6
+ embedded: number;
7
+ verified: number;
8
+ legacy_assumed: number;
9
+ };
10
+ entity: {
11
+ total: number;
12
+ embedded: number;
13
+ verified: number;
14
+ legacy_assumed: number;
15
+ };
16
+ }
17
+ export interface CoordinatorDeps {
18
+ db: () => Database.Database | null;
19
+ gateIsReady: () => boolean;
20
+ gateIsDisabled: () => boolean;
21
+ mode: () => 'lazy' | 'eager' | 'off';
22
+ grandfatherAllowed: () => boolean;
23
+ currentProfileId: () => number;
24
+ buildEntityInputHash: (entityId: string) => string | null;
25
+ hashEntityText: (text: string) => string;
26
+ chunkInputHash: (text: string) => string;
27
+ reembedEntity: (entityId: string) => Promise<boolean>;
28
+ reembedChunk: (rowid: number) => Promise<boolean>;
29
+ }
30
+ export declare class BackfillCoordinator {
31
+ private readonly deps;
32
+ private recon;
33
+ private reconError?;
34
+ private reconPromise;
35
+ private kickTimer;
36
+ private sweepTimer;
37
+ private shuttingDown;
38
+ private scanning;
39
+ private scanPromise;
40
+ private rerunRequested;
41
+ private snapshot;
42
+ constructor(deps: CoordinatorDeps);
43
+ get reconState(): ReconState;
44
+ get reconLastError(): string | undefined;
45
+ get eligible(): boolean;
46
+ private countNullWithVector;
47
+ private countRepairables;
48
+ private sanitize;
49
+ runReconciliation(): Promise<void>;
50
+ private reconcileOnce;
51
+ private reconcileEntities;
52
+ private reconcileChunks;
53
+ kick(): void;
54
+ sweepStart(): void;
55
+ protected scanAndBackfill(): Promise<void>;
56
+ invalidateCoverage(): void;
57
+ coverage(): CoverageSnapshot;
58
+ shutdown(deadlineMs?: number): Promise<void>;
59
+ }
@@ -0,0 +1,552 @@
1
+ // v3.6 lite install (spec 2026-07-18 v5 §3·§6b): BackfillCoordinator owns
2
+ // DB-side embedding recovery — nothing else (A′ boundary).
3
+ //
4
+ // Two phases share one eligibility barrier:
5
+ // reconciliation hash-only pass over legacy rows (provenance NULL). Runs in
6
+ // the background after connect; the model is NOT required.
7
+ // backfill re-embeds missing/stale rows via the gate. Automatic
8
+ // backfill (and vector search, enforced by callers) requires
9
+ // model_ready AND reconciliation ∈ {complete, n/a} — the
10
+ // barrier that prevents the "re-embed the whole legacy DB"
11
+ // race when the model becomes ready first (5R must-fix 1).
12
+ //
13
+ // Work queue = the DB itself (a row is "queued" iff it has no valid vector);
14
+ // kick() merely triggers a debounced scan. All reconciliation writes are
15
+ // per-row transactions with a CAS re-check so a foreground embed that lands
16
+ // first is never downgraded (6R note 3).
17
+ const RECON_BATCH = 50;
18
+ const KICK_DEBOUNCE_MS = 200;
19
+ const SWEEP_MS = 5 * 60_000;
20
+ export class BackfillCoordinator {
21
+ deps;
22
+ recon = 'pending';
23
+ reconError;
24
+ reconPromise = null;
25
+ kickTimer = null;
26
+ sweepTimer = null;
27
+ shuttingDown = false;
28
+ scanning = false;
29
+ scanPromise = null;
30
+ rerunRequested = false;
31
+ snapshot = null;
32
+ constructor(deps) {
33
+ this.deps = deps;
34
+ }
35
+ get reconState() { return this.recon; }
36
+ get reconLastError() { return this.reconError; }
37
+ // Shared eligibility barrier (spec §3): vector search AND automatic backfill.
38
+ // 'n/a' (fresh DB — nothing to reconcile) counts as satisfied (6R note 1).
39
+ get eligible() {
40
+ return this.deps.gateIsReady() && (this.recon === 'complete' || this.recon === 'n/a');
41
+ }
42
+ // "Unreconciled vector" = a row that actually HAS a vector and no provenance.
43
+ // Entity metadata is joined against entity_embeddings (beta B3): pre-v3.6
44
+ // non-atomic writes can leave metadata-without-vector and vector-without-
45
+ // metadata split states, and neither must be miscounted as reconciled work.
46
+ countNullWithVector(db) {
47
+ const ent = db.prepare(`SELECT COUNT(*) c FROM entity_embedding_metadata m JOIN entity_embeddings v ON v.rowid = m.rowid
48
+ WHERE m.provenance_state IS NULL`).get();
49
+ const chk = db.prepare(`SELECT COUNT(*) c FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
50
+ WHERE m.provenance_state IS NULL`).get();
51
+ return ent.c + chk.c;
52
+ }
53
+ // Sanitation (beta B3·B4) — runs on EVERY reconciliation entry, even when no
54
+ // NULL rows exist, so profile changes and legacy split states are repaired
55
+ // before vector eligibility can open:
56
+ // 1. entity vectors without metadata (orphans) -> delete vector
57
+ // 2. entity metadata without vector (split state) -> delete metadata (row becomes a backfill target)
58
+ // 3. provenance-stamped rows whose compatibility profile != current
59
+ // (old engine/model profile) -> delete-to-missing
60
+ // 4. chunk metadata stamped but vectorless -> normalize to missing (NULL provenance)
61
+ // Read-only repair check — used by off mode to classify deferred vs n/a
62
+ // without writing (beta 2R residual: split states and old profiles also
63
+ // count as pending repair work, not only provenance-NULL vectors).
64
+ countRepairables(db) {
65
+ const profileId = this.deps.currentProfileId();
66
+ const q = (sql, ...args) => db.prepare(sql).get(...args).c;
67
+ return this.countNullWithVector(db)
68
+ + q(`SELECT COUNT(*) c FROM entity_embeddings WHERE rowid NOT IN (SELECT rowid FROM entity_embedding_metadata)`)
69
+ + q(`SELECT COUNT(*) c FROM entity_embedding_metadata WHERE rowid NOT IN (SELECT rowid FROM entity_embeddings)`)
70
+ + q(`SELECT COUNT(*) c FROM entity_embedding_metadata WHERE provenance_state IS NOT NULL AND profile_id IS NOT ?`, profileId)
71
+ + q(`SELECT COUNT(*) c FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ?`, profileId)
72
+ // stamped chunk metadata whose vector is gone (beta 3R M3)
73
+ + q(`SELECT COUNT(*) c FROM chunk_metadata WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM chunks)`);
74
+ }
75
+ // Batched (beta 2R residual): each batch commits its own transaction and
76
+ // yields the event loop — a large DB or a profile switch must not stall the
77
+ // freshly connected server (the lite-install point of it all).
78
+ async sanitize(db) {
79
+ const profileId = this.deps.currentProfileId();
80
+ let touched = 0;
81
+ const batchTx = db.transaction((fn) => fn());
82
+ // Split-state cleanup, LIMIT-batched with yields (beta 3R non-blocker): a
83
+ // pathological DB must not stall the freshly connected server even here.
84
+ const batchedRun = async (sql) => {
85
+ for (;;) {
86
+ if (this.shuttingDown)
87
+ throw new Error('shutdown during sanitation');
88
+ let changes = 0;
89
+ batchTx(() => { changes = db.prepare(sql).run().changes; });
90
+ touched += changes;
91
+ if (changes < RECON_BATCH)
92
+ return;
93
+ await new Promise(r => setImmediate(r));
94
+ }
95
+ };
96
+ await batchedRun(`DELETE FROM entity_embeddings WHERE rowid IN (
97
+ SELECT rowid FROM entity_embeddings WHERE rowid NOT IN (SELECT rowid FROM entity_embedding_metadata) LIMIT ${RECON_BATCH})`);
98
+ await batchedRun(`DELETE FROM entity_embedding_metadata WHERE rowid IN (
99
+ SELECT rowid FROM entity_embedding_metadata WHERE rowid NOT IN (SELECT rowid FROM entity_embeddings) LIMIT ${RECON_BATCH})`);
100
+ await batchedRun(`UPDATE chunk_metadata SET input_hash = NULL, profile_id = NULL, provenance_state = NULL
101
+ WHERE rowid IN (
102
+ SELECT rowid FROM chunk_metadata WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM chunks) LIMIT ${RECON_BATCH})`);
103
+ // Old-profile rows: row deletes in bounded batches with yields.
104
+ for (;;) {
105
+ if (this.shuttingDown)
106
+ throw new Error('shutdown during sanitation');
107
+ const staleEnt = db.prepare(`SELECT m.rowid FROM entity_embedding_metadata m
108
+ WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ? LIMIT ${RECON_BATCH}`).all(profileId);
109
+ if (staleEnt.length === 0)
110
+ break;
111
+ batchTx(() => {
112
+ for (const r of staleEnt) {
113
+ db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(r.rowid)}`);
114
+ db.prepare(`DELETE FROM entity_embedding_metadata WHERE rowid = ?`).run(r.rowid);
115
+ touched++;
116
+ }
117
+ });
118
+ await new Promise(r => setImmediate(r));
119
+ }
120
+ for (;;) {
121
+ if (this.shuttingDown)
122
+ throw new Error('shutdown during sanitation');
123
+ const staleChunk = db.prepare(`SELECT m.rowid FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
124
+ WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ? LIMIT ${RECON_BATCH}`).all(profileId);
125
+ if (staleChunk.length === 0)
126
+ break;
127
+ batchTx(() => {
128
+ for (const r of staleChunk) {
129
+ db.exec(`DELETE FROM chunks WHERE rowid = ${Number(r.rowid)}`);
130
+ db.prepare(`UPDATE chunk_metadata SET input_hash = NULL, profile_id = NULL, provenance_state = NULL WHERE rowid = ?`).run(r.rowid);
131
+ touched++;
132
+ }
133
+ });
134
+ await new Promise(r => setImmediate(r));
135
+ }
136
+ if (touched > 0)
137
+ console.error(`🧾 provenance sanitation: ${touched} split-state/old-profile rows invalidated`);
138
+ return touched;
139
+ }
140
+ // Single-flight, idempotent. off mode defers (spec §9); fresh DBs go straight
141
+ // to 'n/a'. Row-level errors are fail-closed (vector deleted -> missing);
142
+ // systemic errors transition to 'failed' and keep eligibility shut.
143
+ runReconciliation() {
144
+ if (!this.reconPromise)
145
+ this.reconPromise = this.reconcileOnce();
146
+ return this.reconPromise;
147
+ }
148
+ async reconcileOnce() {
149
+ const db = this.deps.db();
150
+ if (!db) {
151
+ this.recon = 'failed';
152
+ this.reconError = 'db not initialized';
153
+ return;
154
+ }
155
+ if (this.recon === 'complete' || this.recon === 'n/a')
156
+ return;
157
+ if (this.deps.mode() === 'off') {
158
+ // No writes in off mode — classification only (spec §3 N4). "Repair
159
+ // needed" includes split states and old profiles, not just NULL vectors
160
+ // (beta 2R residual).
161
+ this.recon = this.countRepairables(db) > 0 ? 'deferred' : 'n/a';
162
+ this.reconPromise = null; // a later lazy/eager restart may re-run
163
+ return;
164
+ }
165
+ await this.sanitize(db); // split states + old profiles first (beta B3·B4)
166
+ const legacy = this.countNullWithVector(db);
167
+ if (legacy === 0) {
168
+ this.recon = 'n/a';
169
+ this.kick();
170
+ return;
171
+ }
172
+ this.recon = 'running';
173
+ console.error(`🧾 provenance reconciliation: ${legacy} legacy vector rows to examine...`);
174
+ try {
175
+ await this.reconcileEntities(db);
176
+ await this.reconcileChunks(db);
177
+ const remaining = this.countNullWithVector(db);
178
+ if (remaining !== 0)
179
+ throw new Error(`complete invariant violated: ${remaining} unreconciled vectors remain`);
180
+ this.recon = 'complete';
181
+ this.snapshot = null;
182
+ console.error('✅ provenance reconciliation complete');
183
+ this.kick();
184
+ }
185
+ catch (e) {
186
+ if (this.shuttingDown) {
187
+ // Interrupted by shutdown, not broken: next boot resumes the remaining
188
+ // NULL rows (per-row transactions make this crash-safe).
189
+ this.recon = 'pending';
190
+ this.reconPromise = null;
191
+ return;
192
+ }
193
+ this.recon = 'failed';
194
+ this.reconError = e instanceof Error ? e.message : String(e);
195
+ console.error(`❌ reconciliation failed — vector search stays disabled: ${this.reconError}`);
196
+ }
197
+ }
198
+ async reconcileEntities(db) {
199
+ const profileId = this.deps.currentProfileId();
200
+ const allow = this.deps.grandfatherAllowed();
201
+ // Malformed rows (NULL embedding_text / unbuildable current text) are
202
+ // dropped fail-closed AND recorded (spec §6b — beta 1R supplement).
203
+ const recordMalformed = db.prepare(`INSERT INTO embedding_backfill_failures (kind, target_id, input_hash, profile_id, attempts, last_error, updated_at)
204
+ VALUES ('entity', ?, NULL, ?, 0, ?, datetime('now'))
205
+ ON CONFLICT(kind, target_id) DO UPDATE SET last_error = excluded.last_error, updated_at = datetime('now')`);
206
+ for (;;) {
207
+ if (this.shuttingDown)
208
+ throw new Error('shutdown during reconciliation');
209
+ // Vector-join (beta B3): only rows that actually have a vector are
210
+ // grandfather candidates — metadata-without-vector was already removed by
211
+ // sanitize().
212
+ const rows = db.prepare(`SELECT m.rowid, m.entity_id, m.embedding_text FROM entity_embedding_metadata m
213
+ JOIN entity_embeddings v ON v.rowid = m.rowid
214
+ WHERE m.provenance_state IS NULL LIMIT ${RECON_BATCH}`).all();
215
+ if (rows.length === 0)
216
+ return;
217
+ for (const row of rows) {
218
+ const dropTx = db.transaction(() => {
219
+ // CAS re-check inside the transaction: a foreground embed may have
220
+ // replaced this row with a verified vector in the meantime.
221
+ const cur = db.prepare(`SELECT provenance_state FROM entity_embedding_metadata WHERE rowid = ?`).get(row.rowid);
222
+ if (!cur || cur.provenance_state !== null)
223
+ return;
224
+ db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${row.rowid}`);
225
+ db.prepare(`DELETE FROM entity_embedding_metadata WHERE rowid = ?`).run(row.rowid);
226
+ });
227
+ const stampTx = db.transaction((hash) => {
228
+ const cur = db.prepare(`SELECT provenance_state, embedding_text FROM entity_embedding_metadata WHERE rowid = ?`).get(row.rowid);
229
+ if (!cur || cur.provenance_state !== null || cur.embedding_text !== row.embedding_text)
230
+ return;
231
+ db.prepare(`UPDATE entity_embedding_metadata
232
+ SET input_hash = ?, profile_id = ?, provenance_state = 'legacy_assumed' WHERE rowid = ?`).run(hash, profileId, row.rowid);
233
+ });
234
+ // Malformed-row observability (beta 2R residual): the vector drop and
235
+ // the failure record commit in ONE transaction — a crash between the
236
+ // two cannot lose the record.
237
+ const dropAndRecord = db.transaction((reason) => {
238
+ dropTx();
239
+ recordMalformed.run(row.entity_id, profileId, reason);
240
+ });
241
+ try {
242
+ if (!allow) {
243
+ dropTx();
244
+ continue;
245
+ }
246
+ if (row.embedding_text === null) {
247
+ dropAndRecord('reconciliation: stored embedding_text is NULL');
248
+ continue;
249
+ }
250
+ const currentHash = this.deps.buildEntityInputHash(row.entity_id);
251
+ if (currentHash === null) {
252
+ dropAndRecord('reconciliation: current entity text unbuildable (malformed observations?)');
253
+ continue;
254
+ }
255
+ const storedHash = this.deps.hashEntityText(row.embedding_text);
256
+ if (storedHash === currentHash)
257
+ stampTx(currentHash);
258
+ else
259
+ dropTx(); // stale (e.g. old deleteObservations residue) -> missing, normal backfill target
260
+ }
261
+ catch (rowErr) {
262
+ // Row-level isolation: try to invalidate; if even that fails, escalate.
263
+ try {
264
+ dropTx();
265
+ }
266
+ catch {
267
+ throw new Error(`row-level reconciliation failure on ${row.entity_id}: ${rowErr instanceof Error ? rowErr.message : rowErr}`);
268
+ }
269
+ }
270
+ }
271
+ await new Promise(r => setImmediate(r));
272
+ }
273
+ }
274
+ async reconcileChunks(db) {
275
+ const profileId = this.deps.currentProfileId();
276
+ const allow = this.deps.grandfatherAllowed();
277
+ for (;;) {
278
+ if (this.shuttingDown)
279
+ throw new Error('shutdown during reconciliation');
280
+ const rows = db.prepare(`SELECT m.rowid, m.text FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
281
+ WHERE m.provenance_state IS NULL LIMIT ${RECON_BATCH}`).all();
282
+ if (rows.length === 0)
283
+ return;
284
+ for (const row of rows) {
285
+ const dropTx = db.transaction(() => {
286
+ const cur = db.prepare(`SELECT provenance_state FROM chunk_metadata WHERE rowid = ?`)
287
+ .get(row.rowid);
288
+ if (!cur || cur.provenance_state !== null)
289
+ return;
290
+ // Vector removed; provenance stays NULL. The barrier only protects
291
+ // VECTOR-BEARING NULL rows — vectorless rows are always legitimate
292
+ // backfill targets (spec §6c "벡터 없음").
293
+ db.exec(`DELETE FROM chunks WHERE rowid = ${row.rowid}`);
294
+ db.prepare(`UPDATE chunk_metadata SET input_hash = NULL, profile_id = NULL WHERE rowid = ?`)
295
+ .run(row.rowid);
296
+ });
297
+ try {
298
+ if (!allow || row.text === null) {
299
+ dropTx();
300
+ continue;
301
+ }
302
+ // chunk_metadata.text is never updated in place (runtime code is
303
+ // INSERT/DELETE only — regression-locked in migration12 test), so the
304
+ // stored vector's input IS the current text (spec §6b).
305
+ const hash = this.deps.chunkInputHash(row.text);
306
+ const stampTx = db.transaction(() => {
307
+ const cur = db.prepare(`SELECT provenance_state FROM chunk_metadata WHERE rowid = ?`)
308
+ .get(row.rowid);
309
+ if (!cur || cur.provenance_state !== null)
310
+ return;
311
+ db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'legacy_assumed'
312
+ WHERE rowid = ?`).run(hash, profileId, row.rowid);
313
+ });
314
+ stampTx();
315
+ }
316
+ catch (rowErr) {
317
+ try {
318
+ dropTx();
319
+ }
320
+ catch {
321
+ throw new Error(`row-level chunk reconciliation failure on rowid ${row.rowid}: ${rowErr instanceof Error ? rowErr.message : rowErr}`);
322
+ }
323
+ }
324
+ }
325
+ await new Promise(r => setImmediate(r));
326
+ }
327
+ }
328
+ // Debounced automatic-backfill trigger. No-op unless the shared barrier is
329
+ // open; disabled mode never scans, never records failures (spec §3 N4).
330
+ kick() {
331
+ if (this.shuttingDown || this.deps.gateIsDisabled())
332
+ return;
333
+ if (!this.eligible)
334
+ return;
335
+ // A kick landing during a running scan must not be LOST (beta 4R M2): the
336
+ // running scan works from a snapshot and may miss rows created after it —
337
+ // record the wake-up and re-run once the current scan settles, without
338
+ // overwriting the tracked scanPromise.
339
+ if (this.scanPromise || this.scanning) {
340
+ this.rerunRequested = true;
341
+ return;
342
+ }
343
+ if (this.kickTimer)
344
+ return;
345
+ this.kickTimer = setTimeout(() => {
346
+ this.kickTimer = null;
347
+ // Tracked so shutdown can await the whole scan, not just poll `scanning`
348
+ // (beta 2R B2).
349
+ this.scanPromise = this.scanAndBackfill()
350
+ .catch(e => console.error(`⚠️ backfill scan error: ${e instanceof Error ? e.message : e}`))
351
+ .finally(() => {
352
+ this.scanPromise = null;
353
+ if (this.rerunRequested && !this.shuttingDown) {
354
+ this.rerunRequested = false;
355
+ this.kick();
356
+ }
357
+ });
358
+ }, KICK_DEBOUNCE_MS);
359
+ this.kickTimer.unref?.();
360
+ }
361
+ sweepStart() {
362
+ if (this.deps.mode() === 'off')
363
+ return;
364
+ if (this.sweepTimer)
365
+ return;
366
+ this.sweepTimer = setInterval(() => this.kick(), SWEEP_MS);
367
+ this.sweepTimer.unref?.();
368
+ }
369
+ // Automatic backfill (spec §6c): targets = no vector ∪ input-hash mismatch ∪
370
+ // compatibility-profile mismatch — but NEVER a vector-bearing provenance-NULL
371
+ // row (those belong to reconciliation; 5R barrier). Chunks first (hybridSearch
372
+ // coverage recovers before entity/graph quality). Failures are recorded with
373
+ // a per-target attempts cap; 3 consecutive DISTINCT-target failures abort the
374
+ // batch as a systemic model problem (the gate's retry policy owns recovery).
375
+ async scanAndBackfill() {
376
+ if (this.scanning)
377
+ return;
378
+ this.scanning = true;
379
+ try {
380
+ const db = this.deps.db();
381
+ if (!db || !this.eligible)
382
+ return;
383
+ const profileId = this.deps.currentProfileId();
384
+ const capStmt = db.prepare(`SELECT attempts, input_hash, profile_id FROM embedding_backfill_failures WHERE kind = ? AND target_id = ?`);
385
+ const failStmt = db.prepare(`INSERT INTO embedding_backfill_failures (kind, target_id, input_hash, profile_id, attempts, last_error, updated_at)
386
+ VALUES (?, ?, ?, ?, 1, ?, datetime('now'))
387
+ ON CONFLICT(kind, target_id) DO UPDATE SET
388
+ attempts = attempts + 1, input_hash = excluded.input_hash,
389
+ profile_id = excluded.profile_id, last_error = excluded.last_error, updated_at = datetime('now')`);
390
+ const clearStmt = db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = ? AND target_id = ?`);
391
+ const FAIL_CAP = 5;
392
+ let consecutive = 0;
393
+ let lastFailedTarget = null;
394
+ const shouldSkip = (kind, targetId, currentHash) => {
395
+ const f = capStmt.get(kind, targetId);
396
+ if (!f)
397
+ return false;
398
+ // Reset-on-change: content or profile moved on -> the failure record is stale.
399
+ if (f.input_hash !== currentHash || f.profile_id !== profileId) {
400
+ clearStmt.run(kind, targetId);
401
+ return false;
402
+ }
403
+ return f.attempts >= FAIL_CAP;
404
+ };
405
+ const recordResult = async (kind, targetId, currentHash, run) => {
406
+ try {
407
+ const ok = await run();
408
+ // beta 2R B2: an inference can outlive both settle deadlines. After
409
+ // ANY await, no DB statement may run once shutdown started — the DB
410
+ // handle may already be closed.
411
+ if (this.shuttingDown)
412
+ return 'abort';
413
+ if (ok) {
414
+ clearStmt.run(kind, targetId);
415
+ consecutive = 0;
416
+ return 'ok';
417
+ }
418
+ failStmt.run(kind, targetId, currentHash, profileId, 'reembed returned false');
419
+ }
420
+ catch (e) {
421
+ if (this.shuttingDown)
422
+ return 'abort';
423
+ failStmt.run(kind, targetId, currentHash, profileId, e instanceof Error ? e.message.slice(0, 300) : String(e));
424
+ }
425
+ if (lastFailedTarget !== targetId) {
426
+ consecutive++;
427
+ lastFailedTarget = targetId;
428
+ }
429
+ return consecutive >= 3 ? 'abort' : 'fail';
430
+ };
431
+ // Phase 1: chunks.
432
+ const chunkRows = db.prepare(`SELECT m.rowid, m.text, m.input_hash, m.profile_id, m.provenance_state,
433
+ EXISTS(SELECT 1 FROM chunks v WHERE v.rowid = m.rowid) AS has_vec
434
+ FROM chunk_metadata m WHERE m.text IS NOT NULL`).all();
435
+ let processed = 0;
436
+ for (const row of chunkRows) {
437
+ if (this.shuttingDown || !this.eligible)
438
+ return;
439
+ const currentHash = this.deps.chunkInputHash(row.text);
440
+ const isTarget = !row.has_vec
441
+ || (row.provenance_state !== null && (row.profile_id !== profileId || row.input_hash !== currentHash));
442
+ if (!isTarget)
443
+ continue;
444
+ const targetId = String(row.rowid);
445
+ if (shouldSkip('chunk', targetId, currentHash))
446
+ continue;
447
+ const outcome = await recordResult('chunk', targetId, currentHash, () => this.deps.reembedChunk(row.rowid));
448
+ if (outcome === 'abort') {
449
+ console.error('⚠️ backfill aborted: 3 consecutive distinct-target failures (systemic)');
450
+ return;
451
+ }
452
+ if (++processed % 8 === 0)
453
+ await new Promise(r => setImmediate(r));
454
+ }
455
+ // Phase 2: entities.
456
+ const entityRows = db.prepare(`SELECT e.id, m.input_hash, m.profile_id, m.provenance_state, m.rowid AS meta_rowid,
457
+ CASE WHEN m.rowid IS NOT NULL AND EXISTS(SELECT 1 FROM entity_embeddings v WHERE v.rowid = m.rowid) THEN 1 ELSE 0 END AS has_vec
458
+ FROM entities e LEFT JOIN entity_embedding_metadata m ON m.entity_id = e.id`).all();
459
+ for (const row of entityRows) {
460
+ if (this.shuttingDown || !this.eligible)
461
+ return;
462
+ const currentHash = this.deps.buildEntityInputHash(row.id);
463
+ if (currentHash === null)
464
+ continue; // malformed entity: leave to explicit repair
465
+ // Vector existence checked explicitly (beta B3): metadata alone is not
466
+ // proof of an embedded row.
467
+ const isTarget = row.meta_rowid === null || !row.has_vec
468
+ || (row.provenance_state !== null && (row.profile_id !== profileId || row.input_hash !== currentHash));
469
+ if (!isTarget)
470
+ continue;
471
+ if (shouldSkip('entity', row.id, currentHash))
472
+ continue;
473
+ const outcome = await recordResult('entity', row.id, currentHash, () => this.deps.reembedEntity(row.id));
474
+ if (outcome === 'abort') {
475
+ console.error('⚠️ backfill aborted: 3 consecutive distinct-target failures (systemic)');
476
+ return;
477
+ }
478
+ if (++processed % 8 === 0)
479
+ await new Promise(r => setImmediate(r));
480
+ }
481
+ if (processed > 0) {
482
+ this.snapshot = null;
483
+ console.error(`✅ backfill pass complete (${processed} rows examined for re-embedding)`);
484
+ }
485
+ }
486
+ finally {
487
+ this.scanning = false;
488
+ }
489
+ }
490
+ invalidateCoverage() { this.snapshot = null; }
491
+ coverage() {
492
+ const db = this.deps.db();
493
+ if (!db)
494
+ return { chunk: { total: 0, embedded: 0, verified: 0, legacy_assumed: 0 },
495
+ entity: { total: 0, embedded: 0, verified: 0, legacy_assumed: 0 } };
496
+ if (this.snapshot)
497
+ return this.snapshot;
498
+ const chunkTotal = db.prepare(`SELECT COUNT(*) c FROM chunk_metadata`).get().c;
499
+ const chunkBy = db.prepare(`SELECT m.provenance_state s, COUNT(*) c FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
500
+ GROUP BY m.provenance_state`).all();
501
+ const entTotal = db.prepare(`SELECT COUNT(*) c FROM entities`).get().c;
502
+ // Vector-join (beta B3): metadata without an actual vector is missing, not embedded.
503
+ const entBy = db.prepare(`SELECT m.provenance_state s, COUNT(*) c FROM entity_embedding_metadata m
504
+ JOIN entity_embeddings v ON v.rowid = m.rowid GROUP BY m.provenance_state`).all();
505
+ const pick = (rows, s) => rows.find(r => r.s === s)?.c ?? 0;
506
+ // Searchable = verified + legacy_assumed. NULL rows (pre-reconciliation or
507
+ // deferred in off mode) are NOT counted as embedded (6R note 4).
508
+ const chunkVerified = pick(chunkBy, 'verified');
509
+ const chunkLegacy = pick(chunkBy, 'legacy_assumed');
510
+ const entVerified = pick(entBy, 'verified');
511
+ const entLegacy = pick(entBy, 'legacy_assumed');
512
+ this.snapshot = {
513
+ chunk: { total: chunkTotal, embedded: chunkVerified + chunkLegacy, verified: chunkVerified, legacy_assumed: chunkLegacy },
514
+ entity: { total: entTotal, embedded: entVerified + entLegacy, verified: entVerified, legacy_assumed: entLegacy },
515
+ };
516
+ return this.snapshot;
517
+ }
518
+ // spec §3 shutdown order (beta B1): block new batches, let the current row
519
+ // transaction finish, settle BOTH the backfill loop and an in-flight
520
+ // reconciliation pass, then the caller closes the DB — nothing may touch a
521
+ // closed handle afterwards.
522
+ async shutdown(deadlineMs = 5000) {
523
+ this.shuttingDown = true;
524
+ if (this.kickTimer) {
525
+ clearTimeout(this.kickTimer);
526
+ this.kickTimer = null;
527
+ }
528
+ if (this.sweepTimer) {
529
+ clearInterval(this.sweepTimer);
530
+ this.sweepTimer = null;
531
+ }
532
+ const deadline = Date.now() + deadlineMs;
533
+ if (this.reconPromise) {
534
+ await Promise.race([
535
+ this.reconPromise.catch(() => { }),
536
+ new Promise(r => setTimeout(r, Math.max(0, deadline - Date.now()))),
537
+ ]);
538
+ }
539
+ if (this.scanPromise) {
540
+ await Promise.race([
541
+ this.scanPromise,
542
+ new Promise(r => setTimeout(r, Math.max(0, deadline - Date.now()))),
543
+ ]);
544
+ }
545
+ while (this.scanning && Date.now() < deadline) {
546
+ await new Promise(r => setTimeout(r, 20));
547
+ }
548
+ // Past the deadline a scan may still be pending on a slow inference — the
549
+ // shuttingDown guards inside recordResult() make any late completion a
550
+ // DB-write no-op (beta 2R B2).
551
+ }
552
+ }
@@ -0,0 +1,68 @@
1
+ export type ModelState = 'disabled' | 'idle' | 'loading' | 'downloading' | 'ready' | 'failed';
2
+ export type EmbedPriority = 'interactive' | 'bulk' | 'backfill';
3
+ export type EmbedFn = (text: string, dims: number, isQuery: boolean) => Promise<Float32Array>;
4
+ export declare class GateDisabledError extends Error {
5
+ readonly code = "EMBEDDINGS_DISABLED";
6
+ readonly state = "disabled";
7
+ constructor();
8
+ }
9
+ export declare class GateNotReadyError extends Error {
10
+ readonly state: ModelState;
11
+ readonly retryAfterMs?: number | undefined;
12
+ readonly code = "MODEL_NOT_READY";
13
+ constructor(state: ModelState, retryAfterMs?: number | undefined);
14
+ }
15
+ export declare class TerminalConfigError extends Error {
16
+ readonly terminal = true;
17
+ constructor(message: string);
18
+ }
19
+ export interface GateOptions {
20
+ mode: 'lazy' | 'eager' | 'off';
21
+ loadModel: () => Promise<EmbedFn>;
22
+ onReady?: () => void;
23
+ onStateChange?: (s: ModelState) => void;
24
+ backoffMs?: number[];
25
+ }
26
+ export declare class EmbeddingGate {
27
+ private readonly opts;
28
+ private state;
29
+ private embedFn;
30
+ private startPromise;
31
+ private queue;
32
+ private running;
33
+ private seq;
34
+ private generation;
35
+ private shuttingDown;
36
+ private retryTimer;
37
+ private attempt;
38
+ private failedInputs;
39
+ private demotions;
40
+ private terminalFailure;
41
+ readonly abort: AbortController;
42
+ private readySince?;
43
+ private lastError?;
44
+ private retryAt?;
45
+ private readonly backoff;
46
+ constructor(opts: GateOptions);
47
+ get status(): {
48
+ state: ModelState;
49
+ readySince: string | undefined;
50
+ lastError: string | undefined;
51
+ retryAt: string | undefined;
52
+ };
53
+ get isReady(): boolean;
54
+ get isDisabled(): boolean;
55
+ private setState;
56
+ start(): Promise<void>;
57
+ markDownloading(): void;
58
+ private loadOnce;
59
+ private scheduleRetry;
60
+ embed(text: string, o: {
61
+ dims?: number;
62
+ isQuery?: boolean;
63
+ priority: EmbedPriority;
64
+ }): Promise<Float32Array>;
65
+ private pump;
66
+ get loadInFlight(): boolean;
67
+ shutdown(deadlineMs?: number): Promise<void>;
68
+ }