@mxalbert/context-mode 2.0.1 → 2.0.3

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.
package/build/store.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * you need EXACT text later — not summaries.
9
9
  */
10
10
  var _a;
11
- import { loadDatabase, applyWALPragmas, closeDB, cleanOrphanedWALFiles, withRetry, deleteDBFiles, isSQLiteCorruptionError } from "./db-base.js";
11
+ import { loadDatabase, applyWALPragmas, closeDB, cleanOrphanedWALFiles, withRetry, deleteDBFiles, isSQLiteCorruptionError, logDbError, extractErrorCode, errorMessage } from "./db-base.js";
12
12
  import { readFileSync, readdirSync, unlinkSync, existsSync, statSync, openSync, fstatSync, closeSync } from "node:fs";
13
13
  import { createHash } from "node:crypto";
14
14
  import { tmpdir } from "node:os";
@@ -158,24 +158,30 @@ export function cleanupStaleDBs() {
158
158
  catch { /* ignore readdir errors */ }
159
159
  return cleaned;
160
160
  }
161
- /**
162
- * Check if a PID is still alive (not a zombie holding a WAL lock).
163
- * Returns true if the process exists, false if it's dead.
164
- */
165
- function isProcessAlive(pid) {
166
- try {
167
- process.kill(pid, 0);
168
- return true;
169
- }
170
- catch {
171
- return false;
172
- }
173
- }
174
161
  /**
175
162
  * Clean up stale per-project content store DBs older than maxAgeDays.
176
- * Scans the given directory for *.db files and checks mtime.
177
- * Also detects zombie processes holding WAL locksif a WAL file exists
178
- * but the owning PID is dead, the DB files are cleaned up regardless of age.
163
+ * Scans the given directory for *.db files and unlinks each (plus its
164
+ * -wal/-shm sidecars) when its EFFECTIVE last-write timethe newer of the
165
+ * main .db mtime and a non-empty -wal mtime exceeds the cutoff.
166
+ *
167
+ * Why the -wal mtime participates: in WAL mode commits touch only the -wal
168
+ * file; the main .db mtime advances at checkpoint. A main-only check can
169
+ * delete an actively-used store that simply hasn't checkpointed within the
170
+ * window. Conversely, a non-empty -wal older than some threshold is NOT
171
+ * proof the owning process is dead — a live-but-idle connection (a session
172
+ * quiet over lunch) is indistinguishable from a crashed process by mtime
173
+ * alone. The pre-fix heuristic deleted such DBs unconditionally after 1h,
174
+ * which on macOS unlinks the files under an open connection and surfaces
175
+ * as disk I/O error (SQLITE_IOERR_VNODE) on every later write — no amount
176
+ * of retrying recovers an invalidated fd.
177
+ *
178
+ * Contract: a -wal can only EXTEND a DB's life (its mtime counts as the
179
+ * latest write), never shorten it. A WAL never triggers deletion on its
180
+ * own — the way the old 1-hour zombie rule did — and once the whole DB
181
+ * (main + non-empty wal) is beyond maxAgeDays, the caller's retention
182
+ * policy owns the consequences. Callers that cannot tolerate deleting a
183
+ * possibly-live DB (the per-platform content dir — see getStore() in
184
+ * src/server.ts) must not call this with a nonzero maxAgeDays.
179
185
  */
180
186
  export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
181
187
  let cleaned = 0;
