@harperfast/harper 5.3.0 → 5.3.1

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 (231) hide show
  1. package/bin/copyDb.ts +5 -1
  2. package/bin/deployRenderer.ts +3 -0
  3. package/components/Application.ts +66 -22
  4. package/components/DESIGN.md +89 -36
  5. package/components/componentLoader.ts +2 -0
  6. package/components/deploymentRecorder.ts +18 -1
  7. package/components/installFingerprint.ts +123 -0
  8. package/components/mcp/DESIGN.md +4 -4
  9. package/components/operations.js +29 -0
  10. package/config/DESIGN.md +5 -5
  11. package/dataLayer/DESIGN.md +37 -22
  12. package/dataLayer/backupRepository.ts +248 -0
  13. package/dataLayer/restoreMarker.ts +30 -75
  14. package/dataLayer/rocksdbBackup.ts +253 -110
  15. package/dist/bin/copyDb.js +3 -0
  16. package/dist/bin/copyDb.js.map +1 -1
  17. package/dist/bin/deployRenderer.js +3 -0
  18. package/dist/bin/deployRenderer.js.map +1 -1
  19. package/dist/components/Application.d.ts +5 -0
  20. package/dist/components/Application.js +51 -19
  21. package/dist/components/Application.js.map +1 -1
  22. package/dist/components/componentLoader.js +2 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/deploymentRecorder.js +18 -1
  25. package/dist/components/deploymentRecorder.js.map +1 -1
  26. package/dist/components/installFingerprint.d.ts +39 -0
  27. package/dist/components/installFingerprint.js +113 -0
  28. package/dist/components/installFingerprint.js.map +1 -0
  29. package/dist/components/operations.d.ts +2 -0
  30. package/dist/components/operations.js +31 -0
  31. package/dist/components/operations.js.map +1 -1
  32. package/dist/dataLayer/backupRepository.d.ts +33 -0
  33. package/dist/dataLayer/backupRepository.js +232 -0
  34. package/dist/dataLayer/backupRepository.js.map +1 -0
  35. package/dist/dataLayer/restoreMarker.d.ts +4 -1
  36. package/dist/dataLayer/restoreMarker.js +29 -68
  37. package/dist/dataLayer/restoreMarker.js.map +1 -1
  38. package/dist/dataLayer/rocksdbBackup.js +221 -112
  39. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/json/systemSchema.json +14 -0
  43. package/dist/resources/DatabaseTransaction.d.ts +140 -11
  44. package/dist/resources/DatabaseTransaction.js +1055 -464
  45. package/dist/resources/DatabaseTransaction.js.map +1 -1
  46. package/dist/resources/IterableEventQueue.d.ts +2 -1
  47. package/dist/resources/IterableEventQueue.js +16 -8
  48. package/dist/resources/IterableEventQueue.js.map +1 -1
  49. package/dist/resources/LMDBTransaction.d.ts +13 -2
  50. package/dist/resources/LMDBTransaction.js +219 -93
  51. package/dist/resources/LMDBTransaction.js.map +1 -1
  52. package/dist/resources/RecordEncoder.js +2 -3
  53. package/dist/resources/RecordEncoder.js.map +1 -1
  54. package/dist/resources/Resource.js +5 -6
  55. package/dist/resources/Resource.js.map +1 -1
  56. package/dist/resources/ResourceInterface.d.ts +3 -1
  57. package/dist/resources/ResourceInterface.js.map +1 -1
  58. package/dist/resources/RocksIndexStore.d.ts +2 -1
  59. package/dist/resources/RocksIndexStore.js +3 -0
  60. package/dist/resources/RocksIndexStore.js.map +1 -1
  61. package/dist/resources/Table.js +116 -12
  62. package/dist/resources/Table.js.map +1 -1
  63. package/dist/resources/analytics/write.js +6 -2
  64. package/dist/resources/analytics/write.js.map +1 -1
  65. package/dist/resources/auditStore.d.ts +71 -17
  66. package/dist/resources/auditStore.js +248 -64
  67. package/dist/resources/auditStore.js.map +1 -1
  68. package/dist/resources/branchDatabase.js +2 -0
  69. package/dist/resources/branchDatabase.js.map +1 -1
  70. package/dist/resources/databases.js +8 -2
  71. package/dist/resources/databases.js.map +1 -1
  72. package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
  73. package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
  74. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
  75. package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
  76. package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
  77. package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
  78. package/dist/resources/models/Models.d.ts +11 -0
  79. package/dist/resources/models/Models.js +95 -4
  80. package/dist/resources/models/Models.js.map +1 -1
  81. package/dist/resources/models/backendRegistry.d.ts +8 -0
  82. package/dist/resources/models/backendRegistry.js +15 -1
  83. package/dist/resources/models/backendRegistry.js.map +1 -1
  84. package/dist/resources/models/bootstrap.d.ts +2 -0
  85. package/dist/resources/models/bootstrap.js +6 -0
  86. package/dist/resources/models/bootstrap.js.map +1 -1
  87. package/dist/resources/models/calibration.d.ts +88 -0
  88. package/dist/resources/models/calibration.js +244 -0
  89. package/dist/resources/models/calibration.js.map +1 -0
  90. package/dist/resources/models/calibrationStore.d.ts +163 -0
  91. package/dist/resources/models/calibrationStore.js +986 -0
  92. package/dist/resources/models/calibrationStore.js.map +1 -0
  93. package/dist/resources/models/decisionStore.d.ts +24 -0
  94. package/dist/resources/models/decisionStore.js +45 -0
  95. package/dist/resources/models/decisionStore.js.map +1 -1
  96. package/dist/resources/models/generativeDecision.js +44 -15
  97. package/dist/resources/models/generativeDecision.js.map +1 -1
  98. package/dist/resources/models/types.d.ts +7 -0
  99. package/dist/resources/recordLock.d.ts +8 -5
  100. package/dist/resources/recordLock.js +6 -4
  101. package/dist/resources/recordLock.js.map +1 -1
  102. package/dist/resources/replayLogs.js +8 -1
  103. package/dist/resources/replayLogs.js.map +1 -1
  104. package/dist/resources/scheduler/engine.d.ts +4 -0
  105. package/dist/resources/scheduler/engine.js +30 -2
  106. package/dist/resources/scheduler/engine.js.map +1 -1
  107. package/dist/resources/search.d.ts +16 -4
  108. package/dist/resources/search.js +117 -13
  109. package/dist/resources/search.js.map +1 -1
  110. package/dist/resources/transaction.js +85 -5
  111. package/dist/resources/transaction.js.map +1 -1
  112. package/dist/resources/transactionBroadcast.d.ts +8 -0
  113. package/dist/resources/transactionBroadcast.js +65 -0
  114. package/dist/resources/transactionBroadcast.js.map +1 -1
  115. package/dist/security/keys.d.ts +1 -1
  116. package/dist/security/keys.js +170 -120
  117. package/dist/security/keys.js.map +1 -1
  118. package/dist/server/http.js +10 -0
  119. package/dist/server/http.js.map +1 -1
  120. package/dist/server/serverHelpers/Request.js +11 -2
  121. package/dist/server/serverHelpers/Request.js.map +1 -1
  122. package/dist/server/serverHelpers/contentTypes.js +8 -3
  123. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +38 -7
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/server/threads/manageThreads.d.ts +3 -3
  127. package/dist/server/threads/manageThreads.js +15 -13
  128. package/dist/server/threads/manageThreads.js.map +1 -1
  129. package/dist/server/threads/threadServer.d.ts +3 -0
  130. package/dist/server/threads/threadServer.js +24 -6
  131. package/dist/server/threads/threadServer.js.map +1 -1
  132. package/dist/upgrade/directives/5-3-0.js +9 -3
  133. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  134. package/dist/utility/durableFile.d.ts +36 -0
  135. package/dist/utility/durableFile.js +106 -0
  136. package/dist/utility/durableFile.js.map +1 -0
  137. package/dist/utility/errors/hdbError.d.ts +4 -0
  138. package/dist/utility/errors/hdbError.js +10 -1
  139. package/dist/utility/errors/hdbError.js.map +1 -1
  140. package/dist/utility/globalSchema.d.ts +9 -0
  141. package/dist/utility/hdbTerms.d.ts +1 -0
  142. package/dist/utility/hdbTerms.js +1 -0
  143. package/dist/utility/hdbTerms.js.map +1 -1
  144. package/dist/validation/configValidator.js +29 -0
  145. package/dist/validation/configValidator.js.map +1 -1
  146. package/index.ts +7 -0
  147. package/json/systemSchema.json +14 -0
  148. package/npm-shrinkwrap.json +22 -22
  149. package/package.json +2 -2
  150. package/resources/DESIGN.md +57 -272
  151. package/resources/DatabaseTransaction.ts +708 -87
  152. package/resources/FULL_TEXT.md +6 -2
  153. package/resources/IterableEventQueue.ts +14 -7
  154. package/resources/LMDBTransaction.ts +212 -84
  155. package/resources/RecordEncoder.ts +2 -3
  156. package/resources/Resource.ts +5 -6
  157. package/resources/ResourceInterface.ts +3 -1
  158. package/resources/RocksIndexStore.ts +5 -0
  159. package/resources/Table.ts +118 -15
  160. package/resources/analytics/write.ts +6 -2
  161. package/resources/audit-retention.md +203 -0
  162. package/resources/auditStore.ts +306 -84
  163. package/resources/branchDatabase.ts +2 -0
  164. package/resources/databases.ts +6 -2
  165. package/resources/indexes/DESIGN.md +120 -177
  166. package/resources/indexes/fullTextNativeBinding.ts +22 -3
  167. package/resources/indexes/fullTextQueryIndex.ts +274 -11
  168. package/resources/models/DESIGN.md +11 -3
  169. package/resources/models/Models.ts +127 -4
  170. package/resources/models/backendRegistry.ts +17 -0
  171. package/resources/models/bootstrap.ts +9 -1
  172. package/resources/models/calibration.ts +298 -0
  173. package/resources/models/calibrationStore.ts +1167 -0
  174. package/resources/models/decisionStore.ts +57 -0
  175. package/resources/models/generativeDecision.ts +54 -16
  176. package/resources/models/types.ts +6 -0
  177. package/resources/record-locks.md +155 -141
  178. package/resources/recordLock.ts +12 -6
  179. package/resources/replayLogs.ts +9 -2
  180. package/resources/scheduler/DESIGN.md +4 -3
  181. package/resources/scheduler/engine.ts +35 -2
  182. package/resources/search.ts +154 -16
  183. package/resources/transaction.ts +50 -5
  184. package/resources/transactionBroadcast.ts +69 -0
  185. package/security/DESIGN.md +9 -30
  186. package/security/keys.ts +175 -127
  187. package/server/DESIGN.md +70 -36
  188. package/server/http.ts +10 -0
  189. package/server/serverHelpers/Request.ts +10 -2
  190. package/server/serverHelpers/contentTypes.ts +5 -3
  191. package/server/serverHelpers/uwsServer.ts +34 -7
  192. package/server/threads/manageThreads.js +16 -14
  193. package/server/threads/threadServer.js +24 -6
  194. package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
  195. package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
  196. package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
  197. package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
  198. package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
  199. package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
  200. package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
  201. package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
  202. package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
  203. package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
  204. package/studio/web/assets/index-QLZw6xrU.css +1 -0
  205. package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
  206. package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
  207. package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
  208. package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
  209. package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
  210. package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
  211. package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
  212. package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
  213. package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
  214. package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
  215. package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
  216. package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
  217. package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
  218. package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
  219. package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
  220. package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
  221. package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
  222. package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
  223. package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
  224. package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
  225. package/studio/web/index.html +2 -2
  226. package/upgrade/directives/5-3-0.ts +10 -3
  227. package/utility/durableFile.ts +96 -0
  228. package/utility/errors/hdbError.ts +9 -0
  229. package/utility/hdbTerms.ts +1 -0
  230. package/validation/configValidator.ts +31 -0
  231. package/studio/web/assets/index-72cEH474.css +0 -1
