@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/dist/index.js CHANGED
@@ -45,6 +45,24 @@ async function setupDatabase(db, prefix) {
45
45
  CREATE INDEX IF NOT EXISTS ${prefix}entries_source_hash_idx ON ${prefix}entries(entity_id, source_hash) WHERE source_hash IS NOT NULL;
46
46
  CREATE INDEX IF NOT EXISTS ${prefix}entries_updated_idx ON ${prefix}entries(updated_at DESC);
47
47
 
48
+ -- source_ref_index: per-(entity, source_hash) record of the canonical sourceRef
49
+ -- currently holding that hash. The partial UNIQUE index on (entity_id, source_hash)
50
+ -- WHERE deleted_at IS NULL enforces the sourceRef-level TOCTOU-race invariant;
51
+ -- entries-level uniqueness cannot express it because a single ingestDocument call
52
+ -- writes N facts that all share (entity_id, source_ref, source_hash). See
53
+ -- docs/superpowers/specs/2026-08-07-dependabot-concurrency-release-hygiene-design.md \xA7B1.
54
+ CREATE TABLE IF NOT EXISTS ${prefix}source_ref_index (
55
+ id TEXT PRIMARY KEY,
56
+ entity_id TEXT NOT NULL,
57
+ source_hash TEXT NOT NULL,
58
+ source_ref TEXT NOT NULL,
59
+ created_at INTEGER NOT NULL,
60
+ deleted_at INTEGER
61
+ );
62
+ CREATE UNIQUE INDEX IF NOT EXISTS ${prefix}idx_source_ref_hash
63
+ ON ${prefix}source_ref_index (entity_id, source_hash)
64
+ WHERE deleted_at IS NULL;
65
+
48
66
  CREATE TABLE IF NOT EXISTS ${prefix}tasks (
49
67
  id TEXT PRIMARY KEY,
50
68
  entity_id TEXT NOT NULL,
@@ -149,6 +167,12 @@ var SQLITE_RESULT_CODE_NAMES = {
149
167
  26: "SQLITE_NOTADB",
150
168
  27: "SQLITE_NOTICE",
151
169
  28: "SQLITE_WARNING",
170
+ // Extended result codes (https://sqlite.org/rescode.html#extended_result_code_list).
171
+ // 2067 = SQLITE_CONSTRAINT_UNIQUE — raised when a UNIQUE index/PRIMARY KEY
172
+ // rejects an INSERT/UPDATE; expo-sqlite reports this as a numeric "Error code N:"
173
+ // prefix and we normalize it back to the symbolic name so callers can branch on
174
+ // the same string the rest of the ecosystem uses.
175
+ 2067: "SQLITE_CONSTRAINT_UNIQUE",
152
176
  100: "SQLITE_ROW",
153
177
  101: "SQLITE_DONE"
154
178
  };
@@ -175,6 +199,15 @@ var WikiBusyError = class extends Error {
175
199
  this.entityId = entityId;
176
200
  }
177
201
  };
202
+ var WikiDuplicateHashError = class extends Error {
203
+ constructor(params) {
204
+ super(`Duplicate source hash for entity ${params.entityId}; another ref already holds this content`);
205
+ this.name = "WikiDuplicateHashError";
206
+ this.canonical = params.canonical;
207
+ this.sourceHash = params.sourceHash;
208
+ this.entityId = params.entityId;
209
+ }
210
+ };
178
211
  var WikiTransactionError = class extends Error {
179
212
  constructor(message, options) {
180
213
  super(message, options);
@@ -368,6 +401,61 @@ var MIGRATIONS = [
368
401
  );
369
402
  }
370
403
  }
404
+ },
405
+ {
406
+ version: 9,
407
+ description: "add_source_ref_index",
408
+ run: async (db, prefix) => {
409
+ const duplicates = await db.getAllAsync(
410
+ `SELECT entity_id, source_hash, COUNT(DISTINCT source_ref) AS n_refs
411
+ FROM ${prefix}entries
412
+ WHERE deleted_at IS NULL AND source_hash IS NOT NULL
413
+ GROUP BY entity_id, source_hash
414
+ HAVING COUNT(DISTINCT source_ref) > 1`
415
+ );
416
+ if (duplicates.length > 0) {
417
+ const sample = duplicates.slice(0, 5).map((d) => `(entity_id=${d.entity_id}, source_hash=${d.source_hash.slice(0, 12)}\u2026, n_refs=${d.n_refs})`).join(", ");
418
+ throw new Error(
419
+ `Migration v9 (add_source_ref_index) failed: existing live rows have multiple sourceRefs sharing a hash. Found ${duplicates.length} duplicate (entity_id, source_hash) groups. First ${Math.min(5, duplicates.length)}: ${sample}. Resolve each by calling forget({ sourceRef: <loser> }) for the offending sourceRef, then re-run setup.`
420
+ );
421
+ }
422
+ await db.execAsync(
423
+ `CREATE TABLE IF NOT EXISTS ${prefix}source_ref_index (
424
+ id TEXT PRIMARY KEY,
425
+ entity_id TEXT NOT NULL,
426
+ source_hash TEXT NOT NULL,
427
+ source_ref TEXT NOT NULL,
428
+ created_at INTEGER NOT NULL,
429
+ deleted_at INTEGER
430
+ );
431
+ CREATE UNIQUE INDEX IF NOT EXISTS ${prefix}idx_source_ref_hash
432
+ ON ${prefix}source_ref_index (entity_id, source_hash)
433
+ WHERE deleted_at IS NULL;`
434
+ );
435
+ await db.execAsync(
436
+ `INSERT OR IGNORE INTO ${prefix}source_ref_index (id, entity_id, source_hash, source_ref, created_at, deleted_at)
437
+ SELECT
438
+ 'sri:' || entity_id || ':' || source_hash,
439
+ entity_id,
440
+ source_hash,
441
+ source_ref,
442
+ updated_at,
443
+ NULL
444
+ FROM (
445
+ SELECT
446
+ entity_id, source_hash, source_ref, updated_at,
447
+ ROW_NUMBER() OVER (
448
+ PARTITION BY entity_id, source_hash
449
+ ORDER BY updated_at ASC, id ASC
450
+ ) AS rn
451
+ FROM ${prefix}entries
452
+ WHERE deleted_at IS NULL
453
+ AND source_hash IS NOT NULL
454
+ AND source_ref IS NOT NULL
455
+ ) ranked
456
+ WHERE rn = 1;`
457
+ );
458
+ }
371
459
  }
