@equationalapplications/core-llm-wiki 5.2.0 → 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
  };
@@ -500,6 +506,19 @@ var JobManager = class {
500
506
  this.prefix = prefix;
501
507
  this.activeMaintenanceJobs = /* @__PURE__ */ new Set();
502
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();
503
522
  this.statusSubscribers = /* @__PURE__ */ new Map();
504
523
  /**
505
524
  * Lookup table for acquireLock/releaseLock's dynamic-dispatch branch.
@@ -517,6 +536,51 @@ var JobManager = class {
517
536
  ontologyBackfill: (id) => this._ontologyBackfillKey(id)
518
537
  };
519
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
+ }
520
584
  _pruneKey(entityId) {
521
585
  return `${this.prefix}:${entityId}:prune`;
522
586
  }
@@ -730,6 +794,38 @@ var JobManager = class {
730
794
  this._notifyStatusSubscribers(entityId);
731
795
  return true;
732
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
+ }
733
829
  /**
734
830
  * Validates then acquires global + per-entity import locks atomically.
735
831
  * Validates all entities before acquiring any lock (same as current importDump semantics).
@@ -1170,11 +1266,12 @@ var DEFAULT_CHUNK_OVERLAP = 400;
1170
1266
 
1171
1267
  // src/services/IngestionService.ts
1172
1268
  var IngestionService = class {
1173
- constructor(db, prefix, options, entryRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1269
+ constructor(db, prefix, options, entryRepo, sourceRefIndexRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1174
1270
  this.db = db;
1175
1271
  this.prefix = prefix;
1176
1272
  this.options = options;
1177
1273
  this.entryRepo = entryRepo;
1274
+ this.sourceRefIndexRepo = sourceRefIndexRepo;
1178
1275
  this.searchService = searchService;
1179
1276
  this.jobManager = jobManager;
1180
1277
  this.embeddingService = embeddingService;
@@ -1186,18 +1283,6 @@ var IngestionService = class {
1186
1283
  if (!sourceRef) throw new Error("Invalid sourceRef");
1187
1284
  const sourceHash = normalizeSourceHash(params.sourceHash);
1188
1285
  if (!sourceHash) throw new Error("Invalid sourceHash (must be 64-char hex string)");
1189
- const onDuplicateHash = opts?.onDuplicateHash ?? "ingest";
1190
- if (onDuplicateHash !== "ingest") {
1191
- const refs = await this.entryRepo.findSourceRefsByHash(entityId, sourceHash);
1192
- const others = refs.filter((r) => r !== sourceRef);
1193
- if (others.length > 0) {
1194
- const canonical = others[0];
1195
- if (onDuplicateHash === "throw") {
1196
- throw new WikiDuplicateHashError({ canonical, sourceHash, entityId });
1197
- }
1198
- return { truncated: false, chunks: 0, duplicateOf: canonical };
1199
- }
1200
- }
1201
1286
  const maxChunkLength = params.maxChunkLength ?? this.options.config?.maxChunkLength ?? DEFAULT_MAX_CHUNK_LENGTH;
1202
1287
  const rawOverlap = params.chunkOverlap ?? this.options.config?.chunkOverlap ?? DEFAULT_CHUNK_OVERLAP;
1203
1288
  const chunkOverlap = Math.min(
@@ -1209,8 +1294,18 @@ var IngestionService = class {
1209
1294
  if (typeof params.documentChunk !== "string") {
1210
1295
  throw new Error(`documentChunk must be a string, received ${typeof params.documentChunk}`);
1211
1296
  }
1212
- this.jobManager.acquireLock("ingest", entityId, sourceRef);
1297
+ const releaseIngestLocks = await this.jobManager.acquireIngestLocks(entityId, sourceRef, sourceHash);
1213
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
+ }
1214
1309
  const { chunks, truncated } = chunkText(params.documentChunk, maxChunkLength, chunkOverlap);
1215
1310
  if (chunks.length === 0) return { truncated: false, chunks: 0 };
1216
1311
  const chunkResults = await withConcurrency(
@@ -1246,68 +1341,81 @@ var IngestionService = class {
1246
1341
  const now = Date.now();
1247
1342
  const insertedFacts = [];
1248
1343
  const deletedSourceFactIds = [];
1249
- await this.db.withTransactionAsync(async (tx) => {
1250
- deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
1251
- await this.entryRepo.softDeleteBySource(entityId, tx, sourceRef, null);
1252
- const titleIndex = /* @__PURE__ */ new Map();
1253
- const pendingEdges = [];
1254
- const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx);
1255
- for (const existing of existingFacts) {
1256
- titleIndex.set(normalizeTitleKey(existing.title), {
1257
- id: existing.id,
1258
- okf_type: existing.okf_type ?? null
1259
- });
1260
- }
1261
- let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
1262
- let { mode, manifest } = ontologyState;
1263
- for (const { facts, ontology_updates } of orderedChunkFacts) {
1264
- if (mode === "emergent" && ontology_updates && this.ontologyService) {
1265
- manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
1266
- ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
1267
- 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
+ });
1268
1358
  }