@@ -187,26 +193,18 @@ export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
187
193
  for (const file of files) {
188
194
  try {
189
195
  const filePath = join(contentDir, file);
190
- const mtime = statSync(filePath).mtimeMs;
191
- let shouldClean = mtime < cutoff;
192
- // Detect zombie processes holding WAL locks:
193
- // If a WAL file exists, try to read the WAL header to extract the PID.
194
- // WAL files from dead processes can block new connections.
195
- if (!shouldClean) {
196
- const walPath = filePath + "-wal";
197
- if (existsSync(walPath)) {
198
- try {
199
- const walStat = statSync(walPath);
200
- // If WAL file is non-empty and DB hasn't been modified in >1 hour,
201
- // the owning process may be dead — check via mtime staleness
202
- if (walStat.size > 0 && (Date.now() - walStat.mtimeMs) > 3600_000) {
203
- shouldClean = true;
204
- }
205
- }
206
- catch { /* ignore WAL check errors */ }
196
+ // Effective last-write: newer of main .db and non-empty -wal. A fresh
197
+ // WAL is evidence of a live (or recently-live) connection — it can
198
+ // only push the cutoff later, never mark a fresh DB stale.
199
+ let effectiveMs = statSync(filePath).mtimeMs;
200
+ try {
201
+ const walStat = statSync(filePath + "-wal");
202
+ if (walStat.size > 0 && walStat.mtimeMs > effectiveMs) {
203
+ effectiveMs = walStat.mtimeMs;
207
204
  }
208
205
  }
209
- if (shouldClean) {
206
+ catch { /* no -wal — main mtime stands */ }
207
+ if (effectiveMs < cutoff) {
210
208
  for (const suffix of ["", "-wal", "-shm"]) {
211
209
  try {
212
210
  unlinkSync(filePath + suffix);
@@ -302,6 +300,65 @@ function findMinSpan(positionLists) {
302
300
  }
303
301
  return minSpan;
304
302
  }
303
+ // ─────────────────────────────────────────────────────────
304
+ // Store error handling (v2.0.2 — disk-I/O hardening parity with db-base)
305
+ // ─────────────────────────────────────────────────────────
306
+ /**
307
+ * Prefix for tool-visible ContentStore failures. Store failures used to
308
+ * surface as the raw driver message — for SQLITE_IOERR exactly
309
+ * "disk I/O error", with no op, no DB path, no SQLite code, and nothing
310
+ * in any log (the store layer's catches were all silent). Every store
311
+ * open/write failure now (a) logs to stderr via db-base's logDbError
312
+ * (same `[context-mode:db]` prefix, 30s dedupe, 256-key cap) and
313
+ * (b) rethrows with this prefix plus op, path, and code so the isError
314
+ * text the MCP client sees is actionable.
315
+ */
316
+ const STORE_ERROR_PREFIX = "[context-mode:store]";
317
+ /**
318
+ * Symbol marking Errors that this layer already reported via logDbError
319
+ * (every storeFailure product carries it). Symbol.for so the flag survives
320
+ * module re-imports, mirroring db-base's global-symbol pattern.
321
+ */
322
+ const kDbErrorLogged = Symbol.for("__context_mode_db_error_logged__");
323
+ /** True when `err` was already logged + enriched by an inner store op —
324
+ * outer catches must not emit a second line for the same failure. */
325
+ function isDbErrorAlreadyLogged(err) {
326
+ try {
327
+ return (typeof err === "object" &&
328
+ err !== null &&
329
+ err[kDbErrorLogged] === true);
330
+ }
331
+ catch {
332
+ return false;
333
+ }
334
+ }
335
+ /**
336
+ * Log `err` for store operation `op` and return an Error whose message
337
+ * carries the op, the DB file path, and the SQLite code (when present):
338
+ * `[context-mode:store] <op> failed on <dbPath>: <message> (<code>)`.
339
+ * The returned Error preserves the original `.code` and carries a
340
+ * non-enumerable kDbErrorLogged marker: when it flows through another
341
+ * storeFailure call (e.g. a failed stale re-index surfacing through an
342
+ * outer wrapper) it is returned as-is, so one underlying failure produces
343
+ * exactly one `[context-mode:db]` line, under the op that owns it.
344
+ * `base` replaces `<message>` for callers that must preserve an existing
345
+ * wrap message (the corruption delete-and-recreate retry). Callers throw
346
+ * the result. Logging goes through db-base's logDbError (stderr), never
347
+ * console.log, and logging itself never throws.
348
+ */
349
+ function storeFailure(op, err, dbPath, base) {
350
+ if (isDbErrorAlreadyLogged(err))
351
+ return err;
352
+ logDbError(op, err, dbPath);
353
+ const message = errorMessage(err);
354
+ const code = extractErrorCode(err);
355
+ const codeSuffix = code ? ` (${code})` : "";
356
+ const enriched = new Error(`${STORE_ERROR_PREFIX} ${op} failed on ${dbPath}: ${base ?? message}${codeSuffix}`);
357
+ if (code)
358
+ enriched.code = code;
359
+ Object.defineProperty(enriched, kDbErrorLogged, { value: true, enumerable: false });
360
+ return enriched;
361
+ }
305
362
  export class ContentStore {
306
363
  #db;
307
364
  #dbPath;
@@ -367,31 +424,55 @@ export class ContentStore {
367
424
  this.#dbPath =
368
425
  dbPath ?? join(tmpdir(), `context-mode-${process.pid}.db`);
369
426
  cleanOrphanedWALFiles(this.#dbPath);
427
+ // One open attempt: create the connection and apply WAL pragmas.
428
+ // Wrapped in withRetry below so a transient SQLITE_IOERR / "disk I/O
429
+ // error" on open is retried with the same exponential backoff as
430
+ // SQLITE_BUSY (db-base, v1.0.187 hardening). Corruption signatures are
431
+ // never retried by withRetry — they rethrow for the recovery path.
432
+ const openOnce = () => {
433
+ const db = new Database(this.#dbPath, { timeout: 30000 });
434
+ applyWALPragmas(db);
435
+ return db;
436
+ };
370
437
  let db;
371
438
  try {
372
- db = new Database(this.#dbPath, { timeout: 30000 });
373
- applyWALPragmas(db);
439
+ db = withRetry(openOnce);
374
440
  }
375
441
  catch (err) {
376
442
  const msg = err instanceof Error ? err.message : String(err);
377
443
  if (isSQLiteCorruptionError(msg)) {
444
+ // Surface the corruption + delete-and-recreate instead of silently
445
+ // swapping the file — users lose indexed content and deserve a trace.
446
+ logDbError("store.open", err, this.#dbPath);
378
447
  deleteDBFiles(this.#dbPath);
379
448
  cleanOrphanedWALFiles(this.#dbPath);
380
449
  try {
381
- db = new Database(this.#dbPath, { timeout: 30000 });
382
- applyWALPragmas(db);
450
+ db = withRetry(openOnce);
383
451
  }
384
452
  catch (retryErr) {
385
- throw new Error(`Failed to create fresh DB after deleting corrupt file: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
453
+ // Preserve the existing corruption-retry wrap message, enriched
454
+ // with op + path + code so the tool error is never bare.
455
+ throw storeFailure("store.open", retryErr, this.#dbPath, `Failed to create fresh DB after deleting corrupt file: ${errorMessage(retryErr)}`);
386
456
  }
387
457
  }
388
458
  else {
389
- throw err;
459
+ // Transient-retry exhaustion or a non-transient open failure —
460
+ // either way the caller gets op + path + code, and stderr gets a
461
+ // [context-mode:db] line (deduped).
462
+ throw storeFailure("store.open", err, this.#dbPath);
390
463
  }
391
464
  }
392
465
  this.#db = db;
393
- this.#initSchema();
394
- this.#prepareStatements();
466
+ try {
467
+ this.#initSchema();
468
+ this.#prepareStatements();
469
+ }
470
+ catch (err) {
471
+ // The connection is already open here, so there is no retry contract
472
+ // for schema init — but the failure must not be silent: log the
473
+ // [context-mode:db] line and rethrow with op + path + code context.
474
+ throw storeFailure("store.initSchema", err, this.#dbPath);
475
+ }
395
476
  }
396
477
  /** Delete this session's DB files. Call on process exit. */
397
478
  cleanup() {
@@ -781,7 +862,16 @@ export class ContentStore {
781
862
  // Stale detection: store file_path + SHA-256 for file-backed sources
782
863
  const filePath = path ?? undefined;
783
864
  const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
784
- return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
865
+ // withRetry absorbs transient SQLITE_BUSY/SQLITE_IOERR with exponential
866
+ // backoff; on exhaustion the failure is logged ([context-mode:db]) and
867
+ // rethrown with op + path + code so the tool error is never a bare
868
+ // "disk I/O error".
869
+ try {
870
+ return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
871
+ }
872
+ catch (err) {
873
+ throw storeFailure("store.index", err, this.#dbPath);
874
+ }
785
875
  }
786
876
  // ── Index Directory (#687) ──
787
877
  /**
@@ -836,10 +926,25 @@ export class ContentStore {
836
926
  */
837
927
  indexPlainText(content, source, linesPerChunk = 20, attribution, maxChunkBytes = MAX_CHUNK_BYTES) {
838
928
  if (!content || content.trim().length === 0) {
839
- return this.#insertChunks([], source, "", undefined, undefined, attribution);
929
+ // Empty fast path still writes to the DB (a 0-chunk source row for
930
+ // dedup bookkeeping), so it gets the same retry + failure-context
931
+ // contract as the regular write path instead of bypassing both.
932
+ // indexJSON("") and its 0-chunk fallbacks delegate to this method,
933
+ // which routes them through the same contract.
934
+ try {
935
+ return withRetry(() => this.#insertChunks([], source, "", undefined, undefined, attribution));
936
+ }
937
+ catch (err) {
938
+ throw storeFailure("store.indexPlainText", err, this.#dbPath);
939
+ }
840
940
  }
841
941
  const chunks = this.#chunkPlainText(content, linesPerChunk, maxChunkBytes);
842
- return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
942
+ try {
943
+ return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
944
+ }
945
+ catch (err) {
946
+ throw storeFailure("store.indexPlainText", err, this.#dbPath);
947
+ }
843
948
  }
844
949
  // ── Index JSON ──
845
950
  /**
@@ -865,7 +970,12 @@ export class ContentStore {
865
970
  if (chunks.length === 0) {
866
971
  return this.indexPlainText(content, source, undefined, attribution, maxChunkBytes);
867
972
  }
868
- return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
973
+ try {
974
+ return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
975
+ }
976
+ catch (err) {
977
+ throw storeFailure("store.indexJSON", err, this.#dbPath);
978
+ }
869
979
  }
870
980
  // ── Shared DB Insertion ──
871
981
  /**
@@ -970,7 +1080,12 @@ export class ContentStore {
970
1080
  stmt = this.#stmtSearchPorter;
971
1081
  params = [sanitized, limit];
972
1082
  }
973
- return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
1083
+ try {
1084
+ return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
1085
+ }
1086
+ catch (err) {
1087
+ throw storeFailure("store.search", err, this.#dbPath);
1088
+ }
974
1089
  }
975
1090
  // ── Trigram Search (Layer 2) ──
976
1091
  searchTrigram(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
@@ -999,7 +1114,12 @@ export class ContentStore {
999
1114
  stmt = this.#stmtSearchTrigram;
1000
1115
  params = [sanitized, limit];
1001
1116
  }
1002
- return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
1117
+ try {
1118
+ return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
1119
+ }
1120
+ catch (err) {
1121
+ throw storeFailure("store.searchTrigram", err, this.#dbPath);
1122
+ }
1003
1123
  }
1004
1124
  // ── Fuzzy Correction (Layer 3) ──
1005
1125
  fuzzyCorrect(query) {
@@ -1014,7 +1134,17 @@ export class ContentStore {
1014
1134
  return cached;
1015
1135
  }
1016
1136
  const maxDist = maxEditDistance(word.length);
1017
- const candidates = this.#stmtFuzzyVocab.all(word.length - maxDist, word.length + maxDist);
1137
+ let candidates;
1138
+ try {
1139
+ // The vocab lookup is a read whose raw driver errors used to
1140
+ // propagate unclassified through the search fallback — same retry +
1141
+ // [context-mode:db] logging + op/path/code context as the rest of
1142
+ // the store.
1143
+ candidates = withRetry(() => this.#stmtFuzzyVocab.all(word.length - maxDist, word.length + maxDist));
1144
+ }
1145
+ catch (err) {
1146
+ throw storeFailure("store.fuzzyCorrect", err, this.#dbPath);
1147
+ }
1018
1148
  let bestWord = null;
1019
1149
  let bestDist = maxDist + 1;
1020
1150
  let exactMatch = false;
@@ -1176,7 +1306,16 @@ export class ContentStore {
1176
1306
  */
1177
1307
  #refreshStaleSources() {
1178
1308
  this.lastRefreshCount = 0;
1179
- const sources = this.#db.prepare("SELECT label, file_path, content_hash, indexed_at FROM sources WHERE file_path IS NOT NULL").all();
1309
+ let sources;
1310
+ try {
1311
+ // Staleness scan is a read, but it runs ahead of every search — a
1312
+ // transient IOERR here gets the same retry, and exhaustion the same
1313
+ // op/path/code context, as the write paths.
1314
+ sources = withRetry(() => this.#db.prepare("SELECT label, file_path, content_hash, indexed_at FROM sources WHERE file_path IS NOT NULL").all());
1315
+ }
1316
+ catch (err) {
1317
+ throw storeFailure("store.refreshStaleSources", err, this.#dbPath);
1318
+ }
1180
1319
  for (const src of sources) {
1181
1320
  try {
1182
1321
  if (!existsSync(src.file_path))
@@ -1216,20 +1355,46 @@ export class ContentStore {
1216
1355
  this.index({ content: newContent, path: src.file_path, source: src.label });
1217
1356
  this.lastRefreshCount++;
1218
1357
  }
1219
- catch {
1220
- // Graceful degradation — never break search for stale detection
1358
+ catch (err) {
1359
+ // Graceful degradation — never break search for stale detection.
1360
+ // But the failure (a failed re-index write, a vanished file) is no
1361
+ // longer silent. Errors already enriched by an inner store op carry
1362
+ // the kDbErrorLogged marker: their [context-mode:db] line was
1363
+ // already emitted under the owning op (e.g. store.index) — logging
1364
+ // again here would emit a second, code-stripped line for the same
1365
+ // failure.
1366
+ if (!isDbErrorAlreadyLogged(err)) {
1367
+ logDbError("store.refreshStaleSources", err, this.#dbPath);
1368
+ }
1221
1369
  }
1222
1370
  }
1223
1371
  }
1224
1372
  // ── Sources ──
1225
1373
  getSourceMeta(label) {
1226
- const row = this.#stmtSourceMeta.get(label);
1374
+ // Raw stmt.get() — the ctx_fetch_and_index cache probe (server.ts
1375
+ // fetchOneUrl) runs through here BEFORE any fetch/indexing, so an
1376
+ // IOERR used to reject the whole batch as a bare driver error with
1377
+ // nothing in any log. Same retry + context contract as the writes.
1378
+ let row;
1379
+ try {
1380
+ row = withRetry(() => this.#stmtSourceMeta.get(label));
1381
+ }
1382
+ catch (err) {
1383
+ throw storeFailure("store.getSourceMeta", err, this.#dbPath);
1384
+ }
1227
1385
  if (!row)
1228
1386
  return null;
1229
1387
  return { label: row.label, chunkCount: row.chunk_count, codeChunkCount: row.code_chunk_count, indexedAt: row.indexed_at, filePath: row.file_path ?? null, contentHash: row.content_hash ?? null };
1230
1388
  }
1231
1389
  listSources() {
1232
- return this.#stmtListSources.all();
1390
+ // ctx_search's no-results path lists sources here — same retry +
1391
+ // context contract as the write paths.
1392
+ try {
1393
+ return withRetry(() => this.#stmtListSources.all());
1394
+ }
1395
+ catch (err) {
1396
+ throw storeFailure("store.listSources", err, this.#dbPath);
1397
+ }
1233
1398
  }
1234
1399
  /**
1235
1400
  * Aggregate snapshot of the persistent content store. Returns total
@@ -1238,9 +1403,15 @@ export class ContentStore {
1238
1403
  * round trip instead of inferring it from snapshot diffs.
1239
1404
  */
1240
1405
  getIndexState() {
1241
- const row = this.#db
1242
- .prepare("SELECT COALESCE(SUM(chunk_count), 0) AS total_chunks, COUNT(*) AS total_sources, MAX(indexed_at) AS last_indexed_at FROM sources")
1243
- .get();
1406
+ let row;
1407
+ try {
1408
+ row = withRetry(() => this.#db
1409
+ .prepare("SELECT COALESCE(SUM(chunk_count), 0) AS total_chunks, COUNT(*) AS total_sources, MAX(indexed_at) AS last_indexed_at FROM sources")
1410
+ .get());
1411
+ }
1412
+ catch (err) {
1413
+ throw storeFailure("store.getIndexState", err, this.#dbPath);
1414
+ }
1244
1415
  return {
1245
1416
  totalChunks: row.total_chunks ?? 0,
1246
1417
  totalSources: row.total_sources ?? 0,
@@ -1252,7 +1423,15 @@ export class ContentStore {
1252
1423
  * Use this for inventory/listing where you need all sections, not search.
1253
1424
  */
1254
1425
  getChunksBySource(sourceId) {
1255
- const rows = this.#stmtChunksBySource.all(sourceId);
1426
+ let rows;
1427
+ try {
1428
+ // Batch/introspection path (ctx_index directory mode, chunk listing)
1429
+ // — same retry + context contract as the write paths.
1430
+ rows = withRetry(() => this.#stmtChunksBySource.all(sourceId));
1431
+ }
1432
+ catch (err) {
1433
+ throw storeFailure("store.getChunksBySource", err, this.#dbPath);
1434
+ }
1256
1435
  return rows.map((r) => ({
1257
1436
  title: r.title,
1258
1437
  content: r.content,
@@ -1263,23 +1442,41 @@ export class ContentStore {
1263
1442
  }
1264
1443
  // ── Vocabulary ──
1265
1444
  getDistinctiveTerms(sourceId, maxTerms = 40) {
1266
- const stats = this.#stmtSourceChunkCount.get(sourceId);
1445
+ let stats;
1446
+ try {
1447
+ stats = withRetry(() => this.#stmtSourceChunkCount.get(sourceId));
1448
+ }
1449
+ catch (err) {
1450
+ throw storeFailure("store.getDistinctiveTerms", err, this.#dbPath);
1451
+ }
1267
1452
  if (!stats || stats.chunk_count < 3)
1268
1453
  return [];
1269
1454
  const totalChunks = stats.chunk_count;
1270
1455
  const minAppearances = 2;
1271
1456
  const maxAppearances = Math.max(3, Math.ceil(totalChunks * 0.4));
1272
- // Stream chunks one at a time to avoid loading all content into memory
1273
- // Count document frequency (how many sections contain each word)
1274
- const docFreq = new Map();
1275
- for (const row of this.#stmtChunkContent.iterate(sourceId)) {
1276
- const words = new Set(row.content
1277
- .toLowerCase()
1278
- .split(/[^\p{L}\p{N}_-]+/u)
1279
- .filter((w) => w.length >= 3 && !STOPWORDS.has(w)));
1280
- for (const word of words) {
1281
- docFreq.set(word, (docFreq.get(word) ?? 0) + 1);
1457
+ // Stream chunks one at a time to avoid loading all content into memory.
1458
+ // Count document frequency (how many sections contain each word).
1459
+ // The whole aggregation is wrapped so a transient IOERR mid-stream
1460
+ // retries from a clean slate — docFreq is rebuilt, never half-populated.
1461
+ const collectDocFreq = () => {
1462
+ const docFreq = new Map();
1463
+ for (const row of this.#stmtChunkContent.iterate(sourceId)) {
1464
+ const words = new Set(row.content
1465
+ .toLowerCase()
1466
+ .split(/[^\p{L}\p{N}_-]+/u)
1467
+ .filter((w) => w.length >= 3 && !STOPWORDS.has(w)));
1468
+ for (const word of words) {
1469
+ docFreq.set(word, (docFreq.get(word) ?? 0) + 1);
1470
+ }
1282
1471
  }
1472
+ return docFreq;
1473
+ };
1474
+ let docFreq;
1475
+ try {
1476
+ docFreq = withRetry(collectDocFreq);
1477
+ }
1478
+ catch (err) {
1479
+ throw storeFailure("store.getDistinctiveTerms", err, this.#dbPath);
1283
1480
  }
1284
1481
  const filtered = Array.from(docFreq.entries())
1285
1482
  .filter(([, count]) => count >= minAppearances && count <= maxAppearances);
@@ -1317,8 +1514,16 @@ export class ContentStore {
1317
1514
  this.#stmtCleanupChunksTrigram.run(days);
1318
1515
  return this.#stmtCleanupSources.run(days);
1319
1516
  });
1320
- const info = cleanup(maxAgeDays);
1321
- return info.changes;
1517
+ // Write path — same transient-IOERR/BUSY retry + failure context as
1518
+ // store.index. better-sqlite3 and the db-base adapters roll the
1519
+ // transaction back on throw, so re-invoking after a retry is safe.
1520
+ try {
1521
+ const info = withRetry(() => cleanup(maxAgeDays));
1522
+ return info.changes;
1523
+ }
1524
+ catch (err) {
1525
+ throw storeFailure("store.cleanupStaleSources", err, this.#dbPath);
1526
+ }
1322
1527
  }
1323
1528
  /** Get DB file size in bytes. */
1324
1529
  getDBSizeBytes() {
@@ -1335,7 +1540,11 @@ export class ContentStore {
1335
1540
  this.#db.exec("INSERT INTO chunks(chunks) VALUES('optimize')");
1336
1541
  this.#db.exec("INSERT INTO chunks_trigram(chunks_trigram) VALUES('optimize')");
1337
1542
  }
1338
- catch { /* best effort — don't block indexing */ }
1543
+ catch (err) {
1544
+ // Best effort — don't block indexing, but don't stay silent either:
1545
+ // 'optimize' is a write and its failures were previously invisible.
1546
+ logDbError("store.optimizeFTS", err, this.#dbPath);
1547
+ }
1339
1548
  }
1340
1549
  close() {
1341
1550
  this.#optimizeFTS(); // defragment before close