@@ -40,6 +40,7 @@ const {
40
40
  } = require('./Application.ts');
41
41
  const { COMPONENT_PREPARATION_LOCK_DIR, withComponentPreparationLock } = require('./componentPreparationLock.ts');
42
42
  const { DEPLOYMENT_PROVENANCE_FILE } = require('./deploymentProvenance.ts');
43
+ const { compareInstallFingerprints, describeInstallDrift } = require('./installFingerprint.ts');
43
44
  const {
44
45
  applyRootConfigEffect,
45
46
  assertRootConfigEffectPublishable,
@@ -888,9 +889,11 @@ async function deployComponent(req) {
888
889
  emit('phase', { phase: 'prepare', status: 'done' });
889
890
  await validateComponentLoads(candidateDirPath, emit);
890
891
  },
892
+ fingerprintInstall: !isActivation,
891
893
  });
892
894
  // Nothing ran `validateCandidate`, which ends this phase.
893
895
  if (application.alreadyActive) emit('phase', { phase: 'prepare', status: 'done' });
896
+ if (recorder && application.installFingerprint) recorder.row.install_fingerprint = application.installFingerprint;
894
897
  // The build is certified on disk from here on, so every later failure — a peer result, or a rejection
895
898
  // thrown by the replication layer itself — still leaves an artifact this id can activate.
