@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.
- package/bin/cliOperations.ts +12 -0
- package/bin/restart.ts +66 -6
- package/components/Application.ts +1134 -112
- package/components/DESIGN.md +421 -0
- package/components/OptionsWatcher.ts +368 -102
- package/components/Scope.ts +20 -1
- package/components/componentLoader.ts +43 -4
- package/components/deploymentOperations.ts +4 -1
- package/components/deploymentRecorder.ts +9 -2
- package/components/mcp/DESIGN.md +109 -0
- package/components/mcp/audit.ts +21 -17
- package/components/operations.js +284 -52
- package/components/operationsValidation.js +49 -2
- package/components/packageComponent.ts +25 -1
- package/components/requestRestart.ts +11 -0
- package/config/DESIGN.md +306 -0
- package/config/RootConfigWatcher.ts +191 -37
- package/config/configReadRetry.ts +62 -0
- package/config/configUtils.ts +78 -26
- package/config/parseConfigFile.ts +34 -0
- package/config/readConfigFileSync.ts +44 -0
- package/config/watcherArming.ts +59 -0
- package/config-root.schema.json +4 -0
- package/dataLayer/DESIGN.md +179 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
- package/dataLayer/restoreMarker.ts +92 -25
- package/dist/bin/cliOperations.js +13 -0
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/restart.js +42 -6
- package/dist/bin/restart.js.map +1 -1
- package/dist/components/Application.d.ts +104 -9
- package/dist/components/Application.js +954 -102
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +4 -1
- package/dist/components/OptionsWatcher.js +378 -104
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.js +15 -1
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +35 -3
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deploymentOperations.js +4 -1
- package/dist/components/deploymentOperations.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +4 -2
- package/dist/components/deploymentRecorder.js +1 -0
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/mcp/audit.d.ts +2 -1
- package/dist/components/mcp/audit.js +21 -17
- package/dist/components/mcp/audit.js.map +1 -1
- package/dist/components/operations.d.ts +28 -0
- package/dist/components/operations.js +263 -54
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +48 -2
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/packageComponent.js +24 -0
- package/dist/components/packageComponent.js.map +1 -1
- package/dist/components/requestRestart.d.ts +1 -0
- package/dist/components/requestRestart.js +7 -0
- package/dist/components/requestRestart.js.map +1 -1
- package/dist/config/RootConfigWatcher.d.ts +2 -0
- package/dist/config/RootConfigWatcher.js +189 -35
- package/dist/config/RootConfigWatcher.js.map +1 -1
- package/dist/config/configReadRetry.d.ts +8 -0
- package/dist/config/configReadRetry.js +62 -0
- package/dist/config/configReadRetry.js.map +1 -0
- package/dist/config/configUtils.d.ts +10 -9
- package/dist/config/configUtils.js +63 -27
- package/dist/config/configUtils.js.map +1 -1
- package/dist/config/parseConfigFile.d.ts +4 -0
- package/dist/config/parseConfigFile.js +35 -0
- package/dist/config/parseConfigFile.js.map +1 -0
- package/dist/config/readConfigFileSync.d.ts +1 -0
- package/dist/config/readConfigFileSync.js +47 -0
- package/dist/config/readConfigFileSync.js.map +1 -0
- package/dist/config/watcherArming.d.ts +15 -0
- package/dist/config/watcherArming.js +59 -0
- package/dist/config/watcherArming.js.map +1 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +21 -8
- package/dist/dataLayer/restoreMarker.js +94 -27
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +3 -0
- package/dist/resources/DatabaseTransaction.d.ts +25 -0
- package/dist/resources/DatabaseTransaction.js +224 -18
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +2 -1
- package/dist/resources/LMDBTransaction.js +22 -3
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/PrimaryRocksDatabase.js +22 -6
- package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
- package/dist/resources/RecordEncoder.d.ts +1 -1
- package/dist/resources/RecordEncoder.js +46 -10
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Resource.js +97 -13
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +8 -0
- package/dist/resources/RocksIndexStore.js +2 -1
- package/dist/resources/RocksIndexStore.js.map +1 -1
- package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
- package/dist/resources/RocksTransactionLogStore.js +104 -33
- package/dist/resources/RocksTransactionLogStore.js.map +1 -1
- package/dist/resources/Table.d.ts +58 -7
- package/dist/resources/Table.js +1143 -348
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.d.ts +3 -0
- package/dist/resources/analytics/write.js +49 -16
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/auditStore.d.ts +170 -0
- package/dist/resources/auditStore.js +457 -11
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/crdt.d.ts +10 -0
- package/dist/resources/crdt.js +22 -0
- package/dist/resources/crdt.js.map +1 -1
- package/dist/resources/dataLoader.js +3 -4
- package/dist/resources/dataLoader.js.map +1 -1
- package/dist/resources/databases.d.ts +16 -13
- package/dist/resources/databases.js +625 -177
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/derivedIndexRegistry.d.ts +5 -0
- package/dist/resources/derivedIndexRegistry.js +68 -0
- package/dist/resources/derivedIndexRegistry.js.map +1 -0
- package/dist/resources/derivedIndexRuntime.d.ts +215 -0
- package/dist/resources/derivedIndexRuntime.js +2027 -0
- package/dist/resources/derivedIndexRuntime.js.map +1 -0
- package/dist/resources/graphql.js +3 -2
- package/dist/resources/graphql.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +867 -39
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
- package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
- package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
- package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
- package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
- package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
- package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
- package/dist/resources/nodeIdMapping.d.ts +5 -0
- package/dist/resources/nodeIdMapping.js +49 -0
- package/dist/resources/nodeIdMapping.js.map +1 -1
- package/dist/resources/recordLock.d.ts +47 -4
- package/dist/resources/recordLock.js +138 -7
- package/dist/resources/recordLock.js.map +1 -1
- package/dist/resources/recordLockCoordinator.d.ts +557 -0
- package/dist/resources/recordLockCoordinator.js +2597 -0
- package/dist/resources/recordLockCoordinator.js.map +1 -0
- package/dist/resources/replayLogs.js +5 -0
- package/dist/resources/replayLogs.js.map +1 -1
- package/dist/resources/replicatedApplyFailure.d.ts +16 -0
- package/dist/resources/replicatedApplyFailure.js +63 -0
- package/dist/resources/replicatedApplyFailure.js.map +1 -0
- package/dist/resources/scheduler/scheduler.js +3 -3
- package/dist/resources/scheduler/scheduler.js.map +1 -1
- package/dist/resources/search.d.ts +10 -4
- package/dist/resources/search.js +160 -40
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/tracked.d.ts +5 -1
- package/dist/resources/tracked.js +74 -23
- package/dist/resources/tracked.js.map +1 -1
- package/dist/resources/transactionBroadcast.js +4 -6
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/auth.js +59 -23
- package/dist/security/auth.js.map +1 -1
- package/dist/security/deferredAuthentication.d.ts +11 -0
- package/dist/security/deferredAuthentication.js +25 -3
- package/dist/security/deferredAuthentication.js.map +1 -1
- package/dist/security/jsLoader.js +9 -4
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/server/REST.js +39 -5
- package/dist/server/REST.js.map +1 -1
- package/dist/server/http.d.ts +5 -1
- package/dist/server/http.js +34 -2
- package/dist/server/http.js.map +1 -1
- package/dist/server/mqtt.js +5 -1
- package/dist/server/mqtt.js.map +1 -1
- package/dist/server/serverHelpers/Headers.d.ts +2 -0
- package/dist/server/serverHelpers/Headers.js +6 -0
- package/dist/server/serverHelpers/Headers.js.map +1 -1
- package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
- package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
- package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
- package/dist/server/serverHelpers/Request.d.ts +5 -10
- package/dist/server/serverHelpers/Request.js +38 -136
- package/dist/server/serverHelpers/Request.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
- package/dist/server/serverHelpers/contentTypes.js +189 -15
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
- package/dist/server/serverHelpers/serverUtilities.js +110 -20
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +4 -1
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
- package/dist/server/storageReclamation.js +1 -1
- package/dist/server/storageReclamation.js.map +1 -1
- package/dist/server/threads/isolatedApplications.d.ts +47 -0
- package/dist/server/threads/isolatedApplications.js +171 -0
- package/dist/server/threads/isolatedApplications.js.map +1 -0
- package/dist/server/threads/logRotationTransport.d.ts +1 -0
- package/dist/server/threads/logRotationTransport.js +33 -0
- package/dist/server/threads/logRotationTransport.js.map +1 -0
- package/dist/server/threads/manageThreads.d.ts +64 -6
- package/dist/server/threads/manageThreads.js +261 -12
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/socketRouter.d.ts +1 -0
- package/dist/server/threads/socketRouter.js +196 -13
- package/dist/server/threads/socketRouter.js.map +1 -1
- package/dist/server/threads/threadServer.js +30 -7
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +24 -0
- package/dist/utility/errors/hdbError.js +58 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/hdbTerms.d.ts +2 -0
- package/dist/utility/hdbTerms.js +2 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/logging/harper_logger.js +218 -38
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
- package/dist/utility/logging/logGenerationCoordinator.js +184 -0
- package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
- package/dist/utility/logging/logRotation.d.ts +46 -0
- package/dist/utility/logging/logRotation.js +365 -0
- package/dist/utility/logging/logRotation.js.map +1 -0
- package/dist/utility/logging/logRotator.d.ts +1 -1
- package/dist/utility/logging/logRotator.js +172 -92
- package/dist/utility/logging/logRotator.js.map +1 -1
- package/dist/utility/npmUtilities.js +6 -4
- package/dist/utility/npmUtilities.js.map +1 -1
- package/dist/utility/watcherFallback.d.ts +0 -45
- package/dist/utility/watcherFallback.js +1 -125
- package/dist/utility/watcherFallback.js.map +1 -1
- package/dist/validation/configValidator.js +6 -3
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +9 -0
- package/json/systemSchema.json +3 -0
- package/npm-shrinkwrap.json +177 -87
- package/package.json +13 -5
- package/resources/DESIGN.md +689 -19
- package/resources/DatabaseTransaction.ts +230 -17
- package/resources/LMDBTransaction.ts +21 -3
- package/resources/PrimaryRocksDatabase.ts +20 -7
- package/resources/RecordEncoder.ts +52 -10
- package/resources/Resource.ts +97 -13
- package/resources/ResourceInterface.ts +8 -0
- package/resources/RocksIndexStore.ts +2 -1
- package/resources/RocksTransactionLogStore.ts +111 -31
- package/resources/Table.ts +1273 -396
- package/resources/analytics/DESIGN.md +38 -0
- package/resources/analytics/write.ts +50 -17
- package/resources/auditStore.ts +460 -11
- package/resources/crdt.ts +22 -0
- package/resources/dataLoader.ts +3 -4
- package/resources/databases.ts +611 -147
- package/resources/derivedIndexRegistry.ts +56 -0
- package/resources/derivedIndexRuntime.ts +2292 -0
- package/resources/graphql.ts +3 -2
- package/resources/indexes/DESIGN.md +833 -0
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +924 -46
- package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
- package/resources/indexes/fullTextNativeBinding.ts +146 -0
- package/resources/indexes/hnswDerivedIndex.ts +531 -0
- package/resources/indexes/hnswPlaneBinding.ts +174 -0
- package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
- package/resources/nodeIdMapping.ts +50 -0
- package/resources/record-locks.md +1407 -0
- package/resources/recordLock.ts +173 -7
- package/resources/recordLockCoordinator.ts +3082 -0
- package/resources/replayLogs.ts +5 -0
- package/resources/replicatedApplyFailure.ts +77 -0
- package/resources/scheduler/DESIGN.md +40 -0
- package/resources/scheduler/scheduler.ts +4 -4
- package/resources/search.ts +169 -49
- package/resources/tracked.ts +73 -22
- package/resources/transactionBroadcast.ts +4 -4
- package/security/DESIGN.md +175 -0
- package/security/auth.ts +53 -24
- package/security/deferredAuthentication.ts +24 -2
- package/security/jsLoader.ts +9 -4
- package/server/DESIGN.md +275 -0
- package/server/REST.ts +42 -6
- package/server/http.ts +34 -2
- package/server/mqtt.ts +6 -4
- package/server/serverHelpers/Headers.ts +5 -1
- package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
- package/server/serverHelpers/Request.ts +33 -131
- package/server/serverHelpers/contentTypes.ts +188 -15
- package/server/serverHelpers/serverUtilities.ts +157 -27
- package/server/serverHelpers/uwsServer.ts +4 -1
- package/server/serverHelpers/webSocketCloseReason.ts +25 -0
- package/server/storageReclamation.ts +2 -2
- package/server/threads/isolatedApplications.ts +157 -0
- package/server/threads/logRotationTransport.ts +40 -0
- package/server/threads/manageThreads.js +254 -12
- package/server/threads/socketRouter.ts +217 -11
- package/server/threads/threadServer.js +30 -7
- package/studio/web/assets/{Chat-BnCBegQz.js → Chat-DADFFGe_.js} +1 -1
- package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-D_mI-rZ7.js} +3 -3
- package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-c2NiSDHa.js} +1 -1
- package/studio/web/assets/{applications-DHxGi7JH.js → applications-DktUqh7G.js} +1 -1
- package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-Cs_75Xhw.js} +1 -1
- package/studio/web/assets/{editor-DNcRHK54.js → editor-19b-Y1IN.js} +1 -1
- package/studio/web/assets/{html-Bdssedlg.js → html-DiYEQMpB.js} +1 -1
- package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CmR0y7P_.js} +1 -1
- package/studio/web/assets/{index-D6sxmFLR.js → index-Dm0rfkJ7.js} +5 -5
- package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-7vqt2CC3.js} +1 -1
- package/studio/web/assets/{javascript-B8meVSTH.js → javascript-BWtCFuOt.js} +1 -1
- package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-Buzzbv9y.js} +1 -1
- package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-SqsFWfTM.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
- package/studio/web/assets/{notifications-CvZivSbh.js → notifications-CAB-LZWT.js} +1 -1
- package/studio/web/assets/{notifications-CQf18QKb.js → notifications-DRzmSRxM.js} +1 -1
- package/studio/web/assets/{profile-DdOwtntb.js → profile-BNKAl79n.js} +1 -1
- package/studio/web/assets/{regions-n69fwagr.js → regions-CUow_Zw2.js} +1 -1
- package/studio/web/assets/{register-PfWTCXWB.js → register-Dkt3WUMp.js} +2 -2
- package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BZRfMD0N.js} +1 -1
- package/studio/web/assets/{setup-CUx_aUDl.js → setup-D_yiEPO2.js} +2 -2
- package/studio/web/assets/{status-D7BVKqX9.js → status-DhHh1Ge-.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-D7L1PDtV.js} +1 -1
- package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-CCwLk1YS.js} +1 -1
- package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-BP1j1mjn.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-D7bnYxLw.js} +1 -1
- package/studio/web/assets/{workers-pR3jRY9D.js → workers-tOuCNT17.js} +1 -1
- package/studio/web/assets/{xml-2iRnMhQO.js → xml-BSG_3mQT.js} +1 -1
- package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-DxiLprBB.js} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/DESIGN.md +55 -0
- package/utility/errors/hdbError.ts +54 -0
- package/utility/hdbTerms.ts +2 -0
- package/utility/logging/harper_logger.ts +210 -30
- package/utility/logging/logGenerationCoordinator.ts +196 -0
- package/utility/logging/logRotation.ts +367 -0
- package/utility/logging/logRotator.ts +196 -91
- package/utility/npmUtilities.ts +6 -4
- package/utility/watcherFallback.ts +0 -122
- package/validation/configValidator.ts +6 -3
package/config/DESIGN.md
ADDED
|
@@ -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 {
|
|
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
|
-
#
|
|
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) =>
|
|
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
|
-
|
|
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 =
|
|
251
|
+
config = parseConfigFile(data, this.#configFilePath);
|
|
115
252
|
} catch (error) {
|
|
116
|
-
// A
|
|
117
|
-
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
149
|
-
this.#
|
|
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();
|