@harperfast/harper 5.2.0-beta.4 → 5.2.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 (320) hide show
  1. package/bin/backup.ts +169 -0
  2. package/bin/cliOperations.ts +92 -73
  3. package/bin/copyDb.ts +21 -4
  4. package/bin/harper.ts +43 -55
  5. package/bin/help.ts +216 -0
  6. package/components/Application.ts +236 -46
  7. package/components/ApplicationScope.ts +26 -0
  8. package/components/EntryHandler.ts +410 -105
  9. package/components/RuntimeModuleTracker.ts +189 -0
  10. package/components/Scope.ts +120 -30
  11. package/components/componentLoader.ts +177 -25
  12. package/components/deployLifecycle.ts +119 -33
  13. package/components/mcp/toolRegistry.ts +10 -0
  14. package/components/mcp/tools/application.ts +12 -5
  15. package/components/mcp/tools/operations.ts +9 -0
  16. package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
  17. package/components/mcp/tools/schemas/operations.ts +9 -0
  18. package/components/operations.js +5 -6
  19. package/components/operationsValidation.js +32 -2
  20. package/components/scopeMount.ts +150 -0
  21. package/config/configUtils.ts +12 -9
  22. package/config-root.schema.json +14 -0
  23. package/dataLayer/backupManifest.ts +102 -0
  24. package/dataLayer/blobBackup.ts +286 -0
  25. package/dataLayer/harperBridge/ResourceBridge.ts +69 -5
  26. package/dataLayer/hdbInfoController.ts +8 -0
  27. package/dataLayer/restoreMarker.ts +276 -0
  28. package/dataLayer/rocksdbBackup.ts +1100 -0
  29. package/dataLayer/schemaDescribe.ts +2 -1
  30. package/dist/bin/backup.d.ts +9 -0
  31. package/dist/bin/backup.js +192 -0
  32. package/dist/bin/backup.js.map +1 -0
  33. package/dist/bin/cliOperations.d.ts +13 -0
  34. package/dist/bin/cliOperations.js +89 -70
  35. package/dist/bin/cliOperations.js.map +1 -1
  36. package/dist/bin/copyDb.js +13 -2
  37. package/dist/bin/copyDb.js.map +1 -1
  38. package/dist/bin/harper.d.ts +13 -0
  39. package/dist/bin/harper.js +45 -56
  40. package/dist/bin/harper.js.map +1 -1
  41. package/dist/bin/help.d.ts +8 -0
  42. package/dist/bin/help.js +192 -0
  43. package/dist/bin/help.js.map +1 -0
  44. package/dist/components/Application.d.ts +16 -1
  45. package/dist/components/Application.js +210 -38
  46. package/dist/components/Application.js.map +1 -1
  47. package/dist/components/ApplicationScope.d.ts +7 -0
  48. package/dist/components/ApplicationScope.js +22 -0
  49. package/dist/components/ApplicationScope.js.map +1 -1
  50. package/dist/components/EntryHandler.d.ts +4 -4
  51. package/dist/components/EntryHandler.js +386 -95
  52. package/dist/components/EntryHandler.js.map +1 -1
  53. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  54. package/dist/components/RuntimeModuleTracker.js +189 -0
  55. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  56. package/dist/components/Scope.d.ts +38 -1
  57. package/dist/components/Scope.js +117 -28
  58. package/dist/components/Scope.js.map +1 -1
  59. package/dist/components/componentLoader.d.ts +2 -6
  60. package/dist/components/componentLoader.js +160 -18
  61. package/dist/components/componentLoader.js.map +1 -1
  62. package/dist/components/deployLifecycle.d.ts +6 -2
  63. package/dist/components/deployLifecycle.js +109 -31
  64. package/dist/components/deployLifecycle.js.map +1 -1
  65. package/dist/components/mcp/toolRegistry.d.ts +7 -0
  66. package/dist/components/mcp/toolRegistry.js +10 -0
  67. package/dist/components/mcp/toolRegistry.js.map +1 -1
  68. package/dist/components/mcp/tools/application.js +11 -5
  69. package/dist/components/mcp/tools/application.js.map +1 -1
  70. package/dist/components/mcp/tools/operations.js +9 -0
  71. package/dist/components/mcp/tools/operations.js.map +1 -1
  72. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
  73. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  74. package/dist/components/mcp/tools/schemas/operations.js +9 -0
  75. package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
  76. package/dist/components/operations.js +6 -6
  77. package/dist/components/operations.js.map +1 -1
  78. package/dist/components/operationsValidation.js +34 -2
  79. package/dist/components/operationsValidation.js.map +1 -1
  80. package/dist/components/scopeMount.d.ts +86 -0
  81. package/dist/components/scopeMount.js +131 -0
  82. package/dist/components/scopeMount.js.map +1 -0
  83. package/dist/config/configUtils.js +13 -9
  84. package/dist/config/configUtils.js.map +1 -1
  85. package/dist/dataLayer/backupManifest.d.ts +26 -0
  86. package/dist/dataLayer/backupManifest.js +97 -0
  87. package/dist/dataLayer/backupManifest.js.map +1 -0
  88. package/dist/dataLayer/blobBackup.d.ts +87 -0
  89. package/dist/dataLayer/blobBackup.js +282 -0
  90. package/dist/dataLayer/blobBackup.js.map +1 -0
  91. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
  92. package/dist/dataLayer/harperBridge/ResourceBridge.js +45 -5
  93. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  94. package/dist/dataLayer/hdbInfoController.js +4 -0
  95. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  96. package/dist/dataLayer/restoreMarker.d.ts +122 -0
  97. package/dist/dataLayer/restoreMarker.js +261 -0
  98. package/dist/dataLayer/restoreMarker.js.map +1 -0
  99. package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
  100. package/dist/dataLayer/rocksdbBackup.js +1039 -0
  101. package/dist/dataLayer/rocksdbBackup.js.map +1 -0
  102. package/dist/dataLayer/schemaDescribe.js +2 -1
  103. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  104. package/dist/resources/DatabaseTransaction.d.ts +55 -0
  105. package/dist/resources/DatabaseTransaction.js +282 -109
  106. package/dist/resources/DatabaseTransaction.js.map +1 -1
  107. package/dist/resources/ResourceInterface.d.ts +8 -5
  108. package/dist/resources/ResourceInterface.js.map +1 -1
  109. package/dist/resources/Resources.js +22 -4
  110. package/dist/resources/Resources.js.map +1 -1
  111. package/dist/resources/Table.d.ts +6 -5
  112. package/dist/resources/Table.js +114 -37
  113. package/dist/resources/Table.js.map +1 -1
  114. package/dist/resources/analytics/write.js +6 -6
  115. package/dist/resources/analytics/write.js.map +1 -1
  116. package/dist/resources/blob.d.ts +8 -1
  117. package/dist/resources/blob.js +31 -14
  118. package/dist/resources/blob.js.map +1 -1
  119. package/dist/resources/databases.d.ts +59 -1
  120. package/dist/resources/databases.js +386 -41
  121. package/dist/resources/databases.js.map +1 -1
  122. package/dist/resources/jsResource.d.ts +4 -26
  123. package/dist/resources/jsResource.js +5 -59
  124. package/dist/resources/jsResource.js.map +1 -1
  125. package/dist/resources/models/Models.d.ts +11 -1
  126. package/dist/resources/models/Models.js +10 -1
  127. package/dist/resources/models/Models.js.map +1 -1
  128. package/dist/resources/models/backendRegistry.d.ts +9 -0
  129. package/dist/resources/models/backendRegistry.js +10 -0
  130. package/dist/resources/models/backendRegistry.js.map +1 -1
  131. package/dist/resources/models/openaiStream.d.ts +16 -1
  132. package/dist/resources/models/openaiStream.js +113 -21
  133. package/dist/resources/models/openaiStream.js.map +1 -1
  134. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  135. package/dist/resources/models/v1/chatCompletions.js +115 -0
  136. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  137. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  138. package/dist/resources/models/v1/embeddings.js +71 -0
  139. package/dist/resources/models/v1/embeddings.js.map +1 -0
  140. package/dist/resources/models/v1/errors.d.ts +54 -0
  141. package/dist/resources/models/v1/errors.js +130 -0
  142. package/dist/resources/models/v1/errors.js.map +1 -0
  143. package/dist/resources/models/v1/index.d.ts +36 -0
  144. package/dist/resources/models/v1/index.js +75 -0
  145. package/dist/resources/models/v1/index.js.map +1 -0
  146. package/dist/resources/models/v1/models.d.ts +26 -0
  147. package/dist/resources/models/v1/models.js +44 -0
  148. package/dist/resources/models/v1/models.js.map +1 -0
  149. package/dist/resources/models/v1/translation.d.ts +133 -0
  150. package/dist/resources/models/v1/translation.js +298 -0
  151. package/dist/resources/models/v1/translation.js.map +1 -0
  152. package/dist/resources/roles.d.ts +1 -1
  153. package/dist/resources/roles.js +54 -7
  154. package/dist/resources/roles.js.map +1 -1
  155. package/dist/resources/transaction.js +0 -3
  156. package/dist/resources/transaction.js.map +1 -1
  157. package/dist/security/jsLoader.js +84 -33
  158. package/dist/security/jsLoader.js.map +1 -1
  159. package/dist/security/role.js +4 -0
  160. package/dist/security/role.js.map +1 -1
  161. package/dist/security/superUserGuard.d.ts +7 -0
  162. package/dist/security/superUserGuard.js +23 -0
  163. package/dist/security/superUserGuard.js.map +1 -0
  164. package/dist/security/tokenAuthentication.d.ts +0 -1
  165. package/dist/security/tokenAuthentication.js +6 -2
  166. package/dist/security/tokenAuthentication.js.map +1 -1
  167. package/dist/security/user.d.ts +6 -1
  168. package/dist/security/user.js +23 -1
  169. package/dist/security/user.js.map +1 -1
  170. package/dist/server/REST.js +25 -9
  171. package/dist/server/REST.js.map +1 -1
  172. package/dist/server/fastifyRoutes.js +15 -1
  173. package/dist/server/fastifyRoutes.js.map +1 -1
  174. package/dist/server/http.d.ts +23 -2
  175. package/dist/server/http.js +99 -12
  176. package/dist/server/http.js.map +1 -1
  177. package/dist/server/itc/serverHandlers.js +7 -1
  178. package/dist/server/itc/serverHandlers.js.map +1 -1
  179. package/dist/server/jobs/jobProcess.js +20 -1
  180. package/dist/server/jobs/jobProcess.js.map +1 -1
  181. package/dist/server/jobs/jobRunner.js +10 -0
  182. package/dist/server/jobs/jobRunner.js.map +1 -1
  183. package/dist/server/jobs/jobs.js +11 -0
  184. package/dist/server/jobs/jobs.js.map +1 -1
  185. package/dist/server/middlewareChain.d.ts +10 -1
  186. package/dist/server/middlewareChain.js +81 -21
  187. package/dist/server/middlewareChain.js.map +1 -1
  188. package/dist/server/operationsServer.d.ts +0 -2
  189. package/dist/server/operationsServer.js.map +1 -1
  190. package/dist/server/serverHelpers/multipartParser.js +9 -0
  191. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  192. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  193. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  194. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  195. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  196. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  197. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  198. package/dist/server/serverHelpers/serverHandlers.js +27 -7
  199. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  200. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  201. package/dist/server/serverHelpers/serverUtilities.js +21 -7
  202. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  203. package/dist/server/static.js +68 -21
  204. package/dist/server/static.js.map +1 -1
  205. package/dist/server/storageReclamation.d.ts +17 -0
  206. package/dist/server/storageReclamation.js +90 -6
  207. package/dist/server/storageReclamation.js.map +1 -1
  208. package/dist/server/threads/socketRouter.js +20 -0
  209. package/dist/server/threads/socketRouter.js.map +1 -1
  210. package/dist/server/threads/threadServer.js +12 -0
  211. package/dist/server/threads/threadServer.js.map +1 -1
  212. package/dist/sqlEngine/diff/differential.js +7 -3
  213. package/dist/sqlEngine/diff/differential.js.map +1 -1
  214. package/dist/sqlTranslator/index.js +6 -1
  215. package/dist/sqlTranslator/index.js.map +1 -1
  216. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  217. package/dist/upgrade/upgradePrompt.js +22 -3
  218. package/dist/upgrade/upgradePrompt.js.map +1 -1
  219. package/dist/utility/OperationFunctionCaller.js +25 -6
  220. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  221. package/dist/utility/errors/commonErrors.d.ts +1 -0
  222. package/dist/utility/errors/commonErrors.js +1 -0
  223. package/dist/utility/errors/commonErrors.js.map +1 -1
  224. package/dist/utility/hdbTerms.d.ts +13 -1
  225. package/dist/utility/hdbTerms.js +13 -1
  226. package/dist/utility/hdbTerms.js.map +1 -1
  227. package/dist/utility/install/installer.js +32 -0
  228. package/dist/utility/install/installer.js.map +1 -1
  229. package/dist/utility/logging/harper_logger.d.ts +47 -0
  230. package/dist/utility/logging/harper_logger.js +803 -38
  231. package/dist/utility/logging/harper_logger.js.map +1 -1
  232. package/dist/utility/operation_authorization.js +13 -3
  233. package/dist/utility/operation_authorization.js.map +1 -1
  234. package/npm-shrinkwrap.json +198 -193
  235. package/package.json +12 -7
  236. package/resources/DESIGN.md +2 -0
  237. package/resources/DatabaseTransaction.ts +310 -104
  238. package/resources/ResourceInterface.ts +8 -5
  239. package/resources/Resources.ts +22 -4
  240. package/resources/Table.ts +341 -263
  241. package/resources/analytics/write.ts +22 -20
  242. package/resources/blob.ts +31 -14
  243. package/resources/databases.ts +387 -46
  244. package/resources/jsResource.ts +5 -62
  245. package/resources/models/Models.ts +14 -1
  246. package/resources/models/backendRegistry.ts +10 -0
  247. package/resources/models/openaiStream.ts +131 -19
  248. package/resources/models/v1/chatCompletions.ts +128 -0
  249. package/resources/models/v1/embeddings.ts +70 -0
  250. package/resources/models/v1/errors.ts +141 -0
  251. package/resources/models/v1/index.ts +72 -0
  252. package/resources/models/v1/models.ts +53 -0
  253. package/resources/models/v1/translation.ts +362 -0
  254. package/resources/roles.ts +67 -7
  255. package/resources/transaction.ts +0 -3
  256. package/security/jsLoader.ts +84 -30
  257. package/security/role.ts +7 -0
  258. package/security/superUserGuard.ts +20 -0
  259. package/security/tokenAuthentication.ts +6 -3
  260. package/security/user.ts +26 -1
  261. package/server/DESIGN.md +61 -34
  262. package/server/REST.ts +25 -9
  263. package/server/fastifyRoutes.ts +20 -1
  264. package/server/http.ts +100 -13
  265. package/server/itc/serverHandlers.js +7 -1
  266. package/server/jobs/jobProcess.ts +18 -1
  267. package/server/jobs/jobRunner.ts +10 -0
  268. package/server/jobs/jobs.ts +11 -0
  269. package/server/middlewareChain.ts +79 -20
  270. package/server/operationsServer.ts +0 -2
  271. package/server/serverHelpers/multipartParser.ts +9 -0
  272. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  273. package/server/serverHelpers/registeredOperations.ts +19 -15
  274. package/server/serverHelpers/serverHandlers.js +28 -7
  275. package/server/serverHelpers/serverUtilities.ts +29 -7
  276. package/server/static.ts +87 -23
  277. package/server/storageReclamation.ts +104 -8
  278. package/server/threads/socketRouter.ts +20 -0
  279. package/server/threads/threadServer.js +11 -0
  280. package/sqlTranslator/index.ts +6 -1
  281. package/static/defaultConfig.yaml +3 -0
  282. package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-aApwhRmz.js} +2 -2
  283. package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-aApwhRmz.js.map} +1 -1
  284. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-sC0H91n9.js} +4 -4
  285. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-sC0H91n9.js.map} +1 -1
  286. package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-DZ8JhHOI.js} +2 -2
  287. package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-DZ8JhHOI.js.map} +1 -1
  288. package/studio/web/assets/{applications-BDfH8urd.js → applications-9SEVLIO5.js} +2 -2
  289. package/studio/web/assets/{applications-BDfH8urd.js.map → applications-9SEVLIO5.js.map} +1 -1
  290. package/studio/web/assets/{index-BHo3c2Gk.js → index-Dy3uDGXb.js} +6 -6
  291. package/studio/web/assets/index-Dy3uDGXb.js.map +1 -0
  292. package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-CN1zq4I4.js} +4 -4
  293. package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-CN1zq4I4.js.map} +1 -1
  294. package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-BFAF07xr.js} +2 -2
  295. package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-BFAF07xr.js.map} +1 -1
  296. package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CKlYVvVN.js} +2 -2
  297. package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CKlYVvVN.js.map} +1 -1
  298. package/studio/web/assets/{profile-Doj5FVDE.js → profile-A1zhEdFG.js} +2 -2
  299. package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-A1zhEdFG.js.map} +1 -1
  300. package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-SEtBt_GV.js} +2 -2
  301. package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-SEtBt_GV.js.map} +1 -1
  302. package/studio/web/assets/{setup-DJwR0BHd.js → setup-BisINqdH.js} +2 -2
  303. package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-BisINqdH.js.map} +1 -1
  304. package/studio/web/assets/{status-Br_AbsJs.js → status-BuQoCc7l.js} +2 -2
  305. package/studio/web/assets/{status-Br_AbsJs.js.map → status-BuQoCc7l.js.map} +1 -1
  306. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-CAi_s1PC.js} +2 -2
  307. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-CAi_s1PC.js.map} +1 -1
  308. package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-DT74tlkM.js} +2 -2
  309. package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-DT74tlkM.js.map} +1 -1
  310. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-CGRGc1n7.js} +2 -2
  311. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-CGRGc1n7.js.map} +1 -1
  312. package/studio/web/index.html +1 -1
  313. package/upgrade/upgradePrompt.ts +22 -3
  314. package/utility/OperationFunctionCaller.ts +24 -3
  315. package/utility/errors/commonErrors.ts +2 -0
  316. package/utility/hdbTerms.ts +13 -1
  317. package/utility/install/installer.ts +37 -0
  318. package/utility/logging/harper_logger.ts +786 -41
  319. package/utility/operation_authorization.ts +31 -3
  320. package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
