@equationalapplications/core-llm-wiki 5.1.1 → 5.2.1

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/README.md CHANGED
@@ -825,6 +825,20 @@ const { chunks, truncated } = chunkText(
825
825
 
826
826
  > **Note:** `ingestDocument()` clamps the resolved overlap — whether it came from `chunkOverlap`, `WikiConfig`, or `DEFAULT_CHUNK_OVERLAP` — to `maxChunkLength - 1`. The clamp is evaluated on every call and is a no-op at the shipped defaults (`400 < 12000 - 1`), but it also bites when a custom `maxChunkLength` alone leaves the resolved overlap too large (e.g. `maxChunkLength: 100` with the default overlap `400` ingests at an effective overlap of `99`). That clamp is internal: passing the unclamped pair straight to `chunkText` throws, since it requires `overlap < maxChunkLength`. Apply the same `Math.min(overlap, maxChunkLength - 1)` yourself when re-chunking under a custom config.
827
827
 
828
+ ## Schema Migrations
829
+
830
+ `wikiMemory.setup()` runs pending schema migrations in order and records the applied version. Migrations are additive and safe to re-run.
831
+
832
+ **Migration v9** (`add_live_hash_unique_index`) adds a partial `UNIQUE` index on `(entity_id, source_hash)` scoped to live (`deleted_at IS NULL`), non-null-hash rows. This closes a TOCTOU race where two concurrent `ingestDocument()` calls for different `sourceRef`s could both pass the duplicate-hash pre-check before either write committed, leaving two live rows with the same content hash.
833
+
834
+ If your existing database already has live rows that violate this invariant (created before the app-level race fix shipped), `setup()` throws:
835
+
836
+ ```text
837
+ Migration v9 (add_live_hash_unique_index) failed: existing live rows violate the new UNIQUE index. …
838
+ ```
839
+
840
+ The error lists the offending `(entity_id, source_hash)` groups. `setup()` performs no destructive cleanup on this path — the index is not created and the schema version is not advanced, so it's safe to retry after remediation. To remediate, either soft-delete (or re-ingest with a corrected `sourceRef`) all but one live row per listed group, then re-run `setup()`.
841
+
828
842
  ## Adapter Interface
829
843
 
830
844
  Implement `SQLiteAdapter` to use your platform's SQLite driver:
@@ -38,6 +38,12 @@ var SQLITE_RESULT_CODE_NAMES = {
38
38
  26: "SQLITE_NOTADB",
39
39
  27: "SQLITE_NOTICE",
40
40
  28: "SQLITE_WARNING",
41
+ // Extended result codes (https://sqlite.org/rescode.html#extended_result_code_list).
42
+ // 2067 = SQLITE_CONSTRAINT_UNIQUE — raised when a UNIQUE index/PRIMARY KEY
43
+ // rejects an INSERT/UPDATE; expo-sqlite reports this as a numeric "Error code N:"
44
+ // prefix and we normalize it back to the symbolic name so callers can branch on
45
+ // the same string the rest of the ecosystem uses.
46
+ 2067: "SQLITE_CONSTRAINT_UNIQUE",
41
47
  100: "SQLITE_ROW",
42
48
  101: "SQLITE_DONE"
43
49
  };
@@ -64,6 +70,15 @@ var WikiBusyError = class extends Error {
64
70
  this.entityId = entityId;
65
71
  }
66
72
  };