372
460
  ];
373
461
  for (let i = 1; i < MIGRATIONS.length; i++) {
@@ -1080,12 +1168,154 @@ var EntryRepository = class extends BaseRepository {
1080
1168
  const row = await executor.getFirstAsync(
1081
1169
  `SELECT source_hash FROM ${this.prefix}entries
1082
1170
  WHERE entity_id = ? AND source_ref = ? AND deleted_at IS NULL
1083
- ORDER BY updated_at DESC
1171
+ ORDER BY updated_at DESC, id ASC
1084
1172
  LIMIT 1`,
1085
1173
  [entityId, sourceRef]
1086
1174
  );
1087
1175
  return row?.source_hash ?? null;
1088
1176
  }
1177
+ /**
1178
+ * Batch version of {@link findLatestSourceHash}. Returns a Map covering every
1179
+ * requested ref, where the value is the source_hash from the row with the
1180
+ * most-recently-updated live fact for that ref, or null when no live row exists.
1181
+ *
1182
+ * The SQL uses ROW_NUMBER() OVER (PARTITION BY source_ref ORDER BY updated_at
1183
+ * DESC, id ASC) — NOT MAX(source_hash). Aggregation with MAX(source_hash) is
1184
+ * wrong because MAX computes independently across grouped rows; the hash must
1185
+ * come from the exact row that wins MAX(updated_at). The `id ASC` tie-break
1186
+ * keeps selection deterministic when two live rows share `updated_at`.
1187
+ *
1188
+ * Source refs are de-duplicated and processed in chunks so the per-query bind
1189
+ * parameter count stays under SQLite's `SQLITE_MAX_VARIABLE_NUMBER` (default
1190
+ * 999, leaving one slot for `entity_id`).
1191
+ *
1192
+ * Empty input is a synchronous early return with zero SQL calls.
1193
+ */
1194
+ async findLatestSourceHashes(entityId, sourceRefs, tx) {
1195
+ const out = /* @__PURE__ */ new Map();
1196
+ if (sourceRefs.length === 0) return out;
1197
+ const dedupedRefs = Array.from(new Set(sourceRefs));
1198
+ for (const ref of dedupedRefs) {
1199
+ out.set(ref, null);
1200
+ }
1201
+ const executor = this.getExecutor(tx);
1202
+ const chunkLimit = Math.max(1, this.chunkSize - 1);
1203
+ for (let i = 0; i < dedupedRefs.length; i += chunkLimit) {
1204
+ const chunk = dedupedRefs.slice(i, i + chunkLimit);
1205
+ const placeholders = chunk.map(() => "?").join(",");
1206
+ const rows = await executor.getAllAsync(
1207
+ `WITH ranked AS (
1208
+ SELECT source_ref, source_hash,
1209
+ ROW_NUMBER() OVER (
1210
+ PARTITION BY source_ref
1211
+ ORDER BY updated_at DESC, id ASC
1212
+ ) as rn
1213
+ FROM ${this.prefix}entries
1214
+ WHERE entity_id = ? AND source_ref IN (${placeholders}) AND deleted_at IS NULL
1215
+ )
1216
+ SELECT source_ref, source_hash
1217
+ FROM ranked
1218
+ WHERE rn = 1`,
1219
+ [entityId, ...chunk]
1220
+ );
1221
+ for (const r of rows) {
1222
+ out.set(r.source_ref, r.source_hash);
1223
+ }
1224
+ }
1225
+ return out;
1226
+ }
1227
+ /**
1228
+ * Return the live source_refs for an entity that hold the given source_hash.
1229
+ * Used by the ingestDocument duplicate-hash guard and by hosts auditing
1230
+ * duplicate-content collisions. Sorted `COLLATE BINARY` so the canonical
1231
+ * ref (the code-unit-minimum of the set) is stable across deploys.
1232
+ */
1233
+ async findSourceRefsByHash(entityId, sourceHash, tx) {
1234
+ const executor = this.getExecutor(tx);
1235
+ const rows = await executor.getAllAsync(
1236
+ `SELECT source_ref FROM ${this.prefix}entries
1237
+ WHERE entity_id = ? AND source_hash = ? AND deleted_at IS NULL
1238
+ AND source_ref IS NOT NULL
1239
+ GROUP BY source_ref
1240
+ ORDER BY source_ref COLLATE BINARY`,
1241
+ [entityId, sourceHash]
1242
+ );
1243
+ return rows.map((r) => r.source_ref);
1244
+ }
1245
+ /**
1246
+ * Per-sourceRef rollup for an entity: one row per live source_ref, with the
1247
+ * most-recently-updated live hash (NOT the lexically-max hash) and a live
1248
+ * fact count.
1249
+ *
1250
+ * The hash comes from the same row that wins ROW_NUMBER() over updated_at
1251
+ * DESC, so a ref with multiple live hashes (e.g. from import) reports the
1252
+ * latest one — matching single-doc `findLatestSourceHash` semantics.
1253
+ *
1254
+ * Sort is COLLATE BINARY: locale-dependent ordering re-mints identity on
1255
+ * every deploy, which is the bug this whole section exists to prevent.
1256
+ */
1257
+ async listSourceRefs(entityId, tx) {
1258
+ const executor = this.getExecutor(tx);
1259
+ const rows = await executor.getAllAsync(
1260
+ `WITH ranked AS (
1261
+ SELECT source_ref, source_hash, updated_at,
1262
+ ROW_NUMBER() OVER (
1263
+ PARTITION BY source_ref
1264
+ ORDER BY updated_at DESC, id ASC
1265
+ ) AS rn,
1266
+ COUNT(*) OVER (PARTITION BY source_ref) AS fact_count
1267
+ FROM ${this.prefix}entries
1268
+ WHERE entity_id = ? AND deleted_at IS NULL AND source_ref IS NOT NULL
1269
+ )
1270
+ SELECT source_ref,
1271
+ source_hash AS source_hash,
1272
+ fact_count AS fact_count,
1273
+ updated_at AS last_ingested_at
1274
+ FROM ranked
1275
+ WHERE rn = 1
1276
+ ORDER BY source_ref COLLATE BINARY`,
1277
+ [entityId]
1278
+ );
1279
+ return rows.map((r) => ({
1280
+ sourceRef: r.source_ref,
1281
+ sourceHash: r.source_hash,
1282
+ factCount: Number(r.fact_count),
1283
+ lastIngestedAt: Number(r.last_ingested_at)
1284
+ }));
1285
+ }
1286
+ /**
1287
+ * Count live entries for an entity across all source_refs/source_hashes.
1288
+ * Used by forget({ dryRun, clearAll: true }).
1289
+ */
1290
+ async countLiveByEntityId(entityId, tx) {
1291
+ const executor = this.getExecutor(tx);
1292
+ const row = await executor.getFirstAsync(
1293
+ `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
1294
+ WHERE entity_id = ? AND deleted_at IS NULL`,
1295
+ [entityId]
1296
+ );
1297
+ return row?.cnt ?? 0;
1298
+ }
1299
+ /**
1300
+ * Count live entries matching a source filter (either or both may be null).
1301
+ * Used by forget({ dryRun: true }) with sourceRef/sourceHash.
1302
+ */
1303
+ async countLiveBySource(entityId, sourceRef, sourceHash, tx) {
1304
+ const executor = this.getExecutor(tx);
1305
+ let q = `SELECT COUNT(*) AS cnt FROM ${this.prefix}entries
1306
+ WHERE entity_id = ? AND deleted_at IS NULL`;
1307
+ const args = [entityId];
1308
+ if (sourceRef !== null) {
1309
+ q += ` AND source_ref = ?`;
1310
+ args.push(sourceRef);
1311
+ }
1312
+ if (sourceHash !== null) {
1313
+ q += ` AND source_hash = ?`;
1314
+ args.push(sourceHash);
1315
+ }
1316
+ const row = await executor.getFirstAsync(q, args);
1317
+ return row?.cnt ?? 0;
1318
+ }
1089
1319
  async findMetadataByIds(ids, tx) {
1090
1320
  if (ids.length === 0) return [];
1091
1321
  const executor = this.getExecutor(tx);
@@ -1381,6 +1611,57 @@ var OutboxRepository = class extends BaseRepository {
1381
1611
  }
1382
1612
  };
1383
1613
 
1614
+ // src/repositories/SourceRefIndexRepository.ts
1615
+ var SourceRefIndexRepository = class extends BaseRepository {
1616
+ /**
1617
+ * Idempotent insert: the partial UNIQUE index catches concurrent inserts for
1618
+ * the same (entity_id, source_hash). The caller (IngestionService) catches
1619
+ * the resulting SQLITE_CONSTRAINT_UNIQUE and translates it to the per-mode
1620
+ * duplicate-hash outcome. Runtime IDs use the `sri_` prefix to avoid
1621
+ * collision with the deterministic `sri:<entity>:<hash>` IDs used by the v9
1622
+ * backfill (the colon separator keeps the two ID spaces disjoint).
1623
+ */
1624
+ async upsert(entityId, sourceHash, sourceRef, tx) {
1625
+ const executor = this.getExecutor(tx);
1626
+ await executor.runAsync(
1627
+ `INSERT INTO ${this.prefix}source_ref_index (id, entity_id, source_hash, source_ref, created_at, deleted_at)
1628
+ VALUES (?, ?, ?, ?, ?, NULL)`,
1629
+ [generateId("sri_"), entityId, sourceHash, sourceRef, Date.now()]
1630
+ );
1631
+ }
1632
+ /**
1633
+ * Idempotent soft-delete of the live row for (entity_id, source_ref).
1634
+ * Called at the start of every ingestDocument to remove the prior run's
1635
+ * index row, so the new upsert doesn't collide with itself. No-op when the
1636
+ * row is already soft-deleted or never existed.
1637
+ */
1638
+ async softDeleteByEntityAndSourceRef(entityId, sourceRef, tx) {
1639
+ const executor = this.getExecutor(tx);
1640
+ const now = Date.now();
1641
+ const result = await executor.runAsync(
1642
+ `UPDATE ${this.prefix}source_ref_index
1643
+ SET deleted_at = ?, created_at = ?
1644
+ WHERE entity_id = ? AND source_ref = ? AND deleted_at IS NULL`,
1645
+ [now, now, entityId, sourceRef]
1646
+ );
1647
+ return result.changes;
1648
+ }
1649
+ /**
1650
+ * Returns the live sourceRef holding the given hash, or null when no live
1651
+ * row exists. Used by the IngestionService pre-check (line 82) and the
1652
+ * catch-and-translate canonical lookup (line 212).
1653
+ */
1654
+ async findActiveByEntityAndHash(entityId, sourceHash, tx) {
1655
+ const executor = this.getExecutor(tx);
1656
+ const row = await executor.getFirstAsync(
1657
+ `SELECT source_ref FROM ${this.prefix}source_ref_index
1658
+ WHERE entity_id = ? AND source_hash = ? AND deleted_at IS NULL`,
1659
+ [entityId, sourceHash]
1660
+ );
1661
+ return row?.source_ref ?? null;
1662
+ }
1663
+ };
1664
+
1384
1665
  // src/repositories/TaskRepository.ts
1385
1666
  function mapRowToTask(row) {
1386
1667
  return {
@@ -1637,6 +1918,18 @@ var TaskRepository = class extends BaseRepository {
1637
1918
  }
1638
1919
  return result.changes;
1639
1920
  }
1921
+ /**
1922
+ * Count live tasks for an entity. Used by forget({ dryRun, clearAll: true }).
1923
+ */
1924
+ async countLiveByEntityId(entityId, tx) {
1925
+ const executor = this.getExecutor(tx);
1926
+ const row = await executor.getFirstAsync(
1927
+ `SELECT COUNT(*) AS cnt FROM ${this.prefix}tasks
1928
+ WHERE entity_id = ? AND deleted_at IS NULL`,
1929
+ [entityId]
1930
+ );
1931
+ return row?.cnt ?? 0;
1932
+ }
1640
1933
  };
1641
1934
 
1642
1935
  // src/repositories/EventRepository.ts
@@ -2412,6 +2705,19 @@ var JobManager = class {
2412
2705
  this.prefix = prefix;
2413
2706
  this.activeMaintenanceJobs = /* @__PURE__ */ new Set();
2414
2707
  this.activeIngestJobs = /* @__PURE__ */ new Map();
2708
+ /**
2709
+ * Per-(entityId, sourceHash) promise-chain lock. Serializes ingest races
2710
+ * at the application level (the v9 partial UNIQUE index enforces it at
2711
+ * the DB level as defense-in-depth). Two callers holding the same hash
2712
+ * see FIFO order; different hashes never block one another.
2713
+ *
2714
+ * Keyed `${entityId}\0${sourceHash}` so distinct (entityId, sourceHash)
2715
+ * pairs can never share a lock. A raw concatenation would let
2716
+ * ("ab", "c…") collide with ("a", "bc…"); the NUL separator makes that
2717
+ * impossible because neither component can contain NUL (entityIds are
2718
+ * user-supplied identifiers; sourceHashes are hex digests).
2719
+ */
2720
+ this.hashLocks = /* @__PURE__ */ new Map();
2415
2721
  this.statusSubscribers = /* @__PURE__ */ new Map();
2416
2722
  /**
2417
2723
  * Lookup table for acquireLock/releaseLock's dynamic-dispatch branch.
@@ -2429,6 +2735,51 @@ var JobManager = class {
2429
2735
  ontologyBackfill: (id) => this._ontologyBackfillKey(id)
2430
2736
  };
2431
2737
  }
2738
+ _hashLockKey(entityId, sourceHash) {
2739
+ return `${entityId}\0${sourceHash}`;
2740
+ }
2741
+ /**
2742
+ * Acquire a promise-chain lock scoped to one `(entityId, sourceHash)` pair.
2743
+ * Returns a zero-argument release closure that removes the current tail
2744
+ * from the chain. Releasing is idempotent so a holder that double-fires
2745
+ * (e.g. on a finally that races an explicit release) cannot unblock
2746
+ * the next holder prematurely.
2747
+ *
2748
+ * Documented ordering: ingest acquires the hash lock FIRST, then the
2749
+ * synchronous sourceRef ingest lock, and the release closure unwinds in
2750
+ * the opposite order (sourceRef first, hash second). Hash-then-sourceRef
2751
+ * is required because the v9 partial UNIQUE index conflicts on the
2752
+ * (entity_id, source_hash) pair regardless of source_ref, so two callers
2753
+ * with DIFFERENT source_refs racing the same hash are the exact case the
2754
+ * TOCTOU race fix needs to serialize.
2755
+ *
2756
+ * Mechanics: each holder owns a private `released` promise that resolves
2757
+ * only when THIS holder calls release(). The map stores the holder's
2758
+ * `released` promise as the "tail" — the NEXT caller awaits that tail
2759
+ * via `previous = this.hashLocks.get(key)`. The current caller gets
2760
+ * their unique release function once `previous` (the prior holder's
2761
+ * release signal) settles, so each holder is guaranteed a UNIQUE release
2762
+ * closure bound to their own `released` promise.
2763
+ */
2764
+ acquireHashLock(entityId, sourceHash) {
2765
+ const key = this._hashLockKey(entityId, sourceHash);
2766
+ const previous = this.hashLocks.get(key) ?? Promise.resolve();
2767
+ let resolveReleased;
2768
+ const released = new Promise((resolve) => {
2769
+ resolveReleased = resolve;
2770
+ });
2771
+ let fired = false;
2772
+ const release = () => {
2773
+ if (fired) return;
2774
+ fired = true;
2775
+ if (this.hashLocks.get(key) === released) {
2776
+ this.hashLocks.delete(key);
2777
+ }
2778
+ resolveReleased();
2779
+ };
2780
+ this.hashLocks.set(key, released);
2781
+ return previous.then(() => release);
2782
+ }
2432
2783
  _pruneKey(entityId) {
2433
2784
  return `${this.prefix}:${entityId}:prune`;
2434
2785
  }
@@ -2614,6 +2965,19 @@ var JobManager = class {
2614
2965
  return this.activeMaintenanceJobs.has(this.lockKeyFns[operation](entityId)) || this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this._isReembedActive(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
2615
2966
  case "prune":
2616
2967
  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);
2968
+ case "reembed":
2969
+ 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);
2970
+ case "global_reembed":
2971
+ 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");
2972
+ case "import":
2973
+ case "forget": {
2974
+ const selfKey = operation === "import" ? this._importKey(entityId) : this._forgetKey(entityId);
2975
+ 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);
2976
+ }
2977
+ case "global_import":
2978
+ return this.activeMaintenanceJobs.has(this._globalImportKey());
2979
+ case "ingest":
2980
+ return this._hasIngestJob(entityId) || this.activeMaintenanceJobs.has(this._pruneKey(entityId)) || this._isReembedActive(entityId) || this._isImportActiveFor(entityId) || this._isForgetActiveFor(entityId);
2617
2981
  default:
2618
2982
  return false;
2619
2983
  }
