@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
@@ -0,0 +1,62 @@
1
+ // Why a lock that outlives the reader's budget is retried from a timer, and why both the bound and
2
+ // the backoff are wall clock rather than an attempt count: see "Root config watchers must read
3
+ // synchronously" in DESIGN.md (harper#2191).
4
+ const RETRY_BUDGET_MS = 3_100;
5
+ const INITIAL_DELAY_MS = 100;
6
+ const MAX_DELAY_MS = 1_600;
7
+
8
+ let retryBudgetMs = RETRY_BUDGET_MS;
9
+
10
+ // Test-only, the read-side twin of `atomicWriteFile`'s `retryBudgetMs` option. A case that only
11
+ // needs the ladder *spent* has no way to get there but wall clock, so without this each one sits
12
+ // out the shipped 3.1 s; a dozen of them cost the unit job 40 s. Called with no argument it
13
+ // restores the shipped value, and `configReadRetry.test.js` still spends that value in full, so
14
+ // shortening it elsewhere cannot hide a change to what ships.
15
+ export function _setRetryBudgetForTests(ms: number = RETRY_BUDGET_MS): void {
16
+ retryBudgetMs = ms;
17
+ }
18
+
19
+ export class ConfigReadRetry {
20
+ #timer?: NodeJS.Timeout;
21
+ #deadline?: number;
22
+ // Captured with the deadline: the backoff is a fraction of the budget the deadline came from,
23
+ // so reading the module value again mid-ladder would mis-scale it if a test had changed it.
24
+ #budgetMs: number = retryBudgetMs;
25
+
26
+ // `holdEventLoop` is for a caller whose boot barrier this ladder is the only thing left to
27
+ // settle: an unref'd timer would let the thread drain and exit mid-boot instead.
28
+ schedule(retry: () => void, holdEventLoop: boolean = false): boolean {
29
+ this.cancel();
30
+ const now = performance.now();
31
+ if (this.#deadline === undefined) {
32
+ this.#budgetMs = retryBudgetMs;
33
+ this.#deadline = now + this.#budgetMs;
34
+ }
35
+ const remainingMs = this.#deadline - now;
36
+ if (remainingMs <= 0) {
37
+ this.reset();
38
+ return false;
39
+ }
40
+ const elapsedMs = this.#budgetMs - remainingMs;
41
+ const delayMs = Math.min(Math.max(elapsedMs, INITIAL_DELAY_MS), MAX_DELAY_MS, remainingMs);
42
+ this.#timer = setTimeout(retry, delayMs);
43
+ if (!holdEventLoop) this.#timer.unref();
44
+ return true;
45
+ }
46
+
47
+ get pending(): boolean {
48
+ return this.#timer !== undefined;
49
+ }
50
+
51
+ reset(): void {
52
+ this.cancel();
53
+ this.#deadline = undefined;
54
+ }
55
+
56
+ cancel(): void {
57
+ if (this.#timer) {
58
+ clearTimeout(this.#timer);
59
+ this.#timer = undefined;
60
+ }
61
+ }
62
+ }
@@ -92,22 +92,56 @@ export function getConfigPath(param: string) {
92
92
  return path.resolve(rootPath, value);
93
93
  }
94
94
 
95
- // Write atomically via temp file + rename so readers don't observe a truncated/empty file.
96
- // Temp path includes randomness so two worker threads in the same process (same pid) writing
97
- // in the same millisecond can't collide on the temp name and then race the rename.
98
- //
95
+ // Write atomically via a randomized temp file + rename so readers do not observe partial content
96
+ // and concurrent workers, which share process.pid, do not collide on a temp path.
99
97
  // Windows has no POSIX-style "replace an open file" semantics: rename() fails with
100
- // EPERM/EACCES while another descriptor is open on the destination. The sleep below blocks the
101
- // calling thread, so this can only ride out a holder that releases without needing that
102
- // thread's event loop. A holder on the calling thread would live exactly as long as the budget,
103
- // which is why config readers must not keep a descriptor on this file open across an event-loop
104
- // turn (RootConfigWatcher.handleChange, OptionsWatcher#handleChange).
105
- const RENAME_RETRY_MAX_ATTEMPTS = 12;
98
+ // EPERM/EACCES/EBUSY while another worker or AV holds the destination open. Root config watchers use
99
+ // readConfigFileSync so this blocking retry cannot wait on a read owned by its own worker.
100
+ // The budget is the wall-clock window of the 12-attempt schedule it replaced
101
+ // (10+20+40+80+160+320+500*6), so it stays a deadline rather than an attempt count without
102
+ // widening the stall: this loop blocks the calling worker's event loop, and `set_configuration`
103
+ // reaches it from a live request thread.
104
+ const RENAME_RETRY_BUDGET_MS = 3_630;
105
+ // Secondary guard only: stops a degenerate zero-delay option set from spinning the whole budget.
106
+ const RENAME_RETRY_MAX_ATTEMPTS = 25;
106
107
  const RENAME_RETRY_INITIAL_DELAY_MS = 10;
107
108
  const RENAME_RETRY_MAX_DELAY_MS = 500;
108
- // Never notified; exists only so Atomics.wait can time out (a synchronous, CPU-idle sleep).
109
+ // Never notified; Atomics.wait uses this only as a CPU-idle synchronous sleep.
109
110
  const renameRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4));