@@ -18,6 +18,11 @@ const PROJECT_FILE_NAME_REGEX = /^[a-zA-Z0-9-_]+$/;
18
18
  // containing `=` or a newline) from injecting extra assignments into a .env file.
19
19
  const ENV_KEY_REGEX = /^[\w.-]+$/;
20
20
 
21
+ // Compiled once and reused: a routing `host` is either a DNS hostname or a bare IPv6 literal, and
22
+ // re-compiling these per validation call would allocate on every deploy_component.
23
+ const HOSTNAME_SCHEMA = Joi.string().hostname();
24
+ const IPV6_SCHEMA = Joi.string().ip({ version: 'ipv6' });
25
+
21
26
  module.exports = {
22
27
  getDropCustomFunctionValidator,
23
28
  setCustomFunctionValidator,
@@ -457,10 +462,33 @@ function deployComponentValidator(req) {
457
462
  .min(1)
458
463
  .custom((value, helpers) => {
459
464
  if (value.includes('..')) return helpers.error('any.invalid');
465
+ // A component mount has no relative base and WHATWG clients strip '.' segments before
466
+ // sending the request, so a dot-segment mount would simply be unreachable.
467
+ if (value.split('/').includes('.')) return helpers.error('string.dotSegment');
468
+ return value;
469
+ })
470
+ .optional()
471
+ .messages({
472
+ 'any.invalid': '{#label} must not contain ".."',
473
+ 'string.dotSegment': '{#label} must not contain "." path segments',
474
+ }),
475
+ // Virtual hostname the component is served on. Like `urlPath`, this is deployment routing and
476
+ // belongs on the root-config entry, not in the component's own config.yaml. `hostname()`
477
+ // rejects a value carrying a port or path, which would never match the router's host compare.
478
+ // IPv6 literals are accepted in their bare form only — the router unwraps the brackets it
479
+ // finds in a Host header, so a bracketed value here would never match.
480
+ host: Joi.string()
481
+ .custom((value, helpers) => {
482
+ if (value.startsWith('[') || value.endsWith(']')) return helpers.error('string.bracketedHost');
460
483
  return value;
461
484
  })
485
+ .custom((value, helpers) => {
486
+ if (!HOSTNAME_SCHEMA.validate(value).error) return value;
487
+ // Accept a bare IPv6 literal, which `hostname()` rejects but the router can match.
488
+ return IPV6_SCHEMA.validate(value).error ? helpers.error('string.hostname') : value;
489
+ })
462
490
  .optional()
463
- .messages({ 'any.invalid': 'urlPath must not contain ".."' }),
491
+ .messages({ 'string.bracketedHost': '{#label} must not be bracketed; use the bare IPv6 literal' }),
464
492
  // Deploy credentials. The array is kind-heterogeneous: an entry's kind is implied by its
465
493
  // identifying key rather than a separate discriminator field, so a new kind is added as
466
494
  // another item alternative here without reshaping the field. Today: npm registry auth
@@ -490,7 +518,9 @@ function deployComponentValidator(req) {
490
518
  registryAuth: Joi.any().forbidden().messages({
491
519
  'any.unknown': `'registryAuth' has been renamed to 'credentials'`,
492
520
  }),
493
- }).with('urlPath', 'package');
521
+ })
522
+ .with('urlPath', 'package')
523
+ .with('host', 'package');
494
524
 