1269
- for (const fact of facts) {
1270
- const ontologyFact = fact;
1271
- const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest) ?? { okf_type: null, edges: [] };
1272
- const id = generateId("fact_");
1273
- const wikiFact = {
1274
- id,
1275
- entity_id: entityId,
1276
- title: fact.title,
1277
- body: fact.body,
1278
- tags: fact.tags,
1279
- confidence: fact.confidence,
1280
- source_type: "immutable_document",
1281
- source_hash: sourceHash,
1282
- source_ref: sourceRef,
1283
- created_at: now,
1284
- updated_at: now,
1285
- last_accessed_at: null,
1286
- access_count: 0,
1287
- deleted_at: null,
1288
- okf_type: normalized.okf_type
1289
- };
1290
- await this.entryRepo.upsert(wikiFact, tx);
1291
- insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1292
- titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
1293
- if (normalized.edges.length > 0) {
1294
- 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
+ }
1295
1394
  }
1296
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 });
1297
1416
  }
1298
- for (const item of pendingEdges) {
1299
- await this.ontologyService?.resolveAndPersistEdges(
1300
- entityId,
1301
- item.sourceId,
1302
- item.sourceType,
1303
- item.edges ?? [],
1304
- manifest,
1305
- titleIndex,
1306
- tx,
1307
- now
1308
- );
1309
- }
1310
- });
1417
+ return { truncated: false, chunks: 0, duplicateOf: canonical };
1418
+ }
1311
1419
  await this.searchService.sync(entityId);
1312
1420
  const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
1313
1421
  for (const factId of uniqueDeletedSourceFactIds) {
@@ -1323,7 +1431,7 @@ var IngestionService = class {
1323
1431
  this.searchService.evictCache(entityId);
1324
1432
  return { truncated, chunks: chunks.length };
1325
1433
  } finally {
1326
- this.jobManager.releaseLock("ingest", entityId, sourceRef);
1434
+ releaseIngestLocks();
1327
1435
  }
1328
1436
  }
1329
1437
  };
@@ -1610,11 +1718,12 @@ var HEAL_MAX_PROMPT_CHARS = 4e4;
1610
1718
  var HEAL_BATCH_SIZE = 25;
1611
1719
  var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1612
1720
  var MaintenanceService = class {
1613
- 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) {
1614
1722
  this.db = db;
1615
1723
  this.prefix = prefix;
1616
1724
  this.options = options;
1617
1725
  this.entryRepo = entryRepo;
1726
+ this.sourceRefIndexRepo = sourceRefIndexRepo;
1618
1727
  this.taskRepo = taskRepo;
1619
1728
  this.eventRepo = eventRepo;
1620
1729
  this.metadataRepo = metadataRepo;
@@ -1813,10 +1922,12 @@ var MaintenanceService = class {
1813
1922
  const entryPromise = params.entryId ? this.entryRepo.softDelete(params.entryId, entityId, tx).then((r) => r.changes > 0) : null;
1814
1923
  const taskDeletedPromise = params.taskId ? this.taskRepo.softDeleteById(params.taskId, entityId, tx).then((r) => r.changes > 0) : null;
1815
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;
1816
1926
  const [entryResult, taskResult, refResult] = await Promise.all([
1817
1927
  entryPromise ?? Promise.resolve(false),
1818
1928
  taskDeletedPromise ?? Promise.resolve(false),
1819
- refPromise ?? Promise.resolve(0)
1929
+ refPromise ?? Promise.resolve(0),
1930
+ sourceRefIndexPromise ?? Promise.resolve(void 0)
1820
1931
  ]);
1821
1932
  if (entryResult) deletedEntries++;
1822
1933
  if (taskResult) deletedTasks++;
@@ -3631,5 +3742,5 @@ var WriteService = class {
3631
3742
  };
3632
3743
 
3633
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 };
3634
- //# sourceMappingURL=chunk-J3N3WRK7.mjs.map
3635
- //# sourceMappingURL=chunk-J3N3WRK7.mjs.map
3745
+ //# sourceMappingURL=chunk-6HNIOKT4.mjs.map
3746
+ //# sourceMappingURL=chunk-6HNIOKT4.mjs.map