@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/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { __privateAdd, MetadataRepository, EmbeddingService, SearchService, JobManager, PromptService, IngestionService, MaintenanceService, ImportExportService, RetrievalService, WriteService, __privateGet, __privateSet, normalizeSourceRef, normalizeSourceHash, entitySummaryMetaKey, generateId, BaseRepository, emptyManifest, resolveNodeType, validateInlineEdges, resolveEdgeDefinitions, normalizeTitleKey, WikiTransactionError, extractSqliteCode } from './chunk-J3N3WRK7.mjs';
2
- export { DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_CHUNK_LENGTH, HEAL_BATCH_SIZE, HEAL_RECHECK_MS, HOOK_TIMEOUT_MARKER, ONTOLOGY_BACKFILL_BATCH_SIZE, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, ONTOLOGY_BACKFILL_RECHECK_MS, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, PromptService, PrunePartialFailureError, WikiBusyError, WikiDuplicateHashError, WikiTransactionError, chunkText, configureRandomSource, parseEmbedding, safeSlice, validateManifest } from './chunk-J3N3WRK7.mjs';
1
+ import { __privateAdd, MetadataRepository, EmbeddingService, SearchService, JobManager, PromptService, IngestionService, MaintenanceService, ImportExportService, RetrievalService, WriteService, __privateGet, __privateSet, normalizeSourceRef, normalizeSourceHash, entitySummaryMetaKey, generateId, BaseRepository, emptyManifest, resolveNodeType, validateInlineEdges, resolveEdgeDefinitions, normalizeTitleKey, WikiTransactionError, extractSqliteCode } from './chunk-6HNIOKT4.mjs';
2
+ export { DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_CHUNK_LENGTH, HEAL_BATCH_SIZE, HEAL_RECHECK_MS, HOOK_TIMEOUT_MARKER, ONTOLOGY_BACKFILL_BATCH_SIZE, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, ONTOLOGY_BACKFILL_RECHECK_MS, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, PromptService, PrunePartialFailureError, WikiBusyError, WikiDuplicateHashError, WikiTransactionError, chunkText, configureRandomSource, parseEmbedding, safeSlice, validateManifest } from './chunk-6HNIOKT4.mjs';
3
3
  import { appendRelatedSection, buildConceptDocument, buildLogMd, buildEntityIndexMd, buildRootIndexMd, isAllowedOkfPath, parseRootIndexMd, parseEntityIndexMd, parseConcept, splitRelatedSection, extractMarkdownLinks, parseLogMd, parseEventIdComment, appendEventIdComment } from '@equationalapplications/core-okf';
4
4
 
5
5
  // src/db/schema.ts
@@ -32,6 +32,24 @@ async function setupDatabase(db, prefix) {
32
32
  CREATE INDEX IF NOT EXISTS ${prefix}entries_source_hash_idx ON ${prefix}entries(entity_id, source_hash) WHERE source_hash IS NOT NULL;
33
33
  CREATE INDEX IF NOT EXISTS ${prefix}entries_updated_idx ON ${prefix}entries(updated_at DESC);
34
34
 
35
+ -- source_ref_index: per-(entity, source_hash) record of the canonical sourceRef
36
+ -- currently holding that hash. The partial UNIQUE index on (entity_id, source_hash)
37
+ -- WHERE deleted_at IS NULL enforces the sourceRef-level TOCTOU-race invariant;
38
+ -- entries-level uniqueness cannot express it because a single ingestDocument call
39
+ -- writes N facts that all share (entity_id, source_ref, source_hash). See
40
+ -- docs/superpowers/specs/2026-08-07-dependabot-concurrency-release-hygiene-design.md \xA7B1.
41
+ CREATE TABLE IF NOT EXISTS ${prefix}source_ref_index (
42
+ id TEXT PRIMARY KEY,
43
+ entity_id TEXT NOT NULL,
44
+ source_hash TEXT NOT NULL,
45
+ source_ref TEXT NOT NULL,
46
+ created_at INTEGER NOT NULL,
47
+ deleted_at INTEGER
48
+ );
49
+ CREATE UNIQUE INDEX IF NOT EXISTS ${prefix}idx_source_ref_hash
50
+ ON ${prefix}source_ref_index (entity_id, source_hash)
51
+ WHERE deleted_at IS NULL;
52
+
35
53
  CREATE TABLE IF NOT EXISTS ${prefix}tasks (
36
54
  id TEXT PRIMARY KEY,
37
55
  entity_id TEXT NOT NULL,
@@ -278,6 +296,61 @@ var MIGRATIONS = [
278
296
  );
279
297
  }
280
298
  }