495
525
  return validator.validateBySchema(req, deployProjSchema);
496
526
  }
@@ -0,0 +1,150 @@
1
+ import Joi from 'joi';
2
+ import { InvalidBaseURLPathError, resolveBaseURLPath } from './resolveBaseURLPath.ts';
3
+ import harperLogger from '../utility/logging/harper_logger.ts';
4
+
5
+ /**
6
+ * The routing an operator declared for an application in the *root* config, e.g.
7
+ *
8
+ * ```yaml
9
+ * my-app:
10
+ * package: '@my/app'
11
+ * host: api.example.com
12
+ * urlPath: /v1
13
+ * ```
14
+ *
15
+ * Where an application is served is a deployment concern, not an application concern, so
16
+ * the root config is authoritative: a checked-in `config.yaml` cannot pin the hostname or
17
+ * mount point an operator has chosen. The mount reaches every plugin scope loaded for that
18
+ * application (and, transitively, plugins the application itself declares).
19
+ *
20
+ * The mount is applied at exactly one place: the routing boundary, where `Scope`'s `server`
21
+ * proxy registers a handler. The router strips it before the handler runs, so everything
22
+ * inside the application — entry URL paths, and the resource paths `graphqlSchema` and
23
+ * `jsResource` derive from them — stays mount-relative. Only code that emits an absolute URL
24
+ * back to the client (`Scope.externalBasePath`) or bypasses the routed chain (legacy fastify
25
+ * routes) needs to know the mount exists.
26
+ */
27
+ export interface ScopeMount {
28
+ host?: string;
29
+ urlPath?: string;
30
+ }
31
+
32
+ export class InvalidMountPathError extends Error {
33
+ constructor(urlPath: string) {
34
+ super(`An application mount urlPath must be an absolute path without '.' or '..' segments. Received: '${urlPath}'`);
35
+ }
36
+ }
37
+
38
+ export class InvalidMountHostError extends Error {
39
+ constructor(host: string) {
40
+ super(
41
+ `An application mount host must be a bare hostname or IPv6 literal, with no scheme, port, or path. Received: '${host}'`
42
+ );
43
+ }
44
+ }
45
+
46
+ // Compiled once and reused, mirroring operationsValidation.js's deploy_component 'host' schema:
47
+ // a routing host is either a DNS hostname or a bare IPv6 literal.
48
+ const HOSTNAME_SCHEMA = Joi.string().hostname();
49
+ const IPV6_SCHEMA = Joi.string().ip({ version: 'ipv6' });
50
+
51
+ /**
52
+ * Normalizes a mount prefix to a leading-slash, no-trailing-slash form ('/v1'), or
53
+ * undefined when it constrains nothing ('', '/', undefined) — matching
54
+ * `middlewareChain.normalizeUrlPath`, so a mount that means "the root" composes to
55
+ * exactly the plugin's own path rather than rewriting it.
56
+ *
57
+ * Dot segments are rejected rather than resolved. A plugin's `urlPath` may be plugin-name
58
+ * relative ('./x'), but a mount has no such base, and WHATWG clients strip '.' segments
59
+ * before sending the request — a '/.'-prefixed route would simply be unreachable.
60
+ */
61
+ export function normalizeMountPath(urlPath: string | undefined): string | undefined {
62
+ if (!urlPath) return undefined;
63
+ if (urlPath.includes('..')) throw new InvalidBaseURLPathError(urlPath);
64
+ let normalized = urlPath.startsWith('/') ? urlPath : `/${urlPath}`;
65
+ normalized = normalized.replace(/\/+$/, '');
66
+ if (normalized.split('/').includes('.')) throw new InvalidMountPathError(urlPath);
67
+ return normalized.length <= 1 ? undefined : normalized;
68
+ }
69
+
70
+ /**
71
+ * Hostnames are case-insensitive (RFC 4343) and clients send them lowercased, so a mount is
72
+ * held lowercased and compared that way. The bracket form of an IPv6 literal is unwrapped to
73
+ * match what the router extracts from the Host header.
74
+ *
75
+ * Validated against the same grammar as the `deploy_component` operation's `host` field (a bare
76
+ * DNS hostname or IPv6 literal) — the root config is hand-edited YAML with no equivalent gate,
77
+ * so a value that would never match a Host header (a port, a scheme, a path suffix, malformed
78
+ * IDN) must be rejected here rather than silently mounting the application unreachably.
79
+ */
80
+ export function normalizeMountHost(host: string | undefined): string | undefined {
81
+ if (!host) return undefined;
82
+ const unbracketed = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
83
+ const normalized = unbracketed.toLowerCase();
84
+ if (HOSTNAME_SCHEMA.validate(normalized).error && IPV6_SCHEMA.validate(normalized).error) {
85
+ throw new InvalidMountHostError(host);
86
+ }
87
+ return normalized;
88
+ }
89
+
90
+ /**
91
+ * Reads a mount off a root-config entry, or undefined when the entry declares no routing at
92
+ * all — so an unmounted application takes exactly the code path it did before mounts existed.
93
+ *
94
+ * `host`/`urlPath` present but not a string (e.g. `host: 9926`, a likely typo for a quoted
95
+ * value) is rejected rather than silently treated as absent: this fail-closed behavior only
96
+ * works if a wrong-typed value fails the same way a wrong-format one does (review finding) —
97
+ * `null`/`undefined` are still "not declared", since that's how YAML represents an omitted key.
98
+ */
99
+ export function toScopeMount(config: unknown): ScopeMount | undefined {
100
+ if (!config || typeof config !== 'object') return undefined;
101
+ const { host, urlPath } = config as ScopeMount;
102
+ if (host !== undefined && host !== null && typeof host !== 'string') throw new InvalidMountHostError(String(host));
103
+ if (urlPath !== undefined && urlPath !== null && typeof urlPath !== 'string')
104
+ throw new InvalidMountPathError(String(urlPath));
105
+ const mountHost = normalizeMountHost(typeof host === 'string' ? host : undefined);
106
+ const mountPath = normalizeMountPath(typeof urlPath === 'string' ? urlPath : undefined);
107
+ if (!mountHost && !mountPath) return undefined;
108
+ return { host: mountHost, urlPath: mountPath };
109
+ }
110
+
111
+ /**
112
+ * Composes an application mount with a plugin's own `urlPath`.
113
+ *
114
+ * The plugin part is resolved first (`resolveBaseURLPath` semantics: `'.'`/`'./x'` namespace
115
+ * under the plugin name) and the mount is then prefixed, so app-internal structure survives
116
+ * relocation: mount `/v1` + `static: { urlPath: assets }` → `/v1/assets/`. Composing rather
117
+ * than replacing matters because a plugin's `urlPath` doubles as its app-internal base path;
118
+ * replacing it would silently relocate app-internal URLs and collapse distinct plugins onto
119
+ * one path. The result is already absolute and slash-terminated, making it a fixed point of
120
+ * `resolveBaseURLPath` — a consumer that resolves it again cannot compound the prefix.
121
+ */
122
+ export function composeMountedUrlPath(
123
+ mountPath: string | undefined,
124
+ pluginName: string,
125
+ pluginUrlPath: string | undefined
126
+ ): string | undefined {
127
+ if (!mountPath) return pluginUrlPath;
128
+ return `${mountPath}${resolveBaseURLPath(pluginName, pluginUrlPath)}`;
129
+ }
130
+
131
+ /**
132
+ * Nests a child component's own mount inside the mount its parent application was given.
133
+ *
134
+ * The parent keeps hostname authority — a child cannot escape the host it is served on — while
135
+ * paths compose, so a parent at `/v1` containing a child mounted at `/child` puts the child at
136
+ * `/v1/child`. Returns whichever side is defined when only one is. A child `host` that differs
137
+ * from the parent's is silently discarded by that authority rule; logged so the operator who
138
+ * wrote it isn't left guessing why it had no effect.
139
+ */
140
+ export function nestScopeMount(parent: ScopeMount | undefined, child: ScopeMount | undefined): ScopeMount | undefined {
141
+ if (!parent) return child;
142
+ if (!child) return parent;
143
+ if (parent.host && child.host && parent.host !== child.host) {
144
+ harperLogger.warn(
145
+ `Component mount host '${child.host}' is ignored because it is nested under an application mounted on host '${parent.host}' — a child's host cannot override its parent's.`
146
+ );
147
+ }
148
+ const urlPath = child.urlPath ? `${parent.urlPath ?? ''}${child.urlPath}` : parent.urlPath;
149
+ return { host: parent.host ?? child.host, urlPath: urlPath || undefined };
150
+ }
@@ -7,7 +7,6 @@ import YAML from 'yaml';
7
7
  import path from 'path';