896
899
  if (mode === 'stage') stagedOnOrigin = true;
@@ -933,6 +936,13 @@ async function deployComponent(req) {
933
936
  timeoutMs: peerDeployAnswerTimeoutMs(req),
934
937
  });
935
938
  emit('phase', { phase: 'replicate', status: 'done' });
939
+ if (application.installFingerprint) response.install = application.installFingerprint;
940
+ // Marked on the aggregate, which the recorder re-records below, so a peer's per-peer entry is replaced
941
+ // rather than contradicted.
942
+ const installDrift = recorder
943
+ ? markInstallComparisons(application.installFingerprint, response?.replicated)
944
+ : undefined;
945
+ if (installDrift) emit('warning', { message: installDrift });
936
946
  if (recorder && response?.replicated) {
937
947
  // Fallback path for replicators that don't honor onPeerResult: re-record the
938
948
  // aggregate. recordPeer's upsert-by-node-name semantics make this idempotent
@@ -1081,6 +1091,7 @@ async function deployComponent(req) {
1081
1091
  `Staged: ${application.name}. Deploy it with deploy_component ` + `deployment_id=${recorder.deploymentId}`;
1082
1092
  }
1083
1093
  }
1094
+ if (installDrift) response.message = `${response.message} ${installDrift}`;
1084
1095
  return response;