299
+ },
300
+ {
301
+ version: 9,
302
+ description: "add_source_ref_index",
303
+ run: async (db, prefix) => {
304
+ const duplicates = await db.getAllAsync(
305
+ `SELECT entity_id, source_hash, COUNT(DISTINCT source_ref) AS n_refs
306
+ FROM ${prefix}entries
307
+ WHERE deleted_at IS NULL AND source_hash IS NOT NULL
308
+ GROUP BY entity_id, source_hash
309
+ HAVING COUNT(DISTINCT source_ref) > 1`
310
+ );
311
+ if (duplicates.length > 0) {
312
+ 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(", ");
313
+ throw new Error(
314
+ `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.`
315
+ );
316
+ }
317
+ await db.execAsync(
318
+ `CREATE TABLE IF NOT EXISTS ${prefix}source_ref_index (
319
+ id TEXT PRIMARY KEY,
320
+ entity_id TEXT NOT NULL,
321
+ source_hash TEXT NOT NULL,
322
+ source_ref TEXT NOT NULL,
323
+ created_at INTEGER NOT NULL,
324
+ deleted_at INTEGER
325
+ );
326
+ CREATE UNIQUE INDEX IF NOT EXISTS ${prefix}idx_source_ref_hash
327
+ ON ${prefix}source_ref_index (entity_id, source_hash)
328
+ WHERE deleted_at IS NULL;`
329
+ );
330
+ await db.execAsync(
331
+ `INSERT OR IGNORE INTO ${prefix}source_ref_index (id, entity_id, source_hash, source_ref, created_at, deleted_at)
332
+ SELECT
333
+ 'sri:' || entity_id || ':' || source_hash,
334
+ entity_id,
335
+ source_hash,
336
+ source_ref,
337
+ updated_at,
338
+ NULL
339
+ FROM (
340
+ SELECT
341
+ entity_id, source_hash, source_ref, updated_at,
342
+ ROW_NUMBER() OVER (
343
+ PARTITION BY entity_id, source_hash
344
+ ORDER BY updated_at ASC, id ASC
345
+ ) AS rn
346
+ FROM ${prefix}entries
347
+ WHERE deleted_at IS NULL
348
+ AND source_hash IS NOT NULL
349
+ AND source_ref IS NOT NULL
350
+ ) ranked
351
+ WHERE rn = 1;`
352
+ );
353
+ }
281
354
  }
282
355
  ];
283
356
  for (let i = 1; i < MIGRATIONS.length; i++) {
@@ -1394,6 +1467,57 @@ var OutboxRepository = class extends BaseRepository {
1394
1467
  }
1395
1468
  };
1396
1469
 
1470
+ // src/repositories/SourceRefIndexRepository.ts
1471
+ var SourceRefIndexRepository = class extends BaseRepository {
1472
+ /**
1473
+ * Idempotent insert: the partial UNIQUE index catches concurrent inserts for
1474
+ * the same (entity_id, source_hash). The caller (IngestionService) catches
1475
+ * the resulting SQLITE_CONSTRAINT_UNIQUE and translates it to the per-mode
1476
+ * duplicate-hash outcome. Runtime IDs use the `sri_` prefix to avoid
1477
+ * collision with the deterministic `sri:<entity>:<hash>` IDs used by the v9
1478
+ * backfill (the colon separator keeps the two ID spaces disjoint).
1479
+ */
1480
+ async upsert(entityId, sourceHash, sourceRef, tx) {
1481
+ const executor = this.getExecutor(tx);
1482
+ await executor.runAsync(
1483
+ `INSERT INTO ${this.prefix}source_ref_index (id, entity_id, source_hash, source_ref, created_at, deleted_at)
1484
+ VALUES (?, ?, ?, ?, ?, NULL)`,
1485
+ [generateId("sri_"), entityId, sourceHash, sourceRef, Date.now()]
1486
+ );
1487
+ }
1488
+ /**
1489
+ * Idempotent soft-delete of the live row for (entity_id, source_ref).
1490
+ * Called at the start of every ingestDocument to remove the prior run's
1491
+ * index row, so the new upsert doesn't collide with itself. No-op when the
1492
+ * row is already soft-deleted or never existed.
1493
+ */
1494
+ async softDeleteByEntityAndSourceRef(entityId, sourceRef, tx) {
1495
+ const executor = this.getExecutor(tx);
1496
+ const now = Date.now();
1497
+ const result = await executor.runAsync(
1498
+ `UPDATE ${this.prefix}source_ref_index
1499
+ SET deleted_at = ?, created_at = ?
1500
+ WHERE entity_id = ? AND source_ref = ? AND deleted_at IS NULL`,
1501
+ [now, now, entityId, sourceRef]
1502
+ );
1503
+ return result.changes;
1504
+ }
1505
+ /**
1506
+ * Returns the live sourceRef holding the given hash, or null when no live
1507
+ * row exists. Used by the IngestionService pre-check (line 82) and the
1508
+ * catch-and-translate canonical lookup (line 212).
1509
+ */
1510
+ async findActiveByEntityAndHash(entityId, sourceHash, tx) {
1511
+ const executor = this.getExecutor(tx);
1512
+ const row = await executor.getFirstAsync(
1513
+ `SELECT source_ref FROM ${this.prefix}source_ref_index
1514
+ WHERE entity_id = ? AND source_hash = ? AND deleted_at IS NULL`,
1515
+ [entityId, sourceHash]
1516
+ );
1517
+ return row?.source_ref ?? null;
1518
+ }
1519
+ };
1520
+
1397
1521
  // src/repositories/TaskRepository.ts
