@harperfast/harper 5.3.0-alpha.1 → 5.3.0-beta.2

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 (347) hide show
  1. package/bin/cliOperations.ts +12 -0
  2. package/bin/restart.ts +66 -6
  3. package/components/Application.ts +1134 -112
  4. package/components/DESIGN.md +421 -0
  5. package/components/OptionsWatcher.ts +368 -102
  6. package/components/Scope.ts +20 -1
  7. package/components/componentLoader.ts +43 -4
  8. package/components/deploymentOperations.ts +4 -1
  9. package/components/deploymentRecorder.ts +9 -2
  10. package/components/mcp/DESIGN.md +109 -0
  11. package/components/mcp/audit.ts +21 -17
  12. package/components/operations.js +284 -52
  13. package/components/operationsValidation.js +49 -2
  14. package/components/packageComponent.ts +25 -1
  15. package/components/requestRestart.ts +11 -0
  16. package/config/DESIGN.md +306 -0
  17. package/config/RootConfigWatcher.ts +191 -37
  18. package/config/configReadRetry.ts +62 -0
  19. package/config/configUtils.ts +78 -26
  20. package/config/parseConfigFile.ts +34 -0
  21. package/config/readConfigFileSync.ts +44 -0
  22. package/config/watcherArming.ts +59 -0
  23. package/config-root.schema.json +4 -0
  24. package/dataLayer/DESIGN.md +179 -0
  25. package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
  26. package/dataLayer/restoreMarker.ts +92 -25
  27. package/dist/bin/cliOperations.js +13 -0
  28. package/dist/bin/cliOperations.js.map +1 -1
  29. package/dist/bin/restart.js +42 -6
  30. package/dist/bin/restart.js.map +1 -1
  31. package/dist/components/Application.d.ts +104 -9
  32. package/dist/components/Application.js +954 -102
  33. package/dist/components/Application.js.map +1 -1
  34. package/dist/components/OptionsWatcher.d.ts +4 -1
  35. package/dist/components/OptionsWatcher.js +378 -104
  36. package/dist/components/OptionsWatcher.js.map +1 -1
  37. package/dist/components/Scope.js +15 -1
  38. package/dist/components/Scope.js.map +1 -1
  39. package/dist/components/componentLoader.js +35 -3
  40. package/dist/components/componentLoader.js.map +1 -1
  41. package/dist/components/deploymentOperations.js +4 -1
  42. package/dist/components/deploymentOperations.js.map +1 -1
  43. package/dist/components/deploymentRecorder.d.ts +4 -2
  44. package/dist/components/deploymentRecorder.js +1 -0
  45. package/dist/components/deploymentRecorder.js.map +1 -1
  46. package/dist/components/mcp/audit.d.ts +2 -1
  47. package/dist/components/mcp/audit.js +21 -17
  48. package/dist/components/mcp/audit.js.map +1 -1
  49. package/dist/components/operations.d.ts +28 -0
  50. package/dist/components/operations.js +263 -54
  51. package/dist/components/operations.js.map +1 -1
  52. package/dist/components/operationsValidation.js +48 -2
  53. package/dist/components/operationsValidation.js.map +1 -1
  54. package/dist/components/packageComponent.js +24 -0
  55. package/dist/components/packageComponent.js.map +1 -1
  56. package/dist/components/requestRestart.d.ts +1 -0
  57. package/dist/components/requestRestart.js +7 -0
  58. package/dist/components/requestRestart.js.map +1 -1
  59. package/dist/config/RootConfigWatcher.d.ts +2 -0
  60. package/dist/config/RootConfigWatcher.js +189 -35
  61. package/dist/config/RootConfigWatcher.js.map +1 -1
  62. package/dist/config/configReadRetry.d.ts +8 -0
  63. package/dist/config/configReadRetry.js +62 -0
  64. package/dist/config/configReadRetry.js.map +1 -0
  65. package/dist/config/configUtils.d.ts +10 -9
  66. package/dist/config/configUtils.js +63 -27
  67. package/dist/config/configUtils.js.map +1 -1
  68. package/dist/config/parseConfigFile.d.ts +4 -0
  69. package/dist/config/parseConfigFile.js +35 -0
  70. package/dist/config/parseConfigFile.js.map +1 -0
  71. package/dist/config/readConfigFileSync.d.ts +1 -0
  72. package/dist/config/readConfigFileSync.js +47 -0
  73. package/dist/config/readConfigFileSync.js.map +1 -0
  74. package/dist/config/watcherArming.d.ts +15 -0
  75. package/dist/config/watcherArming.js +59 -0
  76. package/dist/config/watcherArming.js.map +1 -0
  77. package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
  78. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  79. package/dist/dataLayer/restoreMarker.d.ts +21 -8
  80. package/dist/dataLayer/restoreMarker.js +94 -27
  81. package/dist/dataLayer/restoreMarker.js.map +1 -1
  82. package/dist/index.d.ts +2 -0
  83. package/dist/index.js +9 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/json/systemSchema.json +3 -0
  86. package/dist/resources/DatabaseTransaction.d.ts +25 -0
  87. package/dist/resources/DatabaseTransaction.js +224 -18
  88. package/dist/resources/DatabaseTransaction.js.map +1 -1
  89. package/dist/resources/LMDBTransaction.d.ts +2 -1
  90. package/dist/resources/LMDBTransaction.js +22 -3
  91. package/dist/resources/LMDBTransaction.js.map +1 -1
  92. package/dist/resources/PrimaryRocksDatabase.js +22 -6
  93. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  94. package/dist/resources/RecordEncoder.d.ts +1 -1
  95. package/dist/resources/RecordEncoder.js +46 -10
  96. package/dist/resources/RecordEncoder.js.map +1 -1
  97. package/dist/resources/Resource.js +97 -13
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/ResourceInterface.d.ts +8 -0
  100. package/dist/resources/RocksIndexStore.js +2 -1
  101. package/dist/resources/RocksIndexStore.js.map +1 -1
  102. package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
  103. package/dist/resources/RocksTransactionLogStore.js +104 -33
  104. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  105. package/dist/resources/Table.d.ts +58 -7
  106. package/dist/resources/Table.js +1143 -348
  107. package/dist/resources/Table.js.map +1 -1
  108. package/dist/resources/analytics/write.d.ts +3 -0
  109. package/dist/resources/analytics/write.js +49 -16
  110. package/dist/resources/analytics/write.js.map +1 -1
  111. package/dist/resources/auditStore.d.ts +170 -0
  112. package/dist/resources/auditStore.js +457 -11
  113. package/dist/resources/auditStore.js.map +1 -1
  114. package/dist/resources/crdt.d.ts +10 -0
  115. package/dist/resources/crdt.js +22 -0
  116. package/dist/resources/crdt.js.map +1 -1
  117. package/dist/resources/dataLoader.js +3 -4
  118. package/dist/resources/dataLoader.js.map +1 -1
  119. package/dist/resources/databases.d.ts +16 -13
  120. package/dist/resources/databases.js +625 -177
  121. package/dist/resources/databases.js.map +1 -1
  122. package/dist/resources/derivedIndexRegistry.d.ts +5 -0
  123. package/dist/resources/derivedIndexRegistry.js +68 -0
  124. package/dist/resources/derivedIndexRegistry.js.map +1 -0
  125. package/dist/resources/derivedIndexRuntime.d.ts +215 -0
  126. package/dist/resources/derivedIndexRuntime.js +2027 -0
  127. package/dist/resources/derivedIndexRuntime.js.map +1 -0
  128. package/dist/resources/graphql.js +3 -2
  129. package/dist/resources/graphql.js.map +1 -1
  130. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
  131. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +867 -39
  132. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  133. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
  134. package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
  135. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
  136. package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
  137. package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
  138. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
  139. package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
  140. package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
  141. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
  142. package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
  143. package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
  144. package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
  145. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
  146. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
  147. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
  148. package/dist/resources/nodeIdMapping.d.ts +5 -0
  149. package/dist/resources/nodeIdMapping.js +49 -0
  150. package/dist/resources/nodeIdMapping.js.map +1 -1
  151. package/dist/resources/recordLock.d.ts +47 -4
  152. package/dist/resources/recordLock.js +138 -7
  153. package/dist/resources/recordLock.js.map +1 -1
  154. package/dist/resources/recordLockCoordinator.d.ts +557 -0
  155. package/dist/resources/recordLockCoordinator.js +2597 -0
  156. package/dist/resources/recordLockCoordinator.js.map +1 -0
  157. package/dist/resources/replayLogs.js +5 -0
  158. package/dist/resources/replayLogs.js.map +1 -1
  159. package/dist/resources/replicatedApplyFailure.d.ts +16 -0
  160. package/dist/resources/replicatedApplyFailure.js +63 -0
  161. package/dist/resources/replicatedApplyFailure.js.map +1 -0
  162. package/dist/resources/scheduler/scheduler.js +3 -3
  163. package/dist/resources/scheduler/scheduler.js.map +1 -1
  164. package/dist/resources/search.d.ts +10 -4
  165. package/dist/resources/search.js +160 -40
  166. package/dist/resources/search.js.map +1 -1
  167. package/dist/resources/tracked.d.ts +5 -1
  168. package/dist/resources/tracked.js +74 -23
  169. package/dist/resources/tracked.js.map +1 -1
  170. package/dist/resources/transactionBroadcast.js +4 -6
  171. package/dist/resources/transactionBroadcast.js.map +1 -1
  172. package/dist/security/auth.js +59 -23
  173. package/dist/security/auth.js.map +1 -1
  174. package/dist/security/deferredAuthentication.d.ts +11 -0
  175. package/dist/security/deferredAuthentication.js +25 -3
  176. package/dist/security/deferredAuthentication.js.map +1 -1
  177. package/dist/security/jsLoader.js +9 -4
  178. package/dist/security/jsLoader.js.map +1 -1
  179. package/dist/server/REST.js +39 -5
  180. package/dist/server/REST.js.map +1 -1
  181. package/dist/server/http.d.ts +5 -1
  182. package/dist/server/http.js +34 -2
  183. package/dist/server/http.js.map +1 -1
  184. package/dist/server/mqtt.js +5 -1
  185. package/dist/server/mqtt.js.map +1 -1
  186. package/dist/server/serverHelpers/Headers.d.ts +2 -0
  187. package/dist/server/serverHelpers/Headers.js +6 -0
  188. package/dist/server/serverHelpers/Headers.js.map +1 -1
  189. package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
  190. package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
  191. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
  192. package/dist/server/serverHelpers/Request.d.ts +5 -10
  193. package/dist/server/serverHelpers/Request.js +38 -136
  194. package/dist/server/serverHelpers/Request.js.map +1 -1
  195. package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
  196. package/dist/server/serverHelpers/contentTypes.js +189 -15
  197. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  198. package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
  199. package/dist/server/serverHelpers/serverUtilities.js +110 -20
  200. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  201. package/dist/server/serverHelpers/uwsServer.js +4 -1
  202. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  203. package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
  204. package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
  205. package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
  206. package/dist/server/storageReclamation.js +1 -1
  207. package/dist/server/storageReclamation.js.map +1 -1
  208. package/dist/server/threads/isolatedApplications.d.ts +47 -0
  209. package/dist/server/threads/isolatedApplications.js +171 -0
  210. package/dist/server/threads/isolatedApplications.js.map +1 -0
  211. package/dist/server/threads/logRotationTransport.d.ts +1 -0
  212. package/dist/server/threads/logRotationTransport.js +33 -0
  213. package/dist/server/threads/logRotationTransport.js.map +1 -0
  214. package/dist/server/threads/manageThreads.d.ts +64 -6
  215. package/dist/server/threads/manageThreads.js +261 -12
  216. package/dist/server/threads/manageThreads.js.map +1 -1
  217. package/dist/server/threads/socketRouter.d.ts +1 -0
  218. package/dist/server/threads/socketRouter.js +196 -13
  219. package/dist/server/threads/socketRouter.js.map +1 -1
  220. package/dist/server/threads/threadServer.js +30 -7
  221. package/dist/server/threads/threadServer.js.map +1 -1
  222. package/dist/utility/errors/hdbError.d.ts +24 -0
  223. package/dist/utility/errors/hdbError.js +58 -1
  224. package/dist/utility/errors/hdbError.js.map +1 -1
  225. package/dist/utility/hdbTerms.d.ts +2 -0
  226. package/dist/utility/hdbTerms.js +2 -0
  227. package/dist/utility/hdbTerms.js.map +1 -1
  228. package/dist/utility/logging/harper_logger.js +218 -38
  229. package/dist/utility/logging/harper_logger.js.map +1 -1
  230. package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
  231. package/dist/utility/logging/logGenerationCoordinator.js +184 -0
  232. package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
  233. package/dist/utility/logging/logRotation.d.ts +46 -0
  234. package/dist/utility/logging/logRotation.js +365 -0
  235. package/dist/utility/logging/logRotation.js.map +1 -0
  236. package/dist/utility/logging/logRotator.d.ts +1 -1
  237. package/dist/utility/logging/logRotator.js +172 -92
  238. package/dist/utility/logging/logRotator.js.map +1 -1
  239. package/dist/utility/npmUtilities.js +6 -4
  240. package/dist/utility/npmUtilities.js.map +1 -1
  241. package/dist/utility/watcherFallback.d.ts +0 -45
  242. package/dist/utility/watcherFallback.js +1 -125
  243. package/dist/utility/watcherFallback.js.map +1 -1
  244. package/dist/validation/configValidator.js +6 -3
  245. package/dist/validation/configValidator.js.map +1 -1
  246. package/index.ts +9 -0
  247. package/json/systemSchema.json +3 -0
  248. package/npm-shrinkwrap.json +177 -87
  249. package/package.json +13 -5
  250. package/resources/DESIGN.md +689 -19
  251. package/resources/DatabaseTransaction.ts +230 -17
  252. package/resources/LMDBTransaction.ts +21 -3
  253. package/resources/PrimaryRocksDatabase.ts +20 -7
  254. package/resources/RecordEncoder.ts +52 -10
  255. package/resources/Resource.ts +97 -13
  256. package/resources/ResourceInterface.ts +8 -0
  257. package/resources/RocksIndexStore.ts +2 -1
  258. package/resources/RocksTransactionLogStore.ts +111 -31
  259. package/resources/Table.ts +1273 -396
  260. package/resources/analytics/DESIGN.md +38 -0
  261. package/resources/analytics/write.ts +50 -17
  262. package/resources/auditStore.ts +460 -11
  263. package/resources/crdt.ts +22 -0
  264. package/resources/dataLoader.ts +3 -4
  265. package/resources/databases.ts +611 -147
  266. package/resources/derivedIndexRegistry.ts +56 -0
  267. package/resources/derivedIndexRuntime.ts +2292 -0
  268. package/resources/graphql.ts +3 -2
  269. package/resources/indexes/DESIGN.md +833 -0
  270. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +924 -46
  271. package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
  272. package/resources/indexes/fullTextNativeBinding.ts +146 -0
  273. package/resources/indexes/hnswDerivedIndex.ts +531 -0
  274. package/resources/indexes/hnswPlaneBinding.ts +174 -0
  275. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
  276. package/resources/nodeIdMapping.ts +50 -0
  277. package/resources/record-locks.md +1407 -0
  278. package/resources/recordLock.ts +173 -7
  279. package/resources/recordLockCoordinator.ts +3082 -0
  280. package/resources/replayLogs.ts +5 -0
  281. package/resources/replicatedApplyFailure.ts +77 -0
  282. package/resources/scheduler/DESIGN.md +40 -0
  283. package/resources/scheduler/scheduler.ts +4 -4
  284. package/resources/search.ts +169 -49
  285. package/resources/tracked.ts +73 -22
  286. package/resources/transactionBroadcast.ts +4 -4
  287. package/security/DESIGN.md +175 -0
  288. package/security/auth.ts +53 -24
  289. package/security/deferredAuthentication.ts +24 -2
  290. package/security/jsLoader.ts +9 -4
  291. package/server/DESIGN.md +275 -0
  292. package/server/REST.ts +42 -6
  293. package/server/http.ts +34 -2
  294. package/server/mqtt.ts +6 -4
  295. package/server/serverHelpers/Headers.ts +5 -1
  296. package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
  297. package/server/serverHelpers/Request.ts +33 -131
  298. package/server/serverHelpers/contentTypes.ts +188 -15
  299. package/server/serverHelpers/serverUtilities.ts +157 -27
  300. package/server/serverHelpers/uwsServer.ts +4 -1
  301. package/server/serverHelpers/webSocketCloseReason.ts +25 -0
  302. package/server/storageReclamation.ts +2 -2
  303. package/server/threads/isolatedApplications.ts +157 -0
  304. package/server/threads/logRotationTransport.ts +40 -0
  305. package/server/threads/manageThreads.js +254 -12
  306. package/server/threads/socketRouter.ts +217 -11
  307. package/server/threads/threadServer.js +30 -7
  308. package/studio/web/assets/{Chat-BnCBegQz.js → Chat-DADFFGe_.js} +1 -1
  309. package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-D_mI-rZ7.js} +3 -3
  310. package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-c2NiSDHa.js} +1 -1
  311. package/studio/web/assets/{applications-DHxGi7JH.js → applications-DktUqh7G.js} +1 -1
  312. package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-Cs_75Xhw.js} +1 -1
  313. package/studio/web/assets/{editor-DNcRHK54.js → editor-19b-Y1IN.js} +1 -1
  314. package/studio/web/assets/{html-Bdssedlg.js → html-DiYEQMpB.js} +1 -1
  315. package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CmR0y7P_.js} +1 -1
  316. package/studio/web/assets/{index-D6sxmFLR.js → index-Dm0rfkJ7.js} +5 -5
  317. package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-7vqt2CC3.js} +1 -1
  318. package/studio/web/assets/{javascript-B8meVSTH.js → javascript-BWtCFuOt.js} +1 -1
  319. package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-Buzzbv9y.js} +1 -1
  320. package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-SqsFWfTM.js} +1 -1
  321. package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
  322. package/studio/web/assets/{notifications-CvZivSbh.js → notifications-CAB-LZWT.js} +1 -1
  323. package/studio/web/assets/{notifications-CQf18QKb.js → notifications-DRzmSRxM.js} +1 -1
  324. package/studio/web/assets/{profile-DdOwtntb.js → profile-BNKAl79n.js} +1 -1
  325. package/studio/web/assets/{regions-n69fwagr.js → regions-CUow_Zw2.js} +1 -1
  326. package/studio/web/assets/{register-PfWTCXWB.js → register-Dkt3WUMp.js} +2 -2
  327. package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BZRfMD0N.js} +1 -1
  328. package/studio/web/assets/{setup-CUx_aUDl.js → setup-D_yiEPO2.js} +2 -2
  329. package/studio/web/assets/{status-D7BVKqX9.js → status-DhHh1Ge-.js} +1 -1
  330. package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-D7L1PDtV.js} +1 -1
  331. package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-CCwLk1YS.js} +1 -1
  332. package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-BP1j1mjn.js} +1 -1
  333. package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-D7bnYxLw.js} +1 -1
  334. package/studio/web/assets/{workers-pR3jRY9D.js → workers-tOuCNT17.js} +1 -1
  335. package/studio/web/assets/{xml-2iRnMhQO.js → xml-BSG_3mQT.js} +1 -1
  336. package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-DxiLprBB.js} +1 -1
  337. package/studio/web/index.html +1 -1
  338. package/utility/DESIGN.md +55 -0
  339. package/utility/errors/hdbError.ts +54 -0
  340. package/utility/hdbTerms.ts +2 -0
  341. package/utility/logging/harper_logger.ts +210 -30
  342. package/utility/logging/logGenerationCoordinator.ts +196 -0
  343. package/utility/logging/logRotation.ts +367 -0
  344. package/utility/logging/logRotator.ts +196 -91
  345. package/utility/npmUtilities.ts +6 -4
  346. package/utility/watcherFallback.ts +0 -122
  347. package/validation/configValidator.ts +6 -3
