@equationalapplications/core-llm-wiki 5.1.1 → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/{chunk-YKXCMOHH.mjs → chunk-6HNIOKT4.mjs} +258 -70
- package/dist/chunk-6HNIOKT4.mjs.map +1 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +608 -85
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +354 -20
- package/dist/index.mjs.map +1 -1
- package/dist/{testing-DCRNie7k.d.mts → testing-CAk9oFvw.d.mts} +242 -3
- package/dist/{testing-DCRNie7k.d.ts → testing-CAk9oFvw.d.ts} +242 -3
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +309 -67
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +1 -1
- package/package.json +2 -2
- package/dist/chunk-YKXCMOHH.mjs.map +0 -1
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.
|
|
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
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
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
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
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
|
-
|
|
3186
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
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;
|