8
8
  import { threadId } from 'node:worker_threads';
9
9
  import { randomBytes } from 'node:crypto';
10
- import { performance } from 'node:perf_hooks';
11
10
  import isNumber from 'is-number';
12
11
  import propertiesReaderModule from 'properties-reader';
13
12
  import _ from 'lodash';
@@ -87,10 +86,15 @@ export function getConfigPath(param: string) {
87
86
  // Every worker thread runs its own RootConfigWatcher (chokidar), so a write on one thread
88
87
  // routinely races a hot-reload read on another; Windows Defender / AV real-time scanning can
89
88
  // hold a similar transient handle. Retry with exponential backoff to ride out the race -
90
- // callers are synchronous, so the wait is a synchronous busy-loop rather than a real sleep.
91
- const RENAME_RETRY_MAX_ATTEMPTS = 8;
89
+ // callers are synchronous, so the wait is a synchronous sleep rather than an async one.
90
+ // The budget must outlast a single AV real-time scan pass (seconds, not hundreds of ms):
91
+ // the previous ~910ms budget was exhausted twice in a row by the same test on a CI runner
92
+ // (harper#2036), so the worst case is now ~3.6s.
93
+ const RENAME_RETRY_MAX_ATTEMPTS = 12;
92
94
  const RENAME_RETRY_INITIAL_DELAY_MS = 10;
93
- const RENAME_RETRY_MAX_DELAY_MS = 200;
95
+ const RENAME_RETRY_MAX_DELAY_MS = 500;
96
+ // Never notified; exists only so Atomics.wait can time out (a synchronous, CPU-idle sleep).
97
+ const renameRetrySleepBuffer = new Int32Array(new SharedArrayBuffer(4));
94
98
 
95
99
  function atomicWriteFile(
96
100
  filePath,
@@ -112,11 +116,10 @@ function atomicWriteFile(
112
116
  } catch (err) {
113
117
  if (retries > 0 && (err.code === 'EPERM' || err.code === 'EACCES')) {
114
118
  retries--;
115
- // Sleep synchronously (all call sites are sync) to allow the reader to close the
116
- // file. Uses performance.now() rather than Date.now(): the latter tracks wall-clock
117
- // time and can jump backward (NTP sync), which would turn this into an unbounded spin.
118
- const start = performance.now();
119
- while (performance.now() - start < delayMs) {}
119
+ // Sleep synchronously (all call sites are sync) to allow the holder to close the
120
+ // file. Atomics.wait yields the thread to the OS instead of spinning the CPU,
121
+ // which is what makes a multi-second worst-case budget affordable.
122
+ if (delayMs > 0) Atomics.wait(renameRetrySleepBuffer, 0, 0, delayMs);
120
123
  delayMs = Math.min(delayMs * 2, maxDelayMs);
121
124
  continue;
122
125
  }
@@ -470,6 +470,16 @@
470
470
  ],
471
471
  "description": "Enable/Configure compression (LZ4). Default: true"
472
472
  },
473
+ "rocks": {
474
+ "type": "object",
475
+ "additionalProperties": true,
476
+ "properties": {
477
+ "compression": {
478
+ "type": ["string", "null"],
479
+ "description": "RocksDB block and blob compression codec for every column family. Default: null (derive from storage.compression)."
480
+ }
481
+ }
482
+ },
473
483
  "compactOnStart": { "type": "boolean", "description": "Compact non-system databases on start. Default: false" },
474
484
  "compactOnStartKeepBackup": {
475
485
  "type": "boolean",
@@ -486,6 +496,10 @@
486
496
  },
487
497
  "prefetchWrites": { "type": "boolean", "description": "Load data prior to write transactions. Default: true" },
488
498
  "path": { "type": "string", "description": "Directory for all database files. Default: <rootPath>/database" },
499
+ "backupPath": {
500
+ "type": "string",
501
+ "description": "Directory for managed database backups (RocksDB directory backups created by create_backup), one subdirectory per database. Default: <rootPath>/backup"
502
+ },
489
503
  "blobPaths": {
490
504
  "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }],
491
505
  "description": "Path or array of paths for blob storage. Default: <rootPath>/blobs"
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+
7
+ /**
8
+ * Per-backup completion manifest for RocksDB managed backups.
9
+ *
10
+ * `create_backup` is two-phase: the engine backup (`rootStore.backup()`) resolves — and becomes
11
+ * visible to `list_backups`/`verify_backup`/`restore_backup` — before the blob snapshot is copied.
12
+ * Without a completion record, a blob-snapshot failure (or a crash between the two phases) leaves an
13
+ * engine backup on disk that lists and verifies as healthy while silently missing its blobs, and a
14
+ * concurrent restore could select a backup id whose blob snapshot is still being written and treat
15
+ * it as an intentional engine-only backup.
16
+ *
17
+ * A manifest at `<backupDir>/manifests/<backupId>.json` is written (atomically, temp + rename) only
18
+ * after *both* phases are durably in place, and records whether blobs were captured. Consumers treat
19
+ * a backup id with no manifest as incomplete — not usable for restore, and hidden from the listing.
20
+ * The `manifests/` directory is Harper-managed and ignored by the rocksdb-js binding (same as the
21
+ * sibling `blobs/` and `transaction_logs/` directories).
22
+ */
23
+
24
+ const MANIFEST_DIR = 'manifests';
25
+
26
+ export type BackupManifest = {
27
+ /** The RocksDB backup id this manifest completes. */
28
+ backupId: number;
29
+ /** Whether the backup captured the database's file-backed blobs (false = engine-only). */
30
+ blobs: boolean;
31
+ /** Epoch-ms timestamp when both phases completed. */
32
+ completedAt: number;
33
+ };
34
+
35
+ function manifestDir(backupDir: string): string {
36
+ return join(backupDir, MANIFEST_DIR);
37
+ }
38
+
39
+ function manifestPath(backupDir: string, backupId: number): string {
40
+ return join(manifestDir(backupDir), `${backupId}.json`);
41
+ }
42
+
43
+ /**
44
+ * Write a backup's completion manifest atomically. Call only after the engine backup and (when
45
+ * included) the blob snapshot are both durable — its presence is what marks the backup usable.
46
+ */
47
+ export async function writeBackupManifest(backupDir: string, backupId: number, blobs: boolean): Promise<void> {
48
+ const dir = manifestDir(backupDir);
49
+ await mkdir(dir, { recursive: true });
50
+ const manifest: BackupManifest = { backupId, blobs, completedAt: Date.now() };
51
+ const tempPath = join(dir, `.tmp-${backupId}.json`);
52
+ await writeFile(tempPath, JSON.stringify(manifest));
53
+ await rename(tempPath, manifestPath(backupDir, backupId)); // atomic publish
54
+ }
55
+
56
+ /** Read a backup's completion manifest, or null when it has none (incomplete / not yet written). */
57
+ export async function readBackupManifest(backupDir: string, backupId: number): Promise<BackupManifest | null> {
58
+ try {
59
+ return JSON.parse(await readFile(manifestPath(backupDir, backupId), 'utf8')) as BackupManifest;
60
+ } catch (error: any) {
61
+ if (error.code === 'ENOENT') return null;
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ /** Whether a backup id has a completion manifest (i.e. its creation finished). */
67
+ export function isBackupComplete(backupDir: string, backupId: number): boolean {
68
+ return existsSync(manifestPath(backupDir, backupId));
69
+ }
70
+
71
+ /** Remove a backup's manifest (paired with deleting the backup); best-effort. */
72
+ export async function deleteBackupManifest(backupDir: string, backupId: number): Promise<void> {
73
+ await rm(manifestPath(backupDir, backupId), { force: true });
74
+ }
75
+
76
+ /** Remove manifests for every backup id not in `keepIds` (paired with `backups.purge`). */
77
+ export async function purgeBackupManifests(backupDir: string, keepIds: Set<number>): Promise<void> {
78
+ const dir = manifestDir(backupDir);
79
+ if (!existsSync(dir)) return;
80
+ for (const name of await readdir(dir)) {
81
+ const match = /^(\d+)\.json$/.exec(name);
82
+ if (match && !keepIds.has(Number(match[1]))) await rm(join(dir, name), { force: true });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Read every completion manifest in a backup directory, keyed by backup id. Backup ids without a
88
+ * manifest (incomplete) are absent from the map. A manifest that fails to parse is skipped.
89
+ */
90
+ export async function readAllManifests(backupDir: string): Promise<Map<number, BackupManifest>> {
91
+ const dir = manifestDir(backupDir);
92
+ const manifests = new Map<number, BackupManifest>();
93
+ if (!existsSync(dir)) return manifests;
94
+ for (const name of await readdir(dir)) {
95
+ const match = /^(\d+)\.json$/.exec(name);
96
+ if (!match) continue;
97
+ const backupId = Number(match[1]);
98
+ const manifest = await readBackupManifest(backupDir, backupId).catch(() => null);
99
+ if (manifest) manifests.set(backupId, manifest);
100
+ }
101
+ return manifests;
102
+ }
@@ -0,0 +1,286 @@
1
+ 'use strict';
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { copyFile, link, mkdir, readdir, rename, rm, unlink, writeFile } from 'node:fs/promises';
5
+ import { dirname, join, relative } from 'node:path';
6
+ import { ClientError } from '../utility/errors/hdbError.ts';
7
+ import logger from '../utility/logging/harper_logger.ts';
8
+
9
+ /**
10
+ * Managed-backup snapshotting of a database's file-backed blobs.
11
+ *
12
+ * A database's blobs live in one or more root directories *outside* the RocksDB directory (see
13
+ * `resources/blob.ts` `getBlobPathsForDatabaseName`), so the engine's own backup does not capture
14
+ * them. These helpers snapshot the blob roots alongside a RocksDB managed backup and restore them
15
+ * with it, so a restored database's records still resolve their file-backed blobs.
16
+ *
17
+ * Layout mirrors the binding's transaction-log snapshots (`<backupDir>/transaction_logs/<id>/`): a
18
+ * full, non-incremental copy per backup at `<backupDir>/blobs/<backupId>/<rootIndex>/<relpath>`,
19
+ * where `rootIndex` is the position of the source root in the database's blob-root list (preserved
20
+ * so a multi-root database restores each root back to its original slot). Files are hard-linked when
21
+ * possible (cheap, no extra space on the same filesystem) and copied otherwise (never symlinked, so
22
+ * a snapshot is a standalone set of files that survives independent of the live blob).
23
+ *
24
+ * Consistency is best-effort and point-in-time-ish, matching how the engine treats the transaction
25
+ * log: the walk captures whatever files exist at snapshot time. A blob deleted mid-walk is skipped;
26
+ * a blob being written mid-walk is captured as-is (a hard link shares the inode, so it reflects the
27
+ * writer's final bytes; a cross-filesystem copy captures the bytes present at copy time). Harper
28
+ * does not freeze blob writes for the duration of a backup.
29
+ *
30
+ * Hard-linking is safe against later mutation because Harper blobs are content-addressed and
31
+ * write-once: each write allocates a fresh monotonic file id (a new path), and an update or delete
32
+ * unlinks the old path rather than rewriting it in place — so a snapshot's hard link keeps the exact
33
+ * bytes alive even after the live blob is deleted, and no in-place overwrite can retroactively alter
34
+ * a snapshot.
35
+ */
36
+
37
+ /** Directory holding all blob snapshots for a backup repository. */
38
+ export function blobsRootDir(backupDir: string): string {
39
+ return join(backupDir, 'blobs');
40
+ }
41
+
42
+ /** Directory holding a single backup id's blob snapshot. */
43
+ export function blobSnapshotDir(backupDir: string, backupId: number): string {
44
+ return join(blobsRootDir(backupDir), String(backupId));
45
+ }
46
+
47
+ /**
48
+ * Hard-link `src` to `dest`, falling back to a copy when the two are on different filesystems (or
49
+ * the filesystem does not support additional hard links). Never creates a symlink. A source that
50
+ * vanished mid-walk (a concurrent blob delete) is skipped rather than failing the whole snapshot.
51
+ */
52
+ async function linkOrCopy(src: string, dest: string): Promise<void> {
53
+ await mkdir(dirname(dest), { recursive: true });
54
+ try {
55
+ await link(src, dest);
56
+ } catch (error: any) {
57
+ if (error.code === 'ENOENT') {
58
+ // src disappeared (concurrent delete) — nothing to snapshot
59
+ if (!existsSync(src)) return;
60
+ throw error;
61
+ }
62
+ if (
63
+ error.code === 'EXDEV' || // cross-device link
64
+ error.code === 'EMLINK' || // link count exhausted
65
+ error.code === 'EPERM' || // filesystem forbids hard links
66
+ error.code === 'ENOTSUP' ||
67
+ error.code === 'EOPNOTSUPP'
68
+ ) {
69
+ await copyFile(src, dest);
70
+ return;
71
+ }
72
+ if (error.code === 'EEXIST') {
73
+ await unlink(dest);
74
+ await linkOrCopy(src, dest);
75
+ return;
76
+ }
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Recursively copy every file under `srcRoot` into `destRoot` (hard-link-else-copy), preserving the
83
+ * relative directory structure. Missing `srcRoot` is a no-op (a database with no blobs yet).
84
+ */
85
+ async function copyTree(srcRoot: string, destRoot: string): Promise<void> {
86
+ if (!existsSync(srcRoot)) return;
87
+ const stack: string[] = [srcRoot];
88
+ while (stack.length > 0) {
89
+ const dir = stack.pop() as string;
90
+ let entries;
91
+ try {
92
+ entries = await readdir(dir, { withFileTypes: true });
93
+ } catch (error: any) {
94
+ if (error.code === 'ENOENT') continue; // directory removed mid-walk
95
+ throw error;
96
+ }
97
+ for (const entry of entries) {
98
+ const srcPath = join(dir, entry.name);
99
+ if (entry.isDirectory()) {
100
+ stack.push(srcPath);
101
+ } else if (entry.isFile()) {
102
+ await linkOrCopy(srcPath, join(destRoot, relative(srcRoot, srcPath)));
103
+ }
104
+ // symlinks/other node types in a blob root are not expected and are intentionally skipped
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Snapshot a database's blob roots into a backup's blob directory. Writes to a temporary sibling
111
+ * and atomically renames into place so a create_backup that fails mid-copy never leaves a partial
112
+ * `blobs/<backupId>/` that a later restore would trust. Overwrites any pre-existing snapshot for the
113
+ * same id (create_backup always produces a fresh id, so this only matters on a retried offline run).
114
+ */
115
+ export async function snapshotBlobs(backupDir: string, backupId: number, blobRoots: string[]): Promise<void> {
116
+ const finalDir = blobSnapshotDir(backupDir, backupId);
117
+ const tempDir = join(blobsRootDir(backupDir), `.tmp-${backupId}`);
118
+ await rm(tempDir, { recursive: true, force: true });
119
+ await mkdir(tempDir, { recursive: true });
120
+ try {
121
+ for (let index = 0; index < blobRoots.length; index++) {
122
+ await copyTree(blobRoots[index], join(tempDir, String(index)));
123
+ }
124
+ await rm(finalDir, { recursive: true, force: true });
125
+ await rename(tempDir, finalDir);
126
+ } catch (error) {
127
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
128
+ throw error;
129
+ }
130
+ await writeBlobsReadme(backupDir, blobRoots);
131
+ }
132
+
133
+ /**
134
+ * Build the `blobs/README.md` documenting the blob snapshot layout, so an operator inspecting or
135
+ * hand-recovering a backup can decode the numeric directories. Two variants:
136
+ * - managed (default): a create_backup repository, where snapshots are keyed by backup id
137
+ * (`<backupId>/<rootIndex>/…`) and restore is automatic via `restore_backup`.
138
+ * - archive (`archive: true`): a downloaded `get_backup` tar, which holds a single snapshot with no
139
+ * backup-id level (`<rootIndex>/…`) and is restored by extracting the files back into the roots.
140
+ */
141
+ export function blobsReadmeContent(blobRoots: string[], { archive = false }: { archive?: boolean } = {}): string {
142
+ const rootMapping =
143
+ blobRoots.length > 0 ? blobRoots.map((root, index) => ` ${index} -> ${root}`).join('\n') : ' (none)';
144
+ const layout = archive
145
+ ? '<rootIndex>/<shard1>/<shard2>/<fileId>'
146
+ : '<backupId>/<rootIndex>/<shard1>/<shard2>/<fileId>';
147
+ const intro = archive
148
+ ? `This directory holds this database's file-backed blobs within a downloaded \`get_backup\` archive.
149
+ To restore them, extract each \`<rootIndex>/\` tree back into the matching blob root (see the mapping
150
+ below and ../README.md).`
151
+ : `This directory holds point-in-time snapshots of this database's file-backed blobs, captured
152
+ alongside each RocksDB managed backup. You do not restore these by hand — \`restore_backup\` puts
153
+ them back automatically (see ../README.md); this file just documents the layout.`;
154
+ const backupIdBullet = archive
155
+ ? ''
156
+ : `- **<backupId>** matches the RocksDB backup id (\`harper list_backups\`). Each id is a full,
157
+ independent snapshot (not incremental).
158
+ `;
159
+ return `# Harper blob snapshots
160
+
161
+ ${intro}
162
+
163
+ ## Directory layout
164
+
165
+ ${layout}
166
+
167
+ ${backupIdBullet}- **<rootIndex>** is which of the database's blob roots the file came from — the index into
168
+ \`storage.blobPaths[n]\`. When \`storage.blobPaths\` is not configured there is a single default root
169
+ (\`<rootPath>/blobs/<db>\`) at index 0. Current mapping for this backup:
170
+
171
+ ${rootMapping}
172
+
173
+ - **<shard1>/<shard2>/<fileId>** is Harper's on-disk blob layout, copied verbatim from the live
174
+ root: the hex blob file id split into two directory levels plus the file itself (keeping roughly
175
+ 4096 entries per directory). E.g. a blob with id \`0x12345678\` lives at \`12/345/678\`; a short id
176
+ like \`0xc1a\` lives at \`0/0/c1a\`.
177
+
178
+ Files are hard links to the live blobs when the backup is on the same filesystem, and copies
179
+ otherwise.
180
+ `;
181
+ }
182
+
183
+ /**
184
+ * Write the `blobs/README.md` into a managed backup's `blobs/` directory. Best-effort: a failure to
185
+ * write the doc must not fail an otherwise-successful backup.
186
+ */
187
+ export async function writeBlobsReadme(backupDir: string, blobRoots: string[]): Promise<void> {
188
+ try {
189
+ await writeFile(join(blobsRootDir(backupDir), 'README.md'), blobsReadmeContent(blobRoots));
190
+ } catch (error) {
191
+ logger.warn(`Failed to write blob snapshot README in ${backupDir}: ${(error as Error).message}`);
192
+ }
193
+ }
194
+
195
+ /** The blob-root indices present in a snapshot (sorted); [] when the backup has no blob snapshot. */
196
+ async function snapshotRootIndices(snapshotDir: string): Promise<number[]> {
197
+ if (!existsSync(snapshotDir)) return [];
198
+ return (await readdir(snapshotDir, { withFileTypes: true }))
199
+ .filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
200
+ .map((entry) => Number(entry.name))
201
+ .sort((a, b) => a - b);
202
+ }
203
+
204
+ /**
205
+ * Reject (before any destructive step) a restore whose snapshot has more blob roots than the
206
+ * database is currently configured with. File-backed blob references persist their `storageIndex`,
207
+ * so a record written under root index 1 keeps resolving through `blobRoots[1]`; collapsing the
208
+ * out-of-range index onto another root would preserve the bytes at the wrong address and the restore
209
+ * would "succeed" while reads of those blobs fail. The operator must reconcile `storage.blobPaths`
210
+ * to at least as many roots as the backup before restoring. Callers invoke this ahead of the engine
211
+ * restore so a mismatch never purges the database.
212
+ */
213
+ export async function assertBlobSnapshotRestorable(
214
+ backupDir: string,
215
+ backupId: number,
216
+ blobRoots: string[]
217
+ ): Promise<void> {
218
+ const indices = await snapshotRootIndices(blobSnapshotDir(backupDir, backupId));
219
+ const maxIndex = indices.length > 0 ? indices[indices.length - 1] : -1;
220
+ if (maxIndex >= blobRoots.length) {
221
+ throw new ClientError(
222
+ `Cannot restore backup ${backupId}: it captured ${maxIndex + 1} blob root(s) but the database is now configured with ${blobRoots.length}. ` +
223
+ `Blob references persist their root index, so restoring would mis-address blobs — set 'storage.blobPaths' to at least ${maxIndex + 1} root(s) before restoring.`
224
+ );
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Restore a backup's blob snapshot back into the database's blob roots. Each root is purged and
230
+ * rewritten from `blobs/<backupId>/<rootIndex>/` so the restored blob set matches the backup exactly
231
+ * (a newer blob written after the backup is removed, mirroring the engine's `purgeAllFiles` restore).
232
+ *
233
+ * A backup created with blobs excluded (or an older backup that predates blob snapshots) has no
234
+ * snapshot directory: in that case the live blob roots are left untouched and a warning is logged,
235
+ * since purging them would strip blobs the restored records may still reference. Roots are restored
236
+ * by index into the *same* configured root; an incompatible root count is rejected up front (see
237
+ * `assertBlobSnapshotRestorable`) rather than collapsed, so blobs are never mis-addressed.
238
+ */
239
+ export async function restoreBlobSnapshot(
240
+ backupDir: string,
241
+ backupId: number,
242
+ databaseName: string,
243
+ blobRoots: string[]
244
+ ): Promise<void> {
245
+ const snapshotDir = blobSnapshotDir(backupDir, backupId);
246
+ if (!existsSync(snapshotDir)) {
247
+ logger.warn(
248
+ `Backup ${backupId} of database '${databaseName}' has no blob snapshot; leaving existing blob files in place (this backup did not include blobs)`
249
+ );
250
+ return;
251
+ }
252
+ // defense in depth: the restore flow pre-checks before the engine restore, but re-validate here
253
+ // so this function never mis-addresses blobs regardless of caller
254
+ await assertBlobSnapshotRestorable(backupDir, backupId, blobRoots);
255
+ const indexDirs = await snapshotRootIndices(snapshotDir);
256
+ // purge every current blob root first so nothing newer than the backup survives the restore
257
+ for (const root of blobRoots) {
258
+ await rm(root, { recursive: true, force: true });
259
+ }
260
+ for (const index of indexDirs) {
261
+ await copyTree(join(snapshotDir, String(index)), blobRoots[index]);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Remove the blob snapshot for a single backup id (paired with `backups.delete`). Best-effort:
267
+ * a missing snapshot directory is fine.
268
+ */
269
+ export async function deleteBlobSnapshot(backupDir: string, backupId: number): Promise<void> {
270
+ await rm(blobSnapshotDir(backupDir, backupId), { recursive: true, force: true });
271
+ }
272
+
273
+ /**
274
+ * Remove blob snapshots for every backup id not in `keepIds` (paired with `backups.purge`, which
275
+ * reference-counts and removes the engine files; blob snapshots are full per-id copies, so they are
276
+ * simply deleted). Given the ids that survive the purge, this deletes the rest.
277
+ */
278
+ export async function purgeBlobSnapshots(backupDir: string, keepIds: Set<number>): Promise<void> {
279
+ const root = blobsRootDir(backupDir);
280
+ if (!existsSync(root)) return;
281
+ for (const entry of await readdir(root, { withFileTypes: true })) {
282
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
283
+ if (keepIds.has(Number(entry.name))) continue;
284
+ await rm(join(root, entry.name), { recursive: true, force: true });
285
+ }
286
+ }