@@ -1,9 +1,19 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { initSync, getHdbBasePath, get as envGet } from '../utility/environment/environmentManager.ts';
3
4
  import { INTERNAL_DBIS_NAME } from '../utility/lmdb/terms.ts';
4
5
  import { open, compareKeys, type Database, type RootDatabase } from 'lmdb';
5
- import { join, extname, basename } from 'path';
6
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
6
+ import { join, extname, basename } from 'node:path';
7
+ import {
8
+ closeSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ openSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ realpathSync,
15
+ unlinkSync,
16
+ } from 'node:fs';
7
17
  import { unlink } from 'node:fs/promises';
8
18
  import {
9
19
  getBaseSchemaPath,
@@ -28,19 +38,26 @@ import { workerData } from 'worker_threads';
28
38
  import harperLogger from '../utility/logging/harper_logger.ts';
29
39
  const { forComponent } = harperLogger;
30
40
  import * as manageThreads from '../server/threads/manageThreads.js';
31
- import { openAuditStore, readAuditEntry, createAuditEntry, type AuditRecord } from './auditStore.ts';
41
+ import {
42
+ establishAuditFloor,
43
+ openAuditStore,
44
+ readAuditEntry,
45
+ createAuditEntry,
46
+ type AuditRecord,
47
+ } from './auditStore.ts';
32
48
  import { handleLocalTimeForGets } from './RecordEncoder.ts';
33
49
  import { databasePaths, deleteRootBlobPathsForDB } from './blob.ts';
34
50
  import { removeStorageReclamation } from '../server/storageReclamation.ts';
35
51
  import { commonValidators, schemaRegex } from '../validation/common_validators.ts';
36
52
  import { CUSTOM_INDEXES } from './indexes/customIndexes.ts';
53
+ import { planeFilePathFor, planeStalePathFor } from './indexes/hnswPlaneBinding.ts';
37
54
  import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.ts';
38
55
  import { RocksDatabase, supportedCompression, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
39
56
  import { PrimaryRocksDatabase } from './PrimaryRocksDatabase.ts';
40
57
  import { replayLogs } from './replayLogs.ts';
58
+ import { attachDerivedIndexes } from './indexes/hnswDerivedIndex.ts';
41
59
  import { totalmem } from 'node:os';
42
60
  import { RocksIndexStore } from './RocksIndexStore.ts';
43
- import { when } from '../utility/when.ts';
44
61
  import { resolveRocksMemoryConfig } from '../utility/rocksMemoryConfig.ts';
45
62
  import { isProcessRunning } from '../utility/processManagement/processManagement.js';
46
63
  import {
@@ -446,6 +463,21 @@ function applyDurableDeclaration(attribute: any, descriptor: any) {
446
463
  else delete attribute[field];
447
464
  }
448
465
  }
466
+
467
+ /**
468
+ * True when a descriptor claims an index build no live operation in this process can own. The PID and
469
+ * worker generation cannot answer that alone: a container reuses PID 1 and starts the in-memory
470
+ * generation back at 1 while the persisted one is higher. A descriptor with no incarnation was written
471
+ * before the field existed, so it belongs to an earlier process; a thread started without one of its
472
+ * own cannot judge, and falls back rather than declaring a live build dead.
473
+ */
474
+ function isAbandonedIndexBuild(descriptor: any, currentRestartGeneration: number): boolean {
475
+ if (!descriptor) return false;
476
+ if (descriptor.indexingPID && descriptor.indexingPID !== process.pid) return true;
477
+ if (descriptor.restartNumber < currentRestartGeneration) return true;
478
+ const incarnation = manageThreads.processIncarnation;
479
+ return !!descriptor.indexingPID && incarnation != null && descriptor.indexingIncarnation !== incarnation;
480
+ }
449
481
  // How many times the schema load will try to finish a tombstoned drop before
450
482
  // giving up for the rest of this process's lifetime. A drop that fails once
451
483
  // almost always fails identically forever - the usual cause is a RocksDB
@@ -910,6 +942,7 @@ export function readMetaDb(
910
942
  lmdbDatabaseEnvs.set(path, rootStore);
911
943
  }
912
944
 
945
+ rootStore.dbisDb?.resetReadTxn();
913
946
  return initStores(path, rootStore, databaseName, { defaultTable, auditPath, isLegacy });
914
947
  } catch (error) {
915
948
  error.message += ` opening database ${path}`;
@@ -981,7 +1014,7 @@ function initStores(
981
1014
  { defaultTable, auditPath, isLegacy, destination, storeName, openedStores }: InitStoresOptions = {}
982
1015
  ) {
983
1016
  // a store with no tables never reaches the per-table loop below, and blob roots resolve from this
984
- rootStore.databaseName = storeName ?? databaseName;
1017
+ rootStore.databaseName ??= storeName ?? databaseName;
985
1018
  const envInit = new OpenEnvironmentObject(path, isReadOnlyMode());
986
1019
  const internalDbiInit = createOpenDBIObject(false);
987
1020
  let attributesDbi = rootStore.dbisDb;
@@ -1016,6 +1049,9 @@ function initStores(
1016
1049
  }) as any;
1017
1050
  }
1018
1051
  auditStore.isLegacy = true;
1052
+ // A legacy standalone audit root skips openAuditStore, so give it a floor here or it
1053
+ // reports its retention horizon as permanently unknown.
1054
+ establishAuditFloor(auditStore);
1019
1055
  }