@@ -2629,6 +2993,38 @@ var JobManager = class {
2629
2993
  this._notifyStatusSubscribers(entityId);
2630
2994
  return true;
2631
2995
  }
2996
+ /**
2997
+ * Centralized ingest lock acquisition. Acquires the hash lock FIRST
2998
+ * (FIFO across callers racing the same hash), then the synchronous
2999
+ * sourceRef ingest lock (rejects cross-entity conflicts and busy
3000
+ * operations). Returns a single zero-argument release closure that
3001
+ * unwinds in the opposite order — sourceRef first, hash second — so
3002
+ * the sourceRef lock is freed the instant its work is done and only
3003
+ * the hash lock keeps serializing the duplicate-content race window.
3004
+ *
3005
+ * On a sourceRef conflict (WikiBusyError thrown synchronously by the
3006
+ * underlying acquireLock) the hash lock is released BEFORE the error
3007
+ * propagates, so the next caller in the hash chain is not held up by
3008
+ * a request that never reached the DB.
3009
+ */
3010
+ async acquireIngestLocks(entityId, sourceRef, sourceHash) {
3011
+ const releaseHash = await this.acquireHashLock(entityId, sourceHash);
3012
+ let sourceLockAcquired = false;
3013
+ try {
3014
+ this.acquireLock("ingest", entityId, sourceRef);
3015
+ sourceLockAcquired = true;
3016
+ } catch (err) {
3017
+ releaseHash();
3018
+ throw err;
3019
+ }
3020
+ return () => {
3021
+ try {
3022
+ if (sourceLockAcquired) this.releaseLock("ingest", entityId, sourceRef);
3023
+ } finally {
3024
+ releaseHash();
3025
+ }
3026
+ };
3027
+ }
2632
3028
  /**
2633
3029
  * Validates then acquires global + per-entity import locks atomically.
2634
3030
  * Validates all entities before acquiring any lock (same as current importDump semantics).
@@ -3069,18 +3465,19 @@ var DEFAULT_CHUNK_OVERLAP = 400;
3069
3465
 
3070
3466
  // src/services/IngestionService.ts
3071
3467
  var IngestionService = class {
3072
- constructor(db, prefix, options, entryRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
3468
+ constructor(db, prefix, options, entryRepo, sourceRefIndexRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
3073
3469
  this.db = db;
3074
3470
  this.prefix = prefix;
3075
3471
  this.options = options;
3076
3472
  this.entryRepo = entryRepo;
3473
+ this.sourceRefIndexRepo = sourceRefIndexRepo;
3077
3474
  this.searchService = searchService;
3078
3475
  this.jobManager = jobManager;
3079
3476
  this.embeddingService = embeddingService;
3080
3477
  this.ontologyService = ontologyService;
3081
3478
  this.promptService = promptService ?? new PromptService(this.options.config?.prompts);
3082
3479
  }
3083
- async ingestDocument(entityId, params) {
3480
+ async ingestDocument(entityId, params, opts) {
3084
3481
  const sourceRef = normalizeSourceRef(params.sourceRef);
3085
3482
  if (!sourceRef) throw new Error("Invalid sourceRef");
3086
3483
  const sourceHash = normalizeSourceHash(params.sourceHash);
@@ -3096,8 +3493,18 @@ var IngestionService = class {
3096
3493
  if (typeof params.documentChunk !== "string") {
3097
3494
  throw new Error(`documentChunk must be a string, received ${typeof params.documentChunk}`);
3098
3495
  }
3099
- this.jobManager.acquireLock("ingest", entityId, sourceRef);
3496
+ const releaseIngestLocks = await this.jobManager.acquireIngestLocks(entityId, sourceRef, sourceHash);
3100
3497
  try {
3498
+ const onDuplicateHash = opts?.onDuplicateHash ?? "ingest";
3499
+ if (onDuplicateHash !== "ingest") {
3500
+ const canonical = await this.sourceRefIndexRepo.findActiveByEntityAndHash(entityId, sourceHash);
3501
+ if (canonical !== null && canonical !== sourceRef) {
3502
+ if (onDuplicateHash === "throw") {
3503
+ throw new WikiDuplicateHashError({ canonical, sourceHash, entityId });
3504
+ }
3505
+ return { truncated: false, chunks: 0, duplicateOf: canonical };
3506
+ }
3507
+ }
3101
3508
  const { chunks, truncated } = chunkText(params.documentChunk, maxChunkLength, chunkOverlap);
3102
3509
  if (chunks.length === 0) return { truncated: false, chunks: 0 };
3103
3510
  const chunkResults = await withConcurrency(
@@ -3133,68 +3540,81 @@ var IngestionService = class {
3133
3540
  const now = Date.now();
3134
3541
  const insertedFacts = [];
3135
3542
  const deletedSourceFactIds = [];
3136
- await this.db.withTransactionAsync(async (tx) => {
3137
- deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
3138
- await this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, null);
3139
- const titleIndex = /* @__PURE__ */ new Map();
3140
- const pendingEdges = [];
3141
- const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx);
3142
- for (const existing of existingFacts) {
3143
- titleIndex.set(normalizeTitleKey(existing.title), {
3144
- id: existing.id,
3145
- okf_type: existing.okf_type ?? null
3146
- });
3147
- }
3148
- let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
3149
- let { mode, manifest } = ontologyState;
3150
- for (const { facts, ontology_updates } of orderedChunkFacts) {
3151
- if (mode === "emergent" && ontology_updates && this.ontologyService) {
3152
- manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
3153
- ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
3154
- mode = ontologyState.mode;
3543
+ try {
3544
+ await this.db.withTransactionAsync(async (tx) => {
3545
+ deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
3546
+ await this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, null);
3547
+ await this.sourceRefIndexRepo.softDeleteByEntityAndSourceRef(entityId, sourceRef, tx);
3548
+ await this.sourceRefIndexRepo.upsert(entityId, sourceHash, sourceRef, tx);
3549
+ const titleIndex = /* @__PURE__ */ new Map();
3550
+ const pendingEdges = [];
3551
+ const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx);
3552
+ for (const existing of existingFacts) {
3553
+ titleIndex.set(normalizeTitleKey(existing.title), {
3554
+ id: existing.id,
3555
+ okf_type: existing.okf_type ?? null
3556
+ });
3155
3557
  }
3156
- for (const fact of facts) {
3157
- const ontologyFact = fact;
3158
- const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest) ?? { okf_type: null, edges: [] };
3159
- const id = generateId("fact_");
3160
- const wikiFact = {
3161
- id,
3162
- entity_id: entityId,
3163
- title: fact.title,
3164
- body: fact.body,
3165
- tags: fact.tags,
3166
- confidence: fact.confidence,
3167
- source_type: "immutable_document",
3168
- source_hash: sourceHash,
3169
- source_ref: sourceRef,
3170
- created_at: now,
3171
- updated_at: now,
3172
- last_accessed_at: null,
3173
- access_count: 0,
3174
- deleted_at: null,
3175
- okf_type: normalized.okf_type
3176
- };
3177
- await this.entryRepo.upsert(wikiFact, tx);
3178
- insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
3179
- titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
3180
- if (normalized.edges.length > 0) {
3181
- pendingEdges.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
3558
+ let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
3559
+ let { mode, manifest } = ontologyState;
3560
+ for (const { facts, ontology_updates } of orderedChunkFacts) {
3561
+ if (mode === "emergent" && ontology_updates && this.ontologyService) {
3562
+ manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
3563
+ ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
3564
+ mode = ontologyState.mode;
3565
+ }
3566
+ for (const fact of facts) {
3567
+ const ontologyFact = fact;
3568
+ const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest) ?? { okf_type: null, edges: [] };
3569
+ const id = generateId("fact_");
3570
+ const wikiFact = {
3571
+ id,
3572
+ entity_id: entityId,
3573
+ title: fact.title,
3574
+ body: fact.body,
3575
+ tags: fact.tags,
3576
+ confidence: fact.confidence,
3577
+ source_type: "immutable_document",
3578
+ source_hash: sourceHash,
3579
+ source_ref: sourceRef,
3580
+ created_at: now,
3581
+ updated_at: now,
3582
+ last_accessed_at: null,
3583
+ access_count: 0,
3584
+ deleted_at: null,
3585
+ okf_type: normalized.okf_type
3586
+ };
3587
+ await this.entryRepo.upsert(wikiFact, tx);
3588
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
3589
+ titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
3590
+ if (normalized.edges.length > 0) {
3591
+ pendingEdges.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
3592
+ }
3182
3593
  }
3183
3594
  }
3595
+ for (const item of pendingEdges) {
3596
+ await this.ontologyService?.resolveAndPersistEdges(
3597
+ entityId,
3598
+ item.sourceId,
3599
+ item.sourceType,
3600
+ item.edges ?? [],
3601
+ manifest,
3602
+ titleIndex,
3603
+ tx,
3604
+ now
3605
+ );
3606
+ }
3607
+ });
3608
+ } catch (err) {
3609
+ const sqliteCode = err instanceof WikiTransactionError ? err.sqliteErrorCode : extractSqliteCode(err);
3610
+ if (sqliteCode !== "SQLITE_CONSTRAINT_UNIQUE") throw err;
3611
+ const canonical = await this.sourceRefIndexRepo.findActiveByEntityAndHash(entityId, sourceHash);
3612
+ if (canonical === null) throw err;
3613
+ if (onDuplicateHash === "throw" || onDuplicateHash === "ingest") {
3614
+ throw new WikiDuplicateHashError({ canonical, sourceHash, entityId });
3184
3615
  }
3185
- for (const item of pendingEdges) {
3186
- await this.ontologyService?.resolveAndPersistEdges(
3187
- entityId,
3188
- item.sourceId,
3189
- item.sourceType,
3190
- item.edges ?? [],
3191
- manifest,
3192
- titleIndex,
3193
- tx,
3194
- now
3195
- );
3196
- }
3197
- });
3616
+ return { truncated: false, chunks: 0, duplicateOf: canonical };
3617
+ }
3198
3618
  await this.searchService.sync(entityId);
