@remnic/core 10.0.52 → 10.0.54
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/access-admin-ops-surface.js +24 -24
- package/dist/access-authorization-probe.js +25 -25
- package/dist/access-boundary.js +24 -24
- package/dist/access-cli.js +65 -65
- package/dist/access-extraction-force-flush.js +24 -24
- package/dist/access-http-briefing.js +25 -25
- package/dist/access-http-query.js +26 -26
- package/dist/access-http.js +40 -40
- package/dist/access-identity-continuity-surface.js +24 -24
- package/dist/access-lcm-surface.js +24 -24
- package/dist/access-mcp.js +33 -33
- package/dist/access-namespace-preflight.js +24 -24
- package/dist/access-observe-write-surface.js +24 -24
- package/dist/access-operations-batch.js +25 -25
- package/dist/access-operations.js +32 -32
- package/dist/access-recall-concurrency.js +24 -24
- package/dist/access-recall-response.js +24 -24
- package/dist/access-recall-surface.js +24 -24
- package/dist/access-service.js +24 -24
- package/dist/access-surface-catalog.js +26 -26
- package/dist/active-recall.js +9 -9
- package/dist/briefing.js +10 -10
- package/dist/buffer.js +5 -5
- package/dist/causal-consolidation.js +11 -11
- package/dist/{chunk-3NL4ALUP.js → chunk-24SIB3EW.js} +3 -3
- package/dist/{chunk-Y3T5KQCR.js → chunk-2EYCW2ET.js} +2 -2
- package/dist/{chunk-UM3ZLT2X.js → chunk-2GU6XS4L.js} +31 -28
- package/dist/chunk-2GU6XS4L.js.map +1 -0
- package/dist/{chunk-AHGOOZUC.js → chunk-34UVJNSG.js} +2 -2
- package/dist/{chunk-2EXF6IIB.js → chunk-3632XJMR.js} +2 -2
- package/dist/{chunk-KETS5A62.js → chunk-3H3PNQEF.js} +2 -2
- package/dist/{chunk-SFRBUBUZ.js → chunk-3LNAQLW4.js} +3 -3
- package/dist/{chunk-75BK4XGV.js → chunk-4BUJICMI.js} +10 -10
- package/dist/{chunk-KIWO4327.js → chunk-4W5B5Z6P.js} +2 -2
- package/dist/{chunk-B35EQQ7L.js → chunk-53OFDW3P.js} +3 -3
- package/dist/{chunk-2ONKXEC7.js → chunk-5LBBXI2A.js} +2 -2
- package/dist/{chunk-UCLJTGP3.js → chunk-65B5RFKQ.js} +2 -2
- package/dist/{chunk-KAJYSO3U.js → chunk-6CDNOREO.js} +2 -2
- package/dist/{chunk-POJRFSJM.js → chunk-7XGNGLAN.js} +2 -2
- package/dist/{chunk-A6JDT6QF.js → chunk-AO5ZOD32.js} +2 -2
- package/dist/{chunk-RA7YRL6H.js → chunk-AOSCGNIC.js} +3 -3
- package/dist/{chunk-LWMLBVGB.js → chunk-B43P3DTC.js} +2 -2
- package/dist/{chunk-VUMWVYMR.js → chunk-CIMD2MUR.js} +3 -3
- package/dist/{chunk-XRFLR3XF.js → chunk-DLZGSZVC.js} +22 -9
- package/dist/chunk-DLZGSZVC.js.map +1 -0
- package/dist/{chunk-5CWH2LKL.js → chunk-DOJBV6WR.js} +29 -14
- package/dist/chunk-DOJBV6WR.js.map +1 -0
- package/dist/{chunk-44EUCSG4.js → chunk-FFQNNFHX.js} +2 -2
- package/dist/{chunk-HEF4BRMT.js → chunk-FZLZUDJJ.js} +2 -2
- package/dist/{chunk-L3EFYRYK.js → chunk-GGFQAGOT.js} +7 -7
- package/dist/{chunk-ZLCBLDJO.js → chunk-GIRWNTMV.js} +2 -2
- package/dist/{chunk-SHNM7RIT.js → chunk-HJ4X4BGS.js} +2 -2
- package/dist/{chunk-MUNYUSBU.js → chunk-HJQ4NKKO.js} +2 -2
- package/dist/{chunk-V6Z4NAJZ.js → chunk-HM3ZTDAX.js} +2 -2
- package/dist/{chunk-EZS6QJNC.js → chunk-IFOCK3OJ.js} +2 -2
- package/dist/{chunk-LXXIV7Y2.js → chunk-JEX2JL3U.js} +7 -2
- package/dist/{chunk-LXXIV7Y2.js.map → chunk-JEX2JL3U.js.map} +1 -1
- package/dist/{chunk-DF6ZF2BN.js → chunk-LIVNX7WK.js} +2 -2
- package/dist/{chunk-WY66J4PI.js → chunk-LRVFS4KA.js} +2 -2
- package/dist/{chunk-LMB47UM6.js → chunk-LTDK6HBV.js} +2 -2
- package/dist/{chunk-HCIRR3BA.js → chunk-M4U25HLR.js} +2 -2
- package/dist/{chunk-YK7ZJPQM.js → chunk-MGN2RQRG.js} +2 -2
- package/dist/{chunk-G26YNEOC.js → chunk-NWA6IXR3.js} +1 -1
- package/dist/{chunk-G26YNEOC.js.map → chunk-NWA6IXR3.js.map} +1 -1
- package/dist/{chunk-JIJAWTRK.js → chunk-OCXDZJL4.js} +264 -194
- package/dist/chunk-OCXDZJL4.js.map +1 -0
- package/dist/{chunk-IUQXRRJB.js → chunk-OMWQO7H6.js} +2 -2
- package/dist/{chunk-7VO6E5UD.js → chunk-OVZIYLBZ.js} +6 -2
- package/dist/chunk-OVZIYLBZ.js.map +1 -0
- package/dist/{chunk-SPTPBKZX.js → chunk-OWNAWY6D.js} +2 -2
- package/dist/{chunk-XKDQTDXN.js → chunk-PFUXPTBT.js} +36 -15
- package/dist/chunk-PFUXPTBT.js.map +1 -0
- package/dist/{chunk-EC2KVA27.js → chunk-PVG7TCUG.js} +186 -105
- package/dist/chunk-PVG7TCUG.js.map +1 -0
- package/dist/{chunk-DQOGZZJA.js → chunk-QAHP5SBG.js} +3 -3
- package/dist/{chunk-GBGU5OZP.js → chunk-QF5EJ5VP.js} +13 -13
- package/dist/{chunk-FT75AQ74.js → chunk-QS5DUSBM.js} +2 -2
- package/dist/{chunk-72CBJ5VP.js → chunk-R3WZ74B2.js} +3 -3
- package/dist/{chunk-PDTNQLPC.js → chunk-RG3I2663.js} +2 -2
- package/dist/{chunk-CK6GCGMX.js → chunk-RTJJPW3C.js} +2 -2
- package/dist/{chunk-RJDYDF5K.js → chunk-TRAOZ2WP.js} +2 -2
- package/dist/{chunk-6ZAU7LAR.js → chunk-TTTPTQ6S.js} +2 -2
- package/dist/{chunk-JS44PBCM.js → chunk-U2A56LLF.js} +6 -6
- package/dist/{chunk-MC7PXJDP.js → chunk-UBOWDOL5.js} +5 -5
- package/dist/{chunk-4FM35YIA.js → chunk-UQYBPPVC.js} +2 -2
- package/dist/{chunk-OCRLJJOV.js → chunk-V4VCBFAL.js} +3 -3
- package/dist/{chunk-FNNGWN3O.js → chunk-VGE4PUY6.js} +2 -2
- package/dist/{chunk-5DVOBBTV.js → chunk-VV73XT3A.js} +2 -2
- package/dist/{chunk-GE3ZX6YK.js → chunk-W7NKMRAF.js} +2 -2
- package/dist/{chunk-AJJPXUE6.js → chunk-WBN66HPI.js} +2 -2
- package/dist/{chunk-XWJUFER3.js → chunk-WGFEGBFB.js} +2 -2
- package/dist/{chunk-43TUWQNR.js → chunk-XDKEHZUC.js} +2 -2
- package/dist/{chunk-ROAKLYOL.js → chunk-XHEKS4UN.js} +2 -2
- package/dist/{chunk-ZKK3VMWU.js → chunk-XVTB6BZS.js} +2 -2
- package/dist/{chunk-AZLD4CKG.js → chunk-Y56D5FSD.js} +17 -17
- package/dist/{chunk-MPWOAIIH.js → chunk-YDT3OEYF.js} +2 -2
- package/dist/{chunk-IN6U2BS5.js → chunk-YJHOVA42.js} +2 -2
- package/dist/{chunk-TC55M5PO.js → chunk-YLN4ZQV4.js} +2 -2
- package/dist/cli.js +50 -50
- package/dist/coding/export-okf-codegraph.js +9 -9
- package/dist/compounding/engine.js +11 -11
- package/dist/config.js +9 -9
- package/dist/connectors/codex-materialize-runner.js +10 -10
- package/dist/connectors/index.js +10 -10
- package/dist/convergence-refresh.js +12 -12
- package/dist/corpus-watermark.js +12 -12
- package/dist/entity-retrieval.js +10 -10
- package/dist/external-wiki-access.js +25 -25
- package/dist/extraction.js +14 -14
- package/dist/harmonic-retrieval.js +10 -10
- package/dist/index.js +65 -65
- package/dist/location/index.js +12 -12
- package/dist/maintenance/memory-governance.js +10 -10
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -10
- package/dist/maintenance/rebuild-memory-projection-runner.js +12 -12
- package/dist/maintenance/rebuild-memory-projection.js +11 -11
- package/dist/memory-browse-operations.js +25 -25
- package/dist/memory-browse.js +5 -5
- package/dist/namespaces/migrate.js +11 -11
- package/dist/namespaces/search.d.ts.map +1 -1
- package/dist/namespaces/search.js +8 -8
- package/dist/namespaces/storage.js +10 -10
- package/dist/operator-doctor-corpus.js +13 -13
- package/dist/operator-toolkit.js +18 -18
- package/dist/orchestration/lifecycle-pending-fallback-drain.d.ts +18 -0
- package/dist/orchestration/lifecycle-pending-fallback-drain.d.ts.map +1 -0
- package/dist/orchestration/maintenance.d.ts +11 -13
- package/dist/orchestration/maintenance.d.ts.map +1 -1
- package/dist/orchestration/maintenance.js +20 -20
- package/dist/orchestration/qmd-maintenance-completion.d.ts +25 -0
- package/dist/orchestration/qmd-maintenance-completion.d.ts.map +1 -0
- package/dist/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator.js +65 -65
- package/dist/qmd.js +2 -2
- package/dist/recall-composition-decision.js +2 -2
- package/dist/recall-navigation-operations.js +25 -25
- package/dist/recall-navigation.js +24 -24
- package/dist/recall-state-view-admission.js +5 -5
- package/dist/recall-why-service.js +5 -5
- package/dist/resume-bundles.js +9 -9
- package/dist/search/factory.js +7 -7
- package/dist/search/index.js +7 -7
- package/dist/search/lancedb-backend.d.ts +3 -0
- package/dist/search/lancedb-backend.d.ts.map +1 -1
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.d.ts +3 -0
- package/dist/search/meilisearch-backend.d.ts.map +1 -1
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/orama-backend.d.ts +3 -0
- package/dist/search/orama-backend.d.ts.map +1 -1
- package/dist/search/orama-backend.js +2 -2
- package/dist/search/port.d.ts +7 -0
- package/dist/search/port.d.ts.map +1 -1
- package/dist/search/port.js +1 -1
- package/dist/search/remote-backend.d.ts +2 -0
- package/dist/search/remote-backend.d.ts.map +1 -1
- package/dist/search/remote-backend.js +2 -2
- package/dist/semantic-consolidation.js +11 -11
- package/dist/semantic-rule-promotion.js +10 -10
- package/dist/semantic-rule-verifier.js +10 -10
- package/dist/source-agent-qualifier.js +13 -13
- package/dist/standup/build.js +10 -10
- package/dist/storage/entity-canonical-id-migration-adapter.d.ts +7 -1
- package/dist/storage/entity-canonical-id-migration-adapter.d.ts.map +1 -1
- package/dist/storage/entity-migration-maintenance-pending.d.ts +31 -0
- package/dist/storage/entity-migration-maintenance-pending.d.ts.map +1 -0
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +9 -9
- package/dist/support-passport/index.js +27 -27
- package/dist/transfer/export-okf.js +10 -10
- package/dist/verified-recall.js +10 -10
- package/dist/who-knows-cli.js +11 -11
- package/dist/who-knows.js +10 -10
- package/package.json +2 -2
- package/src/namespaces/search.test.ts +121 -0
- package/src/namespaces/search.ts +8 -0
- package/src/orchestration/lifecycle-pending-fallback-drain.ts +97 -0
- package/src/orchestration/maintenance.test.ts +582 -1
- package/src/orchestration/maintenance.ts +82 -95
- package/src/orchestration/qmd-maintenance-completion.ts +44 -0
- package/src/orchestrator.ts +8 -1
- package/src/search/lancedb-backend.ts +29 -12
- package/src/search/meilisearch-backend.test.ts +148 -0
- package/src/search/meilisearch-backend.ts +41 -14
- package/src/search/orama-backend.test.ts +75 -1
- package/src/search/orama-backend.ts +22 -7
- package/src/search/port.ts +7 -0
- package/src/search/remote-backend.ts +4 -0
- package/src/storage/entity-canonical-id-migration-adapter.ts +44 -9
- package/src/storage/entity-migration-maintenance-pending-helpers.test.ts +87 -0
- package/src/storage/entity-migration-maintenance-pending.test.ts +242 -0
- package/src/storage/entity-migration-maintenance-pending.ts +89 -0
- package/src/storage.ts +2 -2
- package/dist/chunk-5CWH2LKL.js.map +0 -1
- package/dist/chunk-7VO6E5UD.js.map +0 -1
- package/dist/chunk-EC2KVA27.js.map +0 -1
- package/dist/chunk-JIJAWTRK.js.map +0 -1
- package/dist/chunk-UM3ZLT2X.js.map +0 -1
- package/dist/chunk-XKDQTDXN.js.map +0 -1
- package/dist/chunk-XRFLR3XF.js.map +0 -1
- /package/dist/{chunk-3NL4ALUP.js.map → chunk-24SIB3EW.js.map} +0 -0
- /package/dist/{chunk-Y3T5KQCR.js.map → chunk-2EYCW2ET.js.map} +0 -0
- /package/dist/{chunk-AHGOOZUC.js.map → chunk-34UVJNSG.js.map} +0 -0
- /package/dist/{chunk-2EXF6IIB.js.map → chunk-3632XJMR.js.map} +0 -0
- /package/dist/{chunk-KETS5A62.js.map → chunk-3H3PNQEF.js.map} +0 -0
- /package/dist/{chunk-SFRBUBUZ.js.map → chunk-3LNAQLW4.js.map} +0 -0
- /package/dist/{chunk-75BK4XGV.js.map → chunk-4BUJICMI.js.map} +0 -0
- /package/dist/{chunk-KIWO4327.js.map → chunk-4W5B5Z6P.js.map} +0 -0
- /package/dist/{chunk-B35EQQ7L.js.map → chunk-53OFDW3P.js.map} +0 -0
- /package/dist/{chunk-2ONKXEC7.js.map → chunk-5LBBXI2A.js.map} +0 -0
- /package/dist/{chunk-UCLJTGP3.js.map → chunk-65B5RFKQ.js.map} +0 -0
- /package/dist/{chunk-KAJYSO3U.js.map → chunk-6CDNOREO.js.map} +0 -0
- /package/dist/{chunk-POJRFSJM.js.map → chunk-7XGNGLAN.js.map} +0 -0
- /package/dist/{chunk-A6JDT6QF.js.map → chunk-AO5ZOD32.js.map} +0 -0
- /package/dist/{chunk-RA7YRL6H.js.map → chunk-AOSCGNIC.js.map} +0 -0
- /package/dist/{chunk-LWMLBVGB.js.map → chunk-B43P3DTC.js.map} +0 -0
- /package/dist/{chunk-VUMWVYMR.js.map → chunk-CIMD2MUR.js.map} +0 -0
- /package/dist/{chunk-44EUCSG4.js.map → chunk-FFQNNFHX.js.map} +0 -0
- /package/dist/{chunk-HEF4BRMT.js.map → chunk-FZLZUDJJ.js.map} +0 -0
- /package/dist/{chunk-L3EFYRYK.js.map → chunk-GGFQAGOT.js.map} +0 -0
- /package/dist/{chunk-ZLCBLDJO.js.map → chunk-GIRWNTMV.js.map} +0 -0
- /package/dist/{chunk-SHNM7RIT.js.map → chunk-HJ4X4BGS.js.map} +0 -0
- /package/dist/{chunk-MUNYUSBU.js.map → chunk-HJQ4NKKO.js.map} +0 -0
- /package/dist/{chunk-V6Z4NAJZ.js.map → chunk-HM3ZTDAX.js.map} +0 -0
- /package/dist/{chunk-EZS6QJNC.js.map → chunk-IFOCK3OJ.js.map} +0 -0
- /package/dist/{chunk-DF6ZF2BN.js.map → chunk-LIVNX7WK.js.map} +0 -0
- /package/dist/{chunk-WY66J4PI.js.map → chunk-LRVFS4KA.js.map} +0 -0
- /package/dist/{chunk-LMB47UM6.js.map → chunk-LTDK6HBV.js.map} +0 -0
- /package/dist/{chunk-HCIRR3BA.js.map → chunk-M4U25HLR.js.map} +0 -0
- /package/dist/{chunk-YK7ZJPQM.js.map → chunk-MGN2RQRG.js.map} +0 -0
- /package/dist/{chunk-IUQXRRJB.js.map → chunk-OMWQO7H6.js.map} +0 -0
- /package/dist/{chunk-SPTPBKZX.js.map → chunk-OWNAWY6D.js.map} +0 -0
- /package/dist/{chunk-DQOGZZJA.js.map → chunk-QAHP5SBG.js.map} +0 -0
- /package/dist/{chunk-GBGU5OZP.js.map → chunk-QF5EJ5VP.js.map} +0 -0
- /package/dist/{chunk-FT75AQ74.js.map → chunk-QS5DUSBM.js.map} +0 -0
- /package/dist/{chunk-72CBJ5VP.js.map → chunk-R3WZ74B2.js.map} +0 -0
- /package/dist/{chunk-PDTNQLPC.js.map → chunk-RG3I2663.js.map} +0 -0
- /package/dist/{chunk-CK6GCGMX.js.map → chunk-RTJJPW3C.js.map} +0 -0
- /package/dist/{chunk-RJDYDF5K.js.map → chunk-TRAOZ2WP.js.map} +0 -0
- /package/dist/{chunk-6ZAU7LAR.js.map → chunk-TTTPTQ6S.js.map} +0 -0
- /package/dist/{chunk-JS44PBCM.js.map → chunk-U2A56LLF.js.map} +0 -0
- /package/dist/{chunk-MC7PXJDP.js.map → chunk-UBOWDOL5.js.map} +0 -0
- /package/dist/{chunk-4FM35YIA.js.map → chunk-UQYBPPVC.js.map} +0 -0
- /package/dist/{chunk-OCRLJJOV.js.map → chunk-V4VCBFAL.js.map} +0 -0
- /package/dist/{chunk-FNNGWN3O.js.map → chunk-VGE4PUY6.js.map} +0 -0
- /package/dist/{chunk-5DVOBBTV.js.map → chunk-VV73XT3A.js.map} +0 -0
- /package/dist/{chunk-GE3ZX6YK.js.map → chunk-W7NKMRAF.js.map} +0 -0
- /package/dist/{chunk-AJJPXUE6.js.map → chunk-WBN66HPI.js.map} +0 -0
- /package/dist/{chunk-XWJUFER3.js.map → chunk-WGFEGBFB.js.map} +0 -0
- /package/dist/{chunk-43TUWQNR.js.map → chunk-XDKEHZUC.js.map} +0 -0
- /package/dist/{chunk-ROAKLYOL.js.map → chunk-XHEKS4UN.js.map} +0 -0
- /package/dist/{chunk-ZKK3VMWU.js.map → chunk-XVTB6BZS.js.map} +0 -0
- /package/dist/{chunk-AZLD4CKG.js.map → chunk-Y56D5FSD.js.map} +0 -0
- /package/dist/{chunk-MPWOAIIH.js.map → chunk-YDT3OEYF.js.map} +0 -0
- /package/dist/{chunk-IN6U2BS5.js.map → chunk-YJHOVA42.js.map} +0 -0
- /package/dist/{chunk-TC55M5PO.js.map → chunk-YLN4ZQV4.js.map} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
@@ -232,3 +232,77 @@ test("Orama rebuild gate inspects every indexed string field, not only content (
|
|
|
232
232
|
await rm(root, { recursive: true, force: true });
|
|
233
233
|
}
|
|
234
234
|
});
|
|
235
|
+
|
|
236
|
+
test("Orama updateCompleted reports a finished refresh, and false for an aborted one (issue #3286)", async () => {
|
|
237
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "remnic-orama-completed-"));
|
|
238
|
+
try {
|
|
239
|
+
await writeMemoryFact(root, "fact.md", "---\nid: mem-completed\n---\nAn indexed fact about completion.");
|
|
240
|
+
const backend = await createBackend(root, path.join(root, "orama-db"), undefined, { update: false });
|
|
241
|
+
|
|
242
|
+
assert.equal(await backend.updateCompleted({ signal: AbortSignal.abort() }), false, "an aborted refresh did not finish");
|
|
243
|
+
assert.equal((await backend.bm25Search("completion")).length, 0, "precondition: nothing was indexed");
|
|
244
|
+
|
|
245
|
+
assert.equal(await backend.updateCompleted(), true);
|
|
246
|
+
assert.equal((await backend.bm25Search("completion")).some((r) => r.docid === "mem-completed"), true);
|
|
247
|
+
} finally {
|
|
248
|
+
await rm(root, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/** A signal that reports aborted from its Nth read on: lets a test abort a refresh at each of its checkpoints. */
|
|
253
|
+
function abortingFromRead(read: number): AbortSignal {
|
|
254
|
+
const signal = new AbortController().signal;
|
|
255
|
+
let reads = 0;
|
|
256
|
+
Object.defineProperty(signal, "aborted", { get: () => ++reads >= read });
|
|
257
|
+
return signal;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
test("Orama updateCompleted is false for a refresh aborted at any checkpoint, and true only once it persisted (issue #3286)", async () => {
|
|
261
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "remnic-orama-abort-sweep-"));
|
|
262
|
+
try {
|
|
263
|
+
await writeMemoryFact(root, "kept.md", "---\nid: mem-kept\n---\nA fact that stays indexed.");
|
|
264
|
+
await writeMemoryFact(root, "second.md", "---\nid: mem-second\n---\nA second fact so the diff loops iterate.");
|
|
265
|
+
const dbPath = path.join(root, "orama-db");
|
|
266
|
+
// An existing, persisted index makes the refresh walk the stored hits as well as the corpus.
|
|
267
|
+
await createBackend(root, dbPath);
|
|
268
|
+
const persistedFile = resolveOramaCollectionDbFilePath(dbPath, "openclaw-engram");
|
|
269
|
+
const before = await readFile(persistedFile);
|
|
270
|
+
await rm(path.join(root, "facts", "second.md"));
|
|
271
|
+
|
|
272
|
+
// Every abort checkpoint must report false and leave the persisted index alone;
|
|
273
|
+
// the first run that reports true must be the one that persisted the change.
|
|
274
|
+
let completedAt = 0;
|
|
275
|
+
for (let read = 1; read < 200 && completedAt === 0; read += 1) {
|
|
276
|
+
// A fresh backend per attempt: an aborted refresh leaves its in-memory edits behind,
|
|
277
|
+
// which would shift the checkpoints of the next attempt.
|
|
278
|
+
const backend = await createBackend(root, dbPath, undefined, { update: false });
|
|
279
|
+
const completed = await backend.updateCompleted({ signal: abortingFromRead(read) });
|
|
280
|
+
const persisted = await readFile(persistedFile);
|
|
281
|
+
if (completed) {
|
|
282
|
+
completedAt = read;
|
|
283
|
+
assert.notDeepEqual(persisted, before, "a refresh reporting completion persisted the index");
|
|
284
|
+
} else {
|
|
285
|
+
assert.deepEqual(persisted, before, `aborted at read ${read}: nothing may be persisted`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
assert.ok(completedAt > 4, `the refresh checks for abort at several points (completed only at read ${completedAt})`);
|
|
289
|
+
} finally {
|
|
290
|
+
await rm(root, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("Orama updateCompleted reports false, not a rejection, when the refresh throws (issue #3286)", async () => {
|
|
295
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "remnic-orama-throws-"));
|
|
296
|
+
try {
|
|
297
|
+
await writeMemoryFact(root, "fact.md", "---\nid: mem-throws\n---\nA fact.");
|
|
298
|
+
const backend = await createBackend(root, path.join(root, "orama-db"), undefined, { update: false });
|
|
299
|
+
Object.defineProperty(backend, "persistDbForCollection", {
|
|
300
|
+
value: async () => {
|
|
301
|
+
throw new Error("disk full");
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
assert.equal(await backend.updateCompleted(), false);
|
|
305
|
+
} finally {
|
|
306
|
+
await rm(root, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
@@ -178,14 +178,28 @@ export class OramaBackend implements SearchBackend {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
async updateCollectionFromDir(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<void> {
|
|
181
|
-
|
|
181
|
+
await this.refreshCollection(collection, memoryDir, execution);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async updateCompleted(execution?: SearchExecutionOptions): Promise<boolean> {
|
|
185
|
+
try {
|
|
186
|
+
return await this.refreshCollection(this.collection, this.memoryDir, execution);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
log.debug(`OramaBackend update did not complete: ${err}`);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Brings the collection up to date; true only when it ran to completion (false: aborted, unavailable, skipped or failed). */
|
|
194
|
+
private async refreshCollection(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<boolean> {
|
|
195
|
+
if (isSearchAborted(execution)) return false;
|
|
182
196
|
const db = await this.ensureDbForCollection(collection);
|
|
183
|
-
if (isSearchAborted(execution)) return;
|
|
184
|
-
if (!db) return;
|
|
197
|
+
if (isSearchAborted(execution)) return false;
|
|
198
|
+
if (!db) return false;
|
|
185
199
|
const { search: oramaSearch, insert, remove, count, getByID } = this.oramaModule;
|
|
186
200
|
|
|
187
201
|
const docs = await scanMemoryDir(memoryDir);
|
|
188
|
-
if (isSearchAborted(execution)) return;
|
|
202
|
+
if (isSearchAborted(execution)) return false;
|
|
189
203
|
const docMap = new Map(docs.map((d) => [d.docid, d]));
|
|
190
204
|
const { update: oramaUpdate } = this.oramaModule;
|
|
191
205
|
|
|
@@ -204,7 +218,7 @@ export class OramaBackend implements SearchBackend {
|
|
|
204
218
|
limit: existingCount + 100,
|
|
205
219
|
});
|
|
206
220
|
for (const hit of allHits.hits) {
|
|
207
|
-
if (isSearchAborted(execution)) return;
|
|
221
|
+
if (isSearchAborted(execution)) return false;
|
|
208
222
|
const storedDocument =
|
|
209
223
|
typeof getByID === "function"
|
|
210
224
|
? await getByID(db, hit.id)
|
|
@@ -227,7 +241,7 @@ export class OramaBackend implements SearchBackend {
|
|
|
227
241
|
|
|
228
242
|
// Insert new docs, update existing ones (preserving vectors since update is remove+insert)
|
|
229
243
|
for (const doc of docs) {
|
|
230
|
-
if (isSearchAborted(execution)) return;
|
|
244
|
+
if (isSearchAborted(execution)) return false;
|
|
231
245
|
const existing = existingDocs.get(doc.docid);
|
|
232
246
|
if (existing) {
|
|
233
247
|
const payload: Record<string, unknown> = {
|
|
@@ -281,13 +295,14 @@ export class OramaBackend implements SearchBackend {
|
|
|
281
295
|
}
|
|
282
296
|
}
|
|
283
297
|
|
|
284
|
-
if (isSearchAborted(execution)) return;
|
|
298
|
+
if (isSearchAborted(execution)) return false;
|
|
285
299
|
await this.persistDbForCollection(db, collection);
|
|
286
300
|
this.rememberVectorProviderCompatibility(
|
|
287
301
|
db,
|
|
288
302
|
embeddingProviderIdentity,
|
|
289
303
|
allRowsCompatible,
|
|
290
304
|
);
|
|
305
|
+
return true;
|
|
291
306
|
}
|
|
292
307
|
|
|
293
308
|
async embed(): Promise<void> {
|
package/src/search/port.ts
CHANGED
|
@@ -182,6 +182,13 @@ export interface SearchBackend {
|
|
|
182
182
|
* calls remain fail-open for migration/maintenance resilience.
|
|
183
183
|
*/
|
|
184
184
|
updateStrict?(execution?: SearchExecutionOptions): Promise<void>;
|
|
185
|
+
/**
|
|
186
|
+
* Fail-open like update(), but reports whether the index was actually brought
|
|
187
|
+
* up to date: false when it returned early (aborted, unavailable, suppressed
|
|
188
|
+
* or skipped) or failed. It never rejects for an update failure. For backends with no strict variant, so a caller
|
|
189
|
+
* that must not lose a re-index (the entity-migration pending marker) can tell.
|
|
190
|
+
*/
|
|
191
|
+
updateCompleted?(execution?: SearchExecutionOptions): Promise<boolean>;
|
|
185
192
|
updateCollection(collection: string, execution?: SearchExecutionOptions): Promise<void>;
|
|
186
193
|
updateCollectionFromDir?(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<void>;
|
|
187
194
|
/**
|
|
@@ -84,6 +84,10 @@ export class RemoteSearchBackend implements SearchBackend {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
async update(_execution?: SearchExecutionOptions): Promise<void> {}
|
|
87
|
+
/** The remote service owns its own indexing, so there is no local re-index that could be left incomplete. */
|
|
88
|
+
async updateCompleted(_execution?: SearchExecutionOptions): Promise<boolean> {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
87
91
|
async updateCollection(_collection: string, _execution?: SearchExecutionOptions): Promise<void> {}
|
|
88
92
|
async embed(): Promise<void> {}
|
|
89
93
|
async embedCollection(_collection: string): Promise<void> {}
|
|
@@ -10,6 +10,12 @@ import {
|
|
|
10
10
|
rewriteProjectedEntityReferences,
|
|
11
11
|
rewriteProjectedMemoryEntityReference,
|
|
12
12
|
} from "../memory-projection-mutations.js";
|
|
13
|
+
import {
|
|
14
|
+
listMaintenancePending,
|
|
15
|
+
settleMaintenancePending,
|
|
16
|
+
trackMaintenanceSettlement,
|
|
17
|
+
writeMaintenancePending,
|
|
18
|
+
} from "./entity-migration-maintenance-pending.js";
|
|
13
19
|
|
|
14
20
|
export interface EntityCanonicalIdMigrationHost {
|
|
15
21
|
readonly baseDir: string;
|
|
@@ -31,7 +37,13 @@ export interface EntityCanonicalIdMigrationHost {
|
|
|
31
37
|
invalidateColdMemoriesCache(): void;
|
|
32
38
|
bumpMemoryStatusVersion(): void;
|
|
33
39
|
getEntityMutationVersion(): number;
|
|
34
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Ask search maintenance to re-index. `false` means the request was refused
|
|
42
|
+
* (e.g. the host is shutting down) and a promise settling to `false` means no
|
|
43
|
+
* pass served it: the pending markers stay so the next start retries. A
|
|
44
|
+
* promise is awaited off the startup path; `true` or nothing clears them.
|
|
45
|
+
*/
|
|
46
|
+
onEntityMigrationComplete?: () => boolean | Promise<boolean> | void;
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
export async function runLegacyEntityCanonicalIdMigration(
|
|
@@ -43,15 +55,26 @@ export async function runLegacyEntityCanonicalIdMigration(
|
|
|
43
55
|
const readMigrationFingerprint = () =>
|
|
44
56
|
getFingerprint(host.baseDir, host.entitiesDir, () => String(host.getEntityMutationVersion()));
|
|
45
57
|
// The migration bumps the status version only after a pass that rewrote
|
|
46
|
-
// references; a no-op replay must not ask search maintenance to run
|
|
58
|
+
// references; a no-op replay must not ask search maintenance to run unless a
|
|
59
|
+
// previous run died owing it (issue #3286 pending marker).
|
|
47
60
|
let rewroteReferences = false;
|
|
61
|
+
// Markers an earlier run left behind: this run owes (and may clear) exactly
|
|
62
|
+
// these plus its own, never a marker a concurrent run arms meanwhile.
|
|
63
|
+
const owed = await listMaintenancePending(host.stateDir);
|
|
64
|
+
// Arm the marker before this run rewrites anything, so a crash between the
|
|
65
|
+
// rewrite and the hook leaves it behind. Idempotent per run.
|
|
66
|
+
let armed: Promise<string> | undefined;
|
|
67
|
+
const armPending = (): Promise<string> => (armed ??= writeMaintenancePending(host.stateDir));
|
|
48
68
|
const completionFingerprint = await migrateLegacyEntityCanonicalIds({
|
|
49
69
|
stateDir: host.stateDir,
|
|
50
70
|
entitiesDir: host.entitiesDir,
|
|
51
71
|
normalizeEntityName: host.normalizeEntityName.bind(host),
|
|
52
72
|
resolveEntityFilePath: host.resolveEntityFilePath.bind(host),
|
|
53
73
|
readStorageSecureFile: host.readStorageSecureFile.bind(host),
|
|
54
|
-
writeStorageSecureFile:
|
|
74
|
+
writeStorageSecureFile: async (filePath, content, forceEncrypt) => {
|
|
75
|
+
await armPending();
|
|
76
|
+
return host.writeStorageSecureFile(filePath, content, forceEncrypt);
|
|
77
|
+
},
|
|
55
78
|
isEncryptedStorageFile: host.isEncryptedFileHeader.bind(host),
|
|
56
79
|
snapshotBeforeWrite: host.snapshotBeforeWrite.bind(host),
|
|
57
80
|
parseEntityFile,
|
|
@@ -63,10 +86,14 @@ export async function runLegacyEntityCanonicalIdMigration(
|
|
|
63
86
|
readArchivedMemories: host.readArchivedMemories.bind(host),
|
|
64
87
|
validateMemoryScanRoots: () =>
|
|
65
88
|
validateRoots(host.baseDir, host.entitiesDir, host.stateDir, host.archiveDir),
|
|
66
|
-
rewriteProjectedEntityReferences: (mappings) =>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
rewriteProjectedEntityReferences: async (mappings) => {
|
|
90
|
+
await armPending();
|
|
91
|
+
return rewriteProjectedEntityReferences(host.baseDir, mappings);
|
|
92
|
+
},
|
|
93
|
+
rewriteProjectedMemoryEntityReference: async (memoryId, previousEntityRef, nextEntityRef) => {
|
|
94
|
+
await armPending();
|
|
95
|
+
return rewriteProjectedMemoryEntityReference(host.baseDir, memoryId, previousEntityRef, nextEntityRef);
|
|
96
|
+
},
|
|
70
97
|
invalidateKnowledgeIndexCache: host.invalidateKnowledgeIndexCache.bind(host),
|
|
71
98
|
invalidateAllMemoriesCache: host.invalidateAllMemoriesCache.bind(host),
|
|
72
99
|
invalidateColdMemoriesCache: host.invalidateColdMemoriesCache.bind(host),
|
|
@@ -76,9 +103,17 @@ export async function runLegacyEntityCanonicalIdMigration(
|
|
|
76
103
|
},
|
|
77
104
|
readMigrationFingerprint,
|
|
78
105
|
} satisfies EntityCanonicalIdMigrationDependencies);
|
|
79
|
-
if (completionFingerprint !== undefined && rewroteReferences) {
|
|
106
|
+
if (completionFingerprint !== undefined && (rewroteReferences || owed.length > 0) && host.onEntityMigrationComplete) {
|
|
107
|
+
// No hook wired (e.g. a CLI-only process) skips this block, keeping the markers too.
|
|
108
|
+
const held = armed ? [...owed, await armed] : owed;
|
|
80
109
|
try {
|
|
81
|
-
host.onEntityMigrationComplete
|
|
110
|
+
const outcome = host.onEntityMigrationComplete();
|
|
111
|
+
const settling = settleMaintenancePending(host.stateDir, held, outcome).catch(() => {
|
|
112
|
+
log.warn("entity migration: search maintenance scheduling failed");
|
|
113
|
+
});
|
|
114
|
+
// An async hook is served after a debounce: never hold startup for it.
|
|
115
|
+
if (outcome instanceof Promise) trackMaintenanceSettlement(host.stateDir, settling);
|
|
116
|
+
else await settling;
|
|
82
117
|
} catch {
|
|
83
118
|
log.warn("entity migration: search maintenance scheduling failed");
|
|
84
119
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
clearMaintenancePending,
|
|
9
|
+
drainMaintenanceSettlements,
|
|
10
|
+
ENTITY_MIGRATION_MAINTENANCE_PENDING_PREFIX,
|
|
11
|
+
listMaintenancePending,
|
|
12
|
+
settleMaintenancePending,
|
|
13
|
+
trackMaintenanceSettlement,
|
|
14
|
+
writeMaintenancePending,
|
|
15
|
+
} from "./entity-migration-maintenance-pending.js";
|
|
16
|
+
|
|
17
|
+
async function withStateDir(run: (stateDir: string) => Promise<void>): Promise<void> {
|
|
18
|
+
const stateDir = await mkdtemp(path.join(os.tmpdir(), "remnic-3286-marker-unit-"));
|
|
19
|
+
try {
|
|
20
|
+
await run(stateDir);
|
|
21
|
+
} finally {
|
|
22
|
+
await rm(stateDir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("each arm writes its own marker, and listing ignores in-progress temp files", async () => {
|
|
27
|
+
await withStateDir(async (stateDir) => {
|
|
28
|
+
assert.deepEqual(await listMaintenancePending(path.join(stateDir, "missing")), [], "an absent state dir owes nothing");
|
|
29
|
+
const first = await writeMaintenancePending(stateDir);
|
|
30
|
+
const second = await writeMaintenancePending(stateDir);
|
|
31
|
+
assert.notEqual(first, second, "concurrent arms never share a file");
|
|
32
|
+
await writeFile(path.join(stateDir, `${ENTITY_MIGRATION_MAINTENANCE_PENDING_PREFIX}.crashed.tmp`), "partial");
|
|
33
|
+
await writeFile(path.join(stateDir, "entity-canonical-id-migration-v1.json"), "{}");
|
|
34
|
+
assert.deepEqual((await listMaintenancePending(stateDir)).sort(), [first, second].sort());
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("clearing named markers never deletes one armed by another run, and tolerates an already-gone name", async () => {
|
|
39
|
+
await withStateDir(async (stateDir) => {
|
|
40
|
+
const mine = await writeMaintenancePending(stateDir);
|
|
41
|
+
const concurrent = await writeMaintenancePending(stateDir);
|
|
42
|
+
await clearMaintenancePending(stateDir, [mine, "entity-canonical-id-maintenance.pending.already-gone"]);
|
|
43
|
+
assert.deepEqual(await listMaintenancePending(stateDir), [concurrent], "the concurrent run's marker survives");
|
|
44
|
+
await clearMaintenancePending(stateDir, [concurrent]);
|
|
45
|
+
assert.deepEqual(await readdir(stateDir), []);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("settling clears on served or no answer, keeps on a refusal or a not-served promise, and propagates a rejection", async () => {
|
|
50
|
+
await withStateDir(async (stateDir) => {
|
|
51
|
+
const cases: Array<[string, boolean | Promise<boolean> | void, boolean]> = [
|
|
52
|
+
["true", true, true],
|
|
53
|
+
["nothing", undefined, true],
|
|
54
|
+
["resolved true", Promise.resolve(true), true],
|
|
55
|
+
["false", false, false],
|
|
56
|
+
["resolved false", Promise.resolve(false), false],
|
|
57
|
+
];
|
|
58
|
+
for (const [label, outcome, clears] of cases) {
|
|
59
|
+
const marker = await writeMaintenancePending(stateDir);
|
|
60
|
+
await settleMaintenancePending(stateDir, [marker], outcome);
|
|
61
|
+
assert.equal((await listMaintenancePending(stateDir)).length, clears ? 0 : 1, label);
|
|
62
|
+
await clearMaintenancePending(stateDir, await listMaintenancePending(stateDir));
|
|
63
|
+
}
|
|
64
|
+
const marker = await writeMaintenancePending(stateDir);
|
|
65
|
+
await assert.rejects(settleMaintenancePending(stateDir, [marker], Promise.reject(new Error("pass failed"))), /pass failed/);
|
|
66
|
+
assert.deepEqual(await listMaintenancePending(stateDir), [marker], "a rejected request keeps the marker");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("tracked settlements can be drained and forgotten once done", async () => {
|
|
71
|
+
await withStateDir(async (stateDir) => {
|
|
72
|
+
const release = Promise.withResolvers<void>();
|
|
73
|
+
let finished = false;
|
|
74
|
+
trackMaintenanceSettlement(
|
|
75
|
+
stateDir,
|
|
76
|
+
release.promise.then(() => {
|
|
77
|
+
finished = true;
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
const drained = drainMaintenanceSettlements(stateDir);
|
|
81
|
+
assert.equal(finished, false);
|
|
82
|
+
release.resolve();
|
|
83
|
+
await drained;
|
|
84
|
+
assert.equal(finished, true);
|
|
85
|
+
await drainMaintenanceSettlements(stateDir);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import { StorageManager, normalizeEntityName } from "../storage.js";
|
|
8
|
+
import { drainMaintenanceSettlements, writeMaintenancePending } from "./entity-migration-maintenance-pending.js";
|
|
9
|
+
|
|
10
|
+
// Issue #3286: a crash between the reference rewrite and the completion hook
|
|
11
|
+
// must not leave the QMD index stale. A "maintenance pending" marker beside the
|
|
12
|
+
// migration journal makes the next startup's no-op replay fire the hook once.
|
|
13
|
+
const MAINTENANCE_PENDING_PREFIX = "entity-canonical-id-maintenance.pending";
|
|
14
|
+
|
|
15
|
+
/** Pending-marker files currently on disk (temp files excluded). */
|
|
16
|
+
async function pendingMarkers(dir: string): Promise<string[]> {
|
|
17
|
+
return (await readdir(path.join(dir, "state")))
|
|
18
|
+
.filter((name) => name.startsWith(MAINTENANCE_PENDING_PREFIX) && !name.endsWith(".tmp"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SeededCorpus {
|
|
22
|
+
dir: string;
|
|
23
|
+
legacy: string;
|
|
24
|
+
canonical: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Seed a corpus with one legacy-typed entity file and one memory referencing
|
|
29
|
+
* it by the legacy id, so a later run has a real rewrite to perform. The
|
|
30
|
+
* legacy file keeps its old id as the filename while its `Type:` was retyped —
|
|
31
|
+
* the production legacy shape (see entity-canonical-id-collision.test.ts).
|
|
32
|
+
*/
|
|
33
|
+
async function seedLegacyCorpus(): Promise<SeededCorpus> {
|
|
34
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-3286-pending-"));
|
|
35
|
+
// Create the directory skeleton (facts/ is date-partitioned on write, so
|
|
36
|
+
// the raw file below lands in a directory ensureDirectories has made).
|
|
37
|
+
await new StorageManager(dir).ensureDirectories();
|
|
38
|
+
const name = "Nightly Ingest";
|
|
39
|
+
const legacy = normalizeEntityName(name, "automation");
|
|
40
|
+
const canonical = normalizeEntityName(name, "automation-cron-job");
|
|
41
|
+
assert.notEqual(legacy, canonical, "fixture must actually straddle the rename");
|
|
42
|
+
await writeFile(
|
|
43
|
+
path.join(dir, "entities", `${legacy}.md`),
|
|
44
|
+
`---\nid: ${legacy}\ncreated: 2026-03-01T00:00:00.000Z\nupdated: 2026-03-01T00:00:00.000Z\n---\n\n`
|
|
45
|
+
+ `# ${name}\n\n**Type:** automation-cron-job\n\nRuns at 02:00.\n`,
|
|
46
|
+
"utf8",
|
|
47
|
+
);
|
|
48
|
+
// Seed the memory file fully raw with the legacy entityRef: writeMemoryFrontmatter
|
|
49
|
+
// would canonicalize the ref through the journal this pass is about to build,
|
|
50
|
+
// and a raw edit after a cached read would serve the stale parse (the corpus
|
|
51
|
+
// version only moves on managed writes).
|
|
52
|
+
const memoryPath = path.join(dir, "facts", new Date().toISOString().slice(0, 10), "note-3286.md");
|
|
53
|
+
await writeFile(
|
|
54
|
+
memoryPath,
|
|
55
|
+
[
|
|
56
|
+
"---",
|
|
57
|
+
"id: fact-3286-note",
|
|
58
|
+
"category: fact",
|
|
59
|
+
"created: 2026-03-01T00:00:00.000Z",
|
|
60
|
+
"updated: 2026-03-01T00:00:00.000Z",
|
|
61
|
+
"source: extraction",
|
|
62
|
+
"confidence: 0.8",
|
|
63
|
+
"confidenceTier: implied",
|
|
64
|
+
"tags: []",
|
|
65
|
+
`entityRef: ${legacy}`,
|
|
66
|
+
"---",
|
|
67
|
+
"",
|
|
68
|
+
`References [[${legacy}]].`,
|
|
69
|
+
"",
|
|
70
|
+
].join("\n"),
|
|
71
|
+
"utf8",
|
|
72
|
+
);
|
|
73
|
+
return { dir, legacy, canonical };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function countHook(storage: StorageManager, counter: { calls: number }, throwFirstTime = false): void {
|
|
77
|
+
storage.onEntityMigrationComplete = () => {
|
|
78
|
+
counter.calls += 1;
|
|
79
|
+
if (throwFirstTime && counter.calls === 1) throw new Error("simulated crash before maintenance was requested");
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test("a crash after the rewrite but before the hook fires the hook once on the next no-op startup", async () => {
|
|
84
|
+
const { dir } = await seedLegacyCorpus();
|
|
85
|
+
const hook = { calls: 0 };
|
|
86
|
+
try {
|
|
87
|
+
// Pass 1: the migration rewrites; the hook throws to emulate the crash
|
|
88
|
+
// between the rewrite and maintenance being requested.
|
|
89
|
+
const first = new StorageManager(dir);
|
|
90
|
+
countHook(first, hook, true);
|
|
91
|
+
await first.ensureDirectories();
|
|
92
|
+
await first.drainEntityCanonicalIdMigration();
|
|
93
|
+
assert.equal(hook.calls, 1, "precondition: the rewriting pass reached the hook");
|
|
94
|
+
|
|
95
|
+
// Pass 2: a fresh manager replays a no-op journal; the pending marker
|
|
96
|
+
// must fire the hook exactly once, then clear itself.
|
|
97
|
+
const second = new StorageManager(dir);
|
|
98
|
+
countHook(second, hook);
|
|
99
|
+
await second.ensureDirectories();
|
|
100
|
+
await second.drainEntityCanonicalIdMigration();
|
|
101
|
+
assert.equal(hook.calls, 2, "the no-op replay must fire the hook once via the pending marker");
|
|
102
|
+
assert.equal((await pendingMarkers(dir)).length, 0, "the marker must clear after the hook");
|
|
103
|
+
|
|
104
|
+
// Pass 3: ordinary no-op startup stays quiet.
|
|
105
|
+
const third = new StorageManager(dir);
|
|
106
|
+
countHook(third, hook);
|
|
107
|
+
await third.ensureDirectories();
|
|
108
|
+
await third.drainEntityCanonicalIdMigration();
|
|
109
|
+
assert.equal(hook.calls, 2, "a no-op startup after the marker is cleared must not fire the hook");
|
|
110
|
+
} finally {
|
|
111
|
+
StorageManager.clearAllStaticCaches();
|
|
112
|
+
await rm(dir, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a rewriting pass with a healthy hook leaves no pending marker", async () => {
|
|
117
|
+
const { dir, legacy, canonical } = await seedLegacyCorpus();
|
|
118
|
+
const hook = { calls: 0 };
|
|
119
|
+
try {
|
|
120
|
+
const storage = new StorageManager(dir);
|
|
121
|
+
countHook(storage, hook);
|
|
122
|
+
await storage.ensureDirectories();
|
|
123
|
+
await storage.drainEntityCanonicalIdMigration();
|
|
124
|
+
assert.equal(hook.calls, 1);
|
|
125
|
+
assert.equal((await pendingMarkers(dir)).length, 0);
|
|
126
|
+
const journal = JSON.parse(
|
|
127
|
+
await readFile(path.join(dir, "state", "entity-canonical-id-migration-v1.json"), "utf8"),
|
|
128
|
+
);
|
|
129
|
+
assert.equal(journal.complete, true, "precondition: the journal completed");
|
|
130
|
+
await assert.rejects(() => readFile(path.join(dir, "entities", `${legacy}.md`), "utf8"));
|
|
131
|
+
await readFile(path.join(dir, "entities", `${canonical}.md`), "utf8");
|
|
132
|
+
} finally {
|
|
133
|
+
StorageManager.clearAllStaticCaches();
|
|
134
|
+
await rm(dir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
test("a process with no hook wired keeps the marker so a later wired start fires once", async () => {
|
|
138
|
+
const { dir } = await seedLegacyCorpus();
|
|
139
|
+
const hook = { calls: 0 };
|
|
140
|
+
try {
|
|
141
|
+
// A CLI-only process runs the rewrite with nothing to request maintenance.
|
|
142
|
+
const bare = new StorageManager(dir);
|
|
143
|
+
await bare.ensureDirectories();
|
|
144
|
+
await bare.drainEntityCanonicalIdMigration();
|
|
145
|
+
assert.equal((await pendingMarkers(dir)).length, 1, "no hook: the owed re-index must persist");
|
|
146
|
+
|
|
147
|
+
const wired = new StorageManager(dir);
|
|
148
|
+
countHook(wired, hook);
|
|
149
|
+
await wired.ensureDirectories();
|
|
150
|
+
await wired.drainEntityCanonicalIdMigration();
|
|
151
|
+
assert.equal(hook.calls, 1);
|
|
152
|
+
assert.equal((await pendingMarkers(dir)).length, 0);
|
|
153
|
+
} finally {
|
|
154
|
+
StorageManager.clearAllStaticCaches();
|
|
155
|
+
await rm(dir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a hook that refuses the request (shutdown) keeps the marker for the next start", async () => {
|
|
160
|
+
const { dir } = await seedLegacyCorpus();
|
|
161
|
+
const hook = { calls: 0 };
|
|
162
|
+
try {
|
|
163
|
+
const stopping = new StorageManager(dir);
|
|
164
|
+
stopping.onEntityMigrationComplete = () => {
|
|
165
|
+
hook.calls += 1;
|
|
166
|
+
return false;
|
|
167
|
+
};
|
|
168
|
+
await stopping.ensureDirectories();
|
|
169
|
+
await stopping.drainEntityCanonicalIdMigration();
|
|
170
|
+
assert.equal(hook.calls, 1);
|
|
171
|
+
assert.equal((await pendingMarkers(dir)).length, 1, "a refused request must stay owed");
|
|
172
|
+
|
|
173
|
+
const next = new StorageManager(dir);
|
|
174
|
+
countHook(next, hook);
|
|
175
|
+
await next.ensureDirectories();
|
|
176
|
+
await next.drainEntityCanonicalIdMigration();
|
|
177
|
+
assert.equal(hook.calls, 2);
|
|
178
|
+
assert.equal((await pendingMarkers(dir)).length, 0);
|
|
179
|
+
} finally {
|
|
180
|
+
StorageManager.clearAllStaticCaches();
|
|
181
|
+
await rm(dir, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("an async hook keeps the marker until its maintenance pass is served, and keeps it when that fails", async () => {
|
|
186
|
+
const { dir } = await seedLegacyCorpus();
|
|
187
|
+
try {
|
|
188
|
+
// The real hook only ARMS a debounced timer; its promise settles when the
|
|
189
|
+
// pass has run. Clearing at request time would lose the re-index to a crash
|
|
190
|
+
// or destroy() inside the debounce window (review finding on #3287).
|
|
191
|
+
const served = Promise.withResolvers<boolean>();
|
|
192
|
+
const first = new StorageManager(dir);
|
|
193
|
+
first.onEntityMigrationComplete = () => served.promise;
|
|
194
|
+
await first.ensureDirectories();
|
|
195
|
+
await first.drainEntityCanonicalIdMigration();
|
|
196
|
+
assert.equal((await pendingMarkers(dir)).length, 1, "the marker must outlive the unserved request");
|
|
197
|
+
|
|
198
|
+
served.resolve(false);
|
|
199
|
+
await drainMaintenanceSettlements(path.join(dir, "state"));
|
|
200
|
+
assert.equal((await pendingMarkers(dir)).length, 1, "a pass that did not serve the request keeps the marker");
|
|
201
|
+
|
|
202
|
+
// A later start still owes it; this time the pass is served.
|
|
203
|
+
const retry = Promise.withResolvers<boolean>();
|
|
204
|
+
const second = new StorageManager(dir);
|
|
205
|
+
second.onEntityMigrationComplete = () => retry.promise;
|
|
206
|
+
await second.ensureDirectories();
|
|
207
|
+
await second.drainEntityCanonicalIdMigration();
|
|
208
|
+
assert.equal((await pendingMarkers(dir)).length, 1);
|
|
209
|
+
retry.resolve(true);
|
|
210
|
+
await drainMaintenanceSettlements(path.join(dir, "state"));
|
|
211
|
+
assert.equal((await pendingMarkers(dir)).length, 0, "a served pass clears the marker");
|
|
212
|
+
} finally {
|
|
213
|
+
StorageManager.clearAllStaticCaches();
|
|
214
|
+
await rm(dir, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("clearing one run's marker never deletes a marker a concurrent run armed meanwhile", async () => {
|
|
219
|
+
const { dir } = await seedLegacyCorpus();
|
|
220
|
+
try {
|
|
221
|
+
const stateDir = path.join(dir, "state");
|
|
222
|
+
const first = new StorageManager(dir);
|
|
223
|
+
// Another process takes the migration lock after this run released it and
|
|
224
|
+
// arms its own marker before this run's hook returns and its clear starts.
|
|
225
|
+
let concurrentArm: Promise<string> | undefined;
|
|
226
|
+
first.onEntityMigrationComplete = () => {
|
|
227
|
+
concurrentArm = writeMaintenancePending(stateDir);
|
|
228
|
+
};
|
|
229
|
+
await first.ensureDirectories();
|
|
230
|
+
await first.drainEntityCanonicalIdMigration();
|
|
231
|
+
await drainMaintenanceSettlements(stateDir);
|
|
232
|
+
await concurrentArm;
|
|
233
|
+
assert.equal(
|
|
234
|
+
(await pendingMarkers(dir)).length,
|
|
235
|
+
1,
|
|
236
|
+
"the concurrent run's marker must survive this run's clear (it is the only owed re-index if that run crashes)",
|
|
237
|
+
);
|
|
238
|
+
} finally {
|
|
239
|
+
StorageManager.clearAllStaticCaches();
|
|
240
|
+
await rm(dir, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|