110
111
 
112
+ // Classified by code alone rather than gated to win32 like the read side: `process.platform` does
113
+ // not answer whether this filesystem can replace an open file — WSL drvfs, CIFS/SMB and Docker
114
+ // Desktop bind mounts all report `linux` and return these codes transiently.
115
+ function isRetryableRenameError(code: string): boolean {
116
+ return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
117
+ }
118
+
119
+ type RenameRetryOptions = {
120
+ retryBudgetMs?: number;
121
+ maxRetries?: number;
122
+ initialDelayMs?: number;
123
+ maxDelayMs?: number;
124
+ };
125
+
126
+ type AtomicWriteOptions = RenameRetryOptions & {
127
+ skipIfUnchanged?: boolean;
128
+ };
129
+
130
+ function validateRenameRetryOptions({ retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs }: RenameRetryOptions) {
131
+ const invalidOption =
132
+ !Number.isFinite(retryBudgetMs) ||
133
+ retryBudgetMs < 0 ||
134
+ (!Number.isFinite(maxRetries) && maxRetries !== Infinity) ||
135
+ maxRetries < 0 ||
136
+ !Number.isFinite(initialDelayMs) ||
137
+ initialDelayMs < 0 ||
138
+ !Number.isFinite(maxDelayMs) ||
139
+ maxDelayMs < 0;
140
+ if (invalidOption) {
141
+ throw new RangeError('rename retry options must be non-negative numbers');
142
+ }
143
+ }
144
+
111
145
  // Linux has no libuv mapping for EDQUOT, so a quota-exhausted write surfaces as
112
146
  // `Unknown system error -122` with an unusable `code`; the numeric errno is the portable signal
113
147
  // (EDQUOT is 122 on Linux, 69 on macOS).