1085
1096
  } catch (err) {
1086
1097
  // Pack phase, install output tail, and deployment_id into http_resp_msg so the
@@ -1145,6 +1156,23 @@ function unconfirmedStagingPeers(replicated) {
1145
1156
  return replicated.filter((peer) => peer && !confirmed(peer));
1146
1157
  }
1147
1158
 
1159
+ /** The fingerprint is read flat or from a wrapped body, as `unconfirmedStagingPeers` reads its marker. */
1160
+ function markInstallComparisons(ownFingerprint, replicated) {
1161
+ if (!ownFingerprint || !Array.isArray(replicated)) return undefined;
1162
+ const peers = [];
1163
+ for (const peer of replicated) {
1164
+ if (!peer || typeof peer !== 'object') continue;
1165
+ const comparison = compareInstallFingerprints(
1166
+ ownFingerprint,
1167
+ peer.install ?? peer.value?.install ?? peer.body?.install
1168
+ );
1169
+ peer.install_matches = comparison.matches;
1170
+ peer.install_differs = comparison.differs;
1171
+ peers.push({ node: peer.node ?? peer.name ?? peer.hostname ?? null, comparison });
1172
+ }
1173
+ return describeInstallDrift(peers);
1174
+ }
1175
+
1148
1176
  // Ring buffer of install stdout/stderr lines, capped by both line count and bytes so
1149
1177
  // a chatty install can't unbounded-grow the error response. snapshot() reports whether
1150
1178
  // the head was dropped so callers can flag truncation.
@@ -1634,6 +1662,7 @@ exports.dropCustomFunctionProject = dropCustomFunctionProject;
1634
1662
  exports.packageComponent = packageComponent;
1635
1663
  exports.deployComponent = deployComponent;
1636
1664
  exports.unconfirmedStagingPeers = unconfirmedStagingPeers;
1665
+ exports.markInstallComparisons = markInstallComparisons;
1637
1666
  exports.peerDeployAnswerTimeoutMs = peerDeployAnswerTimeoutMs;
1638
1667
  exports.getComponents = getComponents;
1639
1668
  exports.getComponentFile = getComponentFile;
package/config/DESIGN.md CHANGED
@@ -11,7 +11,7 @@ Index of every design note: [DESIGN.md](../DESIGN.md).
11
11
  ## `set_configuration` replication is opt-in; `replicateOperation` is default-on (`config/configUtils.ts`)
12
12
 
13
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
14
+ `req.replicated !== false` — absence of the flag means "replicate". That default-on contract is what
15
15
  DDL ops rely on (`dropSchema`/`dropTable` call it unconditionally), so a handler that mirrors the
16
16
  drop_schema pattern without a guard silently becomes replicate-by-default. `setConfiguration` must
17
17
  stay **opt-in** (`if (replicated)` truthy guard) because config bodies routinely carry node-local
@@ -211,13 +211,13 @@ What a scope does about a config that arrives late is the other half of settling
211
211
  config of its own to having one, which is both the recreated-config-file path and a scope that
212
212
  booted while the file was unreadable. Nothing downstream re-runs on it — `componentLoader` is long
213
213
  past its `await scope.ready` — so `Scope` answers a repeat `ready` the same way it answers `remove`,
214
- by requesting a restart. Without that, one worker keeps serving the defaults while every worker
214
+ by requesting a restart, unless the plugin listens for `ready` itself. Without that, one worker keeps serving the defaults while every worker
215
215
  that read the file cleanly serves the operator's config.
216
216
 
217
217
  Arming is a terminal outcome of its own: chokidar reports a scan that found no file by emitting
218
218
  `ready` and nothing else, so `RootConfigWatcher` always re-reads when the gate opens rather than
219
- publishing what an earlier read staged — a missing config file takes the ladder and settles on the
220
- defaults instead of holding the barrier open. That fallback must also discard the staged value:
219
+ publishing what an earlier read staged — a missing config file settles on the defaults at once (it
220
+ never takes the ladder) instead of holding the barrier open. That fallback must also discard the staged value:
221
221
  the arming re-read is authoritative precisely because a write in the unarmed window may have
222
222
  superseded it, including by replacing the file with an unusable or missing one. A watcher scan error
223
223
  also settles the barrier, but preserves a successfully staged value because no read superseded it.
@@ -258,7 +258,7 @@ cleanup that frees space needs a started process (#847). Two rules follow.
258
258
  **Derived boot writes are best-effort; user-requested ones are not.** `persistConfigDuringBoot()`
259
259
  swallows exactly ENOSPC/EDQUOT (matching on `errno` as well as `code`, because Linux has no libuv
260
260
  mapping for EDQUOT and reports `Unknown system error -122`) and lets the boot proceed on the
261
- in-memory config. `updateConfig`/`set_configuration`, `addConfig`, `deleteConfigFromFile` and the
261
+ in-memory config. `set_configuration` (`updateConfigValue`), deploy/drop's `applyRootConfigEffect` and the
262
262
  install path keep persist-or-throw: a caller who asked to persist must not get a silent success, and
263
263
  an install has no last-known-good config to fall back on.
264
264
 
@@ -10,7 +10,7 @@ Index of every design note: [DESIGN.md](../DESIGN.md).
10
10
 
11
11
  ## Version gate at startup: downgrades prompt, and only the minor direction is confirmable
12
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`).
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 [Struct mode is gated to primary DBIs](../resources/DESIGN.md#struct-mode-is-gated-to-primary-dbis-downgrade-compatibility) and `patchHdbSecretIsHashAttribute` in `upgrade/directives/5-2-0.ts`).
14
14
 
15
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
16
  - Upgrades never prompt (see the rationale comment in `bin/upgrade.js`); only the downgrade direction confirms. `upgradeCertsPrompt()` (4.x upgrade path; currently has no in-core caller) has the same no-TTY guard as `forceDowngradePrompt()` — a `GENERATE_CERTS` override is honored first, and with no TTY and no override it throws instead of blocking.
@@ -22,9 +22,9 @@ When `migrateOnStart` opens a source LMDB primary store to read records out for
22
22
 
23
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
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.
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 "keep the family's persisted codec, or the build default (lz4) for a new family" — the inverse default of lmdb. Every RocksDB open therefore takes one per-process deployment codec, `getRocksCompression()` (applied inside `openRocksDatabase`; see [The RocksDB codec is a deployment setting, resolved once per process](../resources/DESIGN.md#the-rocksdb-codec-is-a-deployment-setting-resolved-once-per-process)), derived through `toRocksCompression()` in `resources/databases.ts`, 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
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.
27
+ `bin/copyDb.ts`'s `openRocksDb` applies the same codec (`getRocksCompression() ?? toRocksCompression(...)`), 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
28
 
29
29
  ## System table bootstrap: `systemSchema.json` + upgrade directive
30
30
 
@@ -32,13 +32,13 @@ Adding a new system table (e.g. `hdb_deployment` in #641 Slice A) requires three
32
32
 
33
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
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).
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). Each directive module default-exports an array of `{ 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
36
 
37
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
38
 
39
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
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.
41
+ Set `audit = true` on the directive's `CreateTableObject` (for upgrades): a fresh install audits every system table — `utility/mount_hdb.ts:createTables()` sets `createTable.audit = true` regardless of the schema entry — so a directive that leaves it off makes upgraded nodes diverge from fresh ones.
42
42
 
43
43
  **A replicated system table whose owner declares more than a name list** — a table-level `expiration`, an index — needs that declaration on every node, every boot. `hdb_oidc_token_use` is the case: `security/authn/oidc/tokenUseTable.ts` holds its one definition, and `bin/run.ts initialize()` applies it after the upgrade step and before worker threads start (skipped in read-only mode; a failure is logged and retried on the next start), as do the 5.3.0 directive and the exchange path. Each replay row carries its own expiry as record metadata, which replication applies on the receiving node; the table's stored `expiration` is what arms the cleanup scan that removes those rows once a node loads the table. Metadata rather than an `@expiresAt` attribute, because the attribute needs an index and a sweep of its own on every node, while reads and the cleanup scan already honor the metadata. `systemSchema.json` keeps only its primary key; the declaration completes the table on the first boot after install. Every other route to a copy leaves it partial, which is why the declaration cannot live only on the exchange path or in the directive:
44
44
 
@@ -76,7 +76,7 @@ Three non-obvious mechanics keep that safe:
76
76
  be exactly that database's directory). Because the startup scan opens any `CURRENT`+`MANIFEST-`
77
77
  directory without re-applying `schemaRegex`, it also explicitly skips the reserved `` `restore` ``
78
78
  entry so an out-of-band directory at that name is never loaded as a database. Startup/rescan
79
- detection (`databasesBlockedByRestore` → `scanBlockedRestores` in `dataLayer/restoreMarker.ts`)
79
+ detection (`databasesBlockedByRestore` in `resources/databases.ts` → `scanBlockedRestores` in `dataLayer/restoreMarker.ts`)
80
80
  reads the metadata directory and checks the **marker first**, only probing the lock when the marker exists —
81
81
  probes take the flock and are mutually exclusive across threads, so probing the (persistent) lock
82
82
  file of every long-ago-restored database on every rescan would make concurrent rescans misclassify
@@ -127,15 +127,16 @@ Three non-obvious mechanics keep that safe:
127
127
  - **Online restore is impossible for a database a component holds open — and that failure is
128
128
  correct.** rocksdb-js's registry is process-global but records only a per-path refCount, with no
129
129
  attribution to a thread or component; Harper keeps no component→database ownership map. So when a
130
- loaded component (or the `system` database, which Harper itself never stops while running) holds
131
- its own handle on the target database, `registryStatus()` stays non-zero, Harper can neither
132
- identify nor force-close that handle, and an in-place purge would corrupt a live instance.
130
+ loaded component holds its own handle on the target database, `registryStatus()` stays non-zero,
131
+ Harper can neither identify nor force-close that handle, and an in-place purge would corrupt a live
132
+ instance.
133
133
  `verifyDatabaseClosed` therefore waits only a short grace period (`DATABASE_CLOSE_WAIT_MS`, for a
134
134
  just-finished job worker's own close to drain) and then fails fast with a 409 that points at
135
135
  running the operation offline (`harper restore_backup` with the server stopped, where no
136
136
  components are loaded and nothing holds the database open). Offline restore is the supported path
137
137
  for component-held and `system` databases; online restore serves databases not actively held by a
138
- component. The CLI exposes each backup operation under its operation name only (`create_backup`,
138
+ component. `system`, which Harper itself never stops while running, never reaches this check:
139
+ `validateRestoreBackup` refuses it up front with a 400 naming the offline command. The CLI exposes each backup operation under its operation name only (`create_backup`,
139
140
  `restore_backup`, …) — no hyphenated alias — and `bin/backup.ts` routes it to a reachable server
140
141
  or, when the local server is stopped, to the equivalent offline function.
141
142
  - **Job workers must release their RocksDB handles on exit, or the closure check can never pass.**
@@ -149,6 +150,11 @@ Three non-obvious mechanics keep that safe:
149
150
  calls `closeLoadedDatabases()` (`resources/databases.ts`) in its `finally`, closing every loaded
150
151
  user database on that thread (the non-enumerable `system` DB is intentionally skipped), so an
151
152
  exited job worker leaves no residual handle to be mistaken for a live holder.
153
+ - **A restore stamps a new database generation before `completeRestore`.** The restored files carry
154
+ the backup's generation, so both paths open the restored directory privately, stamp it and flush
155
+ (`stampDatabaseDirectory`, [database generation](../resources/DESIGN.md#database-generation-and-resumable-positions))
156
+ inside the destructive section: a failed stamp leaves the marker, and the rerun re-purges and
157
+ re-stamps. The stamp is as durable as the marker protocol it runs inside.
152
158
  - **`dropDatabase` and `restore_backup` serialize on the same lock, not a check-then-act probe.**
153
159
  A drop's `destroy()` interleaving with a restore's purge-and-copy on the same directory would gut
154
160
  a "successful" restore (or vice versa). `dropDatabase` takes the restore lock for every RocksDB or
@@ -170,22 +176,31 @@ Three non-obvious mechanics keep that safe:
170
176
  `create_table`/`create_schema` must not resurrect a half-purged directory as a fresh empty DB), but
171
177
  the destructive drop path now uses the exclusive lock so the race is closed, not merely narrowed.
172
178
  - **The offline restore probes RocksDB's own `LOCK` file, and fails closed.** The offline path runs
173
- only when the CLI sees no server (a PID heuristic; the PID file is briefly absent mid-`harper
174
- restart`), and `backups.restore`'s `purgeAllFiles` never takes RocksDB's lock — so before purging,
175
- `restoreBackupOffline` opens the database to probe. It now takes the restore lock+marker _before_
176
- probing (so a server that starts afterward sees the marker and refuses to load), and recognizes the
177
- pinned rocksdb-js 2.5.0 lock error — a plain `Error` with no `code` and message
178
- `IO error: While lock file: <db>/LOCK: Resource temporarily unavailable` (`isRocksDbLockError`) —
179
- aborting with a 409 rather than purging a database another process holds open. Any _other_ open
180
- failure (corrupt/half-restored) is exactly what restore recovers, so only a lock conflict aborts.
179
+ only when the CLI sees no server (a PID heuristic; the PID file is briefly absent
180
+ mid-`harper restart`), and `backups.restore`'s `purgeAllFiles` never takes RocksDB's lock — so
181
+ before purging, `restoreBackupOffline` opens the database to probe. It now takes the restore
182
+ lock+marker _before_ probing (so a server that starts afterward sees the marker and refuses to
183
+ load), and recognizes the rocksdb-js lock error by message (`isRocksDbLockError`; at 2.5.0, when
184
+ this was written, a plain `Error` with no `code`) —
185
+ `IO error: While lock file: <db>/LOCK: Resource temporarily unavailable` — aborting with a 409
186
+ rather than purging a database another process holds open. Any _other_ open failure
187
+ (corrupt/half-restored) is exactly what restore recovers, so only a lock conflict aborts.
181
188
 
182
189
  Known limitation: the flock is process-owned; if the restore job's worker _thread_ dies without
183
- the process exiting, the lock stays held (restores 409) until Harper restarts. There is no typed
184
- native lock signal in rocksdb-js 2.5.0, so the offline probe relies on message matching; a native
190
+ the process exiting, the lock stays held (restores 409) until Harper restarts. rocksdb-js had no typed
191
+ native lock signal at 2.5.0 (the pin is now 2.10.0), so the offline probe relies on message matching; a native
185
192
  lock primitive is a rocksdb-js follow-on.
186
193
 
187
194
  ## RocksDB managed backups: blob snapshots (`dataLayer/blobBackup.ts`)
188
195
 
196
+ Repository mutations hold the management lock before entering the engine. Restore admission
197
+ rechecks the source under that lock, then `beginRestore` takes the destination lock and runs a
198
+ synchronous callback that durably writes the source claim before publishing the restoring marker.
199
+ A crash after publication therefore leaves both marker and claim; a crash before publication leaves
200
+ only a claim that lapses when no marker exists. Failed claims preserve any preexisting marker.
201
+ Delete and purge reconcile Harper-managed files even after engine failure: a cleanup failure must
202
+ propagate when the engine succeeded, but remains secondary when the engine already failed.
203
+
189
204
  A database's file-backed blobs live in one or more roots _outside_ the RocksDB directory
190
205
  (`getBlobPathsForDatabaseName` in `resources/blob.ts` — one per configured `storage.blobPaths`, else
191
206
  `<hdb_root>/blobs/<database>`), so the engine's backup does not capture them. `create_backup`,
@@ -201,7 +216,7 @@ engine-only backup):
201
216
  before capture, a terminal ERROR (`0xff`) marker preserves its file id. A file reclaimed before its
202
217
  parent directory is read is outside the snapshot. This keeps a snapshot inode from changing as a
203
218
  live write finishes, while complete blobs remain safe to hard-link because published blob paths are
204
- write-once. The snapshot is built in a `.tmp-<id>` sibling and atomically renamed so a failed create
219
+ write-once. The snapshot is built in a `<backupId>.tmp` sibling and atomically renamed so a failed create
205
220
  leaves no partial snapshot. `restore_backup` purges each blob root and rewrites it from the snapshot;
206
221
  `delete_backup` / `purge_backups` remove the corresponding snapshot directories.
207
222
  - **`get_backup`** appends the blob files to the same tar under `blobs/<rootIndex>/<relpath>`. The
@@ -228,4 +243,4 @@ flag — not the mere presence of a snapshot dir — to decide whether to restor
228
243
  backup leaves live blobs untouched, and a manifest that claims blobs but has no snapshot is flagged
229
244
  corrupt by verify). This closes the "healthy-looking but incomplete" and concurrent-restore races;
230
245
  the remaining engine/blob point-in-time skew (a blob unlinked between the engine cut and the blob
231
- walk) is the documented best-effort limitation above.
246
+ walk) is best-effort: Harper does not freeze blob writes for a backup.
@@ -0,0 +1,248 @@
1
+ 'use strict';
2
+
3
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { fileLockRelease, tryFileLock } from '@harperfast/rocksdb-js';
7
+ import { ClientError } from '../utility/errors/hdbError.ts';
8
+ import logger from '../utility/logging/harper_logger.ts';
9
+ import { restoreMarkerPresent } from './restoreMarker.ts';
10
+ import { fsyncDirectory, removeFileDurably, writeFileDurably } from '../utility/durableFile.ts';
11
+
12
+ /**
13
+ * Harper-level coordination for a backup repository: one exclusive management lock, and pins that
14
+ * keep a backup alive while something is depending on it.
15
+ *
16
+ * rocksdb-js has its own `.backup.lock`, but it only covers the engine files. Harper's blob
17
+ * snapshots and completion manifests live beside them and are written and removed by separate
18
+ * Harper code, so a `purge_backups` can still remove a blob snapshot out from under a
19
+ * `create_backup` that is mid-finalization, or out from under a restore that is reading it
20
+ * (harper#2031). Everything that mutates the *Harper-managed* parts of a repository therefore takes
21
+ * this lock, in this order: management lock first, then any engine call that takes `.backup.lock`.
22
+ *
23
+ * A pin is the other half. A restore selects a backup and then acts on it, and a delete or purge in
24
+ * between would leave it with no source. Pins are installed and checked under the same lock, so "is
25
+ * this id pinned?" cannot be answered stale: a delete could otherwise read "unpinned", pause, and
26
+ * resume after a pin landed.
27
+ *
28
+ * A pin holds no owner or expiry. It names the database directory it is protecting, and it counts
29
+ * only while that database still carries a restoring marker — the marker being present is exactly
30
+ * the condition under which a restore is either running or waiting to be rerun, and therefore the
31
+ * condition under which its source must survive. A process killed mid-restore leaves both, and the
32
+ * rerun clears both; a process killed between clearing the marker and releasing the pin leaves a pin
33
+ * that is ignored and swept the next time anything looks. Nothing has to be reaped on a timer, and
34
+ * no pin can outlive what it protects.
35
+ */
36
+
37
+ const MANAGEMENT_LOCK_FILE = '.management.lock';
38
+ const PINS_DIR = 'pins';
39
+ const PIN_FILE_SUFFIX = '.json';
40
+ /** Filesystem-safe, and short enough to stay well inside NAME_MAX once suffixed. */
41
+ const PIN_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
42
+
43
+ const LOCK_POLL_INTERVAL_MS = 50;
44
+ const LOCK_WAIT_TIMEOUT_MS = 30_000;
45
+
46
+ export function managementLockPath(backupDir: string): string {
47
+ return join(backupDir, MANAGEMENT_LOCK_FILE);
48
+ }
49
+
50
+ export function backupPinsDir(backupDir: string): string {
51
+ return join(backupDir, PINS_DIR);
52
+ }
53
+
54
+ export interface BackupPin {
55
+ pin_id: string;
56
+ backup_id: number;
57
+ reason: string;
58
+ created_at: number;
59
+ /** Directory of the database whose restore this pin protects; the pin counts while it is marked. */
60
+ database_path?: string;
61
+ }
62
+
63
+ /** Codes that mean the repository cannot be written at all, not that this attempt lost a race. */
64
+ const REPOSITORY_NOT_WRITABLE = new Set(['EROFS', 'EACCES', 'EPERM']);
65
+ const { W_OK } = constants;
66
+
67
+ /**
68
+ * A repository Harper cannot write is refused by name rather than by whatever errno surfaced first.
69
+ * Every managed operation — restore included, which only reads the backup itself — records a lock
70
+ * and a claim inside the repository, so read-only media (a `ro` snapshot mount, a read-only share)
71
+ * cannot be used in place. Inferring "immutable, so no claim needed" from a failed write would be
72
+ * wrong: a read-only *view* of a repository another writer can still purge looks identical.
73
+ */
74
+ function assertRepositoryWritable(databaseName: string, error: any): never {
75
+ if (!REPOSITORY_NOT_WRITABLE.has(error?.code)) throw error;
76
+ throw new ClientError(
77
+ `Backup repository for database '${databaseName}' is not writable (${error.code}). Harper records a ` +
78
+ `management lock and a claim inside the repository for every backup operation, including restore, so ` +
79
+ `it cannot be used read-only. Remount it writable, or copy it to writable storage, and retry.`,
80
+ 409
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Run `operation` holding the repository's management lock.
86
+ *
87
+ * Waits rather than failing immediately: the operations it serializes are short (a manifest write,
88
+ * a snapshot removal) except for a blob snapshot of a large database, and a `delete_backup` that
89
+ * 409s because an unrelated `create_backup` was finalizing is a worse answer than a brief wait. A
90
+ * holder that dies releases the lock with its process, so the wait cannot outlive a crash.
91
+ */
92
+ export async function withBackupRepositoryLock<T>(
93
+ backupDir: string,
94
+ databaseName: string,
95
+ operation: () => Promise<T>,
96
+ createRepository = false
97
+ ): Promise<T> {
98
+ const lockPath = managementLockPath(backupDir);
99
+ const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
100
+ if (!existsSync(backupDir)) {
101
+ // Only a create may bring a repository into existence. Creating it here for a delete or purge
102
+ // would leave an empty directory behind after the 404, and a later list_backups for that name
103
+ // would answer [] instead of the 404 a typo is owed. With nothing on disk there is also
104
+ // nothing to exclude, so the operation runs unlocked and refuses on its own.
105
+ if (!createRepository) return operation();
106
+ try {
107
+ mkdirSync(backupDir, { recursive: true });
108
+ } catch (error: any) {
109
+ assertRepositoryWritable(databaseName, error);
110
+ }
111
+ }
112
+ // The binding's lock error carries a message but no errno, so writability is checked here.
113
+ try {
114
+ accessSync(backupDir, W_OK);
115
+ } catch (error: any) {
116
+ assertRepositoryWritable(databaseName, error);
117
+ }
118
+ let token = tryFileLock(lockPath);
119
+ while (token === 0) {
120
+ if (Date.now() >= deadline) {
121
+ throw new ClientError(
122
+ `Another backup management operation is in progress for database '${databaseName}'; retry once it finishes`,
123
+ 409
124
+ );
125
+ }
126
+ await delay(LOCK_POLL_INTERVAL_MS);
127
+ token = tryFileLock(lockPath);
128
+ }
129
+ try {
130
+ return await operation();
131
+ } finally {
132
+ fileLockRelease(token);
133
+ }
134
+ }
135
+
136
+ function pinPath(backupDir: string, pinId: string): string {
137
+ if (!PIN_ID_PATTERN.test(pinId)) throw new ClientError(`Invalid backup pin id '${pinId}'`);
138
+ return join(backupPinsDir(backupDir), pinId + PIN_FILE_SUFFIX);
139
+ }
140
+
141
+ /** Every pin currently held on a repository. A malformed pin file is reported, never silently ignored. */
142
+ export function readBackupPins(backupDir: string): BackupPin[] {
143
+ const pinsDir = backupPinsDir(backupDir);
144
+ if (!existsSync(pinsDir)) return [];
145
+ const pins: BackupPin[] = [];
146
+ for (const entry of readdirSync(pinsDir, { withFileTypes: true })) {
147
+ if (!entry.isFile() || !entry.name.endsWith(PIN_FILE_SUFFIX)) continue;
148
+ const pinId = entry.name.slice(0, -PIN_FILE_SUFFIX.length);
149
+ let parsed: any;
150
+ try {
151
+ parsed = JSON.parse(readFileSync(join(pinsDir, entry.name), 'utf8'));
152
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
153
+ throw new Error('pin file is not an object');
154
+ }
155
+ // A parseable record is not a usable one: `{"backup_id":"7"}` matches no requested id, so
156
+ // without this the claim permits exactly the delete it exists to refuse.
157
+ if (!Number.isInteger(parsed.backup_id) || parsed.backup_id < 1) {
158
+ throw new Error('pin file has no usable backup id');
159
+ }
160
+ } catch (error: any) {
161
+ // A released claim, unlinked between the readdir and the read. Anything else still means
162
+ // something claimed a backup: fail closed against every id, because a torn pin file must not
163
+ // become permission to delete whatever it was protecting.
164
+ if (error?.code === 'ENOENT') continue;
165
+ pins.push({ pin_id: pinId, backup_id: Number.NaN, reason: 'unreadable pin', created_at: 0 });
166
+ continue;
167
+ }
168
+ pins.push({
169
+ pin_id: pinId,
170
+ backup_id: parsed.backup_id,
171
+ reason: typeof parsed.reason === 'string' ? parsed.reason : 'unspecified',
172
+ created_at: parsed.created_at ?? 0,
173
+ database_path: typeof parsed.database_path === 'string' ? parsed.database_path : undefined,
174
+ });
175
+ }
176
+ return pins;
177
+ }
178
+
179
+ /**
180
+ * Claim a backup so it cannot be deleted or purged. The caller must hold the management lock, which
181
+ * is what makes the claim atomic with the delete/purge admission check.
182
+ */
183
+ export function pinBackup(
184
+ backupDir: string,
185
+ pinId: string,
186
+ backupId: number,
187
+ reason: string,
188
+ databasePath?: string
189
+ ): void {
190
+ const path = pinPath(backupDir, pinId);
191
+ const pinsDir = backupPinsDir(backupDir);
192
+ const created = mkdirSync(pinsDir, { recursive: true });
193
+ // writeFileDurably flushes the pins directory, but on the first pin that directory's own entry is
194
+ // new too — without this the whole directory, and so the pin, can be lost to a power cut.
195
+ if (created !== undefined) fsyncDirectory(backupDir);
196
+ const pin: BackupPin = {
197
+ pin_id: pinId,
198
+ backup_id: backupId,
199
+ reason,
200
+ created_at: Date.now(),
201
+ ...(databasePath ? { database_path: databasePath } : {}),
202
+ };
203
+ writeFileDurably(path, JSON.stringify(pin), `${pinId}.tmp`);
204
+ }
205
+
206
+ /** Whether a pin still protects anything: a restore pin counts only while its database is marked. */
207
+ function pinIsLive(pin: BackupPin): boolean {
208
+ if (!pin.database_path) return true;
209
+ return restoreMarkerPresent(pin.database_path);
210
+ }
211
+
212
+ /** Release a claim. Missing is success — the goal is that nothing holds this backup any more. */
213
+ export function unpinBackup(backupDir: string, pinId: string): void {
214
+ removeFileDurably(pinPath(backupDir, pinId));
215
+ }
216
+
217
+ /**
218
+ * Refuse to remove any of `backupIds` while something holds a pin on it. An unreadable pin blocks
219
+ * every id: what it was protecting is exactly what cannot be determined.
220
+ */
221
+ export function assertBackupsUnpinned(backupDir: string, backupIds: number[], databaseName: string): void {
222
+ const pins = readBackupPins(backupDir);
223
+ if (pins.length === 0) return;
224
+ const requested = new Set(backupIds);
225
+ const live: BackupPin[] = [];
226
+ for (const pin of pins) {
227
+ if (pinIsLive(pin)) live.push(pin);
228
+ else {
229
+ // Opportunistic: a lapsed claim we cannot unlink is debris, not a reason to refuse the
230
+ // removal it no longer protects.
231
+ try {
232
+ unpinBackup(backupDir, pin.pin_id);
233
+ } catch (error) {
234
+ logger.warn(`Could not sweep a lapsed backup claim in ${backupDir}`, error);
235
+ }
236
+ }
237
+ }
238
+ const blocking = live.filter((pin) => Number.isNaN(pin.backup_id) || requested.has(pin.backup_id));
239
+ if (blocking.length === 0) return;
240
+ const described = blocking
241
+ .map((pin) => `${Number.isNaN(pin.backup_id) ? 'unknown backup' : `backup ${pin.backup_id}`} (${pin.reason})`)
242
+ .join(', ');
243
+ throw new ClientError(
244
+ `Cannot remove backups of database '${databaseName}' that are in use: ${described}. ` +
245
+ `Finish or cancel the operation holding them first.`,
246
+ 409
247
+ );
248
+ }