3199
3619
  const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
3200
3620
  for (const factId of uniqueDeletedSourceFactIds) {
@@ -3210,7 +3630,7 @@ var IngestionService = class {
3210
3630
  this.searchService.evictCache(entityId);
3211
3631
  return { truncated, chunks: chunks.length };
3212
3632
  } finally {
3213
- this.jobManager.releaseLock("ingest", entityId, sourceRef);
3633
+ releaseIngestLocks();
3214
3634
  }
3215
3635
  }
3216
3636
  };
@@ -3334,11 +3754,12 @@ var HEAL_MAX_PROMPT_CHARS = 4e4;
3334
3754
  var HEAL_BATCH_SIZE = 25;
3335
3755
  var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
3336
3756
  var MaintenanceService = class {
3337
- constructor(db, prefix, options, entryRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
3757
+ constructor(db, prefix, options, entryRepo, sourceRefIndexRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
3338
3758
  this.db = db;
3339
3759
  this.prefix = prefix;
3340
3760
  this.options = options;
3341
3761
  this.entryRepo = entryRepo;
3762
+ this.sourceRefIndexRepo = sourceRefIndexRepo;
3342
3763
  this.taskRepo = taskRepo;
3343
3764
  this.eventRepo = eventRepo;
3344
3765
  this.metadataRepo = metadataRepo;
@@ -3497,10 +3918,13 @@ var MaintenanceService = class {
3497
3918
  this.jobManager.releaseLock(op, entityId ?? "*");
3498
3919
  }
3499
3920
  }
3500
- async forget(entityId, params) {
3921
+ async forget(entityId, params, opts) {
3501
3922
  if (params.clearAll && (params.entryId !== void 0 || params.taskId !== void 0 || params.sourceRef !== void 0 || params.sourceHash !== void 0)) {
3502
3923
  throw new Error("forget() clearAll is mutually exclusive with entryId, taskId, sourceRef, and sourceHash");
3503
3924
  }
3925
+ if (opts?.dryRun === true) {
3926
+ return this.forgetDryRun(entityId, params);
3927
+ }
3504
3928
  this.jobManager.acquireLock("forget", entityId);
3505
3929
  try {
3506
3930
  const now = Date.now();
@@ -3534,10 +3958,12 @@ var MaintenanceService = class {
3534
3958
  const entryPromise = params.entryId ? this.entryRepo.softDelete(params.entryId, entityId, tx).then((r) => r.changes > 0) : null;
3535
3959
  const taskDeletedPromise = params.taskId ? this.taskRepo.softDeleteById(params.taskId, entityId, tx).then((r) => r.changes > 0) : null;
3536
3960
  const refPromise = sourceRef || sourceHash ? this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, sourceHash) : null;
3961
+ const sourceRefIndexPromise = sourceRef ? this.sourceRefIndexRepo.softDeleteByEntityAndSourceRef(entityId, sourceRef, tx).then(() => void 0) : null;
3537
3962
  const [entryResult, taskResult, refResult] = await Promise.all([
3538
3963
  entryPromise ?? Promise.resolve(false),
3539
3964
  taskDeletedPromise ?? Promise.resolve(false),
3540
- refPromise ?? Promise.resolve(0)
3965
+ refPromise ?? Promise.resolve(0),
3966
+ sourceRefIndexPromise ?? Promise.resolve(void 0)
3541
3967
  ]);
3542
3968
  if (entryResult) deletedEntries++;
3543
3969
  if (taskResult) deletedTasks++;
@@ -3561,11 +3987,42 @@ var MaintenanceService = class {
3561
3987
  throw new Error(`forget(${entityId}/${factId}) failed: ANN cleanup hook rejected`, { cause: this._sanitizeRankerError(hookErr) });
3562
3988
  }
3563
3989
  }
3564
- return { deleted: { entries: deletedEntries, tasks: deletedTasks } };
3990
+ return params.clearAll ? { deleted: { entries: deletedEntries, tasks: deletedTasks }, metadataReset: true } : { deleted: { entries: deletedEntries, tasks: deletedTasks } };
3565
3991
  } finally {
3566
3992
  this.jobManager.releaseLock("forget", entityId);
3567
3993
  }
3568
3994
  }
3995
+ /**
3996
+ * Read-only dry-run preview of {@link forget}. Returns the same shape as a
3997
+ * real call would, without acquiring the lock, opening a transaction, staging
3998
+ * outbox events, or firing embedding hooks. Counts may be off-by-N during a
3999
+ * concurrent real forget — this is accepted, not considered a bug.
4000
+ *
4001
+ * `metadataReset` mirrors the real call: only `clearAll: true` returns true,
4002
+ * because only `clearAll` actually resets the metadata checkpoint.
4003
+ * `standard` (sourceRef/sourceHash) returns no metadataReset field, just like
4004
+ * the real call. `entryId`/`taskId` selectors are rejected with a clear error
4005
+ * (the spec keeps dry-run narrow).
4006
+ */
4007
+ async forgetDryRun(entityId, params) {
4008
+ if (params.entryId !== void 0 || params.taskId !== void 0) {
4009
+ throw new Error("forget({ dryRun: true }) does not support entryId/taskId selectors; use sourceRef/sourceHash or clearAll");
4010
+ }
4011
+ if (params.clearAll) {
4012
+ const entries2 = await this.entryRepo.countLiveByEntityId(entityId);
4013
+ const tasks = await this.taskRepo.countLiveByEntityId(entityId);
4014
+ return { deleted: { entries: entries2, tasks }, metadataReset: true };
4015
+ }
4016
+ const sourceRef = params.sourceRef !== void 0 ? normalizeSourceRef(params.sourceRef) : null;
4017
+ if (params.sourceRef !== void 0 && !sourceRef) throw new Error("Invalid sourceRef");
4018
+ const sourceHash = params.sourceHash !== void 0 ? normalizeSourceHash(params.sourceHash) : null;
4019
+ if (params.sourceHash !== void 0 && !sourceHash) throw new Error("Invalid sourceHash (must be 64-char hex string)");
4020
+ if (sourceRef === null && sourceHash === null) {
4021
+ return { deleted: { entries: 0, tasks: 0 } };
4022
+ }
4023
+ const entries = await this.entryRepo.countLiveBySource(entityId, sourceRef, sourceHash);
4024
+ return { deleted: { entries, tasks: 0 } };
4025
+ }
3569
4026
  /** Core librarian pass (locks handled by {@link runLibrarian}). Package-internal orchestration hook. */
3570
4027
  async doRunLibrarian(entityId, promptOverride) {
3571
4028
  const events = await this.eventRepo.getRecent(entityId, 50);
@@ -4289,6 +4746,15 @@ var ImportExportService = class {
4289
4746
  const safeTitle = clip(String(fact.title ?? ""), IMPORT_TITLE_MAX);
4290
4747
  const safeBody = clip(String(fact.body ?? ""), IMPORT_BODY_MAX);
4291
4748
  clippedTextByFactId.set(fact.id, { title: safeTitle, body: safeBody });
4749
+ let normalizedSourceRef = null;
4750
+ if (fact.source_ref !== null && fact.source_ref !== void 0) {
4751
+ normalizedSourceRef = normalizeSourceRef(fact.source_ref);
4752
+ if (normalizedSourceRef === null) {
4753
+ throw new Error(
4754
+ `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)`
4755
+ );
4756
+ }
4757
+ }
4292
4758
  const factObj = {
4293
4759
  id: fact.id,
4294
4760
  entity_id: entityId,
@@ -4298,7 +4764,7 @@ var ImportExportService = class {
4298
4764
  confidence: fact.confidence,
4299
4765
  source_type: sourceType,
4300
4766
  source_hash: fact.source_hash,
4301
- source_ref: fact.source_ref,
4767
+ source_ref: normalizedSourceRef,
4302
4768
  created_at: fact.created_at,
4303
4769
  updated_at: safeUpdatedAt,
4304
4770
  last_accessed_at: fact.last_accessed_at,
@@ -5474,6 +5940,7 @@ var WikiMemory = class {
5474
5940
  }
5475
5941
  this.outboxRepo = new OutboxRepository(this.db, this.prefix, !!options.config?.enableOutbox);
5476
5942
  this.entryRepo = new EntryRepository(this.db, this.prefix, this.outboxRepo);
5943
+ this.sourceRefIndexRepo = new SourceRefIndexRepository(this.db, this.prefix);
5477
5944
  this.taskRepo = new TaskRepository(this.db, this.prefix, this.outboxRepo);
5478
5945
  this.eventRepo = new EventRepository(this.db, this.prefix);
5479
5946
  this.edgeRepo = new EdgeRepository(this.db, this.prefix);
@@ -5492,6 +5959,7 @@ var WikiMemory = class {
5492
5959
  this.prefix,
5493
5960
  this.options,
5494
5961
  this.entryRepo,
5962
+ this.sourceRefIndexRepo,
5495
5963
  this.searchService,
5496
5964
  this.jobManager,
5497
5965
  this.embeddingService,
@@ -5503,6 +5971,7 @@ var WikiMemory = class {
5503
5971
  this.prefix,
5504
5972
  this.options,
5505
5973
  this.entryRepo,
5974
+ this.sourceRefIndexRepo,
5506
5975
  this.taskRepo,
5507
5976
  this.eventRepo,
5508
5977
  this.metadataRepo,
@@ -5567,6 +6036,7 @@ var WikiMemory = class {
5567
6036
  promptService: this.promptService,
5568
6037
  graphTraversalService: this.graphTraversalService,
5569
6038
  entryRepo: this.entryRepo,
6039
+ sourceRefIndexRepo: this.sourceRefIndexRepo,
5570
6040
  metadataRepo: this.metadataRepo,
5571
6041
  jobManager: this.jobManager
5572
6042
  };
@@ -5616,19 +6086,71 @@ var WikiMemory = class {
5616
6086
  });
5617
6087
  await this.searchService.sync();
5618
6088
  }
5619
- async hasChanged(entityId, sourceRef, sourceHash) {
5620
- const normalizedRef = normalizeSourceRef(sourceRef);
5621
- if (!normalizedRef) {
5622
- throw new Error(`Invalid sourceRef: ${JSON.stringify(sourceRef)}`);
5623
- }
5624
- const normalizedHash = normalizeSourceHash(sourceHash);
5625
- if (!normalizedHash) {
5626
- throw new Error(`Invalid sourceHash: must be a 64-character hex string (normalized to lowercase)`);
5627
- }
5628
- const storedHash = await this.entryRepo.findLatestSourceHash(entityId, normalizedRef);
5629
- if (storedHash === null) return true;
5630
- const normalizedStoredHash = normalizeSourceHash(storedHash);
5631
- return normalizedStoredHash !== normalizedHash;
6089
+ async hasChanged(entityId, sourceRefOrEntries, sourceHashArg) {
6090
+ if (typeof sourceRefOrEntries === "string") {
6091
+ const sourceRef = normalizeSourceRef(sourceRefOrEntries);
6092
+ if (!sourceRef) {
6093
+ throw new Error(`Invalid sourceRef: ${JSON.stringify(sourceRefOrEntries)}`);
6094
+ }
6095
+ const sourceHash = normalizeSourceHash(sourceHashArg);
6096
+ if (!sourceHash) {
6097
+ throw new Error("Invalid sourceHash: must be a 64-character hex string (normalized to lowercase)");
6098
+ }
6099
+ const storedHash = await this.entryRepo.findLatestSourceHash(entityId, sourceRef);
6100
+ if (storedHash === null) return true;
6101
+ const normalizedStoredHash = normalizeSourceHash(storedHash);
6102
+ return normalizedStoredHash !== sourceHash;
6103
+ }
6104
+ const entries = sourceRefOrEntries;
6105
+ if (entries.length === 0) return [];
6106
+ const normalized = entries.map((e) => {
6107
+ const r = normalizeSourceRef(e.sourceRef);
6108
+ if (!r) throw new Error(`Invalid sourceRef: ${JSON.stringify(e.sourceRef)}`);
6109
+ const h = normalizeSourceHash(e.sourceHash);
6110
+ if (!h) throw new Error("Invalid sourceHash: must be a 64-character hex string (normalized to lowercase)");
6111
+ return { rawSourceRef: e.sourceRef, sourceRef: r, sourceHash: h };
6112
+ });
6113
+ const latestHashes = await this.entryRepo.findLatestSourceHashes(entityId, normalized.map((e) => e.sourceRef));
6114
+ const distinctHashes = Array.from(new Set(normalized.map((e) => e.sourceHash)));
6115
+ const dupRefs = await Promise.all(
6116
+ distinctHashes.map((h) => this.sourceRefIndexRepo.findActiveByEntityAndHash(entityId, h))
6117
+ );
6118
+ const dupMap = /* @__PURE__ */ new Map();
6119
+ for (let i = 0; i < distinctHashes.length; i++) {
6120
+ dupMap.set(distinctHashes[i], dupRefs[i]);
6121
+ }
6122
+ return normalized.map((e) => {
6123
+ const stored = latestHashes.get(e.sourceRef);
6124
+ const changed = stored === void 0 || stored === null || normalizeSourceHash(stored) !== e.sourceHash;
6125
+ const canonical = dupMap.get(e.sourceHash) ?? null;
6126
+ if (canonical === null || canonical === e.sourceRef) {
6127
+ return { sourceRef: e.rawSourceRef, changed };
6128
+ }
6129
+ return { sourceRef: e.rawSourceRef, changed, duplicateOf: canonical };
6130
+ });
6131
+ }
6132
+ /**
6133
+ * Returns the live source_refs for an entity, one row per ref, with the most
6134
+ * recently-updated live `source_hash` and a live fact count. Refs are sorted
6135
+ * `COLLATE BINARY` (no locale dependency). Used by hosts to reconcile stored
6136
+ * state against a live source, or audit duplicate-hash collisions via
6137
+ * `findSourceRefsByHash`.
6138
+ */
6139
+ async listSourceRefs(entityId) {
6140
+ return this.entryRepo.listSourceRefs(entityId);
6141
+ }
6142
+ /**
6143
+ * Returns the live source_refs for an entity that hold the given source_hash.
6144
+ * With v9, source_ref_index is the source of truth for the sourceRef-level
6145
+ * TOCTOU-race invariant: at most one sourceRef can hold a given
6146
+ * (entity_id, source_hash). The result is either a single-element array
6147
+ * (one canonical ref) or empty (no live ref holds the hash). Returned as
6148
+ * an array to preserve the existing public-API shape used by hosts
6149
+ * auditing duplicate-content collisions.
6150
+ */
6151
+ async findSourceRefsByHash(entityId, sourceHash) {
6152
+ const canonical = await this.sourceRefIndexRepo.findActiveByEntityAndHash(entityId, sourceHash);
6153
+ return canonical === null ? [] : [canonical];
5632
6154
  }
5633
6155
  async runPrune(entityId, options) {
5634
6156
  return this.maintenanceService.runPrune(entityId, options);
@@ -5715,16 +6237,16 @@ var WikiMemory = class {
5715
6237
  async importDump(dump, opts) {
5716
6238
  return this.importExportService.importDump(dump, opts);
5717
6239
  }
5718
- async forget(entityId, params) {
5719
- return this.maintenanceService.forget(entityId, params);
6240
+ async forget(entityId, params, opts) {
6241
+ return this.maintenanceService.forget(entityId, params, opts);
5720
6242
  }
5721
6243
  /**
5722
6244
  * @param params.promptOverride - Overrides the system prompt for this ingest call only.
5723
6245
  * For persistent customization, set `options.config.prompts.ingestSystemPrompt` at
5724
6246
  * WikiMemory construction time.
5725
6247
  */
5726
- async ingestDocument(entityId, params) {
5727
- return this.ingestionService.ingestDocument(entityId, params);
6248
+ async ingestDocument(entityId, params, opts) {
6249
+ return this.ingestionService.ingestDocument(entityId, params, opts);
5728
6250
  }
5729
6251
  /**
5730
6252
  * Returns up to `limit` unprocessed outbox events, oldest first.
@@ -6543,6 +7065,7 @@ exports.ONTOLOGY_BACKFILL_SYSTEM_PROMPT = ONTOLOGY_BACKFILL_SYSTEM_PROMPT;
6543
7065
  exports.PromptService = PromptService;
6544
7066
  exports.PrunePartialFailureError = PrunePartialFailureError;
6545
7067
  exports.WikiBusyError = WikiBusyError;
7068
+ exports.WikiDuplicateHashError = WikiDuplicateHashError;
6546
7069
  exports.WikiMemory = WikiMemory;
6547
7070
  exports.WikiTransactionError = WikiTransactionError;
6548
7071
  exports.chunkText = chunkText;