@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.
Files changed (257) hide show
  1. package/dist/access-admin-ops-surface.js +24 -24
  2. package/dist/access-authorization-probe.js +25 -25
  3. package/dist/access-boundary.js +24 -24
  4. package/dist/access-cli.js +65 -65
  5. package/dist/access-extraction-force-flush.js +24 -24
  6. package/dist/access-http-briefing.js +25 -25
  7. package/dist/access-http-query.js +26 -26
  8. package/dist/access-http.js +40 -40
  9. package/dist/access-identity-continuity-surface.js +24 -24
  10. package/dist/access-lcm-surface.js +24 -24
  11. package/dist/access-mcp.js +33 -33
  12. package/dist/access-namespace-preflight.js +24 -24
  13. package/dist/access-observe-write-surface.js +24 -24
  14. package/dist/access-operations-batch.js +25 -25
  15. package/dist/access-operations.js +32 -32
  16. package/dist/access-recall-concurrency.js +24 -24
  17. package/dist/access-recall-response.js +24 -24
  18. package/dist/access-recall-surface.js +24 -24
  19. package/dist/access-service.js +24 -24
  20. package/dist/access-surface-catalog.js +26 -26
  21. package/dist/active-recall.js +9 -9
  22. package/dist/briefing.js +10 -10
  23. package/dist/buffer.js +5 -5
  24. package/dist/causal-consolidation.js +11 -11
  25. package/dist/{chunk-3NL4ALUP.js → chunk-24SIB3EW.js} +3 -3
  26. package/dist/{chunk-Y3T5KQCR.js → chunk-2EYCW2ET.js} +2 -2
  27. package/dist/{chunk-UM3ZLT2X.js → chunk-2GU6XS4L.js} +31 -28
  28. package/dist/chunk-2GU6XS4L.js.map +1 -0
  29. package/dist/{chunk-AHGOOZUC.js → chunk-34UVJNSG.js} +2 -2
  30. package/dist/{chunk-2EXF6IIB.js → chunk-3632XJMR.js} +2 -2
  31. package/dist/{chunk-KETS5A62.js → chunk-3H3PNQEF.js} +2 -2
  32. package/dist/{chunk-SFRBUBUZ.js → chunk-3LNAQLW4.js} +3 -3
  33. package/dist/{chunk-75BK4XGV.js → chunk-4BUJICMI.js} +10 -10
  34. package/dist/{chunk-KIWO4327.js → chunk-4W5B5Z6P.js} +2 -2
  35. package/dist/{chunk-B35EQQ7L.js → chunk-53OFDW3P.js} +3 -3
  36. package/dist/{chunk-2ONKXEC7.js → chunk-5LBBXI2A.js} +2 -2
  37. package/dist/{chunk-UCLJTGP3.js → chunk-65B5RFKQ.js} +2 -2
  38. package/dist/{chunk-KAJYSO3U.js → chunk-6CDNOREO.js} +2 -2
  39. package/dist/{chunk-POJRFSJM.js → chunk-7XGNGLAN.js} +2 -2
  40. package/dist/{chunk-A6JDT6QF.js → chunk-AO5ZOD32.js} +2 -2
  41. package/dist/{chunk-RA7YRL6H.js → chunk-AOSCGNIC.js} +3 -3
  42. package/dist/{chunk-LWMLBVGB.js → chunk-B43P3DTC.js} +2 -2
  43. package/dist/{chunk-VUMWVYMR.js → chunk-CIMD2MUR.js} +3 -3
  44. package/dist/{chunk-XRFLR3XF.js → chunk-DLZGSZVC.js} +22 -9
  45. package/dist/chunk-DLZGSZVC.js.map +1 -0
  46. package/dist/{chunk-5CWH2LKL.js → chunk-DOJBV6WR.js} +29 -14
  47. package/dist/chunk-DOJBV6WR.js.map +1 -0
  48. package/dist/{chunk-44EUCSG4.js → chunk-FFQNNFHX.js} +2 -2
  49. package/dist/{chunk-HEF4BRMT.js → chunk-FZLZUDJJ.js} +2 -2
  50. package/dist/{chunk-L3EFYRYK.js → chunk-GGFQAGOT.js} +7 -7
  51. package/dist/{chunk-ZLCBLDJO.js → chunk-GIRWNTMV.js} +2 -2
  52. package/dist/{chunk-SHNM7RIT.js → chunk-HJ4X4BGS.js} +2 -2
  53. package/dist/{chunk-MUNYUSBU.js → chunk-HJQ4NKKO.js} +2 -2
  54. package/dist/{chunk-V6Z4NAJZ.js → chunk-HM3ZTDAX.js} +2 -2
  55. package/dist/{chunk-EZS6QJNC.js → chunk-IFOCK3OJ.js} +2 -2
  56. package/dist/{chunk-LXXIV7Y2.js → chunk-JEX2JL3U.js} +7 -2
  57. package/dist/{chunk-LXXIV7Y2.js.map → chunk-JEX2JL3U.js.map} +1 -1
  58. package/dist/{chunk-DF6ZF2BN.js → chunk-LIVNX7WK.js} +2 -2
  59. package/dist/{chunk-WY66J4PI.js → chunk-LRVFS4KA.js} +2 -2
  60. package/dist/{chunk-LMB47UM6.js → chunk-LTDK6HBV.js} +2 -2
  61. package/dist/{chunk-HCIRR3BA.js → chunk-M4U25HLR.js} +2 -2
  62. package/dist/{chunk-YK7ZJPQM.js → chunk-MGN2RQRG.js} +2 -2
  63. package/dist/{chunk-G26YNEOC.js → chunk-NWA6IXR3.js} +1 -1
  64. package/dist/{chunk-G26YNEOC.js.map → chunk-NWA6IXR3.js.map} +1 -1
  65. package/dist/{chunk-JIJAWTRK.js → chunk-OCXDZJL4.js} +264 -194
  66. package/dist/chunk-OCXDZJL4.js.map +1 -0
  67. package/dist/{chunk-IUQXRRJB.js → chunk-OMWQO7H6.js} +2 -2
  68. package/dist/{chunk-7VO6E5UD.js → chunk-OVZIYLBZ.js} +6 -2
  69. package/dist/chunk-OVZIYLBZ.js.map +1 -0
  70. package/dist/{chunk-SPTPBKZX.js → chunk-OWNAWY6D.js} +2 -2
  71. package/dist/{chunk-XKDQTDXN.js → chunk-PFUXPTBT.js} +36 -15
  72. package/dist/chunk-PFUXPTBT.js.map +1 -0
  73. package/dist/{chunk-EC2KVA27.js → chunk-PVG7TCUG.js} +186 -105
  74. package/dist/chunk-PVG7TCUG.js.map +1 -0
  75. package/dist/{chunk-DQOGZZJA.js → chunk-QAHP5SBG.js} +3 -3
  76. package/dist/{chunk-GBGU5OZP.js → chunk-QF5EJ5VP.js} +13 -13
  77. package/dist/{chunk-FT75AQ74.js → chunk-QS5DUSBM.js} +2 -2
  78. package/dist/{chunk-72CBJ5VP.js → chunk-R3WZ74B2.js} +3 -3
  79. package/dist/{chunk-PDTNQLPC.js → chunk-RG3I2663.js} +2 -2
  80. package/dist/{chunk-CK6GCGMX.js → chunk-RTJJPW3C.js} +2 -2
  81. package/dist/{chunk-RJDYDF5K.js → chunk-TRAOZ2WP.js} +2 -2
  82. package/dist/{chunk-6ZAU7LAR.js → chunk-TTTPTQ6S.js} +2 -2
  83. package/dist/{chunk-JS44PBCM.js → chunk-U2A56LLF.js} +6 -6
  84. package/dist/{chunk-MC7PXJDP.js → chunk-UBOWDOL5.js} +5 -5
  85. package/dist/{chunk-4FM35YIA.js → chunk-UQYBPPVC.js} +2 -2
  86. package/dist/{chunk-OCRLJJOV.js → chunk-V4VCBFAL.js} +3 -3
  87. package/dist/{chunk-FNNGWN3O.js → chunk-VGE4PUY6.js} +2 -2
  88. package/dist/{chunk-5DVOBBTV.js → chunk-VV73XT3A.js} +2 -2
  89. package/dist/{chunk-GE3ZX6YK.js → chunk-W7NKMRAF.js} +2 -2
  90. package/dist/{chunk-AJJPXUE6.js → chunk-WBN66HPI.js} +2 -2
  91. package/dist/{chunk-XWJUFER3.js → chunk-WGFEGBFB.js} +2 -2
  92. package/dist/{chunk-43TUWQNR.js → chunk-XDKEHZUC.js} +2 -2
  93. package/dist/{chunk-ROAKLYOL.js → chunk-XHEKS4UN.js} +2 -2
  94. package/dist/{chunk-ZKK3VMWU.js → chunk-XVTB6BZS.js} +2 -2
  95. package/dist/{chunk-AZLD4CKG.js → chunk-Y56D5FSD.js} +17 -17
  96. package/dist/{chunk-MPWOAIIH.js → chunk-YDT3OEYF.js} +2 -2
  97. package/dist/{chunk-IN6U2BS5.js → chunk-YJHOVA42.js} +2 -2
  98. package/dist/{chunk-TC55M5PO.js → chunk-YLN4ZQV4.js} +2 -2
  99. package/dist/cli.js +50 -50
  100. package/dist/coding/export-okf-codegraph.js +9 -9
  101. package/dist/compounding/engine.js +11 -11
  102. package/dist/config.js +9 -9
  103. package/dist/connectors/codex-materialize-runner.js +10 -10
  104. package/dist/connectors/index.js +10 -10
  105. package/dist/convergence-refresh.js +12 -12
  106. package/dist/corpus-watermark.js +12 -12
  107. package/dist/entity-retrieval.js +10 -10
  108. package/dist/external-wiki-access.js +25 -25
  109. package/dist/extraction.js +14 -14
  110. package/dist/harmonic-retrieval.js +10 -10
  111. package/dist/index.js +65 -65
  112. package/dist/location/index.js +12 -12
  113. package/dist/maintenance/memory-governance.js +10 -10
  114. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -10
  115. package/dist/maintenance/rebuild-memory-projection-runner.js +12 -12
  116. package/dist/maintenance/rebuild-memory-projection.js +11 -11
  117. package/dist/memory-browse-operations.js +25 -25
  118. package/dist/memory-browse.js +5 -5
  119. package/dist/namespaces/migrate.js +11 -11
  120. package/dist/namespaces/search.d.ts.map +1 -1
  121. package/dist/namespaces/search.js +8 -8
  122. package/dist/namespaces/storage.js +10 -10
  123. package/dist/operator-doctor-corpus.js +13 -13
  124. package/dist/operator-toolkit.js +18 -18
  125. package/dist/orchestration/lifecycle-pending-fallback-drain.d.ts +18 -0
  126. package/dist/orchestration/lifecycle-pending-fallback-drain.d.ts.map +1 -0
  127. package/dist/orchestration/maintenance.d.ts +11 -13
  128. package/dist/orchestration/maintenance.d.ts.map +1 -1
  129. package/dist/orchestration/maintenance.js +20 -20
  130. package/dist/orchestration/qmd-maintenance-completion.d.ts +25 -0
  131. package/dist/orchestration/qmd-maintenance-completion.d.ts.map +1 -0
  132. package/dist/orchestrator.d.ts.map +1 -1
  133. package/dist/orchestrator.js +65 -65
  134. package/dist/qmd.js +2 -2
  135. package/dist/recall-composition-decision.js +2 -2
  136. package/dist/recall-navigation-operations.js +25 -25
  137. package/dist/recall-navigation.js +24 -24
  138. package/dist/recall-state-view-admission.js +5 -5
  139. package/dist/recall-why-service.js +5 -5
  140. package/dist/resume-bundles.js +9 -9
  141. package/dist/search/factory.js +7 -7
  142. package/dist/search/index.js +7 -7
  143. package/dist/search/lancedb-backend.d.ts +3 -0
  144. package/dist/search/lancedb-backend.d.ts.map +1 -1
  145. package/dist/search/lancedb-backend.js +2 -2
  146. package/dist/search/meilisearch-backend.d.ts +3 -0
  147. package/dist/search/meilisearch-backend.d.ts.map +1 -1
  148. package/dist/search/meilisearch-backend.js +2 -2
  149. package/dist/search/orama-backend.d.ts +3 -0
  150. package/dist/search/orama-backend.d.ts.map +1 -1
  151. package/dist/search/orama-backend.js +2 -2
  152. package/dist/search/port.d.ts +7 -0
  153. package/dist/search/port.d.ts.map +1 -1
  154. package/dist/search/port.js +1 -1
  155. package/dist/search/remote-backend.d.ts +2 -0
  156. package/dist/search/remote-backend.d.ts.map +1 -1
  157. package/dist/search/remote-backend.js +2 -2
  158. package/dist/semantic-consolidation.js +11 -11
  159. package/dist/semantic-rule-promotion.js +10 -10
  160. package/dist/semantic-rule-verifier.js +10 -10
  161. package/dist/source-agent-qualifier.js +13 -13
  162. package/dist/standup/build.js +10 -10
  163. package/dist/storage/entity-canonical-id-migration-adapter.d.ts +7 -1
  164. package/dist/storage/entity-canonical-id-migration-adapter.d.ts.map +1 -1
  165. package/dist/storage/entity-migration-maintenance-pending.d.ts +31 -0
  166. package/dist/storage/entity-migration-maintenance-pending.d.ts.map +1 -0
  167. package/dist/storage.d.ts +2 -2
  168. package/dist/storage.d.ts.map +1 -1
  169. package/dist/storage.js +9 -9
  170. package/dist/support-passport/index.js +27 -27
  171. package/dist/transfer/export-okf.js +10 -10
  172. package/dist/verified-recall.js +10 -10
  173. package/dist/who-knows-cli.js +11 -11
  174. package/dist/who-knows.js +10 -10
  175. package/package.json +2 -2
  176. package/src/namespaces/search.test.ts +121 -0
  177. package/src/namespaces/search.ts +8 -0
  178. package/src/orchestration/lifecycle-pending-fallback-drain.ts +97 -0
  179. package/src/orchestration/maintenance.test.ts +582 -1
  180. package/src/orchestration/maintenance.ts +82 -95
  181. package/src/orchestration/qmd-maintenance-completion.ts +44 -0
  182. package/src/orchestrator.ts +8 -1
  183. package/src/search/lancedb-backend.ts +29 -12
  184. package/src/search/meilisearch-backend.test.ts +148 -0
  185. package/src/search/meilisearch-backend.ts +41 -14
  186. package/src/search/orama-backend.test.ts +75 -1
  187. package/src/search/orama-backend.ts +22 -7
  188. package/src/search/port.ts +7 -0
  189. package/src/search/remote-backend.ts +4 -0
  190. package/src/storage/entity-canonical-id-migration-adapter.ts +44 -9
  191. package/src/storage/entity-migration-maintenance-pending-helpers.test.ts +87 -0
  192. package/src/storage/entity-migration-maintenance-pending.test.ts +242 -0
  193. package/src/storage/entity-migration-maintenance-pending.ts +89 -0
  194. package/src/storage.ts +2 -2
  195. package/dist/chunk-5CWH2LKL.js.map +0 -1
  196. package/dist/chunk-7VO6E5UD.js.map +0 -1
  197. package/dist/chunk-EC2KVA27.js.map +0 -1
  198. package/dist/chunk-JIJAWTRK.js.map +0 -1
  199. package/dist/chunk-UM3ZLT2X.js.map +0 -1
  200. package/dist/chunk-XKDQTDXN.js.map +0 -1
  201. package/dist/chunk-XRFLR3XF.js.map +0 -1
  202. /package/dist/{chunk-3NL4ALUP.js.map → chunk-24SIB3EW.js.map} +0 -0
  203. /package/dist/{chunk-Y3T5KQCR.js.map → chunk-2EYCW2ET.js.map} +0 -0
  204. /package/dist/{chunk-AHGOOZUC.js.map → chunk-34UVJNSG.js.map} +0 -0
  205. /package/dist/{chunk-2EXF6IIB.js.map → chunk-3632XJMR.js.map} +0 -0
  206. /package/dist/{chunk-KETS5A62.js.map → chunk-3H3PNQEF.js.map} +0 -0
  207. /package/dist/{chunk-SFRBUBUZ.js.map → chunk-3LNAQLW4.js.map} +0 -0
  208. /package/dist/{chunk-75BK4XGV.js.map → chunk-4BUJICMI.js.map} +0 -0
  209. /package/dist/{chunk-KIWO4327.js.map → chunk-4W5B5Z6P.js.map} +0 -0
  210. /package/dist/{chunk-B35EQQ7L.js.map → chunk-53OFDW3P.js.map} +0 -0
  211. /package/dist/{chunk-2ONKXEC7.js.map → chunk-5LBBXI2A.js.map} +0 -0
  212. /package/dist/{chunk-UCLJTGP3.js.map → chunk-65B5RFKQ.js.map} +0 -0
  213. /package/dist/{chunk-KAJYSO3U.js.map → chunk-6CDNOREO.js.map} +0 -0
  214. /package/dist/{chunk-POJRFSJM.js.map → chunk-7XGNGLAN.js.map} +0 -0
  215. /package/dist/{chunk-A6JDT6QF.js.map → chunk-AO5ZOD32.js.map} +0 -0
  216. /package/dist/{chunk-RA7YRL6H.js.map → chunk-AOSCGNIC.js.map} +0 -0
  217. /package/dist/{chunk-LWMLBVGB.js.map → chunk-B43P3DTC.js.map} +0 -0
  218. /package/dist/{chunk-VUMWVYMR.js.map → chunk-CIMD2MUR.js.map} +0 -0
  219. /package/dist/{chunk-44EUCSG4.js.map → chunk-FFQNNFHX.js.map} +0 -0
  220. /package/dist/{chunk-HEF4BRMT.js.map → chunk-FZLZUDJJ.js.map} +0 -0
  221. /package/dist/{chunk-L3EFYRYK.js.map → chunk-GGFQAGOT.js.map} +0 -0
  222. /package/dist/{chunk-ZLCBLDJO.js.map → chunk-GIRWNTMV.js.map} +0 -0
  223. /package/dist/{chunk-SHNM7RIT.js.map → chunk-HJ4X4BGS.js.map} +0 -0
  224. /package/dist/{chunk-MUNYUSBU.js.map → chunk-HJQ4NKKO.js.map} +0 -0
  225. /package/dist/{chunk-V6Z4NAJZ.js.map → chunk-HM3ZTDAX.js.map} +0 -0
  226. /package/dist/{chunk-EZS6QJNC.js.map → chunk-IFOCK3OJ.js.map} +0 -0
  227. /package/dist/{chunk-DF6ZF2BN.js.map → chunk-LIVNX7WK.js.map} +0 -0
  228. /package/dist/{chunk-WY66J4PI.js.map → chunk-LRVFS4KA.js.map} +0 -0
  229. /package/dist/{chunk-LMB47UM6.js.map → chunk-LTDK6HBV.js.map} +0 -0
  230. /package/dist/{chunk-HCIRR3BA.js.map → chunk-M4U25HLR.js.map} +0 -0
  231. /package/dist/{chunk-YK7ZJPQM.js.map → chunk-MGN2RQRG.js.map} +0 -0
  232. /package/dist/{chunk-IUQXRRJB.js.map → chunk-OMWQO7H6.js.map} +0 -0
  233. /package/dist/{chunk-SPTPBKZX.js.map → chunk-OWNAWY6D.js.map} +0 -0
  234. /package/dist/{chunk-DQOGZZJA.js.map → chunk-QAHP5SBG.js.map} +0 -0
  235. /package/dist/{chunk-GBGU5OZP.js.map → chunk-QF5EJ5VP.js.map} +0 -0
  236. /package/dist/{chunk-FT75AQ74.js.map → chunk-QS5DUSBM.js.map} +0 -0
  237. /package/dist/{chunk-72CBJ5VP.js.map → chunk-R3WZ74B2.js.map} +0 -0
  238. /package/dist/{chunk-PDTNQLPC.js.map → chunk-RG3I2663.js.map} +0 -0
  239. /package/dist/{chunk-CK6GCGMX.js.map → chunk-RTJJPW3C.js.map} +0 -0
  240. /package/dist/{chunk-RJDYDF5K.js.map → chunk-TRAOZ2WP.js.map} +0 -0
  241. /package/dist/{chunk-6ZAU7LAR.js.map → chunk-TTTPTQ6S.js.map} +0 -0
  242. /package/dist/{chunk-JS44PBCM.js.map → chunk-U2A56LLF.js.map} +0 -0
  243. /package/dist/{chunk-MC7PXJDP.js.map → chunk-UBOWDOL5.js.map} +0 -0
  244. /package/dist/{chunk-4FM35YIA.js.map → chunk-UQYBPPVC.js.map} +0 -0
  245. /package/dist/{chunk-OCRLJJOV.js.map → chunk-V4VCBFAL.js.map} +0 -0
  246. /package/dist/{chunk-FNNGWN3O.js.map → chunk-VGE4PUY6.js.map} +0 -0
  247. /package/dist/{chunk-5DVOBBTV.js.map → chunk-VV73XT3A.js.map} +0 -0
  248. /package/dist/{chunk-GE3ZX6YK.js.map → chunk-W7NKMRAF.js.map} +0 -0
  249. /package/dist/{chunk-AJJPXUE6.js.map → chunk-WBN66HPI.js.map} +0 -0
  250. /package/dist/{chunk-XWJUFER3.js.map → chunk-WGFEGBFB.js.map} +0 -0
  251. /package/dist/{chunk-43TUWQNR.js.map → chunk-XDKEHZUC.js.map} +0 -0
  252. /package/dist/{chunk-ROAKLYOL.js.map → chunk-XHEKS4UN.js.map} +0 -0
  253. /package/dist/{chunk-ZKK3VMWU.js.map → chunk-XVTB6BZS.js.map} +0 -0
  254. /package/dist/{chunk-AZLD4CKG.js.map → chunk-Y56D5FSD.js.map} +0 -0
  255. /package/dist/{chunk-MPWOAIIH.js.map → chunk-YDT3OEYF.js.map} +0 -0
  256. /package/dist/{chunk-IN6U2BS5.js.map → chunk-YJHOVA42.js.map} +0 -0
  257. /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
- if (isSearchAborted(execution)) return;
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> {
@@ -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
- onEntityMigrationComplete?: () => void;
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: host.writeStorageSecureFile.bind(host),
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
- rewriteProjectedEntityReferences(host.baseDir, mappings),
68
- rewriteProjectedMemoryEntityReference: (memoryId, previousEntityRef, nextEntityRef) =>
69
- rewriteProjectedMemoryEntityReference(host.baseDir, memoryId, previousEntityRef, nextEntityRef),
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
+ });