@@ -142,12 +176,15 @@ export function atomicWriteFile(
142
176
  filePath,
143
177
  content,
144
178
  {
179
+ retryBudgetMs = RENAME_RETRY_BUDGET_MS,
145
180
  maxRetries = RENAME_RETRY_MAX_ATTEMPTS,
146
181
  initialDelayMs = RENAME_RETRY_INITIAL_DELAY_MS,
147
182
  maxDelayMs = RENAME_RETRY_MAX_DELAY_MS,
148
183
  skipIfUnchanged = false,
149
- } = {}
184
+ }: AtomicWriteOptions = {}
150
185
  ) {
186
+ // Before the temp write, so an option set that can never rename leaves no file behind.
187
+ validateRenameRetryOptions({ retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs });
151
188
  // Opt-in: skipping means no mtime bump, so no watcher event. Only callers that re-derive the
152
189
  // same file every boot want that.
153
190
  if (skipIfUnchanged && matchesFileContent(filePath, content)) return false;
@@ -160,8 +197,10 @@ export function atomicWriteFile(
160
197
  throw err;
161
198
  }
162
199
  try {
163
- renameWithRetry(tempPath, filePath, { maxRetries, initialDelayMs, maxDelayMs });
200
+ renameWithRetry(tempPath, filePath, { retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs });
164
201
  } catch (err) {
202
+ // The temp name carries fresh randomness on every call, so a spent budget would otherwise
203
+ // leave a file nothing else will ever collect.
165
204
  removeTempFile(tempPath);
166
205
  throw err;
167
206
  }
@@ -172,35 +211,48 @@ export function renameWithRetry(
172
211
  fromPath,
173
212
  toPath,
174
213
  {
214
+ retryBudgetMs = RENAME_RETRY_BUDGET_MS,
175
215
  maxRetries = RENAME_RETRY_MAX_ATTEMPTS,
176
216
  initialDelayMs = RENAME_RETRY_INITIAL_DELAY_MS,
177
217
  maxDelayMs = RENAME_RETRY_MAX_DELAY_MS,
178
- } = {}
218
+ }: RenameRetryOptions = {}
179
219
  ) {
220
+ validateRenameRetryOptions({ retryBudgetMs, maxRetries, initialDelayMs, maxDelayMs });
180
221
  let retries = maxRetries;
181
222
  let delayMs = initialDelayMs;
223
+ let retryDeadline;
224
+ let finalAttempt = false;
182
225
  let attempts = 0;
183
- const startedAt = Date.now();
226
+ const startedAt = performance.now();
184
227
  while (true) {
185
228
  try {
186
229
  attempts++;
187
230
  fs.renameSync(fromPath, toPath);
188
231
  return;
189
232
  } catch (err) {
190
- if (retries > 0 && (err.code === 'EPERM' || err.code === 'EACCES')) {
233
+ if (!finalAttempt && retries > 0 && isRetryableRenameError(err.code)) {
191
234
  retries--;
192
- // Sleep synchronously (all call sites are sync) to allow the holder to close the
193
- // file. Atomics.wait yields the thread to the OS instead of spinning the CPU,
194
- // which is what makes a multi-second worst-case budget affordable.
195
- if (delayMs > 0) Atomics.wait(renameRetrySleepBuffer, 0, 0, delayMs);
196
- delayMs = Math.min(delayMs * 2, maxDelayMs);
197
- continue;
235
+ if (retryDeadline === undefined) {
236
+ retryDeadline = performance.now() + retryBudgetMs;
237
+ }
238
+ const remainingBudgetMs = retryDeadline - performance.now();
239
+ if (remainingBudgetMs > 0) {
240
+ // Sleep synchronously (all call sites are sync) to allow the holder to close the
241
+ // file. Atomics.wait yields the thread to the OS instead of spinning the CPU,
242
+ // which is what makes a multi-second worst-case budget affordable.
243
+ const sleepMs = Math.min(delayMs, remainingBudgetMs);
244
+ finalAttempt = sleepMs === remainingBudgetMs;
245
+ if (sleepMs > 0) Atomics.wait(renameRetrySleepBuffer, 0, 0, sleepMs);
246
+ delayMs = Math.min(Math.max(delayMs * 2, RENAME_RETRY_INITIAL_DELAY_MS), maxDelayMs);
247
+ continue;
248
+ }
198
249
  }
199
- // Attempts and elapsed distinguish a holder that never released from one that lost
200
- // a race, and neither survives on the rethrown error.
201
- if (err.code === 'EPERM' || err.code === 'EACCES') {
250
+ // Whether the budget was spent or the code was never retryable is the difference
251
+ // between a holder that never released and a one-off failure, and neither survives on
252
+ // the rethrown error.
253
+ if (isRetryableRenameError(err.code)) {
202
254
  logger.warn(
203
- `Could not replace ${toPath}: ${err.code} after ${attempts} attempts over ${Date.now() - startedAt}ms`
255
+ `Could not replace ${toPath}: ${err.code} after ${attempts} attempts over ${Math.round(performance.now() - startedAt)}ms`
204
256
  );
205
257
  }
206
258
  throw err;
@@ -0,0 +1,34 @@
1
+ import { parse } from 'yaml';
2
+
3
+ // yaml's prettyErrors frames the offending source lines into `message`, and a config file holds
4
+ // credentials. Neither the cause nor the original stack is carried for the same reason.
5
+ export class ConfigParseError extends Error {
6
+ constructor(filePath: string, error: unknown) {
7
+ const { name, code, linePos } = (error ?? {}) as {
8
+ name?: string;
9
+ code?: string;
10
+ linePos?: { line: number; col: number }[];
11
+ };
12
+ const at = linePos?.[0] ? ` at line ${linePos[0].line}, column ${linePos[0].col}` : '';
13
+ super(`Unable to parse the Harper configuration file at ${filePath}: ${code ?? name ?? 'parse failure'}${at}`);
14
+ this.name = 'ConfigParseError';
15
+ }
16
+ }
17
+
18
+ export function parseConfigFile(contents: string, filePath: string): any {
19
+ try {
20
+ // yaml routes warnings through `process.emitWarning` rather than a throw, so a framed
21
+ // warning would reach stderr with the config's own source lines in it, around this scrub.
22
+ return parse(contents, { logLevel: 'error' });
23
+ } catch (error) {
24
+ // Only yaml's own parse errors frame the source into `message`. Anything else is a fault in
25
+ // the parser, where the message is the whole of the debugging context.
26
+ if (!isYamlParseError(error)) throw error;
27
+ throw new ConfigParseError(filePath, error);
28
+ }
29
+ }
30
+
31
+ function isYamlParseError(error: unknown): boolean {
32
+ const { name, linePos } = (error ?? {}) as { name?: string; linePos?: unknown };
33
+ return linePos !== undefined || name === 'YAMLParseError';
34
+ }
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ // Why root config reads must not be async, and why this budget is shared rather than per call:
4
+ // see "Root config watchers must read synchronously" in DESIGN.md (harper#2191).
5
+ const READ_RETRY_BUDGET_MS = 500;
6
+ const READ_RETRY_INITIAL_DELAY_MS = 10;
7
+ const READ_RETRY_MAX_DELAY_MS = 100;
8
+ const readRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4));
9
+ const retryDeadlines = new Map<string, number>();
10
+
11
+ // A caller that already owns a retry ladder passes `waitForLock` false, so one lock costs one
12
+ // blocking window in total rather than one per rung.
13
+ export function readConfigFileSync(filePath: string, waitForLock: boolean = true): string {
14
+ let delayMs = READ_RETRY_INITIAL_DELAY_MS;
15
+ while (true) {
16
+ try {
17
+ const contents = readFileSync(filePath, 'utf-8');
18
+ retryDeadlines.delete(filePath);
19
+ return contents;
20
+ } catch (error) {
21
+ const remainingBudgetMs = waitForLock && isSharingViolation(error) ? remainingRetryBudgetMs(filePath) : 0;
22
+ if (remainingBudgetMs <= 0) throw error;
23
+ Atomics.wait(readRetrySleepBuffer, 0, 0, Math.min(delayMs, remainingBudgetMs));
24
+ delayMs = Math.min(delayMs * 2, READ_RETRY_MAX_DELAY_MS);
25
+ }
26
+ }
27
+ }
28
+
29
+ function isSharingViolation(error: unknown): boolean {
30
+ if (process.platform !== 'win32') return false;
31
+ const code = (error as { code?: string } | null)?.code;
32
+ return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
33
+ }
34
+
35
+ function remainingRetryBudgetMs(filePath: string): number {
36
+ const now = performance.now();
37
+ let deadline = retryDeadlines.get(filePath);
38
+ // A deadline more than one budget past its expiry belongs to an earlier burst, not this one.
39
+ if (deadline === undefined || now - deadline > READ_RETRY_BUDGET_MS) {
40
+ deadline = now + READ_RETRY_BUDGET_MS;
41
+ retryDeadlines.set(filePath, deadline);
42
+ }
43
+ return deadline - now;
44
+ }
@@ -0,0 +1,59 @@
1
+ // See DESIGN.md, "`ready` means the watcher is armed". Platform is a parameter so a test on any
2
+ // host can pin the darwin value, rather than only observing its own.
3
+ export function armGraceMs(platform: string = process.platform): number {
4
+ return platform === 'darwin' ? 20 : 0;
5
+ }
6
+
7
+ const ARM_GRACE_MS = armGraceMs();
8
+
9
+ /**
10
+ * The gate a config watcher opens when its chokidar watcher is really watching: the initial scan
11
+ * has finished *and* the platform's kernel-side warm-up has had its grace. Shared by
12
+ * `RootConfigWatcher` and `OptionsWatcher` because both read the config synchronously, which is
13
+ * what exposes the unarmed window in the first place.
14
+ */
15
+ export class ArmGate {
16
+ #armed: boolean = false;
17
+ #timer: NodeJS.Timeout | undefined;
18
+ #graceMs: number;
19
+
20
+ // Taken as an argument, not read from the module constant, so the timer branch below is
21
+ // reachable from a host whose own platform has no grace — unit tests are ubuntu-only.
22
+ constructor(graceMs: number = ARM_GRACE_MS) {
23
+ this.#graceMs = graceMs;
24
+ }
25
+
26
+ get armed(): boolean {
27
+ return this.#armed;
28
+ }
29
+
30
+ // Synchronous where the platform needs no grace, so a watcher with no warm-up to wait out
31
+ // keeps reading inside chokidar's own dispatch.
32
+ arm(onArmed: () => void): void {
33
+ if (this.#armed || this.#timer) return;
34
+ if (!this.#graceMs) {
35
+ this.#armed = true;
36
+ onArmed();
37
+ return;
38
+ }
39
+ this.#timer = setTimeout(() => {
40
+ this.#timer = undefined;
41
+ this.#armed = true;
42
+ onArmed();
43
+ }, this.#graceMs);
44
+ }
45
+
46
+ // Drops a grace still counting down, for a watcher that is closing.
47
+ cancel(): void {
48
+ clearTimeout(this.#timer);
49
+ this.#timer = undefined;
50
+ }
51
+
52
+ // A replacement watcher has its own scan and its own unarmed window, so the gate arms again
53
+ // with it: for a file that is *absent* when the replacement scans, chokidar reports `ready` and
54
+ // nothing else, and the arming re-read is the only thing that would notice.
55
+ reset(): void {
56
+ this.cancel();
57
+ this.#armed = false;
58
+ }
59
+ }
@@ -172,6 +172,10 @@
172
172
  ]
173
173
  },
174
174
  "maxHeapMemory": { "type": "number", "description": "Heap memory limit per thread (MB)." },
175
+ "maxIsolated": {
176
+ "type": "integer",
177
+ "description": "Most applications marked isolated that get a dedicated worker thread; the rest fail to load. Default 8."
178
+ },
175
179
  "heapSnapshotNearLimit": { "type": "boolean", "description": "Take a heap snapshot when near the heap limit." },
176
180
  "preload": {
177
181
  "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }],
@@ -0,0 +1,179 @@
1
+ # dataLayer/ — Design notes
2
+
3
+ Backup and restore, version gating, system-table bootstrap and storage migration.
4
+
5
+ **Read this when:** touching `rocksdbBackup.ts`, `restoreMarker.ts`, `blobBackup.ts`, `hdbInfoController.ts`, `bin/copyDb.ts` or `json/systemSchema.json`.
6
+
7
+ Index of every design note: [DESIGN.md](../DESIGN.md).
8
+
9
+ ---
10
+
11
+ ## Version gate at startup: downgrades prompt, and only the minor direction is confirmable
12
+
13
+ `getVersionUpdateInfo()` (`dataLayer/hdbInfoController.ts`) compares the store's `data_version_num` (latest `system.hdb_info` record) against the binary's `packageJson.version` on every start. Data newer than binary by a **major** version → hard refusal. Newer by a **minor** version → `forceDowngradePrompt()` asks for confirmation; answering yes records the data version back down to the binary's version and boots (upgrade directives are deliberately additive/downgrade-compatible — see the struct-mode section above and `patchHdbSecretIsHashAttribute` in `upgrade/directives/5-2-0.ts`).
14
+
15
+ - The prompt's answer can be supplied non-interactively via `CONFIRM_DOWNGRADE` — env var or `--CONFIRM_DOWNGRADE` CLI arg; argv wins (`assignCMDENVVariables`). With no override and no TTY on stdin, the prompt throws instead of blocking on stdin forever (#2046 — services/CI hung with nothing in the log; the mismatch is also logged to hdb.log now).
16
+ - Upgrades never prompt (see the rationale comment in `bin/upgrade.js`); only the downgrade direction confirms. `upgradeCertsPrompt()` on the 4.x upgrade path still has the block-on-stdin hazard.
17
+ - Test-suite gotcha: a suite that supplies the override via `process.argv` affects every later test file in the same mocha process — save and restore `process.argv` in `before`/`after` (see `unitTests/dataLayer/hdbInfoController.test.js`).
18
+
19
+ ## Opening a source LMDB DBI for migration must thread through `compression`
20
+
21
+ When `migrateOnStart` opens a source LMDB primary store to read records out for the RocksDB copy, it constructs an `OpenDBIObject` and calls `sourceRootStore.openDB(key, dbiInit)`. Critically, the per-attribute `compression` setting from the corresponding `__dbis__` entry must be assigned onto `dbiInit` before that call — `dbiInit.compression = attribute.compression`. Without it, lmdb-js doesn't install its decompression layer; every read on the DBI returns raw compressed bytes. msgpackr then misreads bytes in the `0x40–0x7F` range as shared-structure refs, calls `loadStructures` → decodes the (also compressed) structures buffer → finds more bytes in that range → recurses → stack overflow.
22
+
23
+ Harper's normal `databases.ts` path already does this (search for `dbiInit.compression = primaryKeyAttribute.compression`); the migration path in `bin/copyDb.ts` has to match.
24
+
25
+ The persisted `compression` value itself is LMDB-era and loosely shaped: `getDefaultCompression()` historically stored whatever falsy value the config resolved to (`''`, `false`, `null`) when `storage.compression` was disabled, and `{ startingOffset, threshold, dictionary? }` when enabled. lmdb-js interprets falsy as "no compression", but rocksdb-js >= 2.6 validates the option strictly (`''`/booleans throw `Unsupported compression algorithm`) and treats UNSET as "use the build default (lz4)" — the inverse default of lmdb. Every RocksDB open must therefore route through `toRocksCompression()` in `resources/databases.ts` (applied inside `openRocksDatabase`, the single chokepoint), which maps defined-falsy → `'none'` and enabled-without-an-algorithm → an explicit lz4 request when available. Don't pass persisted attribute compression to a RocksDB open directly.
26
+
27
+ `bin/copyDb.ts`'s `openRocksDb` is part of that chokepoint, not an exception to it. This is about the bytes migration writes, not about a later failure: `copyDbToRocks()` closes every target handle before the staging directory is renamed, and rocksdb-js permits an explicit codec change across a close/reopen, so the runtime would open the migrated database fine either way. But a migration that ignores the configured codec writes the entire dataset uncompressed, and those SST/blob files then keep their original codec until write traffic rewrites them — a full LMDB→RocksDB migration is the one moment the whole dataset is written at once, so it is exactly when the deployment's codec should apply.
28
+
29
+ ## System table bootstrap: `systemSchema.json` + upgrade directive
30
+
31
+ Adding a new system table (e.g. `hdb_deployment` in #641 Slice A) requires three changes:
32
+
33
+ 1. **`json/systemSchema.json`** — the table entry. Fresh installs auto-create it via `utility/mount_hdb.ts:createTables()`, which iterates `Object.keys(systemSchema)` on first boot.
34
+ 2. **`utility/hdbTerms.ts`** — add the table name to `SYSTEM_TABLE_NAMES`.
35
+ 3. **`upgrade/directives/<version>.ts`** — provisions the table on existing installs that already have a system schema. Registered in `upgrade/directives/directivesController.ts` (which is otherwise empty — its `versions` Map gets populated by these imports). The directive shape is `{ version, sync_functions, async_functions }`; copy `5-1-0.ts` for the canonical pattern (uses `bridge.createTable` to match what `mount_hdb` does on a fresh install).
36
+
37
+ **Version the directive to the first release that ships the dependent code, not a later one.** Directives only run when `current_version < directive_version <= upgrade_version` (`directivesController.getVersionsForUpgrade`). The `hdb_deployment` directive was originally mis-tagged `5.2.0` while the deployment-recorder code shipped in `5.1.0`, so on every `5.0.x -> 5.1.x` upgrade the directive was filtered out (`5.2.0 > 5.1.x`) and the table never got created — breaking replicated `deploy_component` on peer nodes for the entire existing customer base. Caveat: `utility/common_utils.ts:compareVersions` strips trailing `.0` and therefore sorts a pre-release (`5.1.0-beta.1`) _above_ its GA (`5.1.0`), so an install already on a `5.1.0-beta.x` data version will not pick up a `5.1.0` directive when upgrading to GA; those pre-release installs need the table created by other means.
38
+
39
+ System tables replicate by default. To opt out, add the name to `NON_REPLICATING_SYSTEM_TABLES` in `resources/databases.ts`. The check happens after table init and sets `table.replicate = false` per-node.
40
+
41
+ If the table needs `audit: true`, set it both in the schema (for fresh installs) **and** on the `CreateTableObject` instance in the directive (for upgrades) — otherwise the two paths diverge.
42
+
43
+ ## RocksDB backup/restore: the restore lock + marker protocol (`dataLayer/restoreMarker.ts`, `dataLayer/rocksdbBackup.ts`)
44
+
45
+ The `restore_backup` operation restores a user database on a live server by closing it across all
46
+ worker threads, purging its directory (`backups.restore` with `purgeAllFiles`), and reloading it.
47
+ Three non-obvious mechanics keep that safe:
48
+
49
+ - **Two files in an isolated `` `restore` `` directory beside (never inside) the database directory**,
50
+ each keyed by `sha256(basename(dbPath)).slice(0,32)`: `<key>.lock`, an OS-level exclusive flock
51
+ (rocksdb-js `tryFileLock`, auto-released on process death), serializes restores; `<key>.restoring`,
52
+ a marker written+fsynced (file _and_ the metadata directory) after the lock and before any
53
+ destructive step, means "a restore started and has not finished" (its first line records the
54
+ database directory name so the scan can map a marker back without decoding the key). The metadata
55
+ is hashed into a sibling directory rather than suffixed onto the database name (`<db>.restoring`)
56
+ for two reasons: a legal database literally named `orders.restoring` would otherwise be mistaken
57
+ for the restore marker of `orders`, and a 250-character name (the legal max) plus a `.restore.lock`
58
+ suffix exceeds `NAME_MAX` (255) on most filesystems. The directory name deliberately contains a
59
+ backtick — `schemaRegex` (the database-name validator) forbids only `/` and a backtick among
60
+ filesystem-legal characters — so it can never collide with a legal database name, including a
61
+ database literally named `.restore` (which _is_ a legal name; a plain `.restore/` directory would
62
+ be exactly that database's directory). Because the startup scan opens any `CURRENT`+`MANIFEST-`
63
+ directory without re-applying `schemaRegex`, it also explicitly skips the reserved `` `restore` ``
64
+ entry so an out-of-band directory at that name is never loaded as a database. Startup/rescan
65
+ detection (`databasesBlockedByRestore` → `scanBlockedRestores` in `dataLayer/restoreMarker.ts`)
66
+ reads the metadata directory and checks the **marker first**, only probing the lock when the marker exists —
67
+ probes take the flock and are mutually exclusive across threads, so probing the (persistent) lock
68
+ file of every long-ago-restored database on every rescan would make concurrent rescans misclassify
69
+ healthy databases as in-progress. Marker-present + lock-held = restore in progress (don't load);
70
+ marker-present + lock-free = crashed mid-restore (don't load; rerun the restore to recover).
71
+ - **A recovery restore must not clear a pre-existing marker on a pre-destruction failure.**
72
+ `beginRestore` returns `preexisting: true` when a `.restoring` marker was already present (this run
73
+ is a recovery over a possibly half-purged directory). If such a run fails _before_ any destruction
74
+ (e.g. `verifyDatabaseClosed` finds a leaked handle), it must leave the marker in place — clearing
75
+ it and broadcasting a reload would surface the earlier attempt's partial/corrupt directory as
76
+ healthy. Only a _fresh_ marker on a _previously healthy_ database that failed before destruction is
77
+ safe to clear.
78
+ - **The ITC close broadcast is best-effort, so closure is verified before the purge.** The SCHEMA
79
+ broadcast (`signalSchemaChange`) resolves after remote handlers complete but times out at 30s
80
+ "best-effort", swallows errors, and never reaches job-worker threads at all (their ports are
81
+ excluded from broadcasts to avoid re-entrant deadlocks). A destructive purge cannot trust it:
82
+ `restoreBackup` polls rocksdb-js `registryStatus()` (process-global across worker threads) until
83
+ the database path has no open instance, and aborts with a 409 — _cleaning up the marker, since
84
+ nothing was destroyed_ — if handles remain.
85
+ - **Online restore is impossible for a database a component holds open — and that failure is
86
+ correct.** rocksdb-js's registry is process-global but records only a per-path refCount, with no
87
+ attribution to a thread or component; Harper keeps no component→database ownership map. So when a
88
+ loaded component (or the `system` database, which Harper itself never stops while running) holds
89
+ its own handle on the target database, `registryStatus()` stays non-zero, Harper can neither
90
+ identify nor force-close that handle, and an in-place purge would corrupt a live instance.
91
+ `verifyDatabaseClosed` therefore waits only a short grace period (`DATABASE_CLOSE_WAIT_MS`, for a
92
+ just-finished job worker's own close to drain) and then fails fast with a 409 that points at
93
+ running the operation offline (`harper restore_backup` with the server stopped, where no
94
+ components are loaded and nothing holds the database open). Offline restore is the supported path
95
+ for component-held and `system` databases; online restore serves databases not actively held by a
96
+ component. The CLI exposes each backup operation under its operation name only (`create_backup`,
97
+ `restore_backup`, …) — no hyphenated alias — and `bin/backup.ts` routes it to a reachable server
98
+ or, when the local server is stopped, to the equivalent offline function.
99
+ - **Job workers must release their RocksDB handles on exit, or the closure check can never pass.**
100
+ rocksdb-js's registry is process-global across worker threads, and a thread that exits WITHOUT
101
+ closing leaks its handles (the refCount never drops); the only alternative, `shutdown()`, tears
102
+ down rocksdb for the _entire_ process. A job worker (`server/jobs/jobProcess.ts`) opens the whole
103
+ database graph via `getDatabases()` and exits when the job finishes — and `create_backup` is
104
+ itself a job, so before any `restore_backup` there is always at least one exited job worker that
105
+ touched the database. Without cleanup those leaked handles keep `registryStatus()` non-zero and
106
+ would fail the closure check even when no component holds the database. `jobProcess` therefore
107
+ calls `closeLoadedDatabases()` (`resources/databases.ts`) in its `finally`, closing every loaded
108
+ user database on that thread (the non-enumerable `system` DB is intentionally skipped), so an
109
+ exited job worker leaves no residual handle to be mistaken for a live holder.
110
+ - **`dropDatabase` and `restore_backup` serialize on the same lock, not a check-then-act probe.**
111
+ A drop's `destroy()` interleaving with a restore's purge-and-copy on the same directory would gut
112
+ a "successful" restore (or vice versa). `dropDatabase` therefore _acquires_ the restore lock
113
+ (`acquireRestoreLock`, marker-less) for each RocksDB root store and holds it across the whole drop,
114
+ releasing in a `finally`; a restore in progress makes the acquire fail with 409, and a leftover
115
+ incomplete-restore marker (lock free, detected via `restoreMarkerPresent`, which — unlike
116
+ `checkRestoreState` — is safe while this thread holds the lock) is refused rather than dropped over.
117
+ `database()`'s on-demand open still uses the read-only `throwIfBlockedByRestore` (a
118
+ `create_table`/`create_schema` must not resurrect a half-purged directory as a fresh empty DB), but
119
+ the destructive drop path now uses the exclusive lock so the race is closed, not merely narrowed.
120
+ - **The offline restore probes RocksDB's own `LOCK` file, and fails closed.** The offline path runs
121
+ only when the CLI sees no server (a PID heuristic; the PID file is briefly absent mid-`harper
122
+ restart`), and `backups.restore`'s `purgeAllFiles` never takes RocksDB's lock — so before purging,
123
+ `restoreBackupOffline` opens the database to probe. It now takes the restore lock+marker _before_
124
+ probing (so a server that starts afterward sees the marker and refuses to load), and recognizes the
125
+ pinned rocksdb-js 2.5.0 lock error — a plain `Error` with no `code` and message
126
+ `IO error: While lock file: <db>/LOCK: Resource temporarily unavailable` (`isRocksDbLockError`) —
127
+ aborting with a 409 rather than purging a database another process holds open. Any _other_ open
128
+ failure (corrupt/half-restored) is exactly what restore recovers, so only a lock conflict aborts.
129
+
130
+ Known limitation: the flock is process-owned; if the restore job's worker _thread_ dies without
131
+ the process exiting, the lock stays held (restores 409) until Harper restarts. There is no typed
132
+ native lock signal in rocksdb-js 2.5.0, so the offline probe relies on message matching; a native
133
+ lock primitive is a rocksdb-js follow-on.
134
+
135
+ ## RocksDB managed backups: blob snapshots (`dataLayer/blobBackup.ts`)
136
+
137
+ A database's file-backed blobs live in one or more roots _outside_ the RocksDB directory
138
+ (`getBlobPathsForDatabaseName` in `resources/blob.ts` — one per configured `storage.blobPaths`, else
139
+ `<hdb_root>/blobs/<database>`), so the engine's backup does not capture them. `create_backup`,
140
+ `restore_backup`, `delete_backup`, `purge_backups`, and the streaming `get_backup` therefore handle
141
+ blobs alongside the engine data (the `exclude_blobs` request option — default false — opts out for an
142
+ engine-only backup):
143
+
144
+ - **Managed backups** snapshot the blob roots to `<backupDir>/blobs/<backupId>/<rootIndex>/<relpath>`
145
+ — a full, non-incremental copy per backup, mirroring the binding's `transaction_logs/<id>/` layout.
146
+ Each enumerated entry is classified before capture: complete blobs and existing abort markers are
147
+ hard-linked when possible (copied across filesystems), `.repair` temporaries are omitted, and an
148
+ incomplete blob is replaced by a retryable PENDING (`0xfe`) marker. If a classified blob vanishes
149
+ before capture, a terminal ERROR (`0xff`) marker preserves its file id. A file reclaimed before its
150
+ parent directory is read is outside the snapshot. This keeps a snapshot inode from changing as a
151
+ live write finishes, while complete blobs remain safe to hard-link because published blob paths are
152
+ write-once. The snapshot is built in a `.tmp-<id>` sibling and atomically renamed so a failed create
153
+ leaves no partial snapshot. `restore_backup` purges each blob root and rewrites it from the snapshot;
154
+ `delete_backup` / `purge_backups` remove the corresponding snapshot directories.
155
+ - **`get_backup`** appends the blob files to the same tar under `blobs/<rootIndex>/<relpath>`. The
156
+ binding's streaming backup finalizes its tar with exactly a 1024-byte (two-block) end-of-archive
157
+ marker; `createBackupStream` streams the native _plain_ tar while withholding that trailer
158
+ (verifying it is all-zero), appends the blob entries via `tar-stream` (whose `finalize` writes the
159
+ one real trailer), and gzips the combined stream itself when requested — so the binding is always
160
+ asked for a plain tar and compression happens after the append. No scratch disk. The same blob
161
+ classification rule applies: complete blobs are streamed, incomplete or post-enumeration missing
162
+ blobs become PENDING/ERROR marker entries, and repair temporaries are omitted.
163
+
164
+ **Completion manifest (`dataLayer/backupManifest.ts`).** `create_backup` is two-phase: the engine
165
+ backup (`rootStore.backup()`) resolves — and is immediately visible to `list_backups`/`verify_backup`/
166
+ `restore_backup` — before the blob snapshot is copied. Without a completion record, a blob-snapshot
167
+ failure (or a crash between the phases) would leave an engine backup that lists and verifies as
168
+ healthy while silently missing its blobs, and a concurrent restore could pick a backup id whose
169
+ snapshot is still being written and treat it as intentionally engine-only. So a manifest at
170
+ `<backupDir>/manifests/<backupId>.json` — recording the blob-inclusion policy — is written
171
+ (atomically, temp + rename) only after _both_ phases are durable, and a graceful blob-snapshot
172
+ failure rolls back the just-created engine backup + partial snapshot. Consumers treat a backup id
173
+ with no manifest as incomplete: `list_backups` hides it, `verify_backup`/`restore_backup` reject it
174
+ (409 for a specific id, "no complete backups" for `latest`), and restore uses the manifest's `blobs`
175
+ flag — not the mere presence of a snapshot dir — to decide whether to restore blobs (so an engine-only
176
+ backup leaves live blobs untouched, and a manifest that claims blobs but has no snapshot is flagged
177
+ corrupt by verify). This closes the "healthy-looking but incomplete" and concurrent-restore races;
178
+ the remaining engine/blob point-in-time skew (a blob unlinked between the engine cut and the blob
179
+ walk) is the documented best-effort limitation above.
@@ -25,6 +25,7 @@ import type {
25
25
  import { collapseData } from '../../resources/tracked.ts';
26
26
  import { errorToString } from '../../utility/logging/harper_logger.ts';
27
27
  import { RocksDatabase } from '@harperfast/rocksdb-js';
28
+ import { boundedAuditPruneEnd, raiseAuditFloor } from '../../resources/auditStore.ts';
28
29
  import { BridgeMethods } from './BridgeMethods.ts';
29
30
  import lmdbGetBackup from './lmdbBridge/lmdbMethods/lmdbGetBackup.js';
30
31
  import { createBackupStream, resolveSingleRootStore } from '../rocksdbBackup.ts';
@@ -520,8 +521,26 @@ export class ResourceBridge extends BridgeMethods {
520
521
  deleteObj.timestamp instanceof Date
521
522
  ? deleteObj.timestamp.getTime()
522
523
  : typeof deleteObj.timestamp === 'string'
523
- ? Number.parseInt(deleteObj.timestamp)
524
+ ? // Number, not Number.parseInt: parseInt takes a numeric PREFIX ('9999999999999oops' is a
525
+ // year-2286 bound to it, '1e3' is 1) where Number rejects both as NaN. Empty/whitespace is
526
+ // explicit because Number('') and Number(' ') are 0.
527
+ deleteObj.timestamp.trim() === ''
528
+ ? Number.NaN
529
+ : Number(deleteObj.timestamp)
524
530
  : deleteObj.timestamp;
531
+ // Audit keys are raw float64: NaN and negatives sort above every real timestamp (a range ending
532
+ // there spans the whole log), and Infinity records the unknown sentinel raiseAuditFloor never lifts.
533
+ // Reject anything but a finite non-negative number here, as the 400 it is rather than a server
534
+ // fault. `Number.isFinite` never coerces, so it is the type check too.
535
+ if (!Number.isFinite(before) || before < 0 || Object.is(before, -0))
536
+ throw handleHDBError(
537
+ new Error(),
538
+ `'timestamp' must be a non-negative epoch time or Date, received: ${String(deleteObj.timestamp)}`,
539
+ 400,
540
+ undefined,
541
+ undefined,
542
+ true
543
+ );
525
544
  const databaseName = deleteObj.database || deleteObj.schema || DEFAULT_DATABASE;
526
545
  const table = getTable(deleteObj);
527
546
  // A nonexistent table must not fall through to the no-table branch below — on RocksDB that
@@ -544,7 +563,14 @@ export class ResourceBridge extends BridgeMethods {
544
563
  if (tables) {
545
564
  for (const table of Object.values(tables)) {
546
565
  if (table.primaryStore instanceof RocksDatabase) {
547
- const deleted = table.primaryStore.purgeLogs({ before, includeEntryCounts: true });
566
+ // Clamp before recording: an operator-supplied bound has no ceiling of its own, and a
567
+ // floor above everything reachable never comes down (`raiseAuditFloor` only raises and
568
+ // `establishAuditFloor` skips a store that has a record), so a far-future bound would
569
+ // retire the floor for every table in this database. The purge takes the same clamped
570
+ // bound, so it cannot remove an entry the floor does not cover.
571
+ const pruneEnd = boundedAuditPruneEnd(table.auditStore, before);
572
+ raiseAuditFloor(table.auditStore, pruneEnd);
573
+ const deleted = table.primaryStore.purgeLogs({ before: pruneEnd, includeEntryCounts: true });
548
574
  totalResults.log_files_deleted += deleted.length;
549
575
  totalResults.entries_deleted += deleted.reduce((acc, file) => acc + file.entries, 0);
550
576
  break;