1398
1522
  function mapRowToTask(row) {
1399
1523
  return {
@@ -2070,6 +2194,7 @@ var WikiMemory = class {
2070
2194
  }
2071
2195
  this.outboxRepo = new OutboxRepository(this.db, this.prefix, !!options.config?.enableOutbox);
2072
2196
  this.entryRepo = new EntryRepository(this.db, this.prefix, this.outboxRepo);
2197
+ this.sourceRefIndexRepo = new SourceRefIndexRepository(this.db, this.prefix);
2073
2198
  this.taskRepo = new TaskRepository(this.db, this.prefix, this.outboxRepo);
2074
2199
  this.eventRepo = new EventRepository(this.db, this.prefix);
2075
2200
  this.edgeRepo = new EdgeRepository(this.db, this.prefix);
@@ -2088,6 +2213,7 @@ var WikiMemory = class {
2088
2213
  this.prefix,
2089
2214
  this.options,
2090
2215
  this.entryRepo,
2216
+ this.sourceRefIndexRepo,
2091
2217
  this.searchService,
2092
2218
  this.jobManager,
2093
2219
  this.embeddingService,
@@ -2099,6 +2225,7 @@ var WikiMemory = class {
2099
2225
  this.prefix,
2100
2226
  this.options,
2101
2227
  this.entryRepo,
2228
+ this.sourceRefIndexRepo,
2102
2229
  this.taskRepo,
2103
2230
  this.eventRepo,
2104
2231
  this.metadataRepo,
@@ -2163,6 +2290,7 @@ var WikiMemory = class {
2163
2290
  promptService: this.promptService,
2164
2291
  graphTraversalService: this.graphTraversalService,
2165
2292
  entryRepo: this.entryRepo,
2293
+ sourceRefIndexRepo: this.sourceRefIndexRepo,
2166
2294
  metadataRepo: this.metadataRepo,
2167
2295
  jobManager: this.jobManager
2168
2296
  };
@@ -2239,7 +2367,7 @@ var WikiMemory = class {
2239
2367
  const latestHashes = await this.entryRepo.findLatestSourceHashes(entityId, normalized.map((e) => e.sourceRef));
2240
2368
  const distinctHashes = Array.from(new Set(normalized.map((e) => e.sourceHash)));
2241
2369
  const dupRefs = await Promise.all(
2242
- distinctHashes.map((h) => this.entryRepo.findSourceRefsByHash(entityId, h))
2370
+ distinctHashes.map((h) => this.sourceRefIndexRepo.findActiveByEntityAndHash(entityId, h))
2243
2371
  );
2244
2372
  const dupMap = /* @__PURE__ */ new Map();
2245
2373
  for (let i = 0; i < distinctHashes.length; i++) {
@@ -2248,12 +2376,10 @@ var WikiMemory = class {
2248
2376
  return normalized.map((e) => {
2249
2377
  const stored = latestHashes.get(e.sourceRef);
2250
2378
  const changed = stored === void 0 || stored === null || normalizeSourceHash(stored) !== e.sourceHash;
2251
- const allRefs = dupMap.get(e.sourceHash) ?? [];
2252
- const others = allRefs.filter((r) => r !== e.sourceRef);
2253
- if (others.length === 0) {
2379
+ const canonical = dupMap.get(e.sourceHash) ?? null;
2380
+ if (canonical === null || canonical === e.sourceRef) {
2254
2381
  return { sourceRef: e.rawSourceRef, changed };
2255
2382
  }
2256
- const canonical = others[0];
2257
2383
  return { sourceRef: e.rawSourceRef, changed, duplicateOf: canonical };
2258
2384
  });
2259
2385
  }
@@ -2268,13 +2394,17 @@ var WikiMemory = class {
2268
2394
  return this.entryRepo.listSourceRefs(entityId);
2269
2395
  }
2270
2396
  /**
2271
- * Returns the live source_refs for an entity that hold the given source_hash,
2272
- * sorted `COLLATE BINARY` ascending. The first element is the canonical ref
2273
- * under the code-unit-minimum rule (no locale dependency). Used by the
2274
- * ingestDocument guard and by hosts auditing duplicate-content collisions.
2397
+ * Returns the live source_refs for an entity that hold the given source_hash.
2398
+ * With v9, source_ref_index is the source of truth for the sourceRef-level
2399
+ * TOCTOU-race invariant: at most one sourceRef can hold a given
2400
+ * (entity_id, source_hash). The result is either a single-element array
2401
+ * (one canonical ref) or empty (no live ref holds the hash). Returned as
2402
+ * an array to preserve the existing public-API shape used by hosts
2403
+ * auditing duplicate-content collisions.
2275
2404
  */
2276
2405
  async findSourceRefsByHash(entityId, sourceHash) {
2277
- return this.entryRepo.findSourceRefsByHash(entityId, sourceHash);
2406
+ const canonical = await this.sourceRefIndexRepo.findActiveByEntityAndHash(entityId, sourceHash);
2407
+ return canonical === null ? [] : [canonical];
2278
2408
  }
2279
2409
  async runPrune(entityId, options) {
2280
2410
  return this.maintenanceService.runPrune(entityId, options);