@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.
@@ -943,6 +943,49 @@ declare class EntryRepository extends BaseRepository {
943
943
  }>>;
944
944
  }
945
945
 
946
+ /**
947
+ * Per-(entity_id, source_hash) record of the canonical sourceRef currently
948
+ * holding that hash. The partial UNIQUE index on (entity_id, source_hash)
949
+ * WHERE deleted_at IS NULL enforces the sourceRef-level TOCTOU-race invariant
950
+ * for the #79 fix. See
951
+ * docs/superpowers/specs/2026-08-07-dependabot-concurrency-release-hygiene-design.md §B1.
952
+ *
953
+ * The entries table cannot express this invariant directly because a single
954
+ * `ingestDocument` call writes N facts that all share
955
+ * (entity_id, source_ref, source_hash); a UNIQUE index on
956
+ * `entries(entity_id, source_hash)` would block the 2nd–Nth fact inserts in
957
+ * the normal multi-fact path. The per-(entity, hash) granularity of
958
+ * source_ref_index matches the TOCTOU invariant exactly.
959
+ *
960
+ * No outbox events: this is an internal index table whose state is fully
961
+ * derivable from `entries`. Hosts that need CDC continue to read `entries`
962
+ * outbox events; the index is not part of the user-visible contract.
963
+ */
964
+ declare class SourceRefIndexRepository extends BaseRepository {
965
+ /**
966
+ * Idempotent insert: the partial UNIQUE index catches concurrent inserts for
967
+ * the same (entity_id, source_hash). The caller (IngestionService) catches
968
+ * the resulting SQLITE_CONSTRAINT_UNIQUE and translates it to the per-mode
969
+ * duplicate-hash outcome. Runtime IDs use the `sri_` prefix to avoid
970
+ * collision with the deterministic `sri:<entity>:<hash>` IDs used by the v9
971
+ * backfill (the colon separator keeps the two ID spaces disjoint).
972
+ */
973
+ upsert(entityId: string, sourceHash: string, sourceRef: string, tx: SQLiteAdapter): Promise<void>;
974
+ /**
975
+ * Idempotent soft-delete of the live row for (entity_id, source_ref).
976
+ * Called at the start of every ingestDocument to remove the prior run's
977
+ * index row, so the new upsert doesn't collide with itself. No-op when the
978
+ * row is already soft-deleted or never existed.
979
+ */
980
+ softDeleteByEntityAndSourceRef(entityId: string, sourceRef: string, tx: SQLiteAdapter): Promise<number>;
981
+ /**
982
+ * Returns the live sourceRef holding the given hash, or null when no live
983
+ * row exists. Used by the IngestionService pre-check (line 82) and the
984
+ * catch-and-translate canonical lookup (line 212).
985
+ */
986
+ findActiveByEntityAndHash(entityId: string, sourceHash: string, tx?: SQLiteAdapter): Promise<string | null>;
987
+ }
988
+
946
989
  declare class MetadataRepository extends BaseRepository {
947
990
  getCheckpoint(entityId: string, tx: SQLiteAdapter): Promise<{
948
991
  memory?: number;
@@ -1065,8 +1108,46 @@ declare class JobManager {
1065
1108
  private prefix;
1066
1109
  private activeMaintenanceJobs;
1067
1110
  private activeIngestJobs;
1111
+ /**
1112
+ * Per-(entityId, sourceHash) promise-chain lock. Serializes ingest races
1113
+ * at the application level (the v9 partial UNIQUE index enforces it at
1114
+ * the DB level as defense-in-depth). Two callers holding the same hash
1115
+ * see FIFO order; different hashes never block one another.
1116
+ *
1117
+ * Keyed `${entityId}\0${sourceHash}` so distinct (entityId, sourceHash)
1118
+ * pairs can never share a lock. A raw concatenation would let
1119
+ * ("ab", "c…") collide with ("a", "bc…"); the NUL separator makes that
1120
+ * impossible because neither component can contain NUL (entityIds are
1121
+ * user-supplied identifiers; sourceHashes are hex digests).
1122
+ */
1123
+ private hashLocks;
1068
1124
  private statusSubscribers;
1069
1125
  constructor(prefix: string);
1126
+ private _hashLockKey;
1127
+ /**
1128
+ * Acquire a promise-chain lock scoped to one `(entityId, sourceHash)` pair.
1129
+ * Returns a zero-argument release closure that removes the current tail
1130
+ * from the chain. Releasing is idempotent so a holder that double-fires
1131
+ * (e.g. on a finally that races an explicit release) cannot unblock
1132
+ * the next holder prematurely.
1133
+ *
1134
+ * Documented ordering: ingest acquires the hash lock FIRST, then the
1135
+ * synchronous sourceRef ingest lock, and the release closure unwinds in
1136
+ * the opposite order (sourceRef first, hash second). Hash-then-sourceRef
1137
+ * is required because the v9 partial UNIQUE index conflicts on the
1138
+ * (entity_id, source_hash) pair regardless of source_ref, so two callers
1139
+ * with DIFFERENT source_refs racing the same hash are the exact case the
1140
+ * TOCTOU race fix needs to serialize.
1141
+ *
1142
+ * Mechanics: each holder owns a private `released` promise that resolves
1143
+ * only when THIS holder calls release(). The map stores the holder's
1144
+ * `released` promise as the "tail" — the NEXT caller awaits that tail
1145
+ * via `previous = this.hashLocks.get(key)`. The current caller gets
1146
+ * their unique release function once `previous` (the prior holder's
1147
+ * release signal) settles, so each holder is guaranteed a UNIQUE release
1148
+ * closure bound to their own `released` promise.
1149
+ */
1150
+ acquireHashLock(entityId: string, sourceHash: string): Promise<() => void>;
1070
1151
  private _pruneKey;
1071
1152
  private _reembedKey;
1072
1153
  private _globalReembedKey;
@@ -1103,6 +1184,21 @@ declare class JobManager {
1103
1184
  * for write() auto-trigger paths while preserving stricter checks in acquireLock().
1104
1185
  */
1105
1186
  tryAcquireAutoHealLock(entityId: string): boolean;
1187
+ /**
1188
+ * Centralized ingest lock acquisition. Acquires the hash lock FIRST
1189
+ * (FIFO across callers racing the same hash), then the synchronous
1190
+ * sourceRef ingest lock (rejects cross-entity conflicts and busy
1191
+ * operations). Returns a single zero-argument release closure that
1192
+ * unwinds in the opposite order — sourceRef first, hash second — so
1193
+ * the sourceRef lock is freed the instant its work is done and only
1194
+ * the hash lock keeps serializing the duplicate-content race window.
1195
+ *
1196
+ * On a sourceRef conflict (WikiBusyError thrown synchronously by the
1197
+ * underlying acquireLock) the hash lock is released BEFORE the error
1198
+ * propagates, so the next caller in the hash chain is not held up by
1199
+ * a request that never reached the DB.
1200
+ */
1201
+ acquireIngestLocks(entityId: string, sourceRef: string, sourceHash: string): Promise<() => void>;
1106
1202
  /**
1107
1203
  * Validates then acquires global + per-entity import locks atomically.
1108
1204
  * Validates all entities before acquiring any lock (same as current importDump semantics).
@@ -1224,12 +1320,13 @@ declare class IngestionService {
1224
1320
  private prefix;
1225
1321
  private options;
1226
1322
  private entryRepo;
1323
+ private sourceRefIndexRepo;
1227
1324
  private searchService;
1228
1325
  private jobManager;
1229
1326
  private embeddingService;
1230
1327
  private ontologyService?;
1231
1328
  private promptService;
1232
- constructor(db: SQLiteAdapter, prefix: string, options: WikiOptions, entryRepo: EntryRepository, searchService: SearchService, jobManager: JobManager, embeddingService: EmbeddingService, promptService?: PromptService, ontologyService?: OntologyService | undefined);
1329
+ constructor(db: SQLiteAdapter, prefix: string, options: WikiOptions, entryRepo: EntryRepository, sourceRefIndexRepo: SourceRefIndexRepository, searchService: SearchService, jobManager: JobManager, embeddingService: EmbeddingService, promptService?: PromptService, ontologyService?: OntologyService | undefined);
1233
1330
  ingestDocument(entityId: string, params: {
1234
1331
  sourceRef: string;
1235
1332
  sourceHash: string;
@@ -1361,6 +1458,7 @@ declare class MaintenanceService {
1361
1458
  private prefix;
1362
1459
  private options;
1363
1460
  private entryRepo;
1461
+ private sourceRefIndexRepo;
1364
1462
  private taskRepo;
1365
1463
  private eventRepo;
1366
1464
  private metadataRepo;
@@ -1369,7 +1467,7 @@ declare class MaintenanceService {
1369
1467
  private embeddingService;
1370
1468
  private ontologyService?;
1371
1469
  private promptService;
1372
- constructor(db: SQLiteAdapter, prefix: string, options: WikiOptions, entryRepo: EntryRepository, taskRepo: TaskRepository, eventRepo: EventRepository, metadataRepo: MetadataRepository, searchService: SearchService, jobManager: JobManager, embeddingService: EmbeddingService, promptService?: PromptService, ontologyService?: OntologyService | undefined);
1470
+ constructor(db: SQLiteAdapter, prefix: string, options: WikiOptions, entryRepo: EntryRepository, sourceRefIndexRepo: SourceRefIndexRepository, taskRepo: TaskRepository, eventRepo: EventRepository, metadataRepo: MetadataRepository, searchService: SearchService, jobManager: JobManager, embeddingService: EmbeddingService, promptService?: PromptService, ontologyService?: OntologyService | undefined);
1373
1471
  runPrune(entityId: string, options?: {
1374
1472
  retainSoftDeletedFor?: number | null;
1375
1473
  retainEventsFor?: number | null;
@@ -1582,6 +1680,7 @@ interface WikiMemoryTestAccess {
1582
1680
  promptService: PromptService;
1583
1681
  graphTraversalService: GraphTraversalService;
1584
1682
  entryRepo: EntryRepository;
1683
+ sourceRefIndexRepo: SourceRefIndexRepository;
1585
1684
  metadataRepo: MetadataRepository;
1586
1685
  jobManager: JobManager;
1587
1686
  }
@@ -1592,6 +1691,7 @@ declare class WikiMemory {
1592
1691
  private options;
1593
1692
  private entryRepo;
1594
1693
  private outboxRepo;
1694
+ private sourceRefIndexRepo;
1595
1695
  private taskRepo;
1596
1696
  private eventRepo;
1597
1697
  private edgeRepo;
@@ -1647,10 +1747,13 @@ declare class WikiMemory {
1647
1747
  lastIngestedAt: number;
1648
1748
  }>>;
1649
1749
  /**
1650
- * Returns the live source_refs for an entity that hold the given source_hash,
1651
- * sorted `COLLATE BINARY` ascending. The first element is the canonical ref
1652
- * under the code-unit-minimum rule (no locale dependency). Used by the
1653
- * ingestDocument guard and by hosts auditing duplicate-content collisions.
1750
+ * Returns the live source_refs for an entity that hold the given source_hash.
1751
+ * With v9, source_ref_index is the source of truth for the sourceRef-level
1752
+ * TOCTOU-race invariant: at most one sourceRef can hold a given
1753
+ * (entity_id, source_hash). The result is either a single-element array
1754
+ * (one canonical ref) or empty (no live ref holds the hash). Returned as
1755
+ * an array to preserve the existing public-API shape used by hosts
1756
+ * auditing duplicate-content collisions.
1654
1757
  */
1655
1758
  findSourceRefsByHash(entityId: string, sourceHash: string): Promise<string[]>;
1656
1759
  runPrune(entityId: string, options?: {
@@ -943,6 +943,49 @@ declare class EntryRepository extends BaseRepository {
943
943
  }>>;
944
944
  }
945
945
 
946
+ /**
947
+ * Per-(entity_id, source_hash) record of the canonical sourceRef currently
948
+ * holding that hash. The partial UNIQUE index on (entity_id, source_hash)
949
+ * WHERE deleted_at IS NULL enforces the sourceRef-level TOCTOU-race invariant
950
+ * for the #79 fix. See
951
+ * docs/superpowers/specs/2026-08-07-dependabot-concurrency-release-hygiene-design.md §B1.
952
+ *
953
+ * The entries table cannot express this invariant directly because a single
954
+ * `ingestDocument` call writes N facts that all share
955
+ * (entity_id, source_ref, source_hash); a UNIQUE index on
956
+ * `entries(entity_id, source_hash)` would block the 2nd–Nth fact inserts in
957
+ * the normal multi-fact path. The per-(entity, hash) granularity of
958
+ * source_ref_index matches the TOCTOU invariant exactly.
959
+ *
960
+ * No outbox events: this is an internal index table whose state is fully
961
+ * derivable from `entries`. Hosts that need CDC continue to read `entries`
962
+ * outbox events; the index is not part of the user-visible contract.
963
+ */
964
+ declare class SourceRefIndexRepository extends BaseRepository {
965
+ /**
966
+ * Idempotent insert: the partial UNIQUE index catches concurrent inserts for
967
+ * the same (entity_id, source_hash). The caller (IngestionService) catches
968
+ * the resulting SQLITE_CONSTRAINT_UNIQUE and translates it to the per-mode
969
+ * duplicate-hash outcome. Runtime IDs use the `sri_` prefix to avoid
970
+ * collision with the deterministic `sri:<entity>:<hash>` IDs used by the v9
971
+ * backfill (the colon separator keeps the two ID spaces disjoint).
972
+ */
973
+ upsert(entityId: string, sourceHash: string, sourceRef: string, tx: SQLiteAdapter): Promise<void>;
974
+ /**
975
+ * Idempotent soft-delete of the live row for (entity_id, source_ref).
976
+ * Called at the start of every ingestDocument to remove the prior run's
977
+ * index row, so the new upsert doesn't collide with itself. No-op when the
978
+ * row is already soft-deleted or never existed.
979
+ */
980
+ softDeleteByEntityAndSourceRef(entityId: string, sourceRef: string, tx: SQLiteAdapter): Promise<number>;
981
+ /**
982
+ * Returns the live sourceRef holding the given hash, or null when no live
983
+ * row exists. Used by the IngestionService pre-check (line 82) and the
984
+ * catch-and-translate canonical lookup (line 212).
985
+ */
986
+ findActiveByEntityAndHash(entityId: string, sourceHash: string, tx?: SQLiteAdapter): Promise<string | null>;
987
+ }
988
+
946
989
  declare class MetadataRepository extends BaseRepository {
947
990
  getCheckpoint(entityId: string, tx: SQLiteAdapter): Promise<{
948
991
  memory?: number;
@@ -1065,8 +1108,46 @@ declare class JobManager {
1065
1108
  private prefix;
1066
1109
  private activeMaintenanceJobs;
1067
1110
  private activeIngestJobs;
1111
+ /**
1112
+ * Per-(entityId, sourceHash) promise-chain lock. Serializes ingest races
1113
+ * at the application level (the v9 partial UNIQUE index enforces it at
1114
+ * the DB level as defense-in-depth). Two callers holding the same hash
1115
+ * see FIFO order; different hashes never block one another.
1116
+ *
1117
+ * Keyed `${entityId}\0${sourceHash}` so distinct (entityId, sourceHash)
1118
+ * pairs can never share a lock. A raw concatenation would let
1119
+ * ("ab", "c…") collide with ("a", "bc…"); the NUL separator makes that
1120
+ * impossible because neither component can contain NUL (entityIds are
1121
+ * user-supplied identifiers; sourceHashes are hex digests).
1122
+ */
1123
+ private hashLocks;
1068
1124
  private statusSubscribers;
1069
1125
  constructor(prefix: string);
1126
+ private _hashLockKey;
1127
+ /**
1128
+ * Acquire a promise-chain lock scoped to one `(entityId, sourceHash)` pair.
1129
+ * Returns a zero-argument release closure that removes the current tail
1130
+ * from the chain. Releasing is idempotent so a holder that double-fires
1131
+ * (e.g. on a finally that races an explicit release) cannot unblock
1132
+ * the next holder prematurely.
1133
+ *
1134
+ * Documented ordering: ingest acquires the hash lock FIRST, then the
1135
+ * synchronous sourceRef ingest lock, and the release closure unwinds in
1136
+ * the opposite order (sourceRef first, hash second). Hash-then-sourceRef
1137
+ * is required because the v9 partial UNIQUE index conflicts on the
1138
+ * (entity_id, source_hash) pair regardless of source_ref, so two callers
1139
+ * with DIFFERENT source_refs racing the same hash are the exact case the
1140
+ * TOCTOU race fix needs to serialize.
1141
+ *
1142
+ * Mechanics: each holder owns a private `released` promise that resolves
1143
+ * only when THIS holder calls release(). The map stores the holder's
1144
+ * `released` promise as the "tail" — the NEXT caller awaits that tail
1145
+ * via `previous = this.hashLocks.get(key)`. The current caller gets
1146
+ * their unique release function once `previous` (the prior holder's
1147
+ * release signal) settles, so each holder is guaranteed a UNIQUE release
1148
+ * closure bound to their own `released` promise.
1149
+ */
1150
+ acquireHashLock(entityId: string, sourceHash: string): Promise<() => void>;
1070
1151
  private _pruneKey;
1071
1152
  private _reembedKey;
1072
1153
  private _globalReembedKey;
@@ -1103,6 +1184,21 @@ declare class JobManager {
1103
1184
  * for write() auto-trigger paths while preserving stricter checks in acquireLock().
1104
1185
  */
1105
1186
  tryAcquireAutoHealLock(entityId: string): boolean;
1187
+ /**
1188
+ * Centralized ingest lock acquisition. Acquires the hash lock FIRST
1189
+ * (FIFO across callers racing the same hash), then the synchronous
1190
+ * sourceRef ingest lock (rejects cross-entity conflicts and busy
1191
+ * operations). Returns a single zero-argument release closure that
1192
+ * unwinds in the opposite order — sourceRef first, hash second — so
1193
+ * the sourceRef lock is freed the instant its work is done and only
1194
+ * the hash lock keeps serializing the duplicate-content race window.
1195
+ *
1196
+ * On a sourceRef conflict (WikiBusyError thrown synchronously by the
1197
+ * underlying acquireLock) the hash lock is released BEFORE the error
1198
+ * propagates, so the next caller in the hash chain is not held up by
1199
+ * a request that never reached the DB.
1200
+ */
1201
+ acquireIngestLocks(entityId: string, sourceRef: string, sourceHash: string): Promise<() => void>;
1106
1202
  /**
1107
1203
  * Validates then acquires global + per-entity import locks atomically.
1108
1204
  * Validates all entities before acquiring any lock (same as current importDump semantics).
@@ -1224,12 +1320,13 @@ declare class IngestionService {
1224
1320
  private prefix;
1225
1321
  private options;
1226
1322
  private entryRepo;
1323
+ private sourceRefIndexRepo;
1227
1324
  private searchService;
1228
1325
  private jobManager;
1229
1326
  private embeddingService;
1230
1327
  private ontologyService?;
1231
1328
  private promptService;
1232
- constructor(db: SQLiteAdapter, prefix: string, options: WikiOptions, entryRepo: EntryRepository, searchService: SearchService, jobManager: JobManager, embeddingService: EmbeddingService, promptService?: PromptService, ontologyService?: OntologyService | undefined);
1329
+ constructor(db: SQLiteAdapter, prefix: string, options: WikiOptions, entryRepo: EntryRepository, sourceRefIndexRepo: SourceRefIndexRepository, searchService: SearchService, jobManager: JobManager, embeddingService: EmbeddingService, promptService?: PromptService, ontologyService?: OntologyService | undefined);
1233
1330
  ingestDocument(entityId: string, params: {
1234
1331
  sourceRef: string;
1235
1332
  sourceHash: string;
@@ -1361,6 +1458,7 @@ declare class MaintenanceService {
1361
1458
  private prefix;
1362
1459
  private options;
1363
1460
  private entryRepo;
1461
+ private sourceRefIndexRepo;
1364
1462
  private taskRepo;
1365
1463
  private eventRepo;
1366
1464
  private metadataRepo;
@@ -1369,7 +1467,7 @@ declare class MaintenanceService {
1369
1467
  private embeddingService;
1370
1468
  private ontologyService?;
1371
1469
  private promptService;
1372
- constructor(db: SQLiteAdapter, prefix: string, options: WikiOptions, entryRepo: EntryRepository, taskRepo: TaskRepository, eventRepo: EventRepository, metadataRepo: MetadataRepository, searchService: SearchService, jobManager: JobManager, embeddingService: EmbeddingService, promptService?: PromptService, ontologyService?: OntologyService | undefined);
1470
+ constructor(db: SQLiteAdapter, prefix: string, options: WikiOptions, entryRepo: EntryRepository, sourceRefIndexRepo: SourceRefIndexRepository, taskRepo: TaskRepository, eventRepo: EventRepository, metadataRepo: MetadataRepository, searchService: SearchService, jobManager: JobManager, embeddingService: EmbeddingService, promptService?: PromptService, ontologyService?: OntologyService | undefined);
1373
1471
  runPrune(entityId: string, options?: {
1374
1472
  retainSoftDeletedFor?: number | null;
1375
1473
  retainEventsFor?: number | null;
@@ -1582,6 +1680,7 @@ interface WikiMemoryTestAccess {
1582
1680
  promptService: PromptService;
1583
1681
  graphTraversalService: GraphTraversalService;
1584
1682
  entryRepo: EntryRepository;
1683
+ sourceRefIndexRepo: SourceRefIndexRepository;
1585
1684
  metadataRepo: MetadataRepository;
1586
1685
  jobManager: JobManager;
1587
1686
  }
@@ -1592,6 +1691,7 @@ declare class WikiMemory {
1592
1691
  private options;
1593
1692
  private entryRepo;
1594
1693
  private outboxRepo;
1694
+ private sourceRefIndexRepo;
1595
1695
  private taskRepo;
1596
1696
  private eventRepo;
1597
1697
  private edgeRepo;
@@ -1647,10 +1747,13 @@ declare class WikiMemory {
1647
1747
  lastIngestedAt: number;
1648
1748
  }>>;
1649
1749
  /**
1650
- * Returns the live source_refs for an entity that hold the given source_hash,
1651
- * sorted `COLLATE BINARY` ascending. The first element is the canonical ref
1652
- * under the code-unit-minimum rule (no locale dependency). Used by the
1653
- * ingestDocument guard and by hosts auditing duplicate-content collisions.
1750
+ * Returns the live source_refs for an entity that hold the given source_hash.
1751
+ * With v9, source_ref_index is the source of truth for the sourceRef-level
1752
+ * TOCTOU-race invariant: at most one sourceRef can hold a given
1753
+ * (entity_id, source_hash). The result is either a single-element array
1754
+ * (one canonical ref) or empty (no live ref holds the hash). Returned as
1755
+ * an array to preserve the existing public-API shape used by hosts
1756
+ * auditing duplicate-content collisions.
1654
1757
  */
1655
1758
  findSourceRefsByHash(entityId: string, sourceHash: string): Promise<string[]>;
1656
1759
  runPrune(entityId: string, options?: {
@@ -1,2 +1,2 @@
1
- export { Y as EmbeddingService, Z as ImportExportService, _ as IngestionService, $ as JobManager, $ as JobManagerType, a0 as MaintenanceService, a1 as RetrievalService, a2 as SearchService, a2 as SearchServiceType, Q as WikiMemoryTestAccess, a3 as WriteService } from './testing-DyXISRwS.mjs';
1
+ export { Y as EmbeddingService, Z as ImportExportService, _ as IngestionService, $ as JobManager, $ as JobManagerType, a0 as MaintenanceService, a1 as RetrievalService, a2 as SearchService, a2 as SearchServiceType, Q as WikiMemoryTestAccess, a3 as WriteService } from './testing-CAk9oFvw.mjs';
2
2
  import 'minisearch';
package/dist/testing.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { Y as EmbeddingService, Z as ImportExportService, _ as IngestionService, $ as JobManager, $ as JobManagerType, a0 as MaintenanceService, a1 as RetrievalService, a2 as SearchService, a2 as SearchServiceType, Q as WikiMemoryTestAccess, a3 as WriteService } from './testing-DyXISRwS.js';
1
+ export { Y as EmbeddingService, Z as ImportExportService, _ as IngestionService, $ as JobManager, $ as JobManagerType, a0 as MaintenanceService, a1 as RetrievalService, a2 as SearchService, a2 as SearchServiceType, Q as WikiMemoryTestAccess, a3 as WriteService } from './testing-CAk9oFvw.js';
2
2
  import 'minisearch';