73
+ var WikiDuplicateHashError = class extends Error {
74
+ constructor(params) {
75
+ super(`Duplicate source hash for entity ${params.entityId}; another ref already holds this content`);
76
+ this.name = "WikiDuplicateHashError";
77
+ this.canonical = params.canonical;
78
+ this.sourceHash = params.sourceHash;
79
+ this.entityId = params.entityId;
80
+ }
81
+ };
67
82
  var WikiTransactionError = class extends Error {
68
83
  constructor(message, options) {
69
84
  super(message, options);
@@ -491,6 +506,19 @@ var JobManager = class {
491
506
  this.prefix = prefix;
492
507
  this.activeMaintenanceJobs = /* @__PURE__ */ new Set();
493
508
  this.activeIngestJobs = /* @__PURE__ */ new Map();
509
+ /**
510
+ * Per-(entityId, sourceHash) promise-chain lock. Serializes ingest races
511
+ * at the application level (the v9 partial UNIQUE index enforces it at
512
+ * the DB level as defense-in-depth). Two callers holding the same hash
513
+ * see FIFO order; different hashes never block one another.
514
+ *
515
+ * Keyed `${entityId}\0${sourceHash}` so distinct (entityId, sourceHash)
516
+ * pairs can never share a lock. A raw concatenation would let
517
+ * ("ab", "c…") collide with ("a", "bc…"); the NUL separator makes that
518
+ * impossible because neither component can contain NUL (entityIds are
519
+ * user-supplied identifiers; sourceHashes are hex digests).
520
+ */
521
+ this.hashLocks = /* @__PURE__ */ new Map();
494
522
  this.statusSubscribers = /* @__PURE__ */ new Map();
495
523
  /**
496
524
  * Lookup table for acquireLock/releaseLock's dynamic-dispatch branch.
@@ -508,6 +536,51 @@ var JobManager = class {
508
536
  ontologyBackfill: (id) => this._ontologyBackfillKey(id)
509
537
  };
510
538
  }
539
+ _hashLockKey(entityId, sourceHash) {
540
+ return `${entityId}\0${sourceHash}`;
541
+ }
542
+ /**
543
+ * Acquire a promise-chain lock scoped to one `(entityId, sourceHash)` pair.
544
+ * Returns a zero-argument release closure that removes the current tail
545
+ * from the chain. Releasing is idempotent so a holder that double-fires
546
+ * (e.g. on a finally that races an explicit release) cannot unblock
547
+ * the next holder prematurely.
548
+ *
549
+ * Documented ordering: ingest acquires the hash lock FIRST, then the
550
+ * synchronous sourceRef ingest lock, and the release closure unwinds in
551
+ * the opposite order (sourceRef first, hash second). Hash-then-sourceRef
552
+ * is required because the v9 partial UNIQUE index conflicts on the
553
+ * (entity_id, source_hash) pair regardless of source_ref, so two callers
554
+ * with DIFFERENT source_refs racing the same hash are the exact case the
555
+ * TOCTOU race fix needs to serialize.
556
+ *
557
+ * Mechanics: each holder owns a private `released` promise that resolves
558
+ * only when THIS holder calls release(). The map stores the holder's
559
+ * `released` promise as the "tail" — the NEXT caller awaits that tail
560
+ * via `previous = this.hashLocks.get(key)`. The current caller gets
561
+ * their unique release function once `previous` (the prior holder's
562
+ * release signal) settles, so each holder is guaranteed a UNIQUE release
563
+ * closure bound to their own `released` promise.
564
+ */
565
+ acquireHashLock(entityId, sourceHash) {
566
+ const key = this._hashLockKey(entityId, sourceHash);
567
+ const previous = this.hashLocks.get(key) ?? Promise.resolve();
568
+ let resolveReleased;
569
+ const released = new Promise((resolve) => {
570
+ resolveReleased = resolve;
571
+ });
572
+ let fired = false;
573
+ const release = () => {
574
+ if (fired) return;
575
+ fired = true;
576
+ if (this.hashLocks.get(key) === released) {
577
+ this.hashLocks.delete(key);
578
+ }
579
+ resolveReleased();
580
+ };
581
+ this.hashLocks.set(key, released);
582
+ return previous.then(() => release);
583
+ }
511
584
  _pruneKey(entityId) {
512
585
  return `${this.prefix}:${entityId}:prune`;
513
586
  }
@@ -693,6 +766,19 @@ var JobManager = class {
693
766
  return this.activeMaintenanceJobs.has(this.lockKeyFns[operation](entityId)) || this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this._isReembedActive(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
694
767
  case "prune":
695
768
  return this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this.activeMaintenanceJobs.has(this._librarianKey(entityId)) || this.activeMaintenanceJobs.has(this._healKey(entityId)) || this.activeMaintenanceJobs.has(this._ontologyBackfillKey(entityId)) || this._isReembedActive(entityId) || this._isIngestActiveFor(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
769
+ case "reembed":
770
+ return this.activeMaintenanceJobs.has(this._reembedKey(entityId)) || this.activeMaintenanceJobs.has(this._globalReembedKey()) || this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this.activeMaintenanceJobs.has(this._librarianKey(entityId)) || this.activeMaintenanceJobs.has(this._healKey(entityId)) || this.activeMaintenanceJobs.has(this._ontologyBackfillKey(entityId)) || this._isIngestActiveFor(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
771
+ case "global_reembed":
772
+ return this.activeMaintenanceJobs.has(this._globalReembedKey()) || this._isAnyMaintenanceActiveWithSuffix(":reembed") || this._isAnyMaintenanceActiveWithSuffix(":prune") || this._isAnyMaintenanceActiveWithSuffix(":librarian") || this._isAnyMaintenanceActiveWithSuffix(":heal") || this._isAnyMaintenanceActiveWithSuffix(":ontologyBackfill") || this.activeIngestJobs.size > 0 || this._isAnyMaintenanceActiveWithSuffix(":import") || this._isAnyMaintenanceActiveWithSuffix(":forget");
773
+ case "import":
774
+ case "forget": {
775
+ const selfKey = operation === "import" ? this._importKey(entityId) : this._forgetKey(entityId);
776
+ return this.activeMaintenanceJobs.has(selfKey) || this.activeMaintenanceJobs.has(this._librarianKey(entityId)) || this.activeMaintenanceJobs.has(this._healKey(entityId)) || this.activeMaintenanceJobs.has(this._ontologyBackfillKey(entityId)) || this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this._isReembedActive(entityId) || this._isIngestActiveFor(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
777
+ }
778
+ case "global_import":
779
+ return this.activeMaintenanceJobs.has(this._globalImportKey());
780
+ case "ingest":
781
+ return this._hasIngestJob(entityId) || this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this._isReembedActive(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
696
782
  default:
697
783
  return false;
698
784
  }
@@ -708,6 +794,38 @@ var JobManager = class {
708
794
  this._notifyStatusSubscribers(entityId);
709
795
  return true;
710
796
  }
797
+ /**
798
+ * Centralized ingest lock acquisition. Acquires the hash lock FIRST
799
+ * (FIFO across callers racing the same hash), then the synchronous
800
+ * sourceRef ingest lock (rejects cross-entity conflicts and busy
801
+ * operations). Returns a single zero-argument release closure that
802
+ * unwinds in the opposite order — sourceRef first, hash second — so
803
+ * the sourceRef lock is freed the instant its work is done and only
804
+ * the hash lock keeps serializing the duplicate-content race window.
805
+ *
806
+ * On a sourceRef conflict (WikiBusyError thrown synchronously by the
807
+ * underlying acquireLock) the hash lock is released BEFORE the error
808
+ * propagates, so the next caller in the hash chain is not held up by
809
+ * a request that never reached the DB.
810
+ */
811
+ async acquireIngestLocks(entityId, sourceRef, sourceHash) {
812
+ const releaseHash = await this.acquireHashLock(entityId, sourceHash);
813
+ let sourceLockAcquired = false;
814
+ try {
815
+ this.acquireLock("ingest", entityId, sourceRef);
816
+ sourceLockAcquired = true;
817
+ } catch (err) {
818
+ releaseHash();
819
+ throw err;
820
+ }
821
+ return () => {
822
+ try {
823
+ if (sourceLockAcquired) this.releaseLock("ingest", entityId, sourceRef);
824
+ } finally {
825
+ releaseHash();
826
+ }
827
+ };
828
+ }
711
829
  /**
712
830
  * Validates then acquires global + per-entity import locks atomically.
713
831
  * Validates all entities before acquiring any lock (same as current importDump semantics).
@@ -1148,18 +1266,19 @@ var DEFAULT_CHUNK_OVERLAP = 400;
1148
1266
 
1149
1267
  // src/services/IngestionService.ts
1150
1268
  var IngestionService = class {
1151
- constructor(db, prefix, options, entryRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1269
+ constructor(db, prefix, options, entryRepo, sourceRefIndexRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1152
1270
  this.db = db;
1153
1271
  this.prefix = prefix;
1154
1272
  this.options = options;
1155
1273
  this.entryRepo = entryRepo;
1274
+ this.sourceRefIndexRepo = sourceRefIndexRepo;
1156
1275
  this.searchService = searchService;
1157
1276
  this.jobManager = jobManager;
1158
1277
  this.embeddingService = embeddingService;
1159
1278
  this.ontologyService = ontologyService;
1160
1279
  this.promptService = promptService ?? new PromptService(this.options.config?.prompts);
1161
1280
  }
1162
- async ingestDocument(entityId, params) {
1281
+ async ingestDocument(entityId, params, opts) {
1163
1282
  const sourceRef = normalizeSourceRef(params.sourceRef);
1164
1283
  if (!sourceRef) throw new Error("Invalid sourceRef");
1165
1284
  const sourceHash = normalizeSourceHash(params.sourceHash);
@@ -1175,8 +1294,18 @@ var IngestionService = class {
1175
1294
  if (typeof params.documentChunk !== "string") {
1176
1295
  throw new Error(`documentChunk must be a string, received ${typeof params.documentChunk}`);
1177
1296
  }
1178
- this.jobManager.acquireLock("ingest", entityId, sourceRef);
1297
+ const releaseIngestLocks = await this.jobManager.acquireIngestLocks(entityId, sourceRef, sourceHash);
1179
1298
  try {
1299
+ const onDuplicateHash = opts?.onDuplicateHash ?? "ingest";
1300
+ if (onDuplicateHash !== "ingest") {
1301
+ const canonical = await this.sourceRefIndexRepo.findActiveByEntityAndHash(entityId, sourceHash);
1302
+ if (canonical !== null && canonical !== sourceRef) {
1303
+ if (onDuplicateHash === "throw") {
1304
+ throw new WikiDuplicateHashError({ canonical, sourceHash, entityId });
1305
+ }
1306
+ return { truncated: false, chunks: 0, duplicateOf: canonical };
1307
+ }
1308
+ }
1180
1309
  const { chunks, truncated } = chunkText(params.documentChunk, maxChunkLength, chunkOverlap);
1181
1310
  if (chunks.length === 0) return { truncated: false, chunks: 0 };
1182
1311
  const chunkResults = await withConcurrency(
@@ -1212,68 +1341,81 @@ var IngestionService = class {
1212
1341
  const now = Date.now();
1213
1342
  const insertedFacts = [];
1214
1343
  const deletedSourceFactIds = [];
1215
- await this.db.withTransactionAsync(async (tx) => {
1216
- deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
1217
- await this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, null);
1218
- const titleIndex = /* @__PURE__ */ new Map();
1219
- const pendingEdges = [];
1220
- const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx);
1221
- for (const existing of existingFacts) {
1222
- titleIndex.set(normalizeTitleKey(existing.title), {
1223
- id: existing.id,
1224
- okf_type: existing.okf_type ?? null
1225
- });
1226
- }
1227
- let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
1228
- let { mode, manifest } = ontologyState;
1229
- for (const { facts, ontology_updates } of orderedChunkFacts) {
1230
- if (mode === "emergent" && ontology_updates && this.ontologyService) {
1231
- manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
1232
- ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
1233
- mode = ontologyState.mode;
1344
+ try {
1345
+ await this.db.withTransactionAsync(async (tx) => {
1346
+ deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
1347
+ await this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, null);
1348
+ await this.sourceRefIndexRepo.softDeleteByEntityAndSourceRef(entityId, sourceRef, tx);
1349
+ await this.sourceRefIndexRepo.upsert(entityId, sourceHash, sourceRef, tx);
1350
+ const titleIndex = /* @__PURE__ */ new Map();
1351
+ const pendingEdges = [];
1352
+ const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx);
1353
+ for (const existing of existingFacts) {
1354
+ titleIndex.set(normalizeTitleKey(existing.title), {
1355
+ id: existing.id,
1356
+ okf_type: existing.okf_type ?? null
1357
+ });
1234
1358
  }
1235
- for (const fact of facts) {
1236
- const ontologyFact = fact;
1237
- const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest) ?? { okf_type: null, edges: [] };
1238
- const id = generateId("fact_");
1239
- const wikiFact = {
1240
- id,
1241
- entity_id: entityId,
1242
- title: fact.title,
1243
- body: fact.body,
1244
- tags: fact.tags,
1245
- confidence: fact.confidence,
1246
- source_type: "immutable_document",
1247
- source_hash: sourceHash,
1248
- source_ref: sourceRef,
1249
- created_at: now,
1250
- updated_at: now,
1251
- last_accessed_at: null,
1252
- access_count: 0,
1253
- deleted_at: null,
1254
- okf_type: normalized.okf_type
1255
- };
1256
- await this.entryRepo.upsert(wikiFact, tx);
1257
- insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1258
- titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
1259
- if (normalized.edges.length > 0) {
1260
- pendingEdges.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
1359
+ let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
1360
+ let { mode, manifest } = ontologyState;
1361
+ for (const { facts, ontology_updates } of orderedChunkFacts) {
1362
+ if (mode === "emergent" && ontology_updates && this.ontologyService) {
1363
+ manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
1364
+ ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
1365
+ mode = ontologyState.mode;
1366
+ }
1367
+ for (const fact of facts) {
1368
+ const ontologyFact = fact;
1369
+ const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest) ?? { okf_type: null, edges: [] };
1370
+ const id = generateId("fact_");
1371
+ const wikiFact = {
1372
+ id,
1373
+ entity_id: entityId,
1374
+ title: fact.title,
1375
+ body: fact.body,
1376
+ tags: fact.tags,
1377
+ confidence: fact.confidence,
1378
+ source_type: "immutable_document",
1379
+ source_hash: sourceHash,
1380
+ source_ref: sourceRef,
1381
+ created_at: now,
1382
+ updated_at: now,
1383
+ last_accessed_at: null,
1384
+ access_count: 0,
1385
+ deleted_at: null,
1386
+ okf_type: normalized.okf_type
1387
+ };
1388
+ await this.entryRepo.upsert(wikiFact, tx);
1389
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1390
+ titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
1391
+ if (normalized.edges.length > 0) {
1392
+ pendingEdges.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
1393
+ }
1261
1394
  }
1262
1395
  }
1396
+ for (const item of pendingEdges) {
1397
+ await this.ontologyService?.resolveAndPersistEdges(
1398
+ entityId,
1399
+ item.sourceId,
1400
+ item.sourceType,
1401
+ item.edges ?? [],
1402
+ manifest,
1403
+ titleIndex,
1404
+ tx,
1405
+ now
1406
+ );
1407
+ }
1408
+ });
1409
+ } catch (err) {
1410
+ const sqliteCode = err instanceof WikiTransactionError ? err.sqliteErrorCode : extractSqliteCode(err);
1411
+ if (sqliteCode !== "SQLITE_CONSTRAINT_UNIQUE") throw err;
1412
+ const canonical = await this.sourceRefIndexRepo.findActiveByEntityAndHash(entityId, sourceHash);
1413
+ if (canonical === null) throw err;
1414
+ if (onDuplicateHash === "throw" || onDuplicateHash === "ingest") {
1415
+ throw new WikiDuplicateHashError({ canonical, sourceHash, entityId });
1263
1416
  }
1264
- for (const item of pendingEdges) {
1265
- await this.ontologyService?.resolveAndPersistEdges(
1266
- entityId,
1267
- item.sourceId,
1268
- item.sourceType,
1269
- item.edges ?? [],
1270
- manifest,
1271
- titleIndex,
1272
- tx,
1273
- now
1274
- );
1275
- }
1276
- });
1417
+ return { truncated: false, chunks: 0, duplicateOf: canonical };
1418
+ }
1277
1419
  await this.searchService.sync(entityId);
1278
1420
  const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
1279
1421
  for (const factId of uniqueDeletedSourceFactIds) {
@@ -1289,7 +1431,7 @@ var IngestionService = class {
1289
1431
  this.searchService.evictCache(entityId);
1290
1432
  return { truncated, chunks: chunks.length };
1291
1433
  } finally {
1292
- this.jobManager.releaseLock("ingest", entityId, sourceRef);
1434
+ releaseIngestLocks();
1293
1435
  }
1294
1436
  }
1295
1437
  };
@@ -1576,11 +1718,12 @@ var HEAL_MAX_PROMPT_CHARS = 4e4;
1576
1718
  var HEAL_BATCH_SIZE = 25;
1577
1719
  var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1578
1720
  var MaintenanceService = class {
1579
- constructor(db, prefix, options, entryRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1721
+ constructor(db, prefix, options, entryRepo, sourceRefIndexRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1580
1722
  this.db = db;
1581
1723
  this.prefix = prefix;
1582
1724
  this.options = options;
1583
1725
  this.entryRepo = entryRepo;
1726
+ this.sourceRefIndexRepo = sourceRefIndexRepo;
1584
1727
  this.taskRepo = taskRepo;
1585
1728
  this.eventRepo = eventRepo;
1586
1729
  this.metadataRepo = metadataRepo;
@@ -1739,10 +1882,13 @@ var MaintenanceService = class {
1739
1882
  this.jobManager.releaseLock(op, entityId ?? "*");
1740
1883
  }
1741
1884
  }
1742
- async forget(entityId, params) {
1885
+ async forget(entityId, params, opts) {
1743
1886
  if (params.clearAll && (params.entryId !== void 0 || params.taskId !== void 0 || params.sourceRef !== void 0 || params.sourceHash !== void 0)) {
1744
1887
  throw new Error("forget() clearAll is mutually exclusive with entryId, taskId, sourceRef, and sourceHash");
1745
1888
  }
1889
+ if (opts?.dryRun === true) {
1890
+ return this.forgetDryRun(entityId, params);
1891
+ }
1746
1892
  this.jobManager.acquireLock("forget", entityId);
1747
1893
  try {
1748
1894
  const now = Date.now();
@@ -1776,10 +1922,12 @@ var MaintenanceService = class {
1776
1922
  const entryPromise = params.entryId ? this.entryRepo.softDelete(params.entryId, entityId, tx).then((r) => r.changes > 0) : null;
1777
1923
  const taskDeletedPromise = params.taskId ? this.taskRepo.softDeleteById(params.taskId, entityId, tx).then((r) => r.changes > 0) : null;
1778
1924
  const refPromise = sourceRef || sourceHash ? this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, sourceHash) : null;
1925
+ const sourceRefIndexPromise = sourceRef ? this.sourceRefIndexRepo.softDeleteByEntityAndSourceRef(entityId, sourceRef, tx).then(() => void 0) : null;
1779
1926
  const [entryResult, taskResult, refResult] = await Promise.all([
1780
1927
  entryPromise ?? Promise.resolve(false),
1781
1928
  taskDeletedPromise ?? Promise.resolve(false),
1782
- refPromise ?? Promise.resolve(0)
1929
+ refPromise ?? Promise.resolve(0),
1930
+ sourceRefIndexPromise ?? Promise.resolve(void 0)
1783
1931
  ]);
1784
1932
  if (entryResult) deletedEntries++;
1785
1933
  if (taskResult) deletedTasks++;
@@ -1803,11 +1951,42 @@ var MaintenanceService = class {
1803
1951
  throw new Error(`forget(${entityId}/${factId}) failed: ANN cleanup hook rejected`, { cause: this._sanitizeRankerError(hookErr) });
1804
1952
  }
1805
1953
  }
1806
- return { deleted: { entries: deletedEntries, tasks: deletedTasks } };
1954
+ return params.clearAll ? { deleted: { entries: deletedEntries, tasks: deletedTasks }, metadataReset: true } : { deleted: { entries: deletedEntries, tasks: deletedTasks } };
1807
1955
  } finally {
1808
1956
  this.jobManager.releaseLock("forget", entityId);
1809
1957
  }
1810
1958
  }
1959
+ /**
1960
+ * Read-only dry-run preview of {@link forget}. Returns the same shape as a
1961
+ * real call would, without acquiring the lock, opening a transaction, staging
1962
+ * outbox events, or firing embedding hooks. Counts may be off-by-N during a
1963
+ * concurrent real forget — this is accepted, not considered a bug.
1964
+ *
1965
+ * `metadataReset` mirrors the real call: only `clearAll: true` returns true,
1966
+ * because only `clearAll` actually resets the metadata checkpoint.
1967
+ * `standard` (sourceRef/sourceHash) returns no metadataReset field, just like
1968
+ * the real call. `entryId`/`taskId` selectors are rejected with a clear error
1969
+ * (the spec keeps dry-run narrow).
1970
+ */
1971
+ async forgetDryRun(entityId, params) {
1972
+ if (params.entryId !== void 0 || params.taskId !== void 0) {
1973
+ throw new Error("forget({ dryRun: true }) does not support entryId/taskId selectors; use sourceRef/sourceHash or clearAll");
1974
+ }
1975
+ if (params.clearAll) {
1976
+ const entries2 = await this.entryRepo.countLiveByEntityId(entityId);
1977
+ const tasks = await this.taskRepo.countLiveByEntityId(entityId);
1978
+ return { deleted: { entries: entries2, tasks }, metadataReset: true };
1979
+ }
1980
+ const sourceRef = params.sourceRef !== void 0 ? normalizeSourceRef(params.sourceRef) : null;
1981
+ if (params.sourceRef !== void 0 && !sourceRef) throw new Error("Invalid sourceRef");
1982
+ const sourceHash = params.sourceHash !== void 0 ? normalizeSourceHash(params.sourceHash) : null;
1983
+ if (params.sourceHash !== void 0 && !sourceHash) throw new Error("Invalid sourceHash (must be 64-char hex string)");
1984
+ if (sourceRef === null && sourceHash === null) {
1985
+ return { deleted: { entries: 0, tasks: 0 } };
1986
+ }
1987
+ const entries = await this.entryRepo.countLiveBySource(entityId, sourceRef, sourceHash);
1988
+ return { deleted: { entries, tasks: 0 } };
1989
+ }
1811
1990
  /** Core librarian pass (locks handled by {@link runLibrarian}). Package-internal orchestration hook. */
1812
1991
  async doRunLibrarian(entityId, promptOverride) {
1813
1992
  const events = await this.eventRepo.getRecent(entityId, 50);
@@ -2531,6 +2710,15 @@ var ImportExportService = class {
2531
2710
  const safeTitle = clip(String(fact.title ?? ""), IMPORT_TITLE_MAX);
2532
2711
  const safeBody = clip(String(fact.body ?? ""), IMPORT_BODY_MAX);
2533
2712
  clippedTextByFactId.set(fact.id, { title: safeTitle, body: safeBody });
2713
+ let normalizedSourceRef = null;
2714
+ if (fact.source_ref !== null && fact.source_ref !== void 0) {
2715
+ normalizedSourceRef = normalizeSourceRef(fact.source_ref);
2716
+ if (normalizedSourceRef === null) {
2717
+ throw new Error(
2718
+ `importDump: invalid source_ref ${JSON.stringify(fact.source_ref)} for entity "${entityId}" fact "${fact.id}" (must normalize to a non-empty string; see ingestDocument's sourceRef validation)`
2719
+ );
2720
+ }
2721
+ }
2534
2722
  const factObj = {
2535
2723
  id: fact.id,
2536
2724
  entity_id: entityId,
@@ -2540,7 +2728,7 @@ var ImportExportService = class {
2540
2728
  confidence: fact.confidence,
2541
2729
  source_type: sourceType,
2542
2730
  source_hash: fact.source_hash,
2543
- source_ref: fact.source_ref,
2731
+ source_ref: normalizedSourceRef,
2544
2732
  created_at: fact.created_at,
2545
2733
  updated_at: safeUpdatedAt,
2546
2734
  last_accessed_at: fact.last_accessed_at,
@@ -3553,6 +3741,6 @@ var WriteService = class {
3553
3741
  }
3554
3742
  };
3555
3743
 
3556
- export { BaseRepository, DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_CHUNK_LENGTH, EmbeddingService, HEAL_BATCH_SIZE, HEAL_RECHECK_MS, HOOK_TIMEOUT_MARKER, ImportExportService, IngestionService, JobManager, MaintenanceService, MetadataRepository, ONTOLOGY_BACKFILL_BATCH_SIZE, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, ONTOLOGY_BACKFILL_RECHECK_MS, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, PromptService, PrunePartialFailureError, RetrievalService, SearchService, WikiBusyError, WikiTransactionError, WriteService, __privateAdd, __privateGet, __privateSet, chunkText, configureRandomSource, emptyManifest, entitySummaryMetaKey, extractSqliteCode, generateId, normalizeSourceHash, normalizeSourceRef, normalizeTitleKey, parseEmbedding, resolveEdgeDefinitions, resolveNodeType, safeSlice, validateInlineEdges, validateManifest };
3557
- //# sourceMappingURL=chunk-YKXCMOHH.mjs.map
3558
- //# sourceMappingURL=chunk-YKXCMOHH.mjs.map
3744
+ export { BaseRepository, DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_CHUNK_LENGTH, EmbeddingService, HEAL_BATCH_SIZE, HEAL_RECHECK_MS, HOOK_TIMEOUT_MARKER, ImportExportService, IngestionService, JobManager, MaintenanceService, MetadataRepository, ONTOLOGY_BACKFILL_BATCH_SIZE, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, ONTOLOGY_BACKFILL_RECHECK_MS, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, PromptService, PrunePartialFailureError, RetrievalService, SearchService, WikiBusyError, WikiDuplicateHashError, WikiTransactionError, WriteService, __privateAdd, __privateGet, __privateSet, chunkText, configureRandomSource, emptyManifest, entitySummaryMetaKey, extractSqliteCode, generateId, normalizeSourceHash, normalizeSourceRef, normalizeTitleKey, parseEmbedding, resolveEdgeDefinitions, resolveNodeType, safeSlice, validateInlineEdges, validateManifest };
3745
+ //# sourceMappingURL=chunk-6HNIOKT4.mjs.map
3746
+ //# sourceMappingURL=chunk-6HNIOKT4.mjs.map