@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,306 @@
1
+ # config/ — Design notes
2
+
3
+ Config composition, persistence, env layers and hot reload.
4
+
5
+ **Read this when:** touching `configUtils.ts`, `readConfigFileSync.ts`, `harperConfigEnvVars.ts` or a root config watcher.
6
+
7
+ Index of every design note: [DESIGN.md](../DESIGN.md).
8
+
9
+ ---
10
+
11
+ ## `set_configuration` replication is opt-in; `replicateOperation` is default-on (`config/configUtils.ts`)
12
+
13
+ `server.replication.replicateOperation` (installed by harper-pro's replicator) fans out whenever
14
+ `req.replicated \!== false` — absence of the flag means "replicate". That default-on contract is what
15
+ DDL ops rely on (`dropSchema`/`dropTable` call it unconditionally), so a handler that mirrors the
16
+ drop_schema pattern without a guard silently becomes replicate-by-default. `setConfiguration` must
17
+ stay **opt-in** (`if (replicated)` truthy guard) because config bodies routinely carry node-local
18
+ params (ports, paths, node identity) that would clobber peers. Two invariants to preserve:
19
+ `replicated` must remain in the handler's destructure strip-list on both origin and peers (peers
20
+ receive `replicated: false` in the forwarded body; anything not stripped is treated as a config
21
+ param), and there is deliberately **no** per-param node-local/cluster-wide guard here — per-field
22
+ replicability metadata is deferred to the cluster-level-config work (CORE-3018), which will own that
23
+ schema. Per-peer failures never reject: they come back as `{status: 'failed', reason, node}` entries
24
+ in `response.replicated[]`, and `message` still reads as success (same contract as drop_schema), so
25
+ operators must inspect the array for per-node outcomes.
26
+
27
+ ## Root config watchers must read synchronously (`config/readConfigFileSync.ts`)
28
+
29
+ `atomicWriteFile()` swaps the config file in with `renameSync` and, on Windows, retries the
30
+ `EPERM`/`EACCES`/`EBUSY` a still-open destination handle produces — blocking the calling thread in
31
+ `Atomics.wait`. The handle that blocks it belongs to the _process_, not to the thread that opened
32
+ it: measured on `windows-latest`/Node 24 (harper#2313), a single Node **read** descriptor on the
33
+ destination fails the rename, while `fs.watch` and chokidar's own handles do not.
34
+ `set_configuration` reaches that loop from a live request thread, and every worker runs root config
35
+ watchers over the same file, so an **async** read in a watcher is unsatisfiable by construction:
36
+ libuv opens the descriptor on the threadpool but closes it from JS, which cannot run while the same
37
+ thread is blocked in the retry loop. The worker then deadlocks against its own
38
+ watcher and burns the entire budget before failing (harper#2191, reproduced by the Windows
39
+ integration job). Both root watchers — `RootConfigWatcher.handleChange` and an `OptionsWatcher`
40
+ explicitly identified as a root-config watcher — therefore go through `readConfigFileSync()`, which
41
+ holds no descriptor across a yield. A component's own config remains asynchronous even if the
42
+ component names it `harper-config.yaml` or `harperdb-config.yaml`. Do not "modernize" root-config
43
+ reads back to `fsPromises.readFile`.
44
+
45
+ Three constraints follow from it. The reader gates its retry to win32 (`isSharingViolation`); the
46
+ writer does not (`configUtils`' `isRetryableRenameError`, same three codes, any platform). That
47
+ asymmetry is deliberate: a misclassified read falls through to the timer ladder below and still
48
+ recovers, a rename has nothing to fall through to, and `process.platform` does not answer the
49
+ question that matters — whether this filesystem can replace an open file. A Linux worker whose
50
+ rootPath sits on WSL drvfs, a CIFS/SMB mount, or a Docker Desktop bind mount reports `linux` and
51
+ still returns these codes transiently.
52
+
53
+ The reader's 500ms budget is one deadline **per path shared by all callers on the thread**, not per
54
+ call — a worker holds one `OptionsWatcher` per root-declared plugin (10+ on a stock install,
55
+ `TRUSTED_RESOURCE_PLUGINS`) over the same file, all reacting to a single change event, so a per-call
56
+ budget would serialize into N x 500ms of blocked event loop whenever a writer's lock outlives it.
57
+
58
+ Both watchers parse through `parseConfigFile()` (`config/parseConfigFile.ts`) rather than calling
59
+ `yaml.parse` directly: yaml's `prettyErrors` frames the offending source lines into the error's
60
+ `message`, and the root config holds credentials, so a parse failure would otherwise ship that
61
+ frame to the component log (`OptionsWatcher` → `Scope`) or the config log.
62
+
63
+ And a lock that outlives even that emits no new watcher event when it clears, so both watchers hand
64
+ the failure to `ConfigReadRetry` (`config/configReadRetry.ts`) rather than going stale: retrying from
65
+ a timer holds no descriptor either, so it cannot re-enter the deadlock. A ladder rung passes
66
+ `waitForLock: false` — the ladder already owns the retry, and letting each rung re-enter the
67
+ blocking budget would multiply one lock incident into a stall per rung. The ladder is bounded by
68
+ wall clock and its backoff is derived from elapsed time rather than from how many times it was
69
+ armed, because watcher callbacks and timer callbacks share one entry point: a rename burst delivers
70
+ several chokidar events in milliseconds and would otherwise both spend the ladder and push the next
71
+ rung out to the maximum before the writer has let go.
72
+
73
+ A deletion supersedes the reads already in flight, so `OptionsWatcher.#handleUnlink` claims the
74
+ current read sequence rather than only cancelling the ladder: an asynchronous rung completing after
75
+ it would otherwise put the removed file's options back, or find ENOENT and report the same deletion
76
+ a second time as a `remove` asking `Scope` to restart a scope that deletion just settled. That
77
+ ordering cannot be staged from a real deletion — every technique that holds a `readFile` open past
78
+ chokidar's `unlink` (threadpool saturation, a FIFO) holds the `unlink` behind it too, because
79
+ chokidar's own event delivery needs the same threadpool; measured here, a saturated pool produced no
80
+ `unlink` for at least 3 seconds. The regression therefore delivers the deletion through
81
+ `_simulateUnlinkForTests`.
82
+
83
+ ### An empty read is a writer mid-write, not an empty config
84
+
85
+ A non-atomic writer — an operator's editor, a shell redirect, anything that is not
86
+ `atomicWriteFile()`'s temp-file-and-rename — truncates the config before it writes it, and the
87
+ synchronous read is fast enough to land in that window where the async read never was. chokidar
88
+ throttles change events per path for 50ms and _drops_ the throttled ones, so the event carrying the
89
+ content is routinely discarded as a duplicate of the truncate's: an empty read that is discarded is
90
+ the last read that config gets, and the thread holds the pre-truncate value indefinitely
91
+ (`RootConfigWatcher`) or reports the scope as removed (`OptionsWatcher`). Both therefore hand an
92
+ empty read to `ConfigReadRetry`, the same ladder a lock takes and for the same reason — there is no
93
+ further event to re-read on. `OptionsWatcher` applies it on both read paths, not only the
94
+ synchronous one: the asynchronous read is far less likely to land in a truncate window, but the
95
+ consequence there is a spurious `remove` that tears the scope down.
96
+
97
+ A read that _parses_ to nothing is the same event and takes the same ladder: a truncated document,
98
+ a lone `\n` and a file of nothing but comments all yield `null` from the parser rather than
99
+ throwing. `OptionsWatcher` judges that on the file's own parse, **before** `overlayRootEnvConfig`,
100
+ which returns a non-null object whenever a config env var is set — the norm in containers — and
101
+ would otherwise launder a half-written file into a valid-looking env-only config and wipe the
102
+ file's own options.
103
+
104
+ Past the ladder the emptiness is believed, and what that costs depends on whether the scope has
105
+ settled: a worker still booting starts on the defaults, while one already running keeps the config
106
+ it has and only warns. The asymmetry is deliberate in both halves — a running worker must not let a
107
+ truncate window that outlived the ladder reset every scope, and a booting one must not hold
108
+ `Scope.ready` open waiting for a file that is genuinely empty — but it does mean an operator who
109
+ empties `harper-config.yaml` at runtime gets divergence between workers until the next restart.
110
+
111
+ ### `ready` means the watcher is armed
112
+
113
+ `RootConfigWatcher.ready` is a startup barrier — `harper_logger`'s `updateLogSettings()` attaches
114
+ its `change` listener only after awaiting it — so it has to mean "watching", not merely "the first
115
+ read landed". The synchronous read would otherwise emit `ready` from inside chokidar's initial `add`
116
+ dispatch, and on darwin FSEvents has not armed its stream at that point: a write in that window is
117
+ dropped with no later event to recover it (the async read used to defer past it by a threadpool
118
+ round-trip, which is why this surfaced only when the read went synchronous). Measured on the
119
+ harper#2191 review head, writing that far after `ready`: 0ms is lost, 5ms and beyond is delivered.
120
+
121
+ So `ready` is gated on chokidar's own `ready` — its initial scan has established the native
122
+ watches by then — plus a darwin-only grace over that measurement for the kernel-side warm-up
123
+ chokidar cannot observe. Neither half is sufficient alone: chokidar's event still lands inside the
124
+ warm-up, and a bare timer could elapse before the scan has created any watch. Config read before
125
+ that gate opens is staged into `#config`, re-read once the gate opens — a write that landed while
126
+ the watch was unarmed produced no event, so nothing else would ever deliver it — and then handed to
127
+ `ready` itself rather than to a `change` that would precede it.
128
+
129
+ `OptionsWatcher` shares the gate (`ArmGate`, `config/watcherArming.ts`) because it has the same
130
+ unarmed window and, for the root config, many more of them: `componentLoader` gives every
131
+ `TRUSTED_RESOURCE_PLUGINS` entry its own root-config `OptionsWatcher`, and those read synchronously.
132
+ It shares the arming **re-read**, which is what recovers the otherwise-undeliverable write, but not
133
+ the barrier: its `ready` still goes out on the first read, so it means "the config has been read",
134
+ not "armed". The difference is only ordering, because unlike `harper_logger` its consumer (`Scope`)
135
+ attaches `change`/`remove`/`ready` listeners in its constructor, before any read — so a write made
136
+ in the unarmed window reaches the scope as a post-`ready` `change` (and, for a plugin that doesn't
137
+ handle its own options, a restart) rather than being lost. Holding `OptionsWatcher.ready` behind
138
+ arming as well would need every terminal outcome to open a second barrier, per scope, with a boot
139
+ hang as the failure mode; the ordering is not worth that.
140
+
141
+ Whether a scope is configured is tracked separately from its value, because neither truthiness nor
142
+ `!== undefined` can answer it: `myPlugin:` with no body is a configured scope whose value is `null`,
143
+ and a boot that found no config of its own holds `DEFAULT_CONFIG[name]` — a value the watcher gave
144
+ itself. Reading either as "the file supplied this" costs a restart: for the six scopes
145
+ `DEFAULT_CONFIG` names, the next read of an unchanged file looks like the block being deleted, and
146
+ filling in an empty block looks like the unconfigured → configured transition `Scope` answers by
147
+ restarting rather than the `change` it is.
148
+
149
+ What the arming re-read must _not_ do is report a deletion. Its job is the write no event carried;
150
+ a file that is gone is chokidar's `unlink` to report, and answering the re-read's `ENOENT` with
151
+ `remove` announces it ahead of the event that would confirm it — where there is a grace, ahead of
152
+ chokidar having finished tearing the watch down, so a config recreated on the strength of that
153
+ early `remove` lands in a window where its `add` is not observed at all and the scope keeps the
154
+ defaults with nothing further coming. Settling a barrier that has nothing applied yet is still the
155
+ arming re-read's job: an absent file at boot is the install window, not a deletion.
156
+
157
+ ### Every terminal read outcome settles the barrier
158
+
159
+ Both barriers — `RootConfigWatcher.ready` and, through `Scope`, `OptionsWatcher.ready` — are
160
+ awaited with no timeout, so a read that ends without a config must still settle them or the worker
161
+ hangs at boot rather than failing. Every terminal outcome therefore boots on defaults and logs what
162
+ failed: a read the ladder could not complete, a file still empty when the ladder is spent, and a
163
+ file that will not parse. Only a config that parses is a config; the alternative, failing the boot
164
+ closed on an unreadable file, is a different policy than the one `OptionsWatcher` already applies to
165
+ its ENOENT and read-failure paths, and the two watchers must not disagree about it. A file that
166
+ becomes readable later still arrives, as a `change`.
167
+
168
+ A missing file is not one of those outcomes to wait on: `ENOENT` is not a sharing violation, so
169
+ neither watcher takes the retry ladder for it. `OptionsWatcher` has always settled it at once as
170
+ the install window, and `RootConfigWatcher` does the same rather than spending the whole read
171
+ budget inside `harper_logger.start()` on every boot that has no config file — an env-var-only
172
+ deployment, or a rootPath mounted empty. Neither is a deletion an outcome to wait on. `OptionsWatcher.#handleUnlink` cancels the ladder —
173
+ the deletion settles what a pending read was retrying — so when that read had not produced a config
174
+ yet, the ladder it cancels was the only thing left to settle `ready`. Before the first `ready` there
175
+ is also nothing to remove and nothing to hear it: `Scope` is still inside `await scope.ready`, so a
176
+ `remove` there asks for a restart of a component that never booted. A deletion in the boot window
177
+ therefore settles the barrier on the defaults, exactly as the ENOENT read path does; only a deletion
178
+ after `ready` reports `remove`. A watcher error is terminal for the barrier too, and
179
+ settling it is what removes the `error` listener `once(this, 'ready')` attached — so reporting the
180
+ failure afterwards has to check for a listener rather than assume one, or an unlistened `error`
181
+ throws out of chokidar's dispatch and takes the worker down over a fault it just decided to survive.
182
+
183
+ An env-compose failure rides that settle rather than preceding it: `#envComposeError` is reported
184
+ only after the barrier has settled, because an `error` emitted first rejects `once(this, 'ready')`
185
+ instead of settling it. It is set and reported inside one synchronous call chain, the arming path
186
+ included: that path defers an absence check rather than reporting a removal, and it drops the
187
+ failure before deferring rather than reporting it there. Reporting would duplicate — every
188
+ resolution of the deferral recomposes and reports the env state it finds — and holding it would
189
+ carry a failure that may no longer be true onto whatever event reports next. So the early returns
190
+ taken when the env-only overlay _succeeded_ cannot be carrying one, and hoisting the report ahead of
191
+ them for symmetry would put it back before the settle on the paths this ordering exists for.
192
+
193
+ What a scope does about a config that arrives late is the other half of settling early.
194
+ `OptionsWatcher.ready` is not once-per-watcher: it fires whenever a scope goes from having no
195
+ config of its own to having one, which is both the recreated-config-file path and a scope that
196
+ booted while the file was unreadable. Nothing downstream re-runs on it — `componentLoader` is long
197
+ past its `await scope.ready` — so `Scope` answers a repeat `ready` the same way it answers `remove`,
198
+ by requesting a restart. Without that, one worker keeps serving the defaults while every worker
199
+ that read the file cleanly serves the operator's config.
200
+
201
+ Arming is a terminal outcome of its own: chokidar reports a scan that found no file by emitting
202
+ `ready` and nothing else, so `RootConfigWatcher` always re-reads when the gate opens rather than
203
+ publishing what an earlier read staged — a missing config file takes the ladder and settles on the
204
+ defaults instead of holding the barrier open. That fallback must also discard the staged value:
205
+ the arming re-read is authoritative precisely because a write in the unarmed window may have
206
+ superseded it, including by replacing the file with an unusable or missing one. A watcher scan error
207
+ also settles the barrier, but preserves a successfully staged value because no read superseded it.
208
+ `close()` settles the barrier as well.
209
+
210
+ What settles the barrier is not the same as what the settled value may be _used_ as. A read that
211
+ carried no config settles it carrying nothing — not `{}`, which is a configuration that a consumer
212
+ cannot tell apart from one the file really held, and `updateLogger` reads an absent `rotation` as
213
+ rotation off and an absent `console` as console off. `updateLogSettings()` therefore keeps what
214
+ `initLogSettings()` established until a real config arrives, rather than silently turning logging
215
+ off on the very boot that could not read its configuration.
216
+
217
+ ## Config is composed and memoized before any component runs (`config/configUtils.ts`)
218
+
219
+ `getConfigObj()` composes the config once per thread (module-level memo) at its first call, which
220
+ happens before the root component loads and long before any user component's plugins run. Anything a
221
+ component does at load time — like `loadEnv` writing `process.env` — therefore cannot affect the
222
+ composed config (#1513). By design this stays true: configuration is strictly top-down, so the three
223
+ config-shaping env vars (`HARPER_DEFAULT_CONFIG`/`HARPER_CONFIG`/`HARPER_SET_CONFIG`) are **never
224
+ honored** from a component `.env`. What #1513 fixed is the silence: `config/componentEnvPrepass.ts`
225
+ scans `componentsRoot` + `RUN_HDB_APP` for `loadEnv` declarations during `initConfig` and emits an
226
+ actionable warning per config-shaping var found, and `resources/loadEnv.ts` warns again at
227
+ component-load time (covering post-boot deploys) and **skips the `process.env` assignment** for the
228
+ trio — enforce-at-injection, so anything downstream that (re)composes from `process.env`
229
+ (#1618/#1726) can rely on the trio arriving only via sanctioned channels. The pre-pass deliberately
230
+ mirrors loader behaviors that must stay in sync if the loader changes: config filename precedence
231
+ (`harper-config.yaml` → `harperdb-config.yaml` → `config.yaml`) and `files` pattern validation
232
+ (`..` and absolute patterns rejected). Known limitation: a `componentsRoot` override that itself
233
+ arrives via env var cannot redirect the scan.
234
+
235
+ ## Boot-path config persistence is best-effort, and its two artifacts commit as a unit (`config/configUtils.ts`, `config/harperConfigEnvVars.ts`)
236
+
237
+ Every boot with a `HARPER_*_CONFIG` env var set re-derives the merged config and, historically, wrote
238
+ it back unconditionally. On a full or quota-exhausted volume that write is refused and, being fatal,
239
+ turned a full disk into a container restart loop nothing inside the container could break — the
240
+ cleanup that frees space needs a started process (#847). Two rules follow.
241
+
242
+ **Derived boot writes are best-effort; user-requested ones are not.** `persistConfigDuringBoot()`
243
+ swallows exactly ENOSPC/EDQUOT (matching on `errno` as well as `code`, because Linux has no libuv
244
+ mapping for EDQUOT and reports `Unknown system error -122`) and lets the boot proceed on the
245
+ in-memory config. `updateConfig`/`set_configuration`, `addConfig`, `deleteConfigFromFile` and the
246
+ install path keep persist-or-throw: a caller who asked to persist must not get a silent success, and
247
+ an install has no last-known-good config to fall back on.
248
+
249
+ **The env-config state and the config file must never disagree.** The state file records the
250
+ _pre-env_ values, so it is the only copy of what the operator's config said before an env layer
251
+ overwrote it — the config file itself holds the env-derived value. Both single-file orderings lose
252
+ something: writing the state last means the file it would read originals from is already
253
+ overwritten; writing it first leaves a state ahead of the file, which the next boot's
254
+ `detectConfigDrift` reads as a manual user edit and _permanently_ reassigns those paths to `user`,
255
+ silently disabling the env layer even after space is freed. So the commit is three steps —
256
+ `saveState()` stages the new state in `.harper-config-state.pending.<pid>.json`, the config file is
257
+ written, and `confirmConfigWritten()` **renames** the sidecar over the confirmed record. A rename
258
+ needs no free space, which is the point: no write an exhausted volume can refuse ever stands between
259
+ the confirmed originals and disk. A refused staging write leaves the config file alone; a refused
260
+ config write unlinks the sidecar; a sidecar found at load means a commit was interrupted, so it is
261
+ cleared and drift detection is skipped for that boot rather than mistaking the in-flight write for
262
+ an edit. A boot that re-derives the same state writes nothing at all.
263
+
264
+ Two details the name and the caller carry. The sidecar is **per-process**: every CLI invocation runs
265
+ `initConfig`, and one shared name would let a starting server clear a running process's in-flight
266
+ commit — the loser would then rewrite the config file with the confirmed state still describing the
267
+ old values, which is the failure the protocol exists to prevent. Recovery therefore only clears a
268
+ sidecar whose owning pid is gone. And only the **main thread** persists or runs recovery: workers
269
+ derive the same merged config and would otherwise race over one pair of files for a result they
270
+ already agree on — and since a worker shares its process's pid, a recovery scan from one would
271
+ delete the main thread's in-flight sidecar as if it were the last boot's wreckage.
272
+
273
+ A sidecar owned by a _live_ foreign process is not cleared — that process is mid-commit — but its
274
+ presence still turns drift detection off for this boot: a pair someone else is halfway through is no
275
+ more comparable than one an interruption left behind. That suspension is why a sidecar also ages
276
+ out regardless of what its pid says: without it, a sidecar whose owner was killed and whose pid was
277
+ later recycled would look mid-commit forever and suspend drift detection on every boot. The age-out
278
+ is deliberately far longer than a commit could take — recovery from a recycled pid only has to be
279
+ eventual, while deleting a slow-but-live writer's sidecar is the worse error, stranding its config
280
+ file against an unpromoted state.
281
+
282
+ Drift detection is main-thread-only for the same reason recovery is. A worker never owns the state:
283
+ in the normal sequence the main thread has already classified and persisted before any worker runs,
284
+ and inside the main thread's commit window a file that differs from the snapshot is as likely to be
285
+ the write in flight as an operator edit. A worker that concluded "user edit" would drop the
286
+ env-supplied value for itself alone and serve different config than its siblings.
287
+
288
+ Known limit: the pair commits as a unit _within a process_. Two live processes (a server boot and a
289
+ CLI invocation) can still interleave their config-file writes and promotions, and nothing in the repo
290
+ serializes config writes across processes. Pre-existing — both artifacts were unordered before this
291
+ protocol — and out of scope here, but the "commits as a unit" guarantee stops at the process
292
+ boundary.
293
+
294
+ Related: a log write must not be fatal either. `fs.appendFileSync` in `logQueuedData` throws from
295
+ both inline and timer call sites, so on a full volume every log statement was a crash point. The
296
+ fallback goes through `nativeStdWrite`, never `console` — `installStdioGuard` routes console output
297
+ back into this same file logger when `logging.file` and `logging.console` are both on, so a console
298
+ fallback recurses until the stack blows.
299
+
300
+ ## Env-config empty objects mean three different things (`config/harperConfigEnvVars.ts`)
301
+
302
+ An `{}` in the config system is context-dependent, and conflating the contexts is the root of #2067. In an **env layer** (`HARPER_SET_CONFIG` et al.), an empty object contributes no leaves — `http: {}` means "no overrides under http" (load-bearing removal semantics in `flattenObject`). In the **base config file**, a bare `componentName: {}` is user content — a real empty scope declaration that composition must preserve (`restoreBaseEmptyObjects`, #1618/#1726). An `{}` that is _neither_ — the residue of removing an env-sourced entry leaf-by-leaf — is invalid config that validation may reject forever, because the file is written before validation runs and the residue then reads as user content on every later boot.
303
+
304
+ Removal therefore prunes: `deleteNestedValue` removes ancestors the deletion emptied, only when it actually deleted an existing leaf, and reports what it pruned. The overlap case — a file-declared empty scope an env layer temporarily populated — is tracked in the state file's `emptyScopeOriginals` (separate from `originalValues` so a marker can never mask or be consumed as a real leaf original at the same path; older state files lacking the field are defaulted). Restore consumes a marker only for a path the prune actually removed, so a scalar overwrite or an absent-leaf no-op can never resurrect a scope over live env-layer content. Note there are two coexisting mechanisms for "file `{}` is user content": `restoreBaseEmptyObjects` on the stateless compose path and the marker pair on the stateful removal path — if you touch one, check the other.
305
+
306
+ Two durable limitations of the marker mechanism, both with user config-file content as the blast radius: markers can only be recorded at populate time, so a scope an env layer populated _before_ `emptyScopeOriginals` existed (any pre-upgrade boot) has no marker and prunes away on its first post-upgrade vacate; and a corrupt config-state file resets to fresh state — dropping `originalValues` and `emptyScopeOriginals` for every tracked path — after which the next removal prunes those scopes for good; `saveConfigState` writes via temp+rename precisely so a torn write cannot be the trigger, leaving genuine corruption (disk faults, hand edits) as the remaining path.
@@ -1,19 +1,30 @@
1
1
  import { FSWatcher } from 'chokidar';
2
- import { readFileSync } from 'node:fs';
3
2
  import { getConfigFilePath } from './configUtils.ts';
3
+ import { readConfigFileSync } from './readConfigFileSync.ts';
4
4
  import { EventEmitter, once } from 'node:events';
5
- import { parse } from 'yaml';
5
+ import { parseConfigFile } from './parseConfigFile.ts';
6
6
  import {
7
7
  POLLING_FALLBACK_OPTIONS,
8
- PartialReadRetry,
9
8
  claimLostNativeWatchError,
10
9
  guardedWatch,
11
- isPartialReadError,
12
10
  isWatcherExhaustionError,
13
11
  warnWatcherFallback,
14
- warnWatcherListenerError,
15
12
  } from '../utility/watcherFallback.ts';
16
13
  import { resolveWatchTarget } from '../utility/watchPath.ts';
14
+ import { errorForLog, loggerWithTag } from '../utility/logging/harper_logger.ts';
15
+ import { ConfigReadRetry } from './configReadRetry.ts';
16
+ import { ArmGate } from './watcherArming.ts';
17
+
18
+ function isMissingFile(error: unknown): boolean {
19
+ return !!error && typeof error === 'object' && (error as NodeJS.ErrnoException).code === 'ENOENT';
20
+ }
21
+
22
+ // `harper_logger` imports this module at its own bottom to break their cycle, so a tagged logger
23
+ // built at module scope would run `loggerWithTag()` before `mainLogger` is initialized.
24
+ let taggedLogger: ReturnType<typeof loggerWithTag> | undefined;
25
+ function logger() {
26
+ return (taggedLogger ??= loggerWithTag('config-watcher'));
27
+ }
17
28
 
18
29
  let sharedWatcher: RootConfigWatcher | undefined;
19
30
 
@@ -34,7 +45,15 @@ export class RootConfigWatcher extends EventEmitter {
34
45
  #usingPolling: boolean;
35
46
  #closed: boolean;
36
47
  #openCount: number = 0;
37
- #partialRead: PartialReadRetry;
48
+ #readCount: number = 0;
49
+ #readRetry: ConfigReadRetry = new ConfigReadRetry();
50
+ #armGate: ArmGate = new ArmGate();
51
+ // The gate above is chokidar's scan finishing; this is the barrier's gate. A terminal outcome
52
+ // opens it without claiming the watch is armed, so the arming re-read still runs afterwards.
53
+ #barrierOpen: boolean = false;
54
+ #configLoaded: boolean = false;
55
+ #readyStaged: boolean = false;
56
+ #readyEmitted: boolean = false;
38
57
  ready: Promise<any[]>;
39
58
 
40
59
  constructor(configFilePath: string = getConfigFilePath()) {
@@ -42,7 +61,6 @@ export class RootConfigWatcher extends EventEmitter {
42
61
  this.#configFilePath = configFilePath;
43
62
  const watchTarget = resolveWatchTarget(this.#configFilePath);
44
63
  this.#watchPath = watchTarget.path;
45
- this.#partialRead = new PartialReadRetry(this.#configFilePath);
46
64
  this.#usingPolling = watchTarget.mustPoll;
47
65
  this.#closed = false;
48
66
  this.ready = once(this, 'ready');
@@ -50,14 +68,57 @@ export class RootConfigWatcher extends EventEmitter {
50
68
  }
51
69
 
52
70
  #openWatcher() {
53
- this.#openCount++;
71
+ const generation = ++this.#openCount;
54
72
  this.#watcher = guardedWatch(this.#watchPath, {
55
73
  persistent: false,
56
74
  ...(this.#usingPolling ? POLLING_FALLBACK_OPTIONS : {}),
57
75
  })
58
76
  .on('add', this.handleChange.bind(this))
59
77
  .on('change', this.handleChange.bind(this))
60
- .on('error', this.handleError.bind(this));
78
+ .on('error', this.handleError.bind(this))
79
+ // Generation-bound: `#armGate.reset()` runs before the failed watcher is closed, so a
80
+ // `ready` still queued on it would arm the gate on the replacement's behalf and the
81
+ // replacement's own `ready` would then be a no-op — leaving its scan window unre-read.
82
+ .on('ready', () => this.#handleArmed(generation));
83
+ }
84
+
85
+ #handleArmed(generation: number) {
86
+ if (this.#closed || generation !== this.#openCount) return;
87
+ this.#armGate.arm(() => this.#markArmed());
88
+ }
89
+
90
+ #markArmed() {
91
+ this.#barrierOpen = true;
92
+ // A write that landed while the watch was unarmed was never reported, and a scan that found
93
+ // no file at all reported nothing either, so arming always re-reads rather than publishing
94
+ // what an earlier read staged — or, with no file, staying pending forever.
95
+ this.#read(true);
96
+ // A read that armed the ladder settles `ready` itself, with the newer config.
97
+ if (!this.#readRetry.pending) this.#emitReady();
98
+ }
99
+
100
+ #emitReady() {
101
+ if (this.#readyEmitted || !this.#barrierOpen || !this.#readyStaged || this.#closed) return;
102
+ this.#readyEmitted = true;
103
+ try {
104
+ this.emit('ready', this.#config);
105
+ } catch (error) {
106
+ logger().warn('A Harper configuration listener failed', errorForLog(error));
107
+ }
108
+ }
109
+
110
+ // `harper_logger.start()` awaits `ready` with no timeout, so every terminal outcome has to
111
+ // settle the barrier. A failed read discards an earlier staged value, but a watcher error keeps
112
+ // it because no later read superseded it. No config is represented by `undefined`, not `{}`:
113
+ // an empty object is a configuration that turns logging off.
114
+ #stageBootFallback(discardStaged = true) {
115
+ if (this.#readyEmitted) return;
116
+ if (discardStaged) {
117
+ this.#config = undefined;
118
+ this.#configLoaded = false;
119
+ }
120
+ this.#readyStaged = true;
121
+ this.#emitReady();
61
122
  }
62
123
 
63
124
  // Test-only: simulate the underlying chokidar watcher emitting an error.
@@ -67,17 +128,26 @@ export class RootConfigWatcher extends EventEmitter {
67
128
  this.handleError(error);
68
129
  }
69
130
 
70
- // Test-only: whether the watcher has fallen back to polling.
71
131
  get _usingPollingForTests(): boolean {
72
132
  return this.#usingPolling;
73
133
  }
74
134
 
75
- // Test-only: number of times the underlying watcher has been (re)opened.
76
135
  get _openCountForTests(): number {
77
136
  return this.#openCount;
78
137
  }
79
138
 
139
+ // Distinguishes a ladder rung from a watcher event.
140
+ get _readCountForTests(): number {
141
+ return this.#readCount;
142
+ }
143
+
144
+ get _armedForTests(): boolean {
145
+ return this.#armGate.armed;
146
+ }
147
+
80
148
  handleError(error: unknown) {
149
+ // A queued chokidar error can land after close(), which has dropped every listener.
150
+ if (this.#closed) return;
81
151
  // See EntryHandler.#handleWatcherError: a lost native watch handle is benign
82
152
  // and must not be surfaced to consumers as a config-watch failure.
83
153
  if (claimLostNativeWatchError(error)) return;
@@ -88,6 +158,9 @@ export class RootConfigWatcher extends EventEmitter {
88
158
  if (!this.#usingPolling) {
89
159
  warnWatcherFallback(this.#configFilePath);
90
160
  this.#usingPolling = true;
161
+ // The generation that just failed no longer speaks for the watch; the replacement
162
+ // arms on its own scan, and re-reads then as the first one did.
163
+ this.#armGate.reset();
91
164
  // Start close() from a microtask, not directly here, so a synchronous throw
92
165
  // can't escape this 'error' listener as an uncaught exception.
93
166
  Promise.resolve()
@@ -98,55 +171,136 @@ export class RootConfigWatcher extends EventEmitter {
98
171
  .then(() => {
99
172
  if (!this.#closed) this.#openWatcher();
100
173
  })
101
- .catch((error) => console.error(`Could not reopen the ${this.#configFilePath} watch on polling:`, error));
174
+ .catch((error) =>
175
+ logger().warn(`Could not reopen the ${this.#configFilePath} watch on polling`, errorForLog(error))
176
+ );
177
+ } else {
178
+ // Already polling — the replacement failed too, or the watch was polling from
179
+ // construction (`mustPoll`) and never had a fallback to take. Either way the branch
180
+ // above reopens only once, so this is the watch's terminal outcome and the barrier
181
+ // has to settle or `harper_logger.start()` awaits it forever.
182
+ this.#barrierOpen = true;
183
+ this.#stageBootFallback(false);
102
184
  }
103
185
  return;
104
186
  }
105
- this.emit('error', error);
187
+ // chokidar may never reach its own `ready` after a scan error, and nothing else would
188
+ // settle the barrier: the error is this read's terminal outcome. The scan is not over
189
+ // though, so the arm gate stays closed and a later `ready` still takes the arming re-read.
190
+ this.#barrierOpen = true;
191
+ this.#stageBootFallback(false);
192
+ // Settling the barrier removed the `error` listener `once(this, 'ready')` attached, and an
193
+ // emit with none left throws the error back into chokidar's dispatch — as does a consumer
194
+ // that throws from its own handler.
195
+ if (this.listenerCount('error') === 0) {
196
+ logger().warn(`The Harper configuration watcher at ${this.#configFilePath} failed`, errorForLog(error));
197
+ return;
198
+ }
199
+ try {
200
+ this.emit('error', error);
201
+ } catch (listenerError) {
202
+ logger().warn('A Harper configuration error listener failed', errorForLog(listenerError));
203
+ }
106
204
  }
107
205
 
108
- // See the descriptor-lifetime invariant on atomicWriteFile (DESIGN.md).
109
206
  handleChange() {
207
+ this.#read(true);
208
+ }
209
+
210
+ // `harper_logger.start()` awaits `ready` with no timeout and the ladder may be the only thing
211
+ // left to settle it, so until then its timer keeps the thread alive rather than letting it
212
+ // drain and exit mid-boot.
213
+ #schedule(): boolean {
214
+ return this.#readRetry.schedule(() => this.#read(false), !this.#readyEmitted);
215
+ }
216
+
217
+ #read(waitForLock: boolean) {
218
+ // A queued chokidar callback can still land after close(), which has already discarded the
219
+ // config and dropped every listener.
220
+ if (this.#closed) return;
221
+ this.#readCount++;
222
+ let data: string;
223
+ try {
224
+ data = readConfigFileSync(this.#configFilePath, waitForLock);
225
+ } catch (error) {
226
+ // A missing file is not a lock — `readConfigFileSync` does not retry it either, and
227
+ // `OptionsWatcher` settles it immediately as the install window. Taking the ladder here
228
+ // would disagree with that and cost `harper_logger.start()` the whole budget on every
229
+ // boot that has no config file (an env-var-only deployment, an empty mounted rootPath).
230
+ if (!isMissingFile(error) && this.#schedule()) return;
231
+ // A ladder armed by an earlier empty read is spent by the time a rung lands on ENOENT,
232
+ // and every other terminal path clears its deadline.
233
+ this.#readRetry.reset();
234
+ logger().warn(
235
+ `Unable to read the Harper configuration file at ${this.#configFilePath}` +
236
+ (this.#configLoaded ? ', continuing with the previously loaded configuration' : '; none has been loaded yet'),
237
+ errorForLog(error)
238
+ );
239
+ this.#stageBootFallback();
240
+ return;
241
+ }
242
+ // See DESIGN.md, "An empty read is a writer mid-write, not an empty config".
243
+ if (!data) {
244
+ if (this.#schedule()) return;
245
+ logger().warn(`The Harper configuration file at ${this.#configFilePath} is empty`);
246
+ this.#stageBootFallback();
247
+ return;
248
+ }
110
249
  let config;
111
- // Only the read and parse are guarded: a listener that throws must not be mistaken for a
112
- // half-written file and replayed.
113
250
  try {
114
- config = parse(readFileSync(this.#configFilePath, 'utf-8'));
251
+ config = parseConfigFile(data, this.#configFilePath);
115
252
  } catch (error) {
116
- // A missing file needs no re-read; anything else may be the file being replaced.
117
- if (isPartialReadError(error)) this.#scheduleReread(error);
253
+ // A read taken mid-write is untrustworthy, not only an empty one: the writer's first
254
+ // `write(2)` can land a prefix of the document, and the event carrying the rest is the one
255
+ // chokidar throttles away. So an unparseable read rides out the same ladder as an empty
256
+ // one, and only a read that parses releases it.
257
+ if (this.#schedule()) return;
258
+ logger().warn((error as Error).message);
259
+ this.#stageBootFallback();
118
260
  return;
119
261
  }
120
- // A snapshot that does not parse to an object is the other shape a half-written file
121
- // takes: `''`, `'\n'` and a truncated document all yield null, and adopting that would
122
- // hand every consumer a config with nothing in it.
262
+ // The third shape a mid-write read takes, and the only one that parses: a truncated
263
+ // document, a lone `\n`, a file that is nothing but comments all yield `null` rather than
264
+ // throwing, and adopting one hands every consumer a config with nothing in it. Same ladder
265
+ // as the two above, and past it the file is empty rather than mid-write.
123
266
  if (!config || typeof config !== 'object') {
124
- this.#scheduleReread();
267
+ if (this.#schedule()) return;
268
+ logger().warn(`The Harper configuration file at ${this.#configFilePath} is empty`);
269
+ this.#stageBootFallback();
270
+ return;
271
+ }
272
+ this.#readRetry.reset();
273
+
274
+ // Before `ready` goes out there is no prior state to have changed *since*.
275
+ this.#configLoaded = true;
276
+ this.#readyStaged = true;
277
+ if (!this.#readyEmitted) {
278
+ this.#config = config;
279
+ this.#emitReady();
125
280
  return;
126
281
  }
127
- this.#partialRead.settled();
128
282
 
129
283
  try {
130
- if (!this.#config) {
131
- this.#config = config;
132
- this.emit('ready', this.#config);
133
- return;
134
- }
135
284
  this.emit('change', (this.#config = config));
136
285
  } catch (error) {
137
- warnWatcherListenerError(this.#configFilePath, error);
286
+ logger().warn('A Harper configuration change listener failed', errorForLog(error));
138
287
  }
139
288
  }
140
289
 
141
- #scheduleReread(error?: unknown) {
142
- if (this.#partialRead.schedule(() => this.handleChange())) return;
143
- this.#partialRead.gaveUp(error);
144
- }
145
-
146
290
  close() {
291
+ // Closing is a terminal outcome too: leaving `ready` pending would hang anything still
292
+ // awaiting the barrier. Through `#emitReady`, so a listener that throws cannot skip the
293
+ // teardown below it and leave the watcher and its arm timer running.
294
+ this.#barrierOpen = true;
295
+ this.#readyStaged = true;
296
+ this.#emitReady();
147
297
  this.#closed = true;
148
- this.#partialRead.cancel();
149
- this.#watcher.close();
298
+ this.#readRetry.cancel();
299
+ this.#armGate.cancel();
300
+ // chokidar's close() is a promise; an unhandled teardown rejection would reach Node as one,
301
+ // on the path whose whole job is to stop caring about this watcher. Same shape as the
302
+ // exhaustion-recovery close above, and as `OptionsWatcher.close`.
303
+ Promise.resolve(this.#watcher.close()).catch(() => {});
150
304
  this.#config = undefined;
151
305
  this.emit('close');
152
306
  this.removeAllListeners();