@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.
- package/bin/backup.ts +169 -0
- package/bin/cliOperations.ts +92 -73
- package/bin/copyDb.ts +21 -4
- package/bin/harper.ts +43 -55
- package/bin/help.ts +216 -0
- package/components/Application.ts +236 -46
- package/components/ApplicationScope.ts +26 -0
- package/components/EntryHandler.ts +410 -105
- package/components/RuntimeModuleTracker.ts +189 -0
- package/components/Scope.ts +120 -30
- package/components/componentLoader.ts +177 -25
- package/components/deployLifecycle.ts +119 -33
- package/components/mcp/toolRegistry.ts +10 -0
- package/components/mcp/tools/application.ts +12 -5
- package/components/mcp/tools/operations.ts +9 -0
- package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
- package/components/mcp/tools/schemas/operations.ts +9 -0
- package/components/operations.js +5 -6
- package/components/operationsValidation.js +32 -2
- package/components/scopeMount.ts +150 -0
- package/config/configUtils.ts +12 -9
- package/config-root.schema.json +14 -0
- package/dataLayer/backupManifest.ts +102 -0
- package/dataLayer/blobBackup.ts +286 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +69 -5
- package/dataLayer/hdbInfoController.ts +8 -0
- package/dataLayer/restoreMarker.ts +276 -0
- package/dataLayer/rocksdbBackup.ts +1100 -0
- package/dataLayer/schemaDescribe.ts +2 -1
- package/dist/bin/backup.d.ts +9 -0
- package/dist/bin/backup.js +192 -0
- package/dist/bin/backup.js.map +1 -0
- package/dist/bin/cliOperations.d.ts +13 -0
- package/dist/bin/cliOperations.js +89 -70
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/copyDb.js +13 -2
- package/dist/bin/copyDb.js.map +1 -1
- package/dist/bin/harper.d.ts +13 -0
- package/dist/bin/harper.js +45 -56
- package/dist/bin/harper.js.map +1 -1
- package/dist/bin/help.d.ts +8 -0
- package/dist/bin/help.js +192 -0
- package/dist/bin/help.js.map +1 -0
- package/dist/components/Application.d.ts +16 -1
- package/dist/components/Application.js +210 -38
- package/dist/components/Application.js.map +1 -1
- package/dist/components/ApplicationScope.d.ts +7 -0
- package/dist/components/ApplicationScope.js +22 -0
- package/dist/components/ApplicationScope.js.map +1 -1
- package/dist/components/EntryHandler.d.ts +4 -4
- package/dist/components/EntryHandler.js +386 -95
- package/dist/components/EntryHandler.js.map +1 -1
- package/dist/components/RuntimeModuleTracker.d.ts +11 -0
- package/dist/components/RuntimeModuleTracker.js +189 -0
- package/dist/components/RuntimeModuleTracker.js.map +1 -0
- package/dist/components/Scope.d.ts +38 -1
- package/dist/components/Scope.js +117 -28
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.d.ts +2 -6
- package/dist/components/componentLoader.js +160 -18
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deployLifecycle.d.ts +6 -2
- package/dist/components/deployLifecycle.js +109 -31
- package/dist/components/deployLifecycle.js.map +1 -1
- package/dist/components/mcp/toolRegistry.d.ts +7 -0
- package/dist/components/mcp/toolRegistry.js +10 -0
- package/dist/components/mcp/toolRegistry.js.map +1 -1
- package/dist/components/mcp/tools/application.js +11 -5
- package/dist/components/mcp/tools/application.js.map +1 -1
- package/dist/components/mcp/tools/operations.js +9 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
- package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
- package/dist/components/mcp/tools/schemas/operations.js +9 -0
- package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
- package/dist/components/operations.js +6 -6
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +34 -2
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/scopeMount.d.ts +86 -0
- package/dist/components/scopeMount.js +131 -0
- package/dist/components/scopeMount.js.map +1 -0
- package/dist/config/configUtils.js +13 -9
- package/dist/config/configUtils.js.map +1 -1
- package/dist/dataLayer/backupManifest.d.ts +26 -0
- package/dist/dataLayer/backupManifest.js +97 -0
- package/dist/dataLayer/backupManifest.js.map +1 -0
- package/dist/dataLayer/blobBackup.d.ts +87 -0
- package/dist/dataLayer/blobBackup.js +282 -0
- package/dist/dataLayer/blobBackup.js.map +1 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js +45 -5
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/hdbInfoController.js +4 -0
- package/dist/dataLayer/hdbInfoController.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +122 -0
- package/dist/dataLayer/restoreMarker.js +261 -0
- package/dist/dataLayer/restoreMarker.js.map +1 -0
- package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
- package/dist/dataLayer/rocksdbBackup.js +1039 -0
- package/dist/dataLayer/rocksdbBackup.js.map +1 -0
- package/dist/dataLayer/schemaDescribe.js +2 -1
- package/dist/dataLayer/schemaDescribe.js.map +1 -1
- package/dist/resources/DatabaseTransaction.d.ts +55 -0
- package/dist/resources/DatabaseTransaction.js +282 -109
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +8 -5
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/Resources.js +22 -4
- package/dist/resources/Resources.js.map +1 -1
- package/dist/resources/Table.d.ts +6 -5
- package/dist/resources/Table.js +114 -37
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.js +6 -6
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/blob.d.ts +8 -1
- package/dist/resources/blob.js +31 -14
- package/dist/resources/blob.js.map +1 -1
- package/dist/resources/databases.d.ts +59 -1
- package/dist/resources/databases.js +386 -41
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/jsResource.d.ts +4 -26
- package/dist/resources/jsResource.js +5 -59
- package/dist/resources/jsResource.js.map +1 -1
- package/dist/resources/models/Models.d.ts +11 -1
- package/dist/resources/models/Models.js +10 -1
- package/dist/resources/models/Models.js.map +1 -1
- package/dist/resources/models/backendRegistry.d.ts +9 -0
- package/dist/resources/models/backendRegistry.js +10 -0
- package/dist/resources/models/backendRegistry.js.map +1 -1
- package/dist/resources/models/openaiStream.d.ts +16 -1
- package/dist/resources/models/openaiStream.js +113 -21
- package/dist/resources/models/openaiStream.js.map +1 -1
- package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
- package/dist/resources/models/v1/chatCompletions.js +115 -0
- package/dist/resources/models/v1/chatCompletions.js.map +1 -0
- package/dist/resources/models/v1/embeddings.d.ts +11 -0
- package/dist/resources/models/v1/embeddings.js +71 -0
- package/dist/resources/models/v1/embeddings.js.map +1 -0
- package/dist/resources/models/v1/errors.d.ts +54 -0
- package/dist/resources/models/v1/errors.js +130 -0
- package/dist/resources/models/v1/errors.js.map +1 -0
- package/dist/resources/models/v1/index.d.ts +36 -0
- package/dist/resources/models/v1/index.js +75 -0
- package/dist/resources/models/v1/index.js.map +1 -0
- package/dist/resources/models/v1/models.d.ts +26 -0
- package/dist/resources/models/v1/models.js +44 -0
- package/dist/resources/models/v1/models.js.map +1 -0
- package/dist/resources/models/v1/translation.d.ts +133 -0
- package/dist/resources/models/v1/translation.js +298 -0
- package/dist/resources/models/v1/translation.js.map +1 -0
- package/dist/resources/roles.d.ts +1 -1
- package/dist/resources/roles.js +54 -7
- package/dist/resources/roles.js.map +1 -1
- package/dist/resources/transaction.js +0 -3
- package/dist/resources/transaction.js.map +1 -1
- package/dist/security/jsLoader.js +84 -33
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/security/role.js +4 -0
- package/dist/security/role.js.map +1 -1
- package/dist/security/superUserGuard.d.ts +7 -0
- package/dist/security/superUserGuard.js +23 -0
- package/dist/security/superUserGuard.js.map +1 -0
- package/dist/security/tokenAuthentication.d.ts +0 -1
- package/dist/security/tokenAuthentication.js +6 -2
- package/dist/security/tokenAuthentication.js.map +1 -1
- package/dist/security/user.d.ts +6 -1
- package/dist/security/user.js +23 -1
- package/dist/security/user.js.map +1 -1
- package/dist/server/REST.js +25 -9
- package/dist/server/REST.js.map +1 -1
- package/dist/server/fastifyRoutes.js +15 -1
- package/dist/server/fastifyRoutes.js.map +1 -1
- package/dist/server/http.d.ts +23 -2
- package/dist/server/http.js +99 -12
- package/dist/server/http.js.map +1 -1
- package/dist/server/itc/serverHandlers.js +7 -1
- package/dist/server/itc/serverHandlers.js.map +1 -1
- package/dist/server/jobs/jobProcess.js +20 -1
- package/dist/server/jobs/jobProcess.js.map +1 -1
- package/dist/server/jobs/jobRunner.js +10 -0
- package/dist/server/jobs/jobRunner.js.map +1 -1
- package/dist/server/jobs/jobs.js +11 -0
- package/dist/server/jobs/jobs.js.map +1 -1
- package/dist/server/middlewareChain.d.ts +10 -1
- package/dist/server/middlewareChain.js +81 -21
- package/dist/server/middlewareChain.js.map +1 -1
- package/dist/server/operationsServer.d.ts +0 -2
- package/dist/server/operationsServer.js.map +1 -1
- package/dist/server/serverHelpers/multipartParser.js +9 -0
- package/dist/server/serverHelpers/multipartParser.js.map +1 -1
- package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
- package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
- package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
- package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
- package/dist/server/serverHelpers/registeredOperations.js +14 -13
- package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
- package/dist/server/serverHelpers/serverHandlers.js +27 -7
- package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +21 -7
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/static.js +68 -21
- package/dist/server/static.js.map +1 -1
- package/dist/server/storageReclamation.d.ts +17 -0
- package/dist/server/storageReclamation.js +90 -6
- package/dist/server/storageReclamation.js.map +1 -1
- package/dist/server/threads/socketRouter.js +20 -0
- package/dist/server/threads/socketRouter.js.map +1 -1
- package/dist/server/threads/threadServer.js +12 -0
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/sqlEngine/diff/differential.js +7 -3
- package/dist/sqlEngine/diff/differential.js.map +1 -1
- package/dist/sqlTranslator/index.js +6 -1
- package/dist/sqlTranslator/index.js.map +1 -1
- package/dist/upgrade/upgradePrompt.d.ts +2 -2
- package/dist/upgrade/upgradePrompt.js +22 -3
- package/dist/upgrade/upgradePrompt.js.map +1 -1
- package/dist/utility/OperationFunctionCaller.js +25 -6
- package/dist/utility/OperationFunctionCaller.js.map +1 -1
- package/dist/utility/errors/commonErrors.d.ts +1 -0
- package/dist/utility/errors/commonErrors.js +1 -0
- package/dist/utility/errors/commonErrors.js.map +1 -1
- package/dist/utility/hdbTerms.d.ts +13 -1
- package/dist/utility/hdbTerms.js +13 -1
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/install/installer.js +32 -0
- package/dist/utility/install/installer.js.map +1 -1
- package/dist/utility/logging/harper_logger.d.ts +47 -0
- package/dist/utility/logging/harper_logger.js +803 -38
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/dist/utility/operation_authorization.js +13 -3
- package/dist/utility/operation_authorization.js.map +1 -1
- package/npm-shrinkwrap.json +198 -193
- package/package.json +12 -7
- package/resources/DESIGN.md +2 -0
- package/resources/DatabaseTransaction.ts +310 -104
- package/resources/ResourceInterface.ts +8 -5
- package/resources/Resources.ts +22 -4
- package/resources/Table.ts +341 -263
- package/resources/analytics/write.ts +22 -20
- package/resources/blob.ts +31 -14
- package/resources/databases.ts +387 -46
- package/resources/jsResource.ts +5 -62
- package/resources/models/Models.ts +14 -1
- package/resources/models/backendRegistry.ts +10 -0
- package/resources/models/openaiStream.ts +131 -19
- package/resources/models/v1/chatCompletions.ts +128 -0
- package/resources/models/v1/embeddings.ts +70 -0
- package/resources/models/v1/errors.ts +141 -0
- package/resources/models/v1/index.ts +72 -0
- package/resources/models/v1/models.ts +53 -0
- package/resources/models/v1/translation.ts +362 -0
- package/resources/roles.ts +67 -7
- package/resources/transaction.ts +0 -3
- package/security/jsLoader.ts +84 -30
- package/security/role.ts +7 -0
- package/security/superUserGuard.ts +20 -0
- package/security/tokenAuthentication.ts +6 -3
- package/security/user.ts +26 -1
- package/server/DESIGN.md +61 -34
- package/server/REST.ts +25 -9
- package/server/fastifyRoutes.ts +20 -1
- package/server/http.ts +100 -13
- package/server/itc/serverHandlers.js +7 -1
- package/server/jobs/jobProcess.ts +18 -1
- package/server/jobs/jobRunner.ts +10 -0
- package/server/jobs/jobs.ts +11 -0
- package/server/middlewareChain.ts +79 -20
- package/server/operationsServer.ts +0 -2
- package/server/serverHelpers/multipartParser.ts +9 -0
- package/server/serverHelpers/operationAuthorizationState.ts +11 -0
- package/server/serverHelpers/registeredOperations.ts +19 -15
- package/server/serverHelpers/serverHandlers.js +28 -7
- package/server/serverHelpers/serverUtilities.ts +29 -7
- package/server/static.ts +87 -23
- package/server/storageReclamation.ts +104 -8
- package/server/threads/socketRouter.ts +20 -0
- package/server/threads/threadServer.js +11 -0
- package/sqlTranslator/index.ts +6 -1
- package/static/defaultConfig.yaml +3 -0
- package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-aApwhRmz.js} +2 -2
- package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-aApwhRmz.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-sC0H91n9.js} +4 -4
- package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-sC0H91n9.js.map} +1 -1
- package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-DZ8JhHOI.js} +2 -2
- package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-DZ8JhHOI.js.map} +1 -1
- package/studio/web/assets/{applications-BDfH8urd.js → applications-9SEVLIO5.js} +2 -2
- package/studio/web/assets/{applications-BDfH8urd.js.map → applications-9SEVLIO5.js.map} +1 -1
- package/studio/web/assets/{index-BHo3c2Gk.js → index-Dy3uDGXb.js} +6 -6
- package/studio/web/assets/index-Dy3uDGXb.js.map +1 -0
- package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-CN1zq4I4.js} +4 -4
- package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-CN1zq4I4.js.map} +1 -1
- package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-BFAF07xr.js} +2 -2
- package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-BFAF07xr.js.map} +1 -1
- package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CKlYVvVN.js} +2 -2
- package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CKlYVvVN.js.map} +1 -1
- package/studio/web/assets/{profile-Doj5FVDE.js → profile-A1zhEdFG.js} +2 -2
- package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-A1zhEdFG.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-SEtBt_GV.js} +2 -2
- package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-SEtBt_GV.js.map} +1 -1
- package/studio/web/assets/{setup-DJwR0BHd.js → setup-BisINqdH.js} +2 -2
- package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-BisINqdH.js.map} +1 -1
- package/studio/web/assets/{status-Br_AbsJs.js → status-BuQoCc7l.js} +2 -2
- package/studio/web/assets/{status-Br_AbsJs.js.map → status-BuQoCc7l.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-CAi_s1PC.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-CAi_s1PC.js.map} +1 -1
- package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-DT74tlkM.js} +2 -2
- package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-DT74tlkM.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-CGRGc1n7.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-CGRGc1n7.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/upgrade/upgradePrompt.ts +22 -3
- package/utility/OperationFunctionCaller.ts +24 -3
- package/utility/errors/commonErrors.ts +2 -0
- package/utility/hdbTerms.ts +13 -1
- package/utility/install/installer.ts +37 -0
- package/utility/logging/harper_logger.ts +786 -41
- package/utility/operation_authorization.ts +31 -3
- 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({ '
|
|
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
|
-
})
|
|
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
|
+
}
|
package/config/configUtils.ts
CHANGED
|
@@ -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
|
|
91
|
-
|
|
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 =
|
|
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
|
|
116
|
-
// file.
|
|
117
|
-
//
|
|
118
|
-
|
|
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
|
}
|
package/config-root.schema.json
CHANGED
|
@@ -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
|
+
}
|