1020
1056
  } else {
1021
1057
  auditStore = openAuditStore(rootStore);
@@ -1141,6 +1177,9 @@ function initStores(
1141
1177
  // unless its store was migrated to a different engine (e.g. LMDB to RocksDB on startup)
1142
1178
  const recreateForEngineChange =
1143
1179
  !!table && (table as any).primaryStore?.rootStore instanceof RocksDatabase !== rootStore instanceof RocksDatabase;
1180
+ const recreateForTableIdChange =
1181
+ !!table && primaryAttribute.tableId != null && table.tableId !== primaryAttribute.tableId;
1182
+ const recreateTable = recreateForEngineChange || recreateForTableIdChange;
1144
1183
  let indices = {},
1145
1184
  existingAttributes = [];
1146
1185
  let tableId;
@@ -1154,7 +1193,8 @@ function initStores(
1154
1193
  const cacheControl = primaryAttribute.cacheControl;
1155
1194
  const splitSegments = primaryAttribute.splitSegments;
1156
1195
  const replicate = primaryAttribute.replicate;
1157
- if (table && !recreateForEngineChange) {
1196
+ if (table && !recreateTable) {
1197
+ if (primaryAttribute.audit === true && table.audit !== true) table.enableAuditing();
1158
1198
  indices = table.indices;
1159
1199
  existingAttributes = table.attributes;
1160
1200
  table.schemaVersion++;
@@ -1204,6 +1244,8 @@ function initStores(
1204
1244
  indices[attribute.name] = dbi;
1205
1245
  indices[attribute.name].indexNulls = attribute.indexNulls;
1206
1246
  }
1247
+ // the only way a thread that never declares the schema reaches Table.indices
1248
+ indices[attribute.name].isIndexing = !!attribute.indexingPID;
1207
1249
  const existingAttribute = existingAttributes.find(
1208
1250
  (existingAttribute) => existingAttribute.name === attribute.name
1209
1251
  );
@@ -1269,12 +1311,13 @@ function initStores(
1269
1311
  existingAttributes.splice(existingAttributes.indexOf(existingAttribute), 1);
1270
1312
  attributesUpdated = true;
1271
1313
  }
1272
- if (table && !recreateForEngineChange) {
1314
+ if (table && !recreateTable) {
1273
1315
  if (attributesUpdated) {
1274
1316
  table.schemaVersion++;
1275
1317
  table.updatedAttributes();
1276
1318
  }
1277
1319
  } else {
1320
+ if (recreateForTableIdChange) table.cleanup();
1278
1321
  table = setTable(
1279
1322
  tables,
1280
1323
  tableName,
@@ -1305,6 +1348,8 @@ function initStores(
1305
1348
  table.schemaVersion = 1;
1306
1349
  if (!destination) databaseEventsEmitter.emit('updateTable', table);
1307
1350
  }
1351
+ void table.derivedIndexRuntime?.close();
1352
+ table.derivedIndexRuntime = attachDerivedIndexes(table);
1308
1353
  if (Array.isArray(primaryAttribute.relationships)) {
1309
1354
  relationshipsToHydrate.push({ table, databaseName, tableName, definitions: primaryAttribute.relationships });
1310
1355
  } else if (primaryAttribute.relationships !== undefined) {
@@ -1640,6 +1685,8 @@ export function openBranchDatabase(
1640
1685
  openBranches.set(path, undefined);
1641
1686
  retakeBranchIdentity(storeName);
1642
1687
  try {
1688
+ // before the open: table load schedules TTL, eviction and audit cleanup, which ask who owns this store
1689
+ manageThreads.markBranchStorePath(path);
1643
1690
  rootStore = readRocksMetaDb(path, null, databaseName, { destination: tables, storeName, openedStores });
1644
1691
  // Pin the handle to the roots the caller proved this branch was published with, before it is
1645
1692
  // handed out. A row's `storageIndex` is a position in that list, so resolving through current
@@ -1649,6 +1696,7 @@ export function openBranchDatabase(
1649
1696
  if (blobRoots) databasePaths.set(rootStore as unknown as RootDatabase, blobRoots);
1650
1697
  } catch (error) {
1651
1698
  openBranches.delete(path);
1699
+ manageThreads.markBranchStorePath(path, false);
1652
1700
  releaseBranchIdentity(storeName);
1653
1701
  const stranded = rocksdbDatabaseEnvs.get(path);
1654
1702
  rocksdbDatabaseEnvs.delete(path);
@@ -1672,6 +1720,7 @@ export function openBranchDatabase(
1672
1720
  openBranches.delete(path);
1673
1721
  releaseBranchIdentity(storeName);
1674
1722
  rocksdbDatabaseEnvs.delete(path);
1723
+ manageThreads.markBranchStorePath(path, false);
1675
1724
  closeBranchHandles(path, rootStore, openedStores, tables);
1676
1725
  },
1677
1726
  };
@@ -1765,6 +1814,8 @@ interface TableDefinition {
1765
1814
  // default Cache-Control for anonymous REST reads; null = schema explicitly has none (clears a
1766
1815
  // prior value on reload), undefined = caller is not schema-defining (leave the current value)
1767
1816
  cacheControl?: string | null;
1817
+ /** Internal: this declaration came from the application owned by the current dedicated worker. */
1818
+ isolatedApplicationOwner?: boolean;
1768
1819
  }
1769
1820
  /**
1770
1821
  * Ensure that we have this database object (that holds a set of tables) set up
@@ -2244,6 +2295,9 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
2244
2295
  const CustomIndex = CUSTOM_INDEXES[attribute.indexed.type];
2245
2296
  if (CustomIndex) {
2246
2297
  indexStore.customIndex = new CustomIndex(indexStore, attribute.indexed);
2298
+ // derived state whose maintaining option is now off must not linger to be adopted
2299
+ // stale on a later re-enable
2300
+ indexStore.customIndex.cleanupDisabledPlane?.();
2247
2301
  } else {
2248
2302
  logger.error(`The indexing type '${attribute.indexed.type}' is unknown`);
2249
2303
  }
@@ -2298,13 +2352,18 @@ const GLOBAL_TARGET: TableTarget = {
2298
2352
  /**
2299
2353
  * The factory a branched application declares tables through: each declaration goes to the branch
2300
2354
  * of the database it names, or to `table()` itself for a database the application did not branch.
2301
- * An unbranched application gets `table` by identity -- no wrapper, no per-call routing.
2355
+ * An unbranched, shared application gets `table` by identity. An isolated application still gets a
2356
+ * wrapper so its own declarations can claim their single-threaded maintenance work.
2302
2357
  */
2303
- export function scopedTableFactory(branches?: Map<string, BranchDatabase>): typeof table {
2304
- if (!branches?.size) return table;
2358
+ export function scopedTableFactory(
2359
+ branches?: Map<string, BranchDatabase>,
2360
+ isolatedApplicationOwner = false
2361
+ ): typeof table {
2362
+ if (!branches?.size && !isolatedApplicationOwner) return table;
2305
2363
  return function scopedTable<TableResourceType>(tableDefinition: TableDefinition): TableResourceType {
2364
+ if (isolatedApplicationOwner) tableDefinition = { ...tableDefinition, isolatedApplicationOwner: true };
2306
2365
  // `||`, not `??`: `table()` resolves every falsy name to the default database
2307
- const branch = branches.get(tableDefinition.database || DEFAULT_DATABASE_NAME);
2366
+ const branch = branches?.get(tableDefinition.database || DEFAULT_DATABASE_NAME);
2308
2367
  return branch ? declareTable(branchTarget(branch), tableDefinition) : table(tableDefinition);
2309
2368
  };
2310
2369
  }
@@ -2382,7 +2441,10 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2382
2441
  properties,
2383
2442
  hidden,
2384
2443
  cacheControl,
2444
+ isolatedApplicationOwner,
2385
2445
  } = tableDefinition;
2446
+ const auditExplicitlyEnabled = audit === true;
2447
+ const auditExplicitlyDisabled = audit === false;
2386
2448
  if (!databaseName) databaseName = DEFAULT_DATABASE_NAME;
2387
2449
  // Reject reserved names here too, not only at the operations API: a database
2388
2450
  // is also created by schema authoring — a `schema.graphql` `@table(database:)`
@@ -2417,25 +2479,135 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2417
2479
  if (schemaDefined == undefined) schemaDefined = true;
2418
2480
  const relationshipDefinitions = schemaRelationshipsDefined ? normalizeRelationships(attributes) : undefined;
2419
2481
  const internalDbiInit = createOpenDBIObject(false);
2482
+ let releaseExclusiveLock: (() => void) | undefined;
2420
2483
 
2421
- for (const attribute of attributes) {
2422
- if (attribute.attribute && !attribute.name) {
2423
- // there is some legacy code that calls the attribute's name the attribute's attribute
2424
- attribute.name = attribute.attribute;
2425
- attribute.indexed = true;
2426
- } else attribute.attribute = attribute.name;
2427
- if (attribute.expiresAt) attribute.indexed = true;
2484
+ const hasHnswAtEntry = attributes.some((attribute) => attribute.indexed?.type === 'HNSW');
2485
+ const persistedPrimaryDescriptor = (catalog: any) => {
2486
+ const declaredPrimaryKey = attributes.find((attribute) => attribute.isPrimaryKey)?.name ?? Table?.primaryKey;
2487
+ if (declaredPrimaryKey) {
2488
+ const key = `${tableName}/${declaredPrimaryKey}`;
2489
+ const descriptor = catalog?.getSync(key);
2490
+ if (descriptor?.isPrimaryKey) return { key, descriptor };
2491
+ }
2492
+ const key = `${tableName}/`;
2493
+ return { key, descriptor: catalog?.getSync(key) };
2494
+ };
2495
+ const hasLegacyHnswStateAtEntry =
2496
+ Table &&
2497
+ origin !== 'cluster' &&
2498
+ attributes.some((attribute) => {
2499
+ if (attribute.indexed?.type !== 'HNSW') return false;
2500
+ const persisted = Table.dbisDB?.getSync(`${tableName}/${attribute.name || attribute.attribute || ''}`)?.indexed;
2501
+ if (persisted?.type !== 'HNSW') return false;
2502
+ if (Object.hasOwn(persisted, 'nativePlane') && typeof persisted.nativePlane !== 'boolean') return true;
2503
+ for (const name of CUSTOM_INDEXES.HNSW.numericOptions)
2504
+ if (Object.hasOwn(persisted, name) && typeof persisted[name] !== 'number') return true;
2505
+ return false;
2506
+ });
2507
+ try {
2508
+ if (
2509
+ Table &&
2510
+ hasHnswAtEntry &&
2511
+ origin !== 'cluster' &&
2512
+ (rootStore instanceof RocksDatabase || hasLegacyHnswStateAtEntry)
2513
+ )
2514
+ exclusiveLock();
2515
+ const persistedAuditAtEntry =
2516
+ hasHnswAtEntry || (Table && Table.audit !== true)
2517
+ ? persistedPrimaryDescriptor(Table?.dbisDB).descriptor?.audit
2518
+ : undefined;
2519
+ if (!auditExplicitlyDisabled && persistedAuditAtEntry === true && Table?.audit !== true) Table.enableAuditing();
2520
+ for (const attribute of attributes) {
2521
+ if (attribute.attribute && !attribute.name) {
2522
+ // there is some legacy code that calls the attribute's name the attribute's attribute
2523
+ attribute.name = attribute.attribute;
2524
+ attribute.indexed = true;
2525
+ } else attribute.attribute = attribute.name;
2526
+ if (attribute.expiresAt) attribute.indexed = true;
2527
+ if (attribute.indexed?.type === 'HNSW' && origin !== 'cluster') {
2528
+ const existingAttribute = Table?.attributes.find((existing: any) => existing.name === attribute.name);
2529
+ const persistedIndexed =
2530
+ Table?.dbisDB?.getSync(`${tableName}/${attribute.name || ''}`)?.indexed ?? existingAttribute?.indexed;
2531
+ CUSTOM_INDEXES.HNSW.normalizeDeclarationOptions(attribute.indexed, persistedIndexed);
2532
+ if (attribute.indexed.nativePlane != null) {
2533
+ const persistedNativePlane = persistedIndexed?.nativePlane;
2534
+ const matchesPersistedLegacySpelling =
2535
+ persistedIndexed?.type === 'HNSW' &&
2536
+ Object.hasOwn(persistedIndexed, 'nativePlane') &&
2537
+ typeof persistedNativePlane !== 'boolean' &&
2538
+ (Object.is(persistedNativePlane, attribute.indexed.nativePlane) ||
2539
+ (typeof persistedNativePlane === 'string' &&
2540
+ typeof attribute.indexed.nativePlane === 'number' &&
2541
+ persistedNativePlane.trim() !== '' &&
2542
+ Number(persistedNativePlane) === attribute.indexed.nativePlane));
2543
+ if (matchesPersistedLegacySpelling) {
2544
+ attribute.indexed.nativePlane = persistedNativePlane;
2545
+ } else {
2546
+ attribute.indexed.nativePlane = CUSTOM_INDEXES.HNSW.normalizeNativePlaneDeclaration(
2547
+ attribute.indexed.nativePlane
2548
+ );
2549
+ }
2550
+ }
2551
+ }
2552
+ }
2553
+ const auditEnabledAtEntry =
2554
+ auditExplicitlyEnabled ||
2555
+ (!auditExplicitlyDisabled &&
2556
+ (persistedAuditAtEntry === true || (persistedAuditAtEntry == null && Table?.audit === true)));
2557
+ if (
2558
+ origin !== 'cluster' &&
2559
+ attributes.some((attribute) => {
2560
+ if (attribute.indexed?.type !== 'HNSW') return false;
2561
+ if (attribute.indexed.nativePlane != null) return Boolean(attribute.indexed.nativePlane);
2562
+ const existingAttribute = Table?.attributes.find(
2563
+ (existing: any) => existing.name === attribute.name && existing.indexed?.type === 'HNSW'
2564
+ );
2565
+ return Boolean(existingAttribute?.indexed.nativePlane);
2566
+ }) &&
2567
+ !auditEnabledAtEntry
2568
+ ) {
2569
+ throw new ClientError(
2570
+ `Table '${databaseName}.${tableName}' must enable audit logging before using nativePlane because its transaction log is the derived-index recovery source; set nativePlane: false to use the JS index`
2571
+ );
2572
+ }
2573
+ } catch (error) {
2574
+ releaseLock();
2575
+ throw error;
2428
2576
  }
2577
+ const validateHnswOptions = (catalog: any, auditQualifiesDefault: boolean) => {
2578
+ for (const attribute of attributes) {
2579
+ const indexed = attribute.indexed;
2580
+ if (indexed?.type !== 'HNSW') continue;
2581
+ const persistedIndexed = catalog?.getSync(`${tableName}/${attribute.name || ''}`)?.indexed;
2582
+ if (indexed.nativePlane) {
2583
+ CUSTOM_INDEXES.HNSW.validateNativePlaneOptions(rootStore, indexed);
2584
+ continue;
2585
+ }
2586
+ if (indexed.nativePlane != null) continue;
2587
+ if (persistedIndexed?.type === 'HNSW' && Object.hasOwn(persistedIndexed, 'nativePlane')) {
2588
+ if (persistedIndexed.nativePlane)
2589
+ CUSTOM_INDEXES.HNSW.validateNativePlaneOptions(rootStore, {
2590
+ ...indexed,
2591
+ nativePlane: persistedIndexed.nativePlane,
2592
+ });
2593
+ continue;
2594
+ }
2595
+ if (!auditQualifiesDefault) continue;
2596
+ if (persistedIndexed?.type !== 'HNSW') CUSTOM_INDEXES.HNSW.canDefaultToNativePlane(rootStore, indexed);
2597
+ }
2598
+ };
2599
+ if (!Table && origin !== 'cluster') validateHnswOptions(undefined, auditExplicitlyEnabled);
2429
2600
  let hasChanges;
2430
2601
  let refreshRelationshipAttributes = false;
2602
+ let refreshedLiveAttributes = false;
2431
2603
  let deferredPrimaryRow: any;
2432
2604
  let unpublishedPrimaryStore: any;
2433
2605
  let published = false;
2434
- let releaseExclusiveLock: (() => void) | undefined;
2435
2606
  const attributesToIndex = [];
2436
2607
  const indicesToRemove = [];
2437
2608
  try {
2438
2609
  if (Table) {
2610
+ refreshedLiveAttributes = true;
2439
2611
  primaryKey = Table.primaryKey;
2440
2612
  if (Table.primaryStore.rootStore.status === 'closed') {
2441
2613
  throw new Error(`Can not use a closed data store from ${tableName} class`);
@@ -2464,10 +2636,17 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2464
2636
  }
2465
2637
  }
2466
2638
  // Acquire before the first mutation of the live Table below, so a lost race leaves no
2467
- // attributes this worker describes but never persisted. Only the RocksDB acquire is bounded
2468
- // and can throw, and only it is cheap when uncontended: LMDB's exclusiveLock() opens an
2469
- // environment-wide write transaction that cannot time out, so it stays lazy.
2639
+ // attributes this worker describes but never persisted. Only the RocksDB acquire is bounded;
2640
+ // ordinary LMDB declarations stay lazy, while legacy HNSW normalization locks at entry.
2470
2641
  if (rootStore instanceof RocksDatabase) exclusiveLock();
2642
+ if (origin !== 'cluster') {
2643
+ const lockedAttributesDbi = Table.dbisDB;
2644
+ const persistedAuditUnderLock = persistedPrimaryDescriptor(lockedAttributesDbi).descriptor?.audit;
2645
+ validateHnswOptions(
2646
+ lockedAttributesDbi,
2647
+ auditExplicitlyEnabled || (!auditExplicitlyDisabled && persistedAuditUnderLock === true)
2648
+ );
2649
+ }
2471
2650
  // it table already exists, get the split segments setting
2472
2651
  if (splitSegments == undefined) splitSegments = Table.splitSegments;
2473
2652
  if (origin === 'cluster') {
@@ -2497,6 +2676,13 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2497
2676
  );
2498
2677
  }
2499
2678
  attributes = merged;
2679
+ } else if (!attributes.some((attribute) => attribute.isPrimaryKey)) {
2680
+ const existingPrimary = Table.attributes.find((attribute: any) => attribute.isPrimaryKey);
2681
+ if (existingPrimary && attributes.some((attribute) => attribute.name === existingPrimary.name))
2682
+ throw new ClientError(
2683
+ `Cannot remove the primary key designation from '${databaseName}.${tableName}.${existingPrimary.name}'`
2684
+ );
2685
+ if (existingPrimary) attributes = [existingPrimary, ...attributes];
2500
2686
  }
2501
2687
  Table.attributes.splice(0, Table.attributes.length, ...attributes);
2502
2688
  // Re-assert from the live declaration so a stale value on disk (replicated event,
@@ -2681,7 +2867,7 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2681
2867
  const attribute = attributes.find((attribute) => attribute.name === attribute_name);
2682
2868
  const removeIndex = !attribute?.indexed && value.indexed && !value.isPrimaryKey;
2683
2869
  // rows already present under a create are aborted state
2684
- const staleRow = !attribute || Boolean(deferredPrimaryRow);
2870
+ const staleRow = (!attribute && !value.isPrimaryKey) || Boolean(deferredPrimaryRow);
2685
2871
  if (staleRow || removeIndex) {
2686
2872
  exclusiveLock();
2687
2873
  hasChanges = true;
@@ -2692,9 +2878,79 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2692
2878
  }
2693
2879
  }
2694
2880
  }
2881
+ const hasHnswDeclaration = attributes.some((attribute) => attribute.indexed?.type === 'HNSW');
2882
+ const persistedAudit = hasHnswDeclaration ? persistedPrimaryDescriptor(attributesDbi).descriptor?.audit : undefined;
2883
+ // A cluster declaration can apply audit on a create, but deliberately cannot rewrite an existing
2884
+ // table's primary row. Do not let an incoming audit value qualify a replicated native descriptor
2885
+ // that this node would then persist beside its durable audit:false row.
2886
+ const explicitAuditCanBeApplied = origin !== 'cluster' || Boolean(deferredPrimaryRow);
2887
+ const auditEnabledForNativeDefault =
2888
+ (auditExplicitlyEnabled && explicitAuditCanBeApplied) || (!auditExplicitlyDisabled && persistedAudit === true);
2889
+ const auditEnabledForNativePlane =
2890
+ !auditExplicitlyDisabled && (auditEnabledForNativeDefault || (persistedAudit == null && Table.audit === true));
2891
+ for (const attribute of attributes) {
2892
+ const indexed = attribute.indexed;
2893
+ if (!indexed || typeof indexed !== 'object' || indexed.type !== 'HNSW') continue;
2894
+ const descriptor = attributesDbi.getSync(tableName + '/' + (attribute.name || ''));
2895
+ const existingHnsw = descriptor?.indexed?.type === 'HNSW';
2896
+ if (indexed.nativePlane == null) {
2897
+ if (existingHnsw) {
2898
+ if (Object.hasOwn(descriptor.indexed, 'nativePlane')) {
2899
+ indexed.nativePlane = descriptor.indexed.nativePlane;
2900
+ }
2901
+ } else if (
2902
+ origin !== 'cluster' &&
2903
+ auditEnabledForNativeDefault &&
2904
+ CUSTOM_INDEXES.HNSW.canDefaultToNativePlane(rootStore, indexed)
2905
+ ) {
2906
+ indexed.nativePlane = true;
2907
+ }
2908
+ } else if (origin === 'cluster' && !existingHnsw && indexed.nativePlane) {
2909
+ let canRunNative = false;
2910
+ try {
2911
+ canRunNative = auditEnabledForNativeDefault && CUSTOM_INDEXES.HNSW.canRunNativePlane(rootStore, indexed);
2912
+ } catch {}
2913
+ if (!canRunNative) {
2914
+ logger.warn(
2915
+ `Using the JS HNSW index for replicated attribute ${databaseName}.${tableName}.${attribute.name} because this node does not satisfy the nativePlane requirements`
2916
+ );
2917
+ indexed.nativePlane = false;
2918
+ }
2919
+ }
2920
+ }
2921
+ const nativePlaneEnabled =
2922
+ origin !== 'cluster' &&
2923
+ attributes.some((attribute) => attribute.indexed?.type === 'HNSW' && attribute.indexed.nativePlane);
2924
+ if (nativePlaneEnabled && !auditEnabledForNativePlane) {
2925
+ throw new ClientError(
2926
+ `Table '${databaseName}.${tableName}' must enable audit logging before using nativePlane because its transaction log is the derived-index recovery source; set nativePlane: false to use the JS index`
2927
+ );
2928
+ }
2929
+ if (nativePlaneEnabled && persistedAudit !== true) audit = true;
2930
+ if (nativePlaneEnabled && persistedAudit !== true && !attributes.some((attribute) => attribute.isPrimaryKey)) {
2931
+ exclusiveLock();
2932
+ const primaryKey = primaryDescriptorKey();
2933
+ const primaryDescriptor = attributesDbi.getSync(primaryKey);
2934
+ if (primaryDescriptor && !tableIsDropping(primaryDescriptor, primaryKey)) {
2935
+ Table.enableAuditing();
2936
+ attributesDbi.put(primaryKey, { ...primaryDescriptor, audit: true });
2937
+ hasChanges = true;
2938
+ }
2939
+ }
2695
2940
  // TODO: If we have attributes and the schemaDefined flag is not set, turn it on
2696
2941
  // iterate through the attributes to ensure that we have all the dbis created and indexed
2697
- for (const attribute of attributes || []) {
2942
+ const attributesInPersistenceOrder = nativePlaneEnabled
2943
+ ? [
2944
+ ...attributes.filter((attribute) => attribute.isPrimaryKey),
2945
+ ...attributes.filter((attribute) => !attribute.isPrimaryKey),
2946
+ ]
2947
+ : auditExplicitlyDisabled
2948
+ ? [
2949
+ ...attributes.filter((attribute) => !attribute.isPrimaryKey),
2950
+ ...attributes.filter((attribute) => attribute.isPrimaryKey),
2951
+ ]
2952
+ : attributes;
2953
+ for (const attribute of attributesInPersistenceOrder) {
2698
2954
  if (attribute.relationship) {
2699
2955
  refreshRelationshipAttributes = true;
2700
2956
  continue;
@@ -2717,7 +2973,7 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2717
2973
  if (
2718
2974
  origin !== 'cluster' &&
2719
2975
  (schemaDefinedMismatch ||
2720
- (audit !== undefined && audit !== Table.audit) ||
2976
+ (typeof audit === 'boolean' && audit !== attributeDescriptor.audit) ||
2721
2977
  (sealed !== undefined && sealed !== Table.sealed) ||
2722
2978
  (replicate !== undefined && replicate !== Table.replicate) ||
2723
2979
  (+expiration || undefined) !== (+attributeDescriptor.expiration || undefined) ||
@@ -2754,8 +3010,7 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2754
3010
  const abandonedIndexBuild =
2755
3011
  attribute.indexed &&
2756
3012
  (attributeDescriptor.indexingFailed ||
2757
- (attributeDescriptor.indexingPID && attributeDescriptor.indexingPID !== process.pid) ||
2758
- attributeDescriptor.restartNumber < (workerData?.restartNumber ?? manageThreads.restartNumber));
3013
+ isAbandonedIndexBuild(attributeDescriptor, workerData?.restartNumber ?? manageThreads.restartNumber));
2759
3014
  if (abandonedIndexBuild) {
2760
3015
  // Recovery is the exception to skipping the handling below, because without it `isIndexing`
2761
3016
  // stays pinned on with nothing left to clear it and every query on the attribute fails with
@@ -2845,8 +3100,7 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2845
3100
  changed ||
2846
3101
  indexFormatNeedsPersist ||
2847
3102
  attributeDescriptor?.indexingFailed ||
2848
- (attributeDescriptor?.indexingPID && attributeDescriptor?.indexingPID !== process.pid) ||
2849
- attributeDescriptor?.restartNumber < currentRestartGeneration
3103
+ isAbandonedIndexBuild(attributeDescriptor, currentRestartGeneration)
2850
3104
  ) {
2851
3105
  hasChanges = true;
2852
3106
  exclusiveLock();
@@ -2854,8 +3108,7 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2854
3108
  if (
2855
3109
  structurallyChanged ||
2856
3110
  attributeDescriptor?.indexingFailed ||
2857
- (attributeDescriptor?.indexingPID && attributeDescriptor?.indexingPID !== process.pid) ||
2858
- attributeDescriptor?.restartNumber < currentRestartGeneration
3111
+ isAbandonedIndexBuild(attributeDescriptor, currentRestartGeneration)
2859
3112
  ) {
2860
3113
  hasChanges = true;
2861
3114
  if (attribute.indexNulls === undefined) attribute.indexNulls = true;
@@ -2875,9 +3128,21 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2875
3128
  // resumes rather than restarts. Canonicalized to match structurallyChanged above.
2876
3129
  const indexOptionsChanged =
2877
3130
  canonicalIndexKey(attributeDescriptor?.indexed) !== canonicalIndexKey(attribute.indexed);
2878
- attribute.lastIndexedKey = indexOptionsChanged
2879
- ? undefined
2880
- : (attributeDescriptor?.lastIndexedKey ?? undefined);
3131
+ // Only a checkpoint runIndexing stamped with its own key resumes: earlier releases advanced
3132
+ // lastIndexedKey past failed and unflushed index writes, so any other is a full rebuild.
3133
+ const uncertifiedCheckpoint =
3134
+ attributeDescriptor?.lastIndexedKey !== undefined &&
3135
+ (attributeDescriptor.checkpointCertified === undefined ||
3136
+ attributeDescriptor.checkpointAlgorithm !== CHECKPOINT_ALGORITHM ||
3137
+ compareKeys(attributeDescriptor.checkpointCertified, attributeDescriptor.lastIndexedKey) !== 0);
3138
+ attribute.lastIndexedKey =
3139
+ indexOptionsChanged || uncertifiedCheckpoint
3140
+ ? undefined
3141
+ : (attributeDescriptor?.lastIndexedKey ?? undefined);
3142
+ if (attribute.lastIndexedKey !== undefined) {
3143
+ attribute.checkpointCertified = attribute.lastIndexedKey;
3144
+ attribute.checkpointAlgorithm = CHECKPOINT_ALGORITHM;
3145
+ }
2881
3146
  // Explicit reindex is the upgrade path from a legacy (un-versioned) custom-index
2882
3147
  // object store to the versioned, VT-cacheable format. A full rebuild from scratch
2883
3148
  // (lastIndexedKey === undefined) clears the store and rewrites every node, so the
@@ -2901,6 +3166,9 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2901
3166
  // the new process reuses the old PID. Cleared on clean completion; left in place
2902
3167
  // on failure/crash so the next, higher-numbered restart re-triggers the backfill.
2903
3168
  attribute.restartNumber = currentRestartGeneration;
3169
+ if (manageThreads.processIncarnation != null)
3170
+ attribute.indexingIncarnation = manageThreads.processIncarnation;
3171
+ attribute.indexingBuildId = randomBytes(8).toString('hex');
2904
3172
  delete attribute.indexingFailed; // clear failure flag for the new run
2905
3173
  dbi.isIndexing = true;
2906
3174
  Object.defineProperty(attribute, 'dbi', { value: dbi, configurable: true, enumerable: false });
@@ -2914,6 +3182,13 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2914
3182
  if (attributeDescriptor?.indexingPID && attributeDescriptor.indexingPID !== process.pid)
2915
3183
  reindexReasons.push(`crash-recovery(pid=${attributeDescriptor.indexingPID})`);
2916
3184
  if (attributeDescriptor?.restartNumber < currentRestartGeneration) reindexReasons.push('restart-number');
3185
+ if (uncertifiedCheckpoint) reindexReasons.push('uncertified-checkpoint');
3186
+ if (
3187
+ attributeDescriptor?.indexingPID === process.pid &&
3188
+ manageThreads.processIncarnation != null &&
3189
+ attributeDescriptor.indexingIncarnation !== manageThreads.processIncarnation
3190
+ )
3191
+ reindexReasons.push('abandoned-build(previous process incarnation)');
2917
3192
  logger.info(
2918
3193
  `reindex ${databaseName}.${tableName}.${attribute.name}: reason=${reindexReasons.join(',') || 'unknown'}`
2919
3194
  );
@@ -2927,11 +3202,21 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2927
3202
  // workers / a reload would treat the still-partial index as ready and return incomplete results.
2928
3203
  attribute.indexingPID = attributeDescriptor.indexingPID;
2929
3204
  attribute.lastIndexedKey = attributeDescriptor.lastIndexedKey;
3205
+ if (attributeDescriptor.checkpointCertified !== undefined) {
3206
+ attribute.checkpointCertified = attributeDescriptor.checkpointCertified;
3207
+ attribute.checkpointAlgorithm = attributeDescriptor.checkpointAlgorithm;
3208
+ }
2930
3209
  // Carry the in-progress restart generation too, so persisting this metadata-only
2931
3210
  // change doesn't drop it and break the crash-recovery trigger for the running backfill.
2932
3211
  attribute.restartNumber = attributeDescriptor.restartNumber;
3212
+ attribute.indexingIncarnation = attributeDescriptor.indexingIncarnation;
3213
+ attribute.indexingBuildId = attributeDescriptor.indexingBuildId;
2933
3214
  if (attributeDescriptor.indexingFailed) attribute.indexingFailed = attributeDescriptor.indexingFailed;
2934
3215
  }
3216
+ // The declared attribute never carries the stamp, so any rewrite of a descriptor that has
3217
+ // one would drop it and make a completed index look like a pre-stamp build.
3218
+ if (attribute.checkpointAlgorithm === undefined && attributeDescriptor?.checkpointAlgorithm !== undefined)
3219
+ attribute.checkpointAlgorithm = attributeDescriptor.checkpointAlgorithm;
2935
3220
  attributesDbi.put(dbiKey, attribute);
2936
3221
  }
2937
3222
  // If a migration is in progress (indexingPID set), any newly opened dbi must also
@@ -2987,35 +3272,43 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2987
3272
  } finally {
2988
3273
  releaseLock();
2989
3274
  }
2990
- if (hasChanges || refreshRelationshipAttributes) {
2991
- Table.schemaVersion++;
2992
- Table.updatedAttributes();
2993
- }
3275
+ if (hasChanges || refreshRelationshipAttributes) Table.schemaVersion++;
3276
+ if (hasChanges || refreshRelationshipAttributes || refreshedLiveAttributes) Table.updatedAttributes();
2994
3277
  logger.trace(`${tableName} table loading, running index`);
2995
3278
  const branchPath = target.branch?.path;
2996
3279
  if (attributesToIndex.length > 0 || indicesToRemove.length > 0) {
2997
- Table.indexingOperation = runIndexing(Table, attributesToIndex, indicesToRemove, branchPath);
3280
+ // captured before the backfill can rewrite the attributes
3281
+ const buildIds = new Map(attributesToIndex.map((attribute) => [attribute, attribute.indexingBuildId]));
3282
+ const markSettled = () => markAbandonedIndexBuild(Table, rootStore, buildIds);
3283
+ Table.indexingOperation = runIndexing(Table, attributesToIndex, indicesToRemove, branchPath).then(
3284
+ markSettled,
3285
+ markSettled
3286
+ );
2998
3287
  } else if (hasChanges)
2999
3288
  signalling.signalSchemaChange(
3000
3289
  new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName, undefined, branchPath)
3001
3290
  );
3291
+ void Table.derivedIndexRuntime?.close();
3292
+ Table.derivedIndexRuntime = attachDerivedIndexes(Table);
3002
3293
 
3003
3294
  Table.origin = origin;
3004
3295
  // scope-private: replication and other global subscribers must not learn of a branch class
3005
3296
  if ((hasChanges || refreshRelationshipAttributes) && !target.branch) {
3006
3297
  databaseEventsEmitter.emit('updateTable', Table, origin !== 'cluster');
3007
3298
  }
3008
- if (expiration || eviction || scanInterval)
3299
+ if (expiration || eviction || scanInterval || attributes.some((attribute) => attribute.expiresAt))
3009
3300
  Table.setTTLExpiration({
3010
3301
  expiration,
3011
3302
  eviction,
3012
3303
  scanInterval,
3304
+ fromSchema: true,
3305
+ isolatedApplicationOwner,
3013
3306
  });
3014
3307
  logger.trace(`${tableName} table loaded`);
3015
3308
 
3016
3309
  return Table as TableResourceType;
3017
- // dropTable() tombstones the bare table row, which is not the row a legacy catalog keeps the
3018
- // table's settings in, so a drop in flight has to be checked on both.
3310
+ // A migrated catalog can retain a named primary descriptor beside a bare table tombstone, so a
3311
+ // drop in flight has to be checked on both representations.
3019
3312
  function tableIsDropping(descriptor: any, descriptorKey: string) {
3020
3313
  if (descriptor?.dropping) return true;
3021
3314
  return descriptorKey !== tableName + '/' && attributesDbi.getSync(tableName + '/')?.dropping;
@@ -3023,12 +3316,7 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3023
3316
  // The catalog row initStores() reads a table's settings from: the primary key's own row when it
3024
3317
  // has one, and the bare table row otherwise.
3025
3318
  function primaryDescriptorKey() {
3026
- const declaredPrimaryKey = attributes?.find((attribute) => attribute.isPrimaryKey)?.name;
3027
- if (declaredPrimaryKey) {
3028
- const attributeKey = tableName + '/' + declaredPrimaryKey;
3029
- if (attributesDbi.getSync(attributeKey)) return attributeKey;
3030
- }
3031
- return tableName + '/';
3319
+ return persistedPrimaryDescriptor(attributesDbi).key;
3032
3320
  }
3033
3321
  // The catalog of a published table stays, but a class the registration never accepted is
3034
3322
  // unreachable, so release what makeTable() registered process-wide instead of leaving its timers
@@ -3092,70 +3380,228 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3092
3380
  }
3093
3381
  }
3094
3382
  /**
3095
- * Canonical form used ONLY for the structural (reindex-triggering) comparison of index options.
3096
- * `@indexed(...)` records options in source-argument order and as strings, while the operations API
3097
- * and config objects can supply them reordered or as numbers; without canonicalizing, such a
3098
- * representation-only difference flips the structural comparison and forces a needless full rebuild
3099
- * (clearing + rebuilding the index, 503-ing the attribute throughout) for a semantically identical
3100
- * index. Sorts object keys and coerces numeric-looking (non-zero) string scalars to numbers.
3101
- * Conservative by design: boolean-vs-object, absent-vs-present, and string-"0"-vs-number-0
3102
- * differences are all preserved, so a genuine change (`true` vs `{ type: 'HNSW' }`, an added/removed
3103
- * option, a changed value) still triggers a rebuild. Persistence keys off the raw form, so the stored
3104
- * descriptor self-heals toward this shape over time. harper#1357
3383
+ * Stable structural form for deciding whether an index must be rebuilt. `coerceZero` extends numeric
3384
+ * coercion to zero; a truthiness-sensitive numeric option must normalize its value before using it.
3105
3385
  */
3106
- export function canonicalizeIndexOptions(value: any): any {
3107
- if (Array.isArray(value)) return value.map(canonicalizeIndexOptions);
3386
+ export function canonicalizeIndexOptions(value: any, coerceZero = false): any {
3387
+ if (Array.isArray(value)) return value.map((item) => canonicalizeIndexOptions(item, coerceZero));
3108
3388
  if (value && typeof value === 'object') {
3109
3389
  const canonical: Record<string, any> = {};
3110
- for (const key of Object.keys(value).sort()) canonical[key] = canonicalizeIndexOptions(value[key]);
3390
+ const customIndex = value.type && (CUSTOM_INDEXES as Record<string, any>)[value.type];
3391
+ for (const key of Object.keys(value).sort()) {
3392
+ if (customIndex?.truthyStructuralOptions?.has(key)) {
3393
+ if (value[key]) canonical[key] = true;
3394
+ continue;
3395
+ }
3396
+ const optionValue = customIndex?.normalizeOptionValue
3397
+ ? customIndex.normalizeOptionValue(key, value[key])
3398
+ : value[key];
3399
+ canonical[key] = canonicalizeIndexOptions(
3400
+ optionValue,
3401
+ coerceZero || Boolean(customIndex?.numericOptions?.has(key))
3402
+ );
3403
+ }
3111
3404
  return canonical;
3112
3405
  }
3113
- // Coerce numeric-looking strings ("16" -> 16) so string-vs-number representations of the same
3114
- // option compare equal — EXCEPT zero: the string "0" is truthy while the number 0 is falsy, and
3115
- // index code may branch on truthiness (e.g. HNSW `if (this.optimizeRouting)` doubles maxConnections),
3116
- // so "0" and 0 build structurally different indexes and must still trigger a rebuild. Zero is the
3117
- // only finite number whose string and numeric forms diverge in truthiness, so excluding it fully
3118
- // closes that gap. Leave non-numeric strings, booleans, null, etc. intact.
3119
3406
  if (typeof value === 'string' && value.trim() !== '') {
3120
3407
  const numeric = Number(value);
3121
- if (numeric !== 0 && Number.isFinite(numeric)) return numeric;
3408
+ if ((numeric !== 0 || coerceZero) && Number.isFinite(numeric)) return numeric;
3122
3409
  }
3123
3410
  return value;
3124
3411
  }
3412
+ // Bumped when a change alters which keys a checkpoint may certify. A descriptor stamped by any other
3413
+ // version resumes as uncertified (full rebuild) rather than being trusted, and a completed index keeps
3414
+ // the stamp of the build that wrote it.
3415
+ export const CHECKPOINT_ALGORITHM = 2;
3125
3416
  const MAX_OUTSTANDING_INDEXING = 1000;
3126
3417
  const MIN_OUTSTANDING_INDEXING = 10;
3418
+ const INDEXING_YIELD_INTERVAL = 100;
3419
+ // A resumable checkpoint is written only after a flush (see flushIndexStores), at most once per period
3420
+ // and never before this many more records: the flush seals every column family in the database, so a
3421
+ // slow backfill must not impose the period's flush rate on unrelated tables.
3422
+ let indexingCheckpointPeriodMs = 5000;
3423
+ let indexingCheckpointMinRecords = 10000;
3424
+ export function setIndexingCheckpointPeriod(ms: number, minRecords = indexingCheckpointMinRecords) {
3425
+ const previous = { ms: indexingCheckpointPeriodMs, minRecords: indexingCheckpointMinRecords };
3426
+ indexingCheckpointPeriodMs = ms;
3427
+ indexingCheckpointMinRecords = minRecords;
3428
+ return previous;
3429
+ }
3430
+ const yieldEventTurn = () => new Promise((resolve) => setImmediate(resolve));
3431
+ // RocksDB index stores have no WAL (openRocksDatabase defaults disableWAL), so a flush is what makes the
3432
+ // entries a checkpoint certifies durable. A flush only covers writes issued before it started, so a caller
3433
+ // never joins one in flight: it joins the next one, which every backfill on that database asking meanwhile
3434
+ // shares — at most one in flight and one queued.
3435
+ const indexingFlushes = new WeakMap<object, { inFlight?: Promise<void>; queued?: Promise<void> }>();
3436
+ function flushIndexStores(rootStore: any): Promise<void> | undefined {
3437
+ if (!(rootStore instanceof RocksDatabase)) return;
3438
+ let flushes = indexingFlushes.get(rootStore);
3439
+ if (!flushes) indexingFlushes.set(rootStore, (flushes = {}));
3440
+ if (flushes.queued) return flushes.queued;
3441
+ const start = () => {
3442
+ flushes.queued = undefined;
3443
+ const flush = rootStore.flush().finally(() => {
3444
+ if (flushes.inFlight === flush) flushes.inFlight = undefined;
3445
+ });
3446
+ flushes.inFlight = flush;
3447
+ return flush;
3448
+ };
3449
+ if (!flushes.inFlight) return start();
3450
+ return (flushes.queued = flushes.inFlight.then(start, start));
3451
+ }
3452
+ export function resumeStartKey(attributes: { lastIndexedKey?: any }[]): any {
3453
+ let start: any;
3454
+ for (const attribute of attributes) {
3455
+ if (attribute.lastIndexedKey == undefined) return undefined;
3456
+ if (start === undefined || compareKeys(attribute.lastIndexedKey, start) < 0) start = attribute.lastIndexedKey;
3457
+ }
3458
+ return start;
3459
+ }
3460
+
3461
+ /**
3462
+ * Persists the failure marker for a build that ended without running one of runIndexing's own exit
3463
+ * paths, so something re-triggers it. Fenced on `indexingBuildId` inside the storage engine's catalog
3464
+ * serialization boundary, because a replacement generation (or another thread declaring different index
3465
+ * options) can claim the attribute before an outgoing build's promise settles, and marking that would fail
3466
+ * a live build. The fence read and write stay synchronous, and nothing here may throw because
3467
+ * `Table.indexingOperation` reaches operations-API callers.
3468
+ */
3469
+ async function markAbandonedIndexBuild(Table, rootStore, buildIds: Map<any, string>) {
3470
+ for (const [attribute, buildId] of buildIds) {
3471
+ try {
3472
+ let marked;
3473
+ if (buildId == null || Table.dbisDB.getSync(attribute.key)?.indexingBuildId !== buildId) continue;
3474
+ const markIfOwned = () => {
3475
+ const descriptor = Table.dbisDB.getSync(attribute.key);
3476
+ if (descriptor?.indexingBuildId === buildId && !descriptor.indexingFailed) {
3477
+ Table.dbisDB.putSync(attribute.key, { ...descriptor, indexingFailed: true });
3478
+ marked = true;
3479
+ }
3480
+ };
3481
+ if (rootStore instanceof RocksDatabase) {
3482
+ acquireUpdateAttributesLock(rootStore, `abandoned index build '${Table.tableName}.${attribute.name}'`);
3483
+ try {
3484
+ markIfOwned();
3485
+ } finally {
3486
+ releaseUpdateAttributesLock(rootStore);
3487
+ }
3488
+ } else {
3489
+ rootStore.transactionSync(markIfOwned);
3490
+ }
3491
+ if (marked)
3492
+ logger.warn(
3493
+ `Indexing of ${Table.databaseName}.${Table.tableName}.${attribute.name} ended without completing. ` +
3494
+ `The index stays incomplete and every query on the attribute reports it as not indexed yet; ` +
3495
+ `the next load of the table retries the backfill from the last checkpoint (indexingFailed=true).`
3496
+ );
3497
+ } catch (error) {
3498
+ // A store closed by shutdown is the common case, and it cannot be written to at all.
3499
+ try {
3500
+ logger.debug(`Could not mark the abandoned index build of ${Table.tableName}.${attribute.name}`, error);
3501
+ } catch {}
3502
+ }
3503
+ }
3504
+ }
3127
3505
  async function runIndexing(Table, attributes, indicesToRemove, branchPath?: string) {
3506
+ let checkpointing;
3507
+ let hadIndexingErrors = false;
3508
+ const attributeErrorReported = {};
3509
+ const onIndexPutRejected = (property, error) => {
3510
+ hadIndexingErrors = true;
3511
+ if (attributeErrorReported[property]) return;
3512
+ attributeErrorReported[property] = true;
3513
+ logger.error(`Error indexing attribute ${property}`, error);
3514
+ };
3515
+ const putRejectionHandlers = attributes.map((attribute) => (error) => onIndexPutRejected(attribute.name, error));
3128
3516
  try {
3129
3517
  logger.info(`Indexing ${Table.tableName} attributes`, attributes);
3130
3518
  await signalling.signalSchemaChange(
3131
3519
  new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName, undefined, branchPath)
3132
3520
  );
3133
3521
  let lastResolution;
3522
+ // The checkpoint and completion barriers have to cover every mutation still in flight: any of them
3523
+ // may reject after those barriers read hadIndexingErrors.
3524
+ const pendingMutations = new Set();
3525
+ let settleWaiter;
3526
+ const track = (result, onRejected) => {
3527
+ if (!result?.then) return result;
3528
+ const tracked = result.then(
3529
+ () => {
3530
+ pendingMutations.delete(tracked);
3531
+ settleWaiter?.();
3532
+ return false;
3533
+ },
3534
+ (error) => {
3535
+ pendingMutations.delete(tracked);
3536
+ settleWaiter?.();
3537
+ onRejected(error);
3538
+ return true;
3539
+ }
3540
+ );
3541
+ pendingMutations.add(tracked);
3542
+ return result;
3543
+ };
3544
+ // The tracked promises absorb their own rejections, so one failure never abandons its siblings.
3545
+ const drainMutations = async () => {
3546
+ if (!pendingMutations.size) return false;
3547
+ return (await Promise.all([...pendingMutations])).some(Boolean);
3548
+ };
3549
+ // Waiting on a chosen entry would stall behind a slow one the others have already overtaken.
3550
+ const nextSettlement = () =>
3551
+ new Promise((resolve) => {
3552
+ settleWaiter = () => {
3553
+ settleWaiter = undefined;
3554
+ resolve(undefined);
3555
+ };
3556
+ });
3134
3557
  for (const index of indicesToRemove) {
3135
- lastResolution = index.drop();
3558
+ index.customIndex?.resetDerivedStorage?.();
3559
+ track(index.drop(), (error) => onIndexPutRejected(index.name, error));
3136
3560
  }
3137
3561
  let interrupted;
3138
- let hadIndexingErrors = false;
3139
- const attributeErrorReported = {};
3140
3562
  let indexed = 0;
3141
3563
  const attributesLength = attributes.length;
3142
3564
  await new Promise((resolve) => setImmediate(resolve)); // yield event turn, indexing should consistently take at least one event turn
3143
3565
  if (attributesLength > 0) {
3144
- let start: any;
3145
- for (const attribute of attributes) {
3146
- // if we are resuming, we need to start from the last key we indexed by all attributes
3147
- if (compareKeys(attribute.lastIndexedKey, start) < 0) start = attribute.lastIndexedKey;
3148
- if (attribute.lastIndexedKey == undefined) {
3566
+ const start = resumeStartKey(attributes);
3567
+ if (start === undefined) {
3568
+ for (const attribute of attributes) {
3149
3569
  // if we are starting from the beginning, clear out any previous index entries since we are rewriting
3570
+ attribute.dbi.customIndex?.resetDerivedStorage?.();
3150
3571
  if (attribute.dbi.clearAsync) {
3151
- // LMDB, note that we don't need to wait for this to complete, just gets enqueued in front of the other writes
3152
- attribute.dbi.clearAsync();
3572
+ // LMDB enqueues this ahead of the index writes, so the scan need not wait for it — but the
3573
+ // barriers must, or a rejected clear certifies a checkpoint over stale entries.
3574
+ track(attribute.dbi.clearAsync(), (error) => onIndexPutRejected(attribute.name, error));
3153
3575
  } else {
3154
3576
  await attribute.dbi.clear();
3155
3577
  }
3156
3578
  }
3157
3579
  }
3158
- let outstanding = 0;
3580
+ // A resumed scan starts at the checkpoint, so it must only name a key whose every predecessor is
3581
+ // durably indexed: persisted once the writes it covers have settled and flushed, frozen after any
3582
+ // record fails so the retry re-covers it, and stamped with its own key (see the trigger in table()).
3583
+ const persistCheckpoint = async (key) => {
3584
+ if (hadIndexingErrors) return;
3585
+ try {
3586
+ // Everything still in flight was issued for a key at or before this one: the scan has not
3587
+ // moved past it yet. So a failure among them is a failure this checkpoint would cover.
3588
+ const failed = await drainMutations();
3589
+ if (failed) return;
3590
+ await flushIndexStores(Table.primaryStore.rootStore);
3591
+ const puts = [];
3592
+ for (const attribute of attributes) {
3593
+ attribute.lastIndexedKey = key;
3594
+ attribute.checkpointCertified = key;
3595
+ attribute.checkpointAlgorithm = CHECKPOINT_ALGORITHM;
3596
+ puts.push(Table.dbisDB.put(attribute.key, attribute));
3597
+ }
3598
+ await Promise.all(puts);
3599
+ } catch (error) {
3600
+ logger.warn(`Could not persist the indexing checkpoint for ${Table.tableName}`, error);
3601
+ }
3602
+ };
3603
+ let nextCheckpointAt = performance.now() + indexingCheckpointPeriodMs;
3604
+ let nextCheckpointRecord = indexingCheckpointMinRecords;
3159
3605
  // this means that a new attribute has been introduced that needs to be indexed
3160
3606
  for (const { key, value: record } of Table.primaryStore.getRange({
3161
3607
  start,
@@ -3163,93 +3609,86 @@ async function runIndexing(Table, attributes, indicesToRemove, branchPath?: stri
3163
3609
  versions: true,
3164
3610
  snapshot: false, // don't hold a read transaction this whole time
3165
3611
  })) {
3166
- if (!record) continue; // deletion entry
3612
+ const atInterval = ++indexed % INDEXING_YIELD_INTERVAL === 0;
3167
3613
  // TODO: Do we ever need to interrupt due to a schema change that was not a restart?
3168
3614
  //if (Table.schemaVersion !== schemaVersion) return; // break out if there are any schema changes and let someone else pick it up
3169
- outstanding++;
3170
- // Custom indexes (e.g. HNSW) index synchronously and never raise `outstanding`, so the
3171
- // outstanding-based yield below never fires for them. Track that this row did synchronous
3615
+ // Custom indexes (e.g. HNSW) index synchronously and leave pendingMutations empty, so the
3616
+ // backpressure yield below never fires for them. Track that this row did synchronous
3172
3617
  // indexing work so we can still yield the event loop after it.
3173
3618
  let didSynchronousIndexing = false;
3174
3619
  // every index operation needs to be guarded by the version still be the same. If it has already changed before
3175
3620
  // we index, that's fine because indexing is idempotent, we can just put the same values again. If it changes
3176
3621
  // during the indexing, the indexing here will fail. This is also fine because it means the other thread will have
3177
3622
  // performed indexing and we don't need to do anything further
3178
- for (let i = 0; i < attributesLength; i++) {
3179
- const attribute = attributes[i];
3180
- const property = attribute.name;
3181
- const index = attribute.dbi;
3182
- try {
3183
- const resolver = attribute.resolve;
3184
- const value = record && (resolver ? resolver(record) : record[property]);
3185
- if (index.customIndex) {
3186
- index.customIndex.index(key, value);
3187
- didSynchronousIndexing = true;
3188
- continue;
3189
- }
3190
- const values = getIndexedValues(value, index.indexNulls);
3191
- if (values) {
3192
- for (let i = 0, l = values.length; i < l; i++) {
3193
- lastResolution = index.put(values[i], key);
3623
+ if (record) {
3624
+ for (let i = 0; i < attributesLength; i++) {
3625
+ const attribute = attributes[i];
3626
+ const property = attribute.name;
3627
+ const index = attribute.dbi;
3628
+ const onPutRejected = putRejectionHandlers[i];
3629
+ try {
3630
+ const resolver = attribute.resolve;
3631
+ const value = record && (resolver ? resolver(record) : record[property]);
3632
+ if (index.customIndex) {
3633
+ index.customIndex.index(key, value);
3634
+ didSynchronousIndexing = true;
3635
+ continue;
3636
+ }
3637
+ const values = getIndexedValues(value, index.indexNulls);
3638
+ if (values) {
3639
+ for (let i = 0, l = values.length; i < l; i++) {
3640
+ track(index.put(values[i], key), onPutRejected);
3641
+ }
3642
+ }
3643
+ } catch (error) {
3644
+ hadIndexingErrors = true;
3645
+ if (!attributeErrorReported[property]) {
3646
+ // just report an indexing error once per attribute so we don't spam the logs.
3647
+ // A store closed by worker shutdown surfaces here as "Database not open"; that is
3648
+ // a benign interruption (the next generation re-runs the backfill), so don't log
3649
+ // it as an error — the outer catch returns quietly once the iterator also throws.
3650
+ attributeErrorReported[property] = true;
3651
+ if (Table.primaryStore?.rootStore?.status === 'closed')
3652
+ logger.debug(`Indexing attribute ${property} interrupted by store shutdown`, error);
3653
+ else logger.error(`Error indexing attribute ${property}`, error);
3194
3654
  }
3195
- }
3196
- } catch (error) {
3197
- hadIndexingErrors = true;
3198
- if (!attributeErrorReported[property]) {
3199
- // just report an indexing error once per attribute so we don't spam the logs.
3200
- // A store closed by worker shutdown surfaces here as "Database not open"; that is
3201
- // a benign interruption (the next generation re-runs the backfill), so don't log
3202
- // it as an error — the outer catch returns quietly once the iterator also throws.
3203
- attributeErrorReported[property] = true;
3204
- if (Table.primaryStore?.rootStore?.status === 'closed')
3205
- logger.debug(`Indexing attribute ${property} interrupted by store shutdown`, error);
3206
- else logger.error(`Error indexing attribute ${property}`, error);
3207
3655
  }
3208
3656
  }
3209
3657
  }
3210
- when(
3211
- lastResolution,
3212
- () => outstanding--,
3213
- (error) => {
3214
- outstanding--;
3215
- hadIndexingErrors = true;
3216
- logger.error(error);
3217
- }
3218
- );
3219
3658
  if (workerData && workerData.restartNumber !== manageThreads.restartNumber) {
3220
3659
  interrupted = true;
3221
3660
  }
3222
- if (++indexed % 100 === 0 || interrupted) {
3223
- // occasionally update our progress so if we crash, we can resume
3224
- for (const attribute of attributes) {
3225
- attribute.lastIndexedKey = key;
3226
- Table.dbisDB.put(attribute.key, attribute);
3227
- }
3228
- if (interrupted) return;
3661
+ if (interrupted) {
3662
+ await drainMutations();
3663
+ await checkpointing;
3664
+ await persistCheckpoint(key);
3665
+ return;
3229
3666
  }
3230
- if (outstanding > MAX_OUTSTANDING_INDEXING) await lastResolution;
3231
- else if (outstanding > MIN_OUTSTANDING_INDEXING)
3232
- await new Promise((resolve) => setImmediate(resolve)); // yield event turn, don't want to use all computation
3233
- else if (didSynchronousIndexing) await new Promise((resolve) => setImmediate(resolve)); // custom indexes (e.g. HNSW) index synchronously and never raise `outstanding`; without this yield a large backfill runs in a single event-loop turn, starving keepalive/replication and queries and never letting the isIndexing flag be observed
3667
+ if (atInterval && indexed >= nextCheckpointRecord && performance.now() >= nextCheckpointAt) {
3668
+ nextCheckpointAt = performance.now() + indexingCheckpointPeriodMs;
3669
+ nextCheckpointRecord = indexed + indexingCheckpointMinRecords;
3670
+ await checkpointing;
3671
+ checkpointing = persistCheckpoint(key);
3672
+ }
3673
+ // Checked once per record, so a record's own fan-out can overshoot before the bound applies.
3674
+ while (pendingMutations.size > MAX_OUTSTANDING_INDEXING) await nextSettlement();
3675
+ if (atInterval || didSynchronousIndexing || pendingMutations.size > MIN_OUTSTANDING_INDEXING)
3676
+ await yieldEventTurn();
3234
3677
  }
3235
3678
  }
3236
- // Await the last pending put. If it rejects, that is also an indexing error.
3237
- // Note: the when() calls above already attach rejection handlers to each record's
3238
- // last-put promise; this try-catch specifically handles the case where lastResolution
3239
- // itself rejects (i.e. the very last put in the loop failed) which would otherwise
3240
- // throw past the hadIndexingErrors check to the outer catch. The broader issue of
3241
- // unhandled rejections from non-last puts in multi-value attributes is pre-existing
3242
- // and out of scope for this fix.
3243
- try {
3244
- await lastResolution;
3245
- } catch (error) {
3246
- hadIndexingErrors = true;
3247
- logger.error(error);
3679
+ await checkpointing;
3680
+ // A mutation that rejects after completion is declared has no build left to park.
3681
+ await drainMutations();
3682
+ // the tail since the last checkpoint is not durable until flushed; announcing the index complete
3683
+ // before that would outlive a crash that loses it
3684
+ if (!hadIndexingErrors) {
3685
+ try {
3686
+ await flushIndexStores(Table.primaryStore.rootStore);
3687
+ } catch (error) {
3688
+ hadIndexingErrors = true;
3689
+ logger.error(`Could not flush the indexes of ${Table.tableName} before marking them complete`, error);
3690
+ }
3248
3691
  }
3249
- // Yield one more event turn so any queued when() error callbacks (which fire as
3250
- // microtasks when their tracked promise settles) have a chance to set hadIndexingErrors
3251
- // before we decide whether to mark indexing as complete.
3252
- await new Promise((resolve) => setImmediate(resolve));
3253
3692
  if (hadIndexingErrors) {
3254
3693
  // Some records failed to index. Persist the failure marker in the descriptor so
3255
3694
  // the next call to table() (including after a restart with a fresh PID) re-triggers
@@ -3278,9 +3717,15 @@ async function runIndexing(Table, attributes, indicesToRemove, branchPath?: stri
3278
3717
  // update the attributes to indicate that we are finished
3279
3718
  for (const attribute of attributes) {
3280
3719
  delete attribute.lastIndexedKey;
3720
+ delete attribute.checkpointCertified;
3721
+ // Survives completion, unlike the checkpoint fields: without it an index built here is
3722
+ // indistinguishable from one a release that could skip a failed record declared complete.
3723
+ attribute.checkpointAlgorithm = CHECKPOINT_ALGORITHM;
3281
3724
  delete attribute.indexingPID;
3282
3725
  delete attribute.indexingFailed;
3283
3726
  delete attribute.restartNumber;
3727
+ delete attribute.indexingIncarnation;
3728
+ delete attribute.indexingBuildId;
3284
3729
  attribute.dbi.isIndexing = false;
3285
3730
  // Also clear isIndexing on the currently-active dbi in Table.indices, which may
3286
3731
  // differ from attribute.dbi if a resetDatabases() call during this migration
@@ -3297,6 +3742,7 @@ async function runIndexing(Table, attributes, indicesToRemove, branchPath?: stri
3297
3742
  logger.info(`Finished indexing ${Table.tableName} attributes`, attributes);
3298
3743
  }
3299
3744
  } catch (error) {
3745
+ await checkpointing;
3300
3746
  // A worker shutting down closes its stores mid-backfill, so the range iterator or a
3301
3747
  // put throws (e.g. "Database not open" / "Iterator not initialized"). This is an
3302
3748
  // interruption, not a data error: the next worker generation re-runs the backfill via
@@ -3360,6 +3806,24 @@ function completeInterruptedDrop(rootStore, attributesDbi, databaseName: string,
3360
3806
  } finally {
3361
3807
  columnStore.close();
3362
3808
  }
3809
+ // derived HNSW plane files live next to the store; the normal drop path removes
3810
+ // them through the custom index, but this recovery path drops raw column stores,
3811
+ // and a same-name recreate must never open a stale plane over a fresh CF
3812
+ try {
3813
+ unlinkSync(planeFilePathFor(rootStore.path, columnName));
3814
+ } catch (error: any) {
3815
+ // a stale plane left behind (e.g. Windows EBUSY while still mapped) would be
3816
+ // opened over a fresh same-name CF, resolving another graph's node ids
3817
+ // against it — tombstone it so no attach ever adopts it
3818
+ if (error?.code !== 'ENOENT') {
3819
+ logger.warn(`could not delete the HNSW plane file for ${columnName}; tombstoning it as stale`, error);
3820
+ try {
3821
+ closeSync(openSync(planeStalePathFor(planeFilePathFor(rootStore.path, columnName)), 'w'));
3822
+ } catch (tombstoneError) {
3823
+ logger.warn(`could not tombstone the stale HNSW plane file for ${columnName}`, tombstoneError);
3824
+ }
3825
+ }
3826
+ }
3363
3827
  }
3364
3828
  }
3365
3829
  } else {