@harperfast/harper 5.3.0-beta.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 (126) hide show
  1. package/components/DESIGN.md +421 -0
  2. package/components/mcp/DESIGN.md +109 -0
  3. package/components/mcp/audit.ts +21 -17
  4. package/config/DESIGN.md +306 -0
  5. package/dataLayer/DESIGN.md +179 -0
  6. package/dataLayer/restoreMarker.ts +92 -25
  7. package/dist/components/mcp/audit.d.ts +2 -1
  8. package/dist/components/mcp/audit.js +21 -17
  9. package/dist/components/mcp/audit.js.map +1 -1
  10. package/dist/dataLayer/restoreMarker.d.ts +21 -8
  11. package/dist/dataLayer/restoreMarker.js +94 -27
  12. package/dist/dataLayer/restoreMarker.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +6 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/resources/RecordEncoder.js +25 -5
  17. package/dist/resources/RecordEncoder.js.map +1 -1
  18. package/dist/resources/Table.js +48 -2
  19. package/dist/resources/Table.js.map +1 -1
  20. package/dist/resources/analytics/write.d.ts +3 -0
  21. package/dist/resources/analytics/write.js +39 -13
  22. package/dist/resources/analytics/write.js.map +1 -1
  23. package/dist/resources/crdt.d.ts +10 -0
  24. package/dist/resources/crdt.js +22 -0
  25. package/dist/resources/crdt.js.map +1 -1
  26. package/dist/resources/databases.js +1 -1
  27. package/dist/resources/databases.js.map +1 -1
  28. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -2
  29. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +43 -24
  30. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  31. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
  32. package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
  33. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
  34. package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
  35. package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
  36. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
  37. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
  38. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
  39. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
  40. package/dist/resources/recordLockCoordinator.d.ts +5 -5
  41. package/dist/resources/recordLockCoordinator.js +48 -16
  42. package/dist/resources/recordLockCoordinator.js.map +1 -1
  43. package/dist/resources/transactionBroadcast.js +4 -6
  44. package/dist/resources/transactionBroadcast.js.map +1 -1
  45. package/dist/security/auth.js +59 -23
  46. package/dist/security/auth.js.map +1 -1
  47. package/dist/security/deferredAuthentication.d.ts +11 -0
  48. package/dist/security/deferredAuthentication.js +25 -3
  49. package/dist/security/deferredAuthentication.js.map +1 -1
  50. package/dist/security/jsLoader.js +3 -0
  51. package/dist/security/jsLoader.js.map +1 -1
  52. package/dist/server/REST.js +6 -3
  53. package/dist/server/REST.js.map +1 -1
  54. package/dist/server/mqtt.js +5 -1
  55. package/dist/server/mqtt.js.map +1 -1
  56. package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
  57. package/dist/server/serverHelpers/serverUtilities.js +14 -3
  58. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  59. package/dist/server/serverHelpers/uwsServer.js +4 -1
  60. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  61. package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
  62. package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
  63. package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
  64. package/dist/utility/logging/harper_logger.js +1 -0
  65. package/dist/utility/logging/harper_logger.js.map +1 -1
  66. package/index.ts +3 -0
  67. package/npm-shrinkwrap.json +104 -104
  68. package/package.json +6 -5
  69. package/resources/DESIGN.md +568 -3
  70. package/resources/RecordEncoder.ts +27 -5
  71. package/resources/Table.ts +49 -3
  72. package/resources/analytics/DESIGN.md +38 -0
  73. package/resources/analytics/write.ts +40 -14
  74. package/resources/crdt.ts +22 -0
  75. package/resources/databases.ts +1 -1
  76. package/resources/indexes/DESIGN.md +833 -0
  77. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +44 -25
  78. package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
  79. package/resources/indexes/fullTextNativeBinding.ts +146 -0
  80. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
  81. package/resources/record-locks.md +1407 -0
  82. package/resources/recordLockCoordinator.ts +61 -22
  83. package/resources/scheduler/DESIGN.md +40 -0
  84. package/resources/transactionBroadcast.ts +4 -4
  85. package/security/DESIGN.md +175 -0
  86. package/security/auth.ts +53 -24
  87. package/security/deferredAuthentication.ts +24 -2
  88. package/security/jsLoader.ts +3 -0
  89. package/server/DESIGN.md +264 -0
  90. package/server/REST.ts +6 -3
  91. package/server/mqtt.ts +6 -4
  92. package/server/serverHelpers/serverUtilities.ts +14 -3
  93. package/server/serverHelpers/uwsServer.ts +4 -1
  94. package/server/serverHelpers/webSocketCloseReason.ts +25 -0
  95. package/studio/web/assets/{Chat-D3j-1yY1.js → Chat-DADFFGe_.js} +1 -1
  96. package/studio/web/assets/{FloatingChat-BxJGYcfB.js → FloatingChat-D_mI-rZ7.js} +3 -3
  97. package/studio/web/assets/{apiToken-CT55oWOe.js → apiToken-c2NiSDHa.js} +1 -1
  98. package/studio/web/assets/{applications-D9Ct9_vm.js → applications-DktUqh7G.js} +1 -1
  99. package/studio/web/assets/{cssMode-DV8H7VwA.js → cssMode-Cs_75Xhw.js} +1 -1
  100. package/studio/web/assets/{editor-uatc0unt.js → editor-19b-Y1IN.js} +1 -1
  101. package/studio/web/assets/{html-Bm6D6paN.js → html-DiYEQMpB.js} +1 -1
  102. package/studio/web/assets/{htmlMode-CEn7tpLG.js → htmlMode-CmR0y7P_.js} +1 -1
  103. package/studio/web/assets/{index-BIXW6Pu4.js → index-Dm0rfkJ7.js} +5 -5
  104. package/studio/web/assets/{index.lazy-UI7L-Vrk.js → index.lazy-7vqt2CC3.js} +1 -1
  105. package/studio/web/assets/{javascript-CJ0G3AFZ.js → javascript-BWtCFuOt.js} +1 -1
  106. package/studio/web/assets/{jsonMode-DQADAYEa.js → jsonMode-Buzzbv9y.js} +1 -1
  107. package/studio/web/assets/{languageServices-CAQJXWcI.js → languageServices-SqsFWfTM.js} +1 -1
  108. package/studio/web/assets/{lspLanguageFeatures-CCQ8P5sY.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
  109. package/studio/web/assets/{notifications-Cvb3P1lB.js → notifications-CAB-LZWT.js} +1 -1
  110. package/studio/web/assets/{notifications-BbxTU6Aw.js → notifications-DRzmSRxM.js} +1 -1
  111. package/studio/web/assets/{profile-Yyb7gsvL.js → profile-BNKAl79n.js} +1 -1
  112. package/studio/web/assets/{regions-OgjGHlU5.js → regions-CUow_Zw2.js} +1 -1
  113. package/studio/web/assets/{register-6qwNEOY3.js → register-Dkt3WUMp.js} +2 -2
  114. package/studio/web/assets/{setComponentFile-BilDMtgB.js → setComponentFile-BZRfMD0N.js} +1 -1
  115. package/studio/web/assets/{setup-J6qJ7OIU.js → setup-D_yiEPO2.js} +2 -2
  116. package/studio/web/assets/{status-0RWGcfyD.js → status-DhHh1Ge-.js} +1 -1
  117. package/studio/web/assets/{toggleHighContrast-BIn-vErT.js → toggleHighContrast-D7L1PDtV.js} +1 -1
  118. package/studio/web/assets/{tsMode-DgUXku4d.js → tsMode-CCwLk1YS.js} +1 -1
  119. package/studio/web/assets/{typescript-C9orXcsM.js → typescript-BP1j1mjn.js} +1 -1
  120. package/studio/web/assets/{useEntityRestURL-BEoXXbUB.js → useEntityRestURL-D7bnYxLw.js} +1 -1
  121. package/studio/web/assets/{workers-JVzSDmgx.js → workers-tOuCNT17.js} +1 -1
  122. package/studio/web/assets/{xml-Cq-S8S4X.js → xml-BSG_3mQT.js} +1 -1
  123. package/studio/web/assets/{yaml-sfoRdh1M.js → yaml-DxiLprBB.js} +1 -1
  124. package/studio/web/index.html +1 -1
  125. package/utility/DESIGN.md +55 -0
  126. package/utility/logging/harper_logger.ts +1 -0
@@ -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.
@@ -0,0 +1,179 @@
1
+ # dataLayer/ — Design notes
2
+
3
+ Backup and restore, version gating, system-table bootstrap and storage migration.
4
+
5
+ **Read this when:** touching `rocksdbBackup.ts`, `restoreMarker.ts`, `blobBackup.ts`, `hdbInfoController.ts`, `bin/copyDb.ts` or `json/systemSchema.json`.
6
+
7
+ Index of every design note: [DESIGN.md](../DESIGN.md).
8
+
9
+ ---
10
+
11
+ ## Version gate at startup: downgrades prompt, and only the minor direction is confirmable
12
+
13
+ `getVersionUpdateInfo()` (`dataLayer/hdbInfoController.ts`) compares the store's `data_version_num` (latest `system.hdb_info` record) against the binary's `packageJson.version` on every start. Data newer than binary by a **major** version → hard refusal. Newer by a **minor** version → `forceDowngradePrompt()` asks for confirmation; answering yes records the data version back down to the binary's version and boots (upgrade directives are deliberately additive/downgrade-compatible — see the struct-mode section above and `patchHdbSecretIsHashAttribute` in `upgrade/directives/5-2-0.ts`).
14
+
15
+ - The prompt's answer can be supplied non-interactively via `CONFIRM_DOWNGRADE` — env var or `--CONFIRM_DOWNGRADE` CLI arg; argv wins (`assignCMDENVVariables`). With no override and no TTY on stdin, the prompt throws instead of blocking on stdin forever (#2046 — services/CI hung with nothing in the log; the mismatch is also logged to hdb.log now).
16
+ - Upgrades never prompt (see the rationale comment in `bin/upgrade.js`); only the downgrade direction confirms. `upgradeCertsPrompt()` on the 4.x upgrade path still has the block-on-stdin hazard.
17
+ - Test-suite gotcha: a suite that supplies the override via `process.argv` affects every later test file in the same mocha process — save and restore `process.argv` in `before`/`after` (see `unitTests/dataLayer/hdbInfoController.test.js`).
18
+
19
+ ## Opening a source LMDB DBI for migration must thread through `compression`
20
+
21
+ When `migrateOnStart` opens a source LMDB primary store to read records out for the RocksDB copy, it constructs an `OpenDBIObject` and calls `sourceRootStore.openDB(key, dbiInit)`. Critically, the per-attribute `compression` setting from the corresponding `__dbis__` entry must be assigned onto `dbiInit` before that call — `dbiInit.compression = attribute.compression`. Without it, lmdb-js doesn't install its decompression layer; every read on the DBI returns raw compressed bytes. msgpackr then misreads bytes in the `0x40–0x7F` range as shared-structure refs, calls `loadStructures` → decodes the (also compressed) structures buffer → finds more bytes in that range → recurses → stack overflow.
22
+
23
+ Harper's normal `databases.ts` path already does this (search for `dbiInit.compression = primaryKeyAttribute.compression`); the migration path in `bin/copyDb.ts` has to match.
24
+
25
+ The persisted `compression` value itself is LMDB-era and loosely shaped: `getDefaultCompression()` historically stored whatever falsy value the config resolved to (`''`, `false`, `null`) when `storage.compression` was disabled, and `{ startingOffset, threshold, dictionary? }` when enabled. lmdb-js interprets falsy as "no compression", but rocksdb-js >= 2.6 validates the option strictly (`''`/booleans throw `Unsupported compression algorithm`) and treats UNSET as "use the build default (lz4)" — the inverse default of lmdb. Every RocksDB open must therefore route through `toRocksCompression()` in `resources/databases.ts` (applied inside `openRocksDatabase`, the single chokepoint), which maps defined-falsy → `'none'` and enabled-without-an-algorithm → an explicit lz4 request when available. Don't pass persisted attribute compression to a RocksDB open directly.
26
+
27
+ `bin/copyDb.ts`'s `openRocksDb` is part of that chokepoint, not an exception to it. This is about the bytes migration writes, not about a later failure: `copyDbToRocks()` closes every target handle before the staging directory is renamed, and rocksdb-js permits an explicit codec change across a close/reopen, so the runtime would open the migrated database fine either way. But a migration that ignores the configured codec writes the entire dataset uncompressed, and those SST/blob files then keep their original codec until write traffic rewrites them — a full LMDB→RocksDB migration is the one moment the whole dataset is written at once, so it is exactly when the deployment's codec should apply.
28
+
29
+ ## System table bootstrap: `systemSchema.json` + upgrade directive
30
+
31
+ Adding a new system table (e.g. `hdb_deployment` in #641 Slice A) requires three changes:
32
+
33
+ 1. **`json/systemSchema.json`** — the table entry. Fresh installs auto-create it via `utility/mount_hdb.ts:createTables()`, which iterates `Object.keys(systemSchema)` on first boot.
34
+ 2. **`utility/hdbTerms.ts`** — add the table name to `SYSTEM_TABLE_NAMES`.
35
+ 3. **`upgrade/directives/<version>.ts`** — provisions the table on existing installs that already have a system schema. Registered in `upgrade/directives/directivesController.ts` (which is otherwise empty — its `versions` Map gets populated by these imports). The directive shape is `{ version, sync_functions, async_functions }`; copy `5-1-0.ts` for the canonical pattern (uses `bridge.createTable` to match what `mount_hdb` does on a fresh install).
36
+
37
+ **Version the directive to the first release that ships the dependent code, not a later one.** Directives only run when `current_version < directive_version <= upgrade_version` (`directivesController.getVersionsForUpgrade`). The `hdb_deployment` directive was originally mis-tagged `5.2.0` while the deployment-recorder code shipped in `5.1.0`, so on every `5.0.x -> 5.1.x` upgrade the directive was filtered out (`5.2.0 > 5.1.x`) and the table never got created — breaking replicated `deploy_component` on peer nodes for the entire existing customer base. Caveat: `utility/common_utils.ts:compareVersions` strips trailing `.0` and therefore sorts a pre-release (`5.1.0-beta.1`) _above_ its GA (`5.1.0`), so an install already on a `5.1.0-beta.x` data version will not pick up a `5.1.0` directive when upgrading to GA; those pre-release installs need the table created by other means.
38
+
39
+ System tables replicate by default. To opt out, add the name to `NON_REPLICATING_SYSTEM_TABLES` in `resources/databases.ts`. The check happens after table init and sets `table.replicate = false` per-node.
40
+
41
+ If the table needs `audit: true`, set it both in the schema (for fresh installs) **and** on the `CreateTableObject` instance in the directive (for upgrades) — otherwise the two paths diverge.
42
+
43
+ ## RocksDB backup/restore: the restore lock + marker protocol (`dataLayer/restoreMarker.ts`, `dataLayer/rocksdbBackup.ts`)
44
+
45
+ The `restore_backup` operation restores a user database on a live server by closing it across all
46
+ worker threads, purging its directory (`backups.restore` with `purgeAllFiles`), and reloading it.
47
+ Three non-obvious mechanics keep that safe:
48
+
49
+ - **Two files in an isolated `` `restore` `` directory beside (never inside) the database directory**,
50
+ each keyed by `sha256(basename(dbPath)).slice(0,32)`: `<key>.lock`, an OS-level exclusive flock
51
+ (rocksdb-js `tryFileLock`, auto-released on process death), serializes restores; `<key>.restoring`,
52
+ a marker written+fsynced (file _and_ the metadata directory) after the lock and before any
53
+ destructive step, means "a restore started and has not finished" (its first line records the
54
+ database directory name so the scan can map a marker back without decoding the key). The metadata
55
+ is hashed into a sibling directory rather than suffixed onto the database name (`<db>.restoring`)
56
+ for two reasons: a legal database literally named `orders.restoring` would otherwise be mistaken
57
+ for the restore marker of `orders`, and a 250-character name (the legal max) plus a `.restore.lock`
58
+ suffix exceeds `NAME_MAX` (255) on most filesystems. The directory name deliberately contains a
59
+ backtick — `schemaRegex` (the database-name validator) forbids only `/` and a backtick among
60
+ filesystem-legal characters — so it can never collide with a legal database name, including a
61
+ database literally named `.restore` (which _is_ a legal name; a plain `.restore/` directory would
62
+ be exactly that database's directory). Because the startup scan opens any `CURRENT`+`MANIFEST-`
63
+ directory without re-applying `schemaRegex`, it also explicitly skips the reserved `` `restore` ``
64
+ entry so an out-of-band directory at that name is never loaded as a database. Startup/rescan
65
+ detection (`databasesBlockedByRestore` → `scanBlockedRestores` in `dataLayer/restoreMarker.ts`)
66
+ reads the metadata directory and checks the **marker first**, only probing the lock when the marker exists —
67
+ probes take the flock and are mutually exclusive across threads, so probing the (persistent) lock
68
+ file of every long-ago-restored database on every rescan would make concurrent rescans misclassify
69
+ healthy databases as in-progress. Marker-present + lock-held = restore in progress (don't load);
70
+ marker-present + lock-free = crashed mid-restore (don't load; rerun the restore to recover).
71
+ - **A recovery restore must not clear a pre-existing marker on a pre-destruction failure.**
72
+ `beginRestore` returns `preexisting: true` when a `.restoring` marker was already present (this run
73
+ is a recovery over a possibly half-purged directory). If such a run fails _before_ any destruction
74
+ (e.g. `verifyDatabaseClosed` finds a leaked handle), it must leave the marker in place — clearing
75
+ it and broadcasting a reload would surface the earlier attempt's partial/corrupt directory as
76
+ healthy. Only a _fresh_ marker on a _previously healthy_ database that failed before destruction is
77
+ safe to clear.
78
+ - **The ITC close broadcast is best-effort, so closure is verified before the purge.** The SCHEMA
79
+ broadcast (`signalSchemaChange`) resolves after remote handlers complete but times out at 30s
80
+ "best-effort", swallows errors, and never reaches job-worker threads at all (their ports are
81
+ excluded from broadcasts to avoid re-entrant deadlocks). A destructive purge cannot trust it:
82
+ `restoreBackup` polls rocksdb-js `registryStatus()` (process-global across worker threads) until
83
+ the database path has no open instance, and aborts with a 409 — _cleaning up the marker, since
84
+ nothing was destroyed_ — if handles remain.
85
+ - **Online restore is impossible for a database a component holds open — and that failure is
86
+ correct.** rocksdb-js's registry is process-global but records only a per-path refCount, with no
87
+ attribution to a thread or component; Harper keeps no component→database ownership map. So when a
88
+ loaded component (or the `system` database, which Harper itself never stops while running) holds
89
+ its own handle on the target database, `registryStatus()` stays non-zero, Harper can neither
90
+ identify nor force-close that handle, and an in-place purge would corrupt a live instance.
91
+ `verifyDatabaseClosed` therefore waits only a short grace period (`DATABASE_CLOSE_WAIT_MS`, for a
92
+ just-finished job worker's own close to drain) and then fails fast with a 409 that points at
93
+ running the operation offline (`harper restore_backup` with the server stopped, where no
94
+ components are loaded and nothing holds the database open). Offline restore is the supported path
95
+ for component-held and `system` databases; online restore serves databases not actively held by a
96
+ component. The CLI exposes each backup operation under its operation name only (`create_backup`,
97
+ `restore_backup`, …) — no hyphenated alias — and `bin/backup.ts` routes it to a reachable server
98
+ or, when the local server is stopped, to the equivalent offline function.
99
+ - **Job workers must release their RocksDB handles on exit, or the closure check can never pass.**
100
+ rocksdb-js's registry is process-global across worker threads, and a thread that exits WITHOUT
101
+ closing leaks its handles (the refCount never drops); the only alternative, `shutdown()`, tears
102
+ down rocksdb for the _entire_ process. A job worker (`server/jobs/jobProcess.ts`) opens the whole
103
+ database graph via `getDatabases()` and exits when the job finishes — and `create_backup` is
104
+ itself a job, so before any `restore_backup` there is always at least one exited job worker that
105
+ touched the database. Without cleanup those leaked handles keep `registryStatus()` non-zero and
106
+ would fail the closure check even when no component holds the database. `jobProcess` therefore
107
+ calls `closeLoadedDatabases()` (`resources/databases.ts`) in its `finally`, closing every loaded
108
+ user database on that thread (the non-enumerable `system` DB is intentionally skipped), so an
109
+ exited job worker leaves no residual handle to be mistaken for a live holder.
110
+ - **`dropDatabase` and `restore_backup` serialize on the same lock, not a check-then-act probe.**
111
+ A drop's `destroy()` interleaving with a restore's purge-and-copy on the same directory would gut
112
+ a "successful" restore (or vice versa). `dropDatabase` therefore _acquires_ the restore lock
113
+ (`acquireRestoreLock`, marker-less) for each RocksDB root store and holds it across the whole drop,
114
+ releasing in a `finally`; a restore in progress makes the acquire fail with 409, and a leftover
115
+ incomplete-restore marker (lock free, detected via `restoreMarkerPresent`, which — unlike
116
+ `checkRestoreState` — is safe while this thread holds the lock) is refused rather than dropped over.
117
+ `database()`'s on-demand open still uses the read-only `throwIfBlockedByRestore` (a
118
+ `create_table`/`create_schema` must not resurrect a half-purged directory as a fresh empty DB), but
119
+ the destructive drop path now uses the exclusive lock so the race is closed, not merely narrowed.
120
+ - **The offline restore probes RocksDB's own `LOCK` file, and fails closed.** The offline path runs
121
+ only when the CLI sees no server (a PID heuristic; the PID file is briefly absent mid-`harper
122
+ restart`), and `backups.restore`'s `purgeAllFiles` never takes RocksDB's lock — so before purging,
123
+ `restoreBackupOffline` opens the database to probe. It now takes the restore lock+marker _before_
124
+ probing (so a server that starts afterward sees the marker and refuses to load), and recognizes the
125
+ pinned rocksdb-js 2.5.0 lock error — a plain `Error` with no `code` and message
126
+ `IO error: While lock file: <db>/LOCK: Resource temporarily unavailable` (`isRocksDbLockError`) —
127
+ aborting with a 409 rather than purging a database another process holds open. Any _other_ open
128
+ failure (corrupt/half-restored) is exactly what restore recovers, so only a lock conflict aborts.
129
+
130
+ Known limitation: the flock is process-owned; if the restore job's worker _thread_ dies without
131
+ the process exiting, the lock stays held (restores 409) until Harper restarts. There is no typed
132
+ native lock signal in rocksdb-js 2.5.0, so the offline probe relies on message matching; a native
133
+ lock primitive is a rocksdb-js follow-on.
134
+
135
+ ## RocksDB managed backups: blob snapshots (`dataLayer/blobBackup.ts`)
136
+
137
+ A database's file-backed blobs live in one or more roots _outside_ the RocksDB directory
138
+ (`getBlobPathsForDatabaseName` in `resources/blob.ts` — one per configured `storage.blobPaths`, else
139
+ `<hdb_root>/blobs/<database>`), so the engine's backup does not capture them. `create_backup`,
140
+ `restore_backup`, `delete_backup`, `purge_backups`, and the streaming `get_backup` therefore handle
141
+ blobs alongside the engine data (the `exclude_blobs` request option — default false — opts out for an
142
+ engine-only backup):
143
+
144
+ - **Managed backups** snapshot the blob roots to `<backupDir>/blobs/<backupId>/<rootIndex>/<relpath>`
145
+ — a full, non-incremental copy per backup, mirroring the binding's `transaction_logs/<id>/` layout.
146
+ Each enumerated entry is classified before capture: complete blobs and existing abort markers are
147
+ hard-linked when possible (copied across filesystems), `.repair` temporaries are omitted, and an
148
+ incomplete blob is replaced by a retryable PENDING (`0xfe`) marker. If a classified blob vanishes
149
+ before capture, a terminal ERROR (`0xff`) marker preserves its file id. A file reclaimed before its
150
+ parent directory is read is outside the snapshot. This keeps a snapshot inode from changing as a
151
+ live write finishes, while complete blobs remain safe to hard-link because published blob paths are
152
+ write-once. The snapshot is built in a `.tmp-<id>` sibling and atomically renamed so a failed create
153
+ leaves no partial snapshot. `restore_backup` purges each blob root and rewrites it from the snapshot;
154
+ `delete_backup` / `purge_backups` remove the corresponding snapshot directories.
155
+ - **`get_backup`** appends the blob files to the same tar under `blobs/<rootIndex>/<relpath>`. The
156
+ binding's streaming backup finalizes its tar with exactly a 1024-byte (two-block) end-of-archive
157
+ marker; `createBackupStream` streams the native _plain_ tar while withholding that trailer
158
+ (verifying it is all-zero), appends the blob entries via `tar-stream` (whose `finalize` writes the
159
+ one real trailer), and gzips the combined stream itself when requested — so the binding is always
160
+ asked for a plain tar and compression happens after the append. No scratch disk. The same blob
161
+ classification rule applies: complete blobs are streamed, incomplete or post-enumeration missing
162
+ blobs become PENDING/ERROR marker entries, and repair temporaries are omitted.
163
+
164
+ **Completion manifest (`dataLayer/backupManifest.ts`).** `create_backup` is two-phase: the engine
165
+ backup (`rootStore.backup()`) resolves — and is immediately visible to `list_backups`/`verify_backup`/
166
+ `restore_backup` — before the blob snapshot is copied. Without a completion record, a blob-snapshot
167
+ failure (or a crash between the phases) would leave an engine backup that lists and verifies as
168
+ healthy while silently missing its blobs, and a concurrent restore could pick a backup id whose
169
+ snapshot is still being written and treat it as intentionally engine-only. So a manifest at
170
+ `<backupDir>/manifests/<backupId>.json` — recording the blob-inclusion policy — is written
171
+ (atomically, temp + rename) only after _both_ phases are durable, and a graceful blob-snapshot
172
+ failure rolls back the just-created engine backup + partial snapshot. Consumers treat a backup id
173
+ with no manifest as incomplete: `list_backups` hides it, `verify_backup`/`restore_backup` reject it
174
+ (409 for a specific id, "no complete backups" for `latest`), and restore uses the manifest's `blobs`
175
+ flag — not the mere presence of a snapshot dir — to decide whether to restore blobs (so an engine-only
176
+ backup leaves live blobs untouched, and a manifest that claims blobs but has no snapshot is flagged
177
+ corrupt by verify). This closes the "healthy-looking but incomplete" and concurrent-restore races;
178
+ the remaining engine/blob point-in-time skew (a blob unlinked between the engine cut and the blob
179
+ walk) is the documented best-effort limitation above.