@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
|
@@ -0,0 +1,1039 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.BackupInProgressError = exports.BackupNotFoundError = void 0;
|
|
40
|
+
exports.getBackupsRoot = getBackupsRoot;
|
|
41
|
+
exports.validateDatabaseName = validateDatabaseName;
|
|
42
|
+
exports.backupDirForDatabase = backupDirForDatabase;
|
|
43
|
+
exports.resolveSingleRootStore = resolveSingleRootStore;
|
|
44
|
+
exports.listBackupsInDir = listBackupsInDir;
|
|
45
|
+
exports.listBackups = listBackups;
|
|
46
|
+
exports.deleteBackup = deleteBackup;
|
|
47
|
+
exports.purgeBackups = purgeBackups;
|
|
48
|
+
exports.validateCreateBackup = validateCreateBackup;
|
|
49
|
+
exports.createBackup = createBackup;
|
|
50
|
+
exports.validateVerifyBackup = validateVerifyBackup;
|
|
51
|
+
exports.verifyBackup = verifyBackup;
|
|
52
|
+
exports.validateRestoreBackup = validateRestoreBackup;
|
|
53
|
+
exports.restoreBackup = restoreBackup;
|
|
54
|
+
exports.createBackupStream = createBackupStream;
|
|
55
|
+
exports.createBackupOffline = createBackupOffline;
|
|
56
|
+
exports.restoreBackupOffline = restoreBackupOffline;
|
|
57
|
+
exports.listBackupsOffline = listBackupsOffline;
|
|
58
|
+
exports.verifyBackupOffline = verifyBackupOffline;
|
|
59
|
+
exports.deleteBackupOffline = deleteBackupOffline;
|
|
60
|
+
exports.purgeBackupsOffline = purgeBackupsOffline;
|
|
61
|
+
const node_fs_1 = require("node:fs");
|
|
62
|
+
const promises_1 = require("node:fs/promises");
|
|
63
|
+
const node_path_1 = require("node:path");
|
|
64
|
+
const node_stream_1 = require("node:stream");
|
|
65
|
+
const promises_2 = require("node:stream/promises");
|
|
66
|
+
const node_zlib_1 = require("node:zlib");
|
|
67
|
+
const promises_3 = require("node:timers/promises");
|
|
68
|
+
const tar_stream_1 = require("tar-stream");
|
|
69
|
+
const rocksdb_js_1 = require("@harperfast/rocksdb-js");
|
|
70
|
+
const databases_ts_1 = require("../resources/databases.js");
|
|
71
|
+
const blob_ts_1 = require("../resources/blob.js");
|
|
72
|
+
const environmentManager_ts_1 = require("../utility/environment/environmentManager.js");
|
|
73
|
+
const configUtils_ts_1 = require("../config/configUtils.js");
|
|
74
|
+
const configHelpers_ts_1 = require("../config/configHelpers.js");
|
|
75
|
+
const hdbTerms_ts_1 = require("../utility/hdbTerms.js");
|
|
76
|
+
const hdbError_ts_1 = require("../utility/errors/hdbError.js");
|
|
77
|
+
const signalling = __importStar(require("../utility/signalling.js"));
|
|
78
|
+
const itc_js_1 = require("../server/threads/itc.js");
|
|
79
|
+
const restoreMarker_ts_1 = require("./restoreMarker.js");
|
|
80
|
+
const blobBackup_ts_1 = require("./blobBackup.js");
|
|
81
|
+
const backupManifest_ts_1 = require("./backupManifest.js");
|
|
82
|
+
const harper_logger_ts_1 = __importDefault(require("../utility/logging/harper_logger.js"));
|
|
83
|
+
/**
|
|
84
|
+
* Shared core for the RocksDB managed-backup operations (`create_backup`, `list_backups`,
|
|
85
|
+
* `verify_backup`, `delete_backup`, `purge_backups`, `restore_backup`) and the RocksDB path of
|
|
86
|
+
* `get_backup`. Used by both the operation API (running server) and the CLI (stopped server) so
|
|
87
|
+
* the two behave identically.
|
|
88
|
+
*
|
|
89
|
+
* Directory backups are confined to `<backupsRoot>/<database>/` where the backups root comes
|
|
90
|
+
* from the `storage.backupPath` config (default `<hdb_root>/backups`); operations never accept
|
|
91
|
+
* arbitrary filesystem paths.
|
|
92
|
+
*/
|
|
93
|
+
class BackupNotFoundError extends hdbError_ts_1.ClientError {
|
|
94
|
+
constructor(message) {
|
|
95
|
+
super(message, 404);
|
|
96
|
+
this.name = 'BackupNotFoundError';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
exports.BackupNotFoundError = BackupNotFoundError;
|
|
100
|
+
class BackupInProgressError extends hdbError_ts_1.ClientError {
|
|
101
|
+
constructor(message) {
|
|
102
|
+
super(message, 409);
|
|
103
|
+
this.name = 'BackupInProgressError';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
exports.BackupInProgressError = BackupInProgressError;
|
|
107
|
+
/**
|
|
108
|
+
* Enforce super_user for the managed-backup operations. These are whole-database administrative
|
|
109
|
+
* operations (not table-scoped), so they must never be delegable to a non-super_user role. The
|
|
110
|
+
* registered permission alone can't guarantee that: operation_authorization gate-2 authorizes any
|
|
111
|
+
* `requires_su` op placed in a role's `operations` allowlist without evaluating the declared table
|
|
112
|
+
* CRUD perms, so a non-SU role could otherwise reach these. Enforcing here — mirroring
|
|
113
|
+
* get_deployment_payload's requireSuperUser — closes that path regardless of the allowlist. For the
|
|
114
|
+
* job operations (create/verify/restore) this runs in the request-context validator, before any job
|
|
115
|
+
* record is created.
|
|
116
|
+
*/
|
|
117
|
+
function requireSuperUser(request, operationName) {
|
|
118
|
+
if (!request?.hdb_user?.role?.permission?.super_user) {
|
|
119
|
+
throw new hdbError_ts_1.ClientError(`Operation '${operationName}' is restricted to super_user roles`, 403);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function getBackupsRoot() {
|
|
123
|
+
const configured = (0, configUtils_ts_1.getConfigPath)(hdbTerms_ts_1.CONFIG_PARAMS.STORAGE_BACKUPPATH);
|
|
124
|
+
if (configured && typeof configured === 'string')
|
|
125
|
+
return configured;
|
|
126
|
+
// same <hdb_root>/backup directory as config-file backups; databases get subdirectories
|
|
127
|
+
return (0, configHelpers_ts_1.getBackupDirPath)((0, environmentManager_ts_1.getHdbBasePath)());
|
|
128
|
+
}
|
|
129
|
+
function getDatabaseName(request) {
|
|
130
|
+
const databaseName = request.database || request.schema || 'data';
|
|
131
|
+
validateDatabaseName(databaseName);
|
|
132
|
+
return databaseName;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The database name becomes a path segment under the backups root and the databases root —
|
|
136
|
+
* reject anything that could traverse outside them.
|
|
137
|
+
*/
|
|
138
|
+
function validateDatabaseName(databaseName) {
|
|
139
|
+
if (typeof databaseName !== 'string' || databaseName.length === 0) {
|
|
140
|
+
throw new hdbError_ts_1.ClientError(`'database' must be a non-empty string`);
|
|
141
|
+
}
|
|
142
|
+
if (databaseName.includes('/') ||
|
|
143
|
+
databaseName.includes('\\') ||
|
|
144
|
+
databaseName.includes('\0') ||
|
|
145
|
+
databaseName === '.' ||
|
|
146
|
+
databaseName === '..') {
|
|
147
|
+
throw new hdbError_ts_1.ClientError(`Invalid database name '${databaseName}'`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function backupDirForDatabase(databaseName) {
|
|
151
|
+
validateDatabaseName(databaseName);
|
|
152
|
+
return (0, node_path_1.join)(getBackupsRoot(), databaseName);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the single root store for a database. A database can span multiple root stores when a
|
|
156
|
+
* table has a per-table `path` config; backing up such a database is not supported and errors
|
|
157
|
+
* descriptively. Engine gating (RocksDB vs LMDB) is done inline at each call site.
|
|
158
|
+
*/
|
|
159
|
+
function resolveSingleRootStore(databaseName) {
|
|
160
|
+
const database = (0, databases_ts_1.getDatabases)()[databaseName];
|
|
161
|
+
if (!database) {
|
|
162
|
+
throw new BackupNotFoundError(`Database '${databaseName}' does not exist`);
|
|
163
|
+
}
|
|
164
|
+
const rootStores = new Set();
|
|
165
|
+
for (const tableName in database) {
|
|
166
|
+
const rootStore = database[tableName]?.primaryStore?.rootStore;
|
|
167
|
+
if (rootStore)
|
|
168
|
+
rootStores.add(rootStore);
|
|
169
|
+
}
|
|
170
|
+
if (rootStores.size > 1) {
|
|
171
|
+
throw new hdbError_ts_1.ClientError(`Database '${databaseName}' spans multiple root stores (tables with a per-table 'path' config); backup operations only support single-root databases`);
|
|
172
|
+
}
|
|
173
|
+
if (rootStores.size === 0) {
|
|
174
|
+
throw new hdbError_ts_1.ClientError(`Database '${databaseName}' has no tables to back up`);
|
|
175
|
+
}
|
|
176
|
+
return rootStores.values().next().value;
|
|
177
|
+
}
|
|
178
|
+
function requireRocksRootStore(databaseName, operation) {
|
|
179
|
+
const rootStore = resolveSingleRootStore(databaseName);
|
|
180
|
+
if (!(rootStore instanceof rocksdb_js_1.RocksDatabase)) {
|
|
181
|
+
throw new hdbError_ts_1.ClientError(`Operation '${operation}' requires a RocksDB database; '${databaseName}' uses the LMDB storage engine (use 'get_backup' to download an LMDB backup)`);
|
|
182
|
+
}
|
|
183
|
+
return rootStore;
|
|
184
|
+
}
|
|
185
|
+
function requireBackupId(backupId) {
|
|
186
|
+
if (!Number.isSafeInteger(backupId) || backupId <= 0) {
|
|
187
|
+
throw new hdbError_ts_1.ClientError(`'backup_id' must be a positive integer`);
|
|
188
|
+
}
|
|
189
|
+
return backupId;
|
|
190
|
+
}
|
|
191
|
+
function requireBooleanOption(value, name) {
|
|
192
|
+
if (value !== undefined && typeof value !== 'boolean') {
|
|
193
|
+
throw new hdbError_ts_1.ClientError(`'${name}' must be a boolean`);
|
|
194
|
+
}
|
|
195
|
+
return value === true;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The binding serializes backup-directory writers with an on-disk `.backup.lock`; a concurrent
|
|
199
|
+
* writer rejects with a "locked" error. Map it to a descriptive 409 — fail fast, no queueing.
|
|
200
|
+
*/
|
|
201
|
+
function mapLockedError(error, databaseName) {
|
|
202
|
+
if (typeof error?.message === 'string' && error.message.includes('is locked')) {
|
|
203
|
+
return new BackupInProgressError(`Backup operation already in progress for database '${databaseName}'`);
|
|
204
|
+
}
|
|
205
|
+
return error;
|
|
206
|
+
}
|
|
207
|
+
// --- directory helpers (operate on a backup directory only; no open database, usable offline) ---
|
|
208
|
+
async function listBackupsInDir(backupDir) {
|
|
209
|
+
// the backup dir doesn't exist until the first create_backup
|
|
210
|
+
if (!(0, node_fs_1.existsSync)(backupDir))
|
|
211
|
+
return [];
|
|
212
|
+
return rocksdb_js_1.backups.list(backupDir);
|
|
213
|
+
}
|
|
214
|
+
async function findBackup(backupDir, backupId, databaseName) {
|
|
215
|
+
requireBackupId(backupId); // every id-taking path flows through here, including the offline CLI
|
|
216
|
+
const list = await listBackupsInDir(backupDir);
|
|
217
|
+
const info = list.find((backup) => backup.backupId === backupId);
|
|
218
|
+
if (!info) {
|
|
219
|
+
throw new BackupNotFoundError(`Backup ${backupId} not found for database '${databaseName}'`);
|
|
220
|
+
}
|
|
221
|
+
return info;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Shape a rocksdb-js BackupInfo into the snake_case response the Operations API exposes — the
|
|
225
|
+
* binding's fields are camelCase, and `appMetadata` is internal, so it is not passed through.
|
|
226
|
+
* `blobs` reflects the completion manifest's recorded blob-inclusion policy.
|
|
227
|
+
*/
|
|
228
|
+
function toBackupResponse(info, blobs) {
|
|
229
|
+
return {
|
|
230
|
+
backup_id: info.backupId,
|
|
231
|
+
timestamp: info.timestamp,
|
|
232
|
+
size: info.size,
|
|
233
|
+
file_count: info.numberFiles,
|
|
234
|
+
blobs,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The engine backups in a directory that have a completion manifest — i.e. whose creation finished
|
|
239
|
+
* successfully. An engine backup without a manifest is incomplete (still being written, or a failed
|
|
240
|
+
* create) and is never listed or restored, so a blob snapshot that is mid-copy or absent-after-
|
|
241
|
+
* failure can't be mistaken for a healthy or intentionally-engine-only backup.
|
|
242
|
+
*/
|
|
243
|
+
async function listCompleteBackups(backupDir) {
|
|
244
|
+
const [engineBackups, manifests] = await Promise.all([listBackupsInDir(backupDir), (0, backupManifest_ts_1.readAllManifests)(backupDir)]);
|
|
245
|
+
const complete = [];
|
|
246
|
+
for (const info of engineBackups) {
|
|
247
|
+
const manifest = manifests.get(info.backupId);
|
|
248
|
+
if (manifest)
|
|
249
|
+
complete.push({ ...info, blobs: manifest.blobs });
|
|
250
|
+
}
|
|
251
|
+
return complete;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Load a specific backup's completion manifest, rejecting it as incomplete (409) when the engine
|
|
255
|
+
* backup exists but has no manifest — its creation did not finish, or is still in progress.
|
|
256
|
+
*/
|
|
257
|
+
async function requireBackupComplete(backupDir, backupId, databaseName) {
|
|
258
|
+
const manifest = await (0, backupManifest_ts_1.readBackupManifest)(backupDir, backupId);
|
|
259
|
+
if (!manifest) {
|
|
260
|
+
throw new BackupInProgressError(`Backup ${backupId} of database '${databaseName}' is incomplete (its creation did not finish or is still in progress); it cannot be restored or verified`);
|
|
261
|
+
}
|
|
262
|
+
return manifest;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Resolve which backup id a restore/verify should act on and load its completion manifest. A
|
|
266
|
+
* specific id that exists in the engine but has no manifest is rejected as incomplete; without a
|
|
267
|
+
* requested id, the latest *complete* backup is chosen.
|
|
268
|
+
*/
|
|
269
|
+
async function resolveCompleteBackup(backupDir, requestedId, databaseName) {
|
|
270
|
+
if (requestedId !== undefined) {
|
|
271
|
+
await findBackup(backupDir, requestedId, databaseName); // validates id + engine presence
|
|
272
|
+
return { backupId: requestedId, manifest: await requireBackupComplete(backupDir, requestedId, databaseName) };
|
|
273
|
+
}
|
|
274
|
+
const complete = await listCompleteBackups(backupDir);
|
|
275
|
+
if (complete.length === 0) {
|
|
276
|
+
throw new BackupNotFoundError(`No complete backups found for database '${databaseName}'`);
|
|
277
|
+
}
|
|
278
|
+
const latest = complete.reduce((a, b) => (b.backupId > a.backupId ? b : a));
|
|
279
|
+
return { backupId: latest.backupId, manifest: { backupId: latest.backupId, blobs: latest.blobs, completedAt: 0 } };
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Publish a backup's completion manifest after the engine backup and (when included) blob snapshot
|
|
283
|
+
* are durable. On failure, best-effort roll back the just-created engine backup, its partial blob
|
|
284
|
+
* snapshot, and any manifest so an incomplete backup never lingers as usable.
|
|
285
|
+
*/
|
|
286
|
+
async function finalizeBackup(backupDir, backupId, databaseName, blobs) {
|
|
287
|
+
try {
|
|
288
|
+
if (blobs)
|
|
289
|
+
await (0, blobBackup_ts_1.snapshotBlobs)(backupDir, backupId, (0, blob_ts_1.getBlobPathsForDatabaseName)(databaseName));
|
|
290
|
+
await (0, backupManifest_ts_1.writeBackupManifest)(backupDir, backupId, blobs);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
await (0, backupManifest_ts_1.deleteBackupManifest)(backupDir, backupId).catch(() => { });
|
|
294
|
+
await (0, blobBackup_ts_1.deleteBlobSnapshot)(backupDir, backupId).catch(() => { });
|
|
295
|
+
await rocksdb_js_1.backups.delete(backupDir, backupId).catch(() => { });
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// --- synchronous operations ---
|
|
300
|
+
async function listBackups(request) {
|
|
301
|
+
requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.LIST_BACKUPS);
|
|
302
|
+
const databaseName = getDatabaseName(request);
|
|
303
|
+
harper_logger_ts_1.default.info(`Listing backups for database '${databaseName}'`);
|
|
304
|
+
requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.LIST_BACKUPS);
|
|
305
|
+
return (await listCompleteBackups(backupDirForDatabase(databaseName))).map((backup) => toBackupResponse(backup, backup.blobs));
|
|
306
|
+
}
|
|
307
|
+
async function deleteBackup(request) {
|
|
308
|
+
requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.DELETE_BACKUP);
|
|
309
|
+
const databaseName = getDatabaseName(request);
|
|
310
|
+
requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.DELETE_BACKUP);
|
|
311
|
+
const backupId = requireBackupId(request.backup_id);
|
|
312
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
313
|
+
await findBackup(backupDir, backupId, databaseName);
|
|
314
|
+
try {
|
|
315
|
+
await rocksdb_js_1.backups.delete(backupDir, backupId);
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
throw mapLockedError(error, databaseName);
|
|
319
|
+
}
|
|
320
|
+
// the engine's delete leaves the (Harper-managed) blob snapshot + manifest behind — remove them too
|
|
321
|
+
await (0, blobBackup_ts_1.deleteBlobSnapshot)(backupDir, backupId);
|
|
322
|
+
await (0, backupManifest_ts_1.deleteBackupManifest)(backupDir, backupId);
|
|
323
|
+
return { ok: true };
|
|
324
|
+
}
|
|
325
|
+
async function purgeBackups(request) {
|
|
326
|
+
requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.PURGE_BACKUPS);
|
|
327
|
+
const databaseName = getDatabaseName(request);
|
|
328
|
+
requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.PURGE_BACKUPS);
|
|
329
|
+
const keepCount = request.keep_count;
|
|
330
|
+
if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
|
|
331
|
+
throw new hdbError_ts_1.ClientError(`'keep_count' must be a non-negative integer`);
|
|
332
|
+
}
|
|
333
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
334
|
+
const before = await listBackupsInDir(backupDir);
|
|
335
|
+
if (before.length === 0) {
|
|
336
|
+
throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
await rocksdb_js_1.backups.purge(backupDir, keepCount);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
throw mapLockedError(error, databaseName);
|
|
343
|
+
}
|
|
344
|
+
const remainingBackups = await listBackupsInDir(backupDir);
|
|
345
|
+
// drop blob snapshots + manifests for every id the engine purged (keep only the survivors')
|
|
346
|
+
const keepIds = new Set(remainingBackups.map((backup) => backup.backupId));
|
|
347
|
+
await (0, blobBackup_ts_1.purgeBlobSnapshots)(backupDir, keepIds);
|
|
348
|
+
await (0, backupManifest_ts_1.purgeBackupManifests)(backupDir, keepIds);
|
|
349
|
+
// clamp: a concurrent create between the two lists can otherwise make this negative
|
|
350
|
+
return { deleted: Math.max(0, before.length - remainingBackups.length), remaining: remainingBackups.length };
|
|
351
|
+
}
|
|
352
|
+
// --- job operations: create_backup / verify_backup / restore_backup ---
|
|
353
|
+
// Each has a synchronous-validation function (run by jobs.addJob before the job record is
|
|
354
|
+
// created) and the job function itself (run in the job worker thread).
|
|
355
|
+
async function validateCreateBackup(request) {
|
|
356
|
+
requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.CREATE_BACKUP);
|
|
357
|
+
const databaseName = getDatabaseName(request);
|
|
358
|
+
requireBooleanOption(request.exclude_blobs, 'exclude_blobs');
|
|
359
|
+
requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.CREATE_BACKUP);
|
|
360
|
+
}
|
|
361
|
+
async function createBackup(request) {
|
|
362
|
+
const databaseName = getDatabaseName(request);
|
|
363
|
+
// blobs are captured by default; exclude_blobs=true produces an engine-only backup
|
|
364
|
+
const excludeBlobs = requireBooleanOption(request.exclude_blobs, 'exclude_blobs');
|
|
365
|
+
const rootStore = requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.CREATE_BACKUP);
|
|
366
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
367
|
+
let backupId;
|
|
368
|
+
try {
|
|
369
|
+
backupId = await rootStore.backup(backupDir, { transactionLogs: true });
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
throw mapLockedError(error, databaseName);
|
|
373
|
+
}
|
|
374
|
+
// snapshot blobs (unless excluded) then publish the completion manifest; rolls back on failure
|
|
375
|
+
await finalizeBackup(backupDir, backupId, databaseName, !excludeBlobs);
|
|
376
|
+
await writeBackupReadme(backupDir, databaseName);
|
|
377
|
+
return {
|
|
378
|
+
database: databaseName,
|
|
379
|
+
backup_id: backupId,
|
|
380
|
+
blobs: !excludeBlobs,
|
|
381
|
+
...(await describeBackup(backupDir, backupId)),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* db.backup() returns only the id; size/timestamp come from a list() match. A missing match
|
|
386
|
+
* (e.g. a concurrent delete/purge between the two calls) is logged rather than silently
|
|
387
|
+
* reported as undefined fields.
|
|
388
|
+
*/
|
|
389
|
+
async function describeBackup(backupDir, backupId) {
|
|
390
|
+
const info = (await listBackupsInDir(backupDir)).find((backup) => backup.backupId === backupId);
|
|
391
|
+
if (!info) {
|
|
392
|
+
harper_logger_ts_1.default.warn(`Backup ${backupId} was created but is no longer listed in ${backupDir} (deleted concurrently?)`);
|
|
393
|
+
return {};
|
|
394
|
+
}
|
|
395
|
+
return { size: info.size, timestamp: info.timestamp };
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Write a `README.md` into a database's backup directory with the commands to list, verify, and
|
|
399
|
+
* restore the backup, so the repository is recoverable without reading the source. Best-effort:
|
|
400
|
+
* a failure to write the doc must not fail an otherwise-successful backup. Overwritten on each
|
|
401
|
+
* create so it stays current.
|
|
402
|
+
*/
|
|
403
|
+
async function writeBackupReadme(backupDir, databaseName) {
|
|
404
|
+
const content = `# Harper backup — database "${databaseName}"
|
|
405
|
+
|
|
406
|
+
This directory is a Harper-managed backup repository for the "${databaseName}" database: one or more
|
|
407
|
+
RocksDB backups (engine data + transaction logs) and, unless created with \`exclude_blobs\`, a
|
|
408
|
+
\`blobs/\` snapshot of the database's file-backed blobs (see blobs/README.md). It lives under
|
|
409
|
+
\`storage.backupPath\` (default \`<rootPath>/backup\`), one directory per database. Do not edit these
|
|
410
|
+
files by hand.
|
|
411
|
+
|
|
412
|
+
## List / verify
|
|
413
|
+
|
|
414
|
+
harper list_backups database=${databaseName}
|
|
415
|
+
harper verify_backup database=${databaseName} backup_id=<id>
|
|
416
|
+
|
|
417
|
+
## Restore
|
|
418
|
+
|
|
419
|
+
Restore is destructive: it purges and rewrites the database directory — and every blob root — from
|
|
420
|
+
the backup (blobs are restored automatically). Restore the latest backup in place:
|
|
421
|
+
|
|
422
|
+
harper restore_backup database=${databaseName}
|
|
423
|
+
|
|
424
|
+
...or a specific id:
|
|
425
|
+
|
|
426
|
+
harper restore_backup database=${databaseName} backup_id=<id>
|
|
427
|
+
|
|
428
|
+
A database held open by a loaded component — and always the \`system\` database — cannot be restored
|
|
429
|
+
while Harper is running; stop the server and run the same command offline. Offline you can also
|
|
430
|
+
restore into a *copy*, leaving the original untouched:
|
|
431
|
+
|
|
432
|
+
harper restore_backup database=${databaseName} target_database=${databaseName}-restore
|
|
433
|
+
|
|
434
|
+
## Prune
|
|
435
|
+
|
|
436
|
+
harper delete_backup database=${databaseName} backup_id=<id>
|
|
437
|
+
harper purge_backups database=${databaseName} keep_count=<n>
|
|
438
|
+
|
|
439
|
+
Both remove the RocksDB backup and its blob snapshot.
|
|
440
|
+
`;
|
|
441
|
+
try {
|
|
442
|
+
await (0, promises_1.writeFile)((0, node_path_1.join)(backupDir, 'README.md'), content);
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
harper_logger_ts_1.default.warn(`Failed to write backup README in ${backupDir}: ${error.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async function validateVerifyBackup(request) {
|
|
449
|
+
requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.VERIFY_BACKUP);
|
|
450
|
+
const databaseName = getDatabaseName(request);
|
|
451
|
+
requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.VERIFY_BACKUP);
|
|
452
|
+
requireBooleanOption(request.verify_checksum, 'verify_checksum');
|
|
453
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
454
|
+
const backupId = requireBackupId(request.backup_id);
|
|
455
|
+
await findBackup(backupDir, backupId, databaseName);
|
|
456
|
+
await requireBackupComplete(backupDir, backupId, databaseName);
|
|
457
|
+
}
|
|
458
|
+
async function verifyBackup(request) {
|
|
459
|
+
const databaseName = getDatabaseName(request);
|
|
460
|
+
requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.VERIFY_BACKUP);
|
|
461
|
+
const backupId = requireBackupId(request.backup_id);
|
|
462
|
+
const verifyWithChecksum = requireBooleanOption(request.verify_checksum, 'verify_checksum');
|
|
463
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
464
|
+
await findBackup(backupDir, backupId, databaseName);
|
|
465
|
+
const manifest = await requireBackupComplete(backupDir, backupId, databaseName);
|
|
466
|
+
await rocksdb_js_1.backups.verify(backupDir, backupId, { verifyWithChecksum });
|
|
467
|
+
// verification covers the declared blob snapshot: a backup that recorded blobs must still have its
|
|
468
|
+
// snapshot directory (a manifest without its blobs is a corrupt backup)
|
|
469
|
+
if (manifest.blobs && !(0, node_fs_1.existsSync)((0, blobBackup_ts_1.blobSnapshotDir)(backupDir, backupId))) {
|
|
470
|
+
throw new hdbError_ts_1.ClientError(`Backup ${backupId} of database '${databaseName}' declares captured blobs but its blob snapshot is missing (corrupt backup)`);
|
|
471
|
+
}
|
|
472
|
+
return { database: databaseName, backup_id: backupId, ok: true, blobs: manifest.blobs };
|
|
473
|
+
}
|
|
474
|
+
async function validateRestoreBackup(request) {
|
|
475
|
+
requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.RESTORE_BACKUP);
|
|
476
|
+
const databaseName = getDatabaseName(request);
|
|
477
|
+
if (databaseName === 'system') {
|
|
478
|
+
throw new hdbError_ts_1.ClientError(`The 'system' database cannot be restored while Harper is running; stop the server and run: harper restore_backup database=system`);
|
|
479
|
+
}
|
|
480
|
+
if (request.target_database !== undefined) {
|
|
481
|
+
// silently ignoring this would destructively restore over the source database instead of
|
|
482
|
+
// the copy the caller asked for
|
|
483
|
+
throw new hdbError_ts_1.ClientError(`'target_database' is not supported while Harper is running (restore_backup always restores in place); stop the server and run: harper restore_backup database=${databaseName} target_database=<name>`);
|
|
484
|
+
}
|
|
485
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
486
|
+
if (request.backup_id !== undefined) {
|
|
487
|
+
const backupId = requireBackupId(request.backup_id);
|
|
488
|
+
await findBackup(backupDir, backupId, databaseName);
|
|
489
|
+
await requireBackupComplete(backupDir, backupId, databaseName);
|
|
490
|
+
}
|
|
491
|
+
else if ((await listCompleteBackups(backupDir)).length === 0) {
|
|
492
|
+
throw new BackupNotFoundError(`No complete backups found for database '${databaseName}'`);
|
|
493
|
+
}
|
|
494
|
+
// Only a loaded database that actually has tables can be validated as a single-root RocksDB
|
|
495
|
+
// store here (an empty/tableless database has no table to resolve a root store from, and an
|
|
496
|
+
// unloaded one recovering an interrupted restore isn't open yet); those cases are validated when
|
|
497
|
+
// the restore job runs. `Object.keys` skips the DEFINED_TABLES symbol, so an empty database is 0.
|
|
498
|
+
const loaded = (0, databases_ts_1.getDatabases)()[databaseName];
|
|
499
|
+
if (loaded != null && Object.keys(loaded).length > 0) {
|
|
500
|
+
requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.RESTORE_BACKUP);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Online restore of a user database (see the design's restore lock + marker protocol):
|
|
505
|
+
* take the per-database restore lock, write the restoring marker, close the database across all
|
|
506
|
+
* worker threads, restore, delete the marker, release the lock, and reload everywhere.
|
|
507
|
+
*/
|
|
508
|
+
async function restoreBackup(request) {
|
|
509
|
+
const databaseName = getDatabaseName(request);
|
|
510
|
+
if (databaseName === 'system') {
|
|
511
|
+
throw new hdbError_ts_1.ClientError(`The 'system' database cannot be restored while Harper is running; stop the server and run: harper restore_backup database=system`);
|
|
512
|
+
}
|
|
513
|
+
if (request.target_database !== undefined) {
|
|
514
|
+
throw new hdbError_ts_1.ClientError(`'target_database' is not supported while Harper is running (restore_backup always restores in place); stop the server and run: harper restore_backup database=${databaseName} target_database=<name>`);
|
|
515
|
+
}
|
|
516
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
517
|
+
// choose the latest complete backup (or the requested id, rejected if incomplete); the manifest
|
|
518
|
+
// tells us whether blobs were captured so we restore them only when they were
|
|
519
|
+
const { backupId, manifest } = await resolveCompleteBackup(backupDir, request.backup_id === undefined ? undefined : requireBackupId(request.backup_id), databaseName);
|
|
520
|
+
// a loaded database *with tables* knows its real directory via its root store (which can differ
|
|
521
|
+
// from the computed default, e.g. legacy layouts); fall back to the computed path when the
|
|
522
|
+
// database is unloaded (recovering an interrupted restore) or empty (no table to resolve a root
|
|
523
|
+
// store from — Object.keys skips the DEFINED_TABLES symbol)
|
|
524
|
+
const loaded = (0, databases_ts_1.getDatabases)()[databaseName];
|
|
525
|
+
const databaseDir = loaded != null && Object.keys(loaded).length > 0
|
|
526
|
+
? requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.RESTORE_BACKUP).path
|
|
527
|
+
: (0, databases_ts_1.resolveDatabasePath)(databaseName);
|
|
528
|
+
// reject a backup with more blob roots than the current config *before* anything destructive —
|
|
529
|
+
// restoring it would mis-address blobs (records persist their root index)
|
|
530
|
+
await (0, blobBackup_ts_1.assertBlobSnapshotRestorable)(backupDir, backupId, (0, blob_ts_1.getBlobPathsForDatabaseName)(databaseName));
|
|
531
|
+
const lock = beginRestoreForDatabase(databaseDir, databaseName);
|
|
532
|
+
let destructionStarted = false;
|
|
533
|
+
try {
|
|
534
|
+
// close the database across all worker threads (each thread also rescans, and the
|
|
535
|
+
// restoring marker keeps the scan from reloading it mid-restore)
|
|
536
|
+
await signalling.signalSchemaChange(new itc_js_1.SchemaEventMsg(process.pid, hdbTerms_ts_1.OPERATIONS_ENUM.RESTORE_BACKUP, databaseName));
|
|
537
|
+
// A live component (or the system database) can hold its own handle on the database that
|
|
538
|
+
// Harper does not track and cannot close, so verify actual process-wide closure before
|
|
539
|
+
// purging — restoring under an open instance would corrupt it. If handles remain, fail
|
|
540
|
+
// with a clear pointer to the offline CLI path rather than purging.
|
|
541
|
+
await verifyDatabaseClosed(databaseDir, databaseName);
|
|
542
|
+
destructionStarted = true;
|
|
543
|
+
await rocksdb_js_1.backups.restore(backupDir, databaseDir, { backupId, mode: 'purgeAllFiles' });
|
|
544
|
+
// restore blobs only for a backup that captured them (an engine-only backup leaves the live
|
|
545
|
+
// blob roots untouched); the manifest, not the mere presence of a snapshot dir, is the source
|
|
546
|
+
// of truth so a mid-copy or absent snapshot can't be misread
|
|
547
|
+
if (manifest.blobs) {
|
|
548
|
+
await (0, blobBackup_ts_1.restoreBlobSnapshot)(backupDir, backupId, databaseName, (0, blob_ts_1.getBlobPathsForDatabaseName)(databaseName));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
// Leave the marker (so startup/rescan detection reports an incomplete restore until a rerun
|
|
553
|
+
// succeeds) when either the destructive purge has begun, OR this attempt was itself a recovery
|
|
554
|
+
// over a pre-existing marker: in that case the directory may already be half-purged from an
|
|
555
|
+
// earlier failed restore, so clearing the marker and reloading it as healthy would surface
|
|
556
|
+
// partial/corrupt data. Only a *fresh* marker on a *previously healthy* database that failed
|
|
557
|
+
// before any destruction is safe to clear.
|
|
558
|
+
if (destructionStarted || lock.preexisting) {
|
|
559
|
+
(0, restoreMarker_ts_1.abandonRestore)(lock);
|
|
560
|
+
// wrap rather than mutate error.message: a frozen/library error can have a non-writable
|
|
561
|
+
// message (assigning it throws TypeError under 'use strict')
|
|
562
|
+
throw new Error(`Restore of database '${databaseName}' from backup ${backupId} failed (rerun restore_backup to recover): ${error.message}`, { cause: error });
|
|
563
|
+
}
|
|
564
|
+
// nothing destructive happened and the marker was fresh — clear it and let every thread reload
|
|
565
|
+
// the intact database
|
|
566
|
+
(0, restoreMarker_ts_1.completeRestore)(lock);
|
|
567
|
+
await signalling.signalSchemaChange(new itc_js_1.SchemaEventMsg(process.pid, hdbTerms_ts_1.OPERATIONS_ENUM.RESTORE_BACKUP, databaseName));
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
(0, restoreMarker_ts_1.completeRestore)(lock);
|
|
571
|
+
// signal again: with the marker gone, every thread's rescan reloads the restored database
|
|
572
|
+
await signalling.signalSchemaChange(new itc_js_1.SchemaEventMsg(process.pid, hdbTerms_ts_1.OPERATIONS_ENUM.RESTORE_BACKUP, databaseName));
|
|
573
|
+
return { database: databaseName, backup_id: backupId };
|
|
574
|
+
}
|
|
575
|
+
// After the close broadcast is acknowledged, every worker thread has released its Harper-managed
|
|
576
|
+
// handles; a short grace period covers a just-finished job worker still draining its own close.
|
|
577
|
+
// Anything still open past that is a handle Harper neither tracks nor controls (a loaded component
|
|
578
|
+
// holding its own instance, or the system database), which will never close on its own — so fail
|
|
579
|
+
// fast rather than waiting out a long timeout.
|
|
580
|
+
const DATABASE_CLOSE_WAIT_MS = 3000;
|
|
581
|
+
const DATABASE_CLOSE_POLL_INTERVAL_MS = 250;
|
|
582
|
+
/**
|
|
583
|
+
* Verify no thread in this process still has the database open (rocksdb-js's registry is
|
|
584
|
+
* process-global across worker threads), polling briefly to let a just-finished job worker's own
|
|
585
|
+
* close drain. Throws 409 with an actionable message if handles remain — which means a loaded
|
|
586
|
+
* component is holding the database open (Harper can neither detect which component nor force its
|
|
587
|
+
* handle closed), so an online in-place restore is not possible and the offline CLI is the path.
|
|
588
|
+
*/
|
|
589
|
+
async function verifyDatabaseClosed(databaseDir, databaseName) {
|
|
590
|
+
const targetPath = (0, node_path_1.resolve)(databaseDir);
|
|
591
|
+
const deadline = Date.now() + DATABASE_CLOSE_WAIT_MS;
|
|
592
|
+
for (;;) {
|
|
593
|
+
const stillOpen = (0, rocksdb_js_1.registryStatus)().some((instance) => (0, node_path_1.resolve)(instance.path) === targetPath && instance.refCount > 0);
|
|
594
|
+
if (!stillOpen)
|
|
595
|
+
return;
|
|
596
|
+
if (Date.now() >= deadline) {
|
|
597
|
+
throw new BackupInProgressError(`Cannot restore database '${databaseName}' while Harper is running: it is held open by a loaded component (or is the system database). ` +
|
|
598
|
+
`Restore it offline instead — stop the server and run: harper restore_backup database=${databaseName}` +
|
|
599
|
+
(databaseName === 'system' ? '' : ` backup_id=<id>`));
|
|
600
|
+
}
|
|
601
|
+
await (0, promises_3.setTimeout)(DATABASE_CLOSE_POLL_INTERVAL_MS);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* beginRestore's own error message carries the filesystem path (useful in CLI/server logs);
|
|
606
|
+
* client-facing operations report by database name instead.
|
|
607
|
+
*/
|
|
608
|
+
function beginRestoreForDatabase(databaseDir, databaseName) {
|
|
609
|
+
try {
|
|
610
|
+
return (0, restoreMarker_ts_1.beginRestore)(databaseDir);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
if (error.statusCode === 409) {
|
|
614
|
+
throw new BackupInProgressError(`Restore already in progress for database '${databaseName}'`);
|
|
615
|
+
}
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Recognize RocksDB's own on-disk `LOCK`-file contention error. The pinned rocksdb-js 2.5.0 binding
|
|
621
|
+
* surfaces it as a plain `Error` with no `code` and a message like
|
|
622
|
+
* `IO error: While lock file: <db>/LOCK: Resource temporarily unavailable`, so string-matching is
|
|
623
|
+
* the only signal available (there is no typed error to key on — a native primitive is a rocksdb-js
|
|
624
|
+
* follow-on). We match conservatively and fail *closed* on a hit so the offline restore never purges
|
|
625
|
+
* a database another process still has open.
|
|
626
|
+
*/
|
|
627
|
+
function isRocksDbLockError(error) {
|
|
628
|
+
const message = typeof error?.message === 'string' ? error.message : '';
|
|
629
|
+
return (/lock file:/i.test(message) ||
|
|
630
|
+
message.includes('LOCK:') ||
|
|
631
|
+
message.includes('Resource temporarily unavailable') ||
|
|
632
|
+
message.includes('is locked'));
|
|
633
|
+
}
|
|
634
|
+
// --- get_backup (RocksDB path): stream a fresh full-snapshot tar in the HTTP response ---
|
|
635
|
+
/**
|
|
636
|
+
* Returns a Readable (with `.headers`) streaming a full-snapshot tar (optionally gzipped) of the
|
|
637
|
+
* database's current state. No scratch disk; a consumer error aborts the native backup cleanly.
|
|
638
|
+
* `noCompression` opts out of serverHandlers' accept-encoding auto-gzip — this response must never
|
|
639
|
+
* be compressed by the server.
|
|
640
|
+
*
|
|
641
|
+
* With blobs included (the default; `excludeBlobs` opts out), the database's file-backed blob roots
|
|
642
|
+
* are appended to the same archive under `blobs/<rootIndex>/<relpath>` so a downloaded backup is a
|
|
643
|
+
* complete Harper database. Blob capture is best-effort point-in-time (the archive contains whatever
|
|
644
|
+
* files exist while it streams); a blob deleted mid-stream is skipped.
|
|
645
|
+
*/
|
|
646
|
+
function createBackupStream(rootStore, databaseName, gzip, excludeBlobs = false) {
|
|
647
|
+
const stream = new node_stream_1.PassThrough();
|
|
648
|
+
// database names may legally contain `"` and `\` (schemaRegex) — sanitize so the quoted
|
|
649
|
+
// content-disposition filename stays parseable
|
|
650
|
+
const filename = `${databaseName.replace(/["\\]/g, '_')}.tar${gzip ? '.gz' : ''}`;
|
|
651
|
+
stream.headers = new Map([
|
|
652
|
+
['content-type', gzip ? 'application/gzip' : 'application/x-tar'],
|
|
653
|
+
['content-disposition', `attachment; filename="${filename}"`],
|
|
654
|
+
]);
|
|
655
|
+
stream.noCompression = true;
|
|
656
|
+
if (excludeBlobs) {
|
|
657
|
+
// engine-only: the binding produces (and gzips) the whole archive directly
|
|
658
|
+
rootStore
|
|
659
|
+
.backup(node_stream_1.Writable.toWeb(stream), { gzip, transactionLogs: true })
|
|
660
|
+
.catch((error) => stream.destroy(error));
|
|
661
|
+
return stream;
|
|
662
|
+
}
|
|
663
|
+
streamBackupWithBlobs(rootStore, databaseName, gzip, stream).catch((error) => {
|
|
664
|
+
// the consumer aborting (destroying the response) is the common case, not an error to re-raise
|
|
665
|
+
if (!stream.destroyed)
|
|
666
|
+
stream.destroy(error);
|
|
667
|
+
});
|
|
668
|
+
return stream;
|
|
669
|
+
}
|
|
670
|
+
// The native streaming backup finalizes its tar with exactly two zero-filled 512-byte blocks (the
|
|
671
|
+
// USTAR end-of-archive marker). To append blob entries into the same archive we drop that trailer
|
|
672
|
+
// from the native tar and let tar-stream write the single real end-of-archive marker after the blob
|
|
673
|
+
// entries.
|
|
674
|
+
const TAR_TRAILER_BYTES = 1024;
|
|
675
|
+
/**
|
|
676
|
+
* Stream a full-snapshot tar of the database followed by its blob roots as one archive. The native
|
|
677
|
+
* (plain) tar is streamed with its end-of-archive trailer stripped, blob files are appended as
|
|
678
|
+
* `blobs/<rootIndex>/<relpath>` entries via tar-stream, and the combined plain tar is gzipped here
|
|
679
|
+
* when requested (the binding is asked for a plain tar so we can append before compressing).
|
|
680
|
+
*/
|
|
681
|
+
async function streamBackupWithBlobs(rootStore, databaseName, gzip, out) {
|
|
682
|
+
const plain = new node_stream_1.PassThrough(); // the combined, uncompressed tar
|
|
683
|
+
const nativeTar = new node_stream_1.PassThrough(); // native (plain) tar, before its trailer is stripped
|
|
684
|
+
// consumer side: gzip the combined archive (or pass it through) into the response stream
|
|
685
|
+
const consumed = gzip ? (0, promises_2.pipeline)(plain, (0, node_zlib_1.createGzip)(), out) : (0, promises_2.pipeline)(plain, out);
|
|
686
|
+
// producer side: native plain tar → nativeTar, copied into `plain` minus its trailer
|
|
687
|
+
const nativeDone = rootStore.backup(node_stream_1.Writable.toWeb(nativeTar), { gzip: false, transactionLogs: true });
|
|
688
|
+
// A consumer that aborts (destroys `out`) rejects `consumed` before we reach the `await` below, so
|
|
689
|
+
// attach silent observers now to close the unhandled-rejection window; the awaits/allSettled still
|
|
690
|
+
// see the rejection and drive the real teardown.
|
|
691
|
+
consumed.catch(() => { });
|
|
692
|
+
nativeDone.catch(() => { });
|
|
693
|
+
try {
|
|
694
|
+
await copyDroppingTarTrailer(nativeTar, plain);
|
|
695
|
+
await nativeDone; // surface any native backup error before we append blobs
|
|
696
|
+
const pack = (0, tar_stream_1.pack)();
|
|
697
|
+
const packed = (0, promises_2.pipeline)(pack, plain); // ends `plain` once the blob entries + trailer are written
|
|
698
|
+
const blobRoots = (0, blob_ts_1.getBlobPathsForDatabaseName)(databaseName);
|
|
699
|
+
await appendBlobEntries(pack, blobRoots);
|
|
700
|
+
// generate the same self-documenting READMEs a managed backup writes to disk, on the fly
|
|
701
|
+
await addTextEntry(pack, 'README.md', streamedBackupReadme(databaseName));
|
|
702
|
+
await addTextEntry(pack, 'blobs/README.md', (0, blobBackup_ts_1.blobsReadmeContent)(blobRoots, { archive: true }));
|
|
703
|
+
pack.finalize();
|
|
704
|
+
await packed;
|
|
705
|
+
await consumed;
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
// Tear down both pipelines and observe every side promise so a consumer abort (or any mid-
|
|
709
|
+
// stream failure) can never leave an unhandled rejection from `consumed`/`nativeDone`.
|
|
710
|
+
if (!nativeTar.destroyed)
|
|
711
|
+
nativeTar.destroy();
|
|
712
|
+
if (!plain.destroyed)
|
|
713
|
+
plain.destroy(error);
|
|
714
|
+
await Promise.allSettled([consumed, nativeDone]);
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Copy `src` into `dest` (without ending `dest`) while withholding the final {@link TAR_TRAILER_BYTES}
|
|
720
|
+
* bytes — the native tar's end-of-archive marker — so more entries can be appended. Verifies the
|
|
721
|
+
* withheld bytes are the expected all-zero trailer so a format change in the binding fails loudly
|
|
722
|
+
* rather than producing a silently-corrupt archive.
|
|
723
|
+
*/
|
|
724
|
+
async function copyDroppingTarTrailer(src, dest) {
|
|
725
|
+
let tail = Buffer.alloc(0);
|
|
726
|
+
for await (const chunk of src) {
|
|
727
|
+
tail = tail.length === 0 ? chunk : Buffer.concat([tail, chunk]);
|
|
728
|
+
if (tail.length > TAR_TRAILER_BYTES) {
|
|
729
|
+
const emit = tail.subarray(0, tail.length - TAR_TRAILER_BYTES);
|
|
730
|
+
tail = Buffer.from(tail.subarray(tail.length - TAR_TRAILER_BYTES));
|
|
731
|
+
await writeWithBackpressure(dest, emit);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (tail.length !== TAR_TRAILER_BYTES || tail.some((byte) => byte !== 0)) {
|
|
735
|
+
throw new Error(`Unexpected trailer from native backup stream (expected ${TAR_TRAILER_BYTES} zero bytes, got ${tail.length}); cannot append blobs`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/** Write to a stream, awaiting `drain` on backpressure and rejecting (rather than hanging) on error. */
|
|
739
|
+
function writeWithBackpressure(dest, chunk) {
|
|
740
|
+
return new Promise((resolvePromise, reject) => {
|
|
741
|
+
if (dest.write(chunk))
|
|
742
|
+
return resolvePromise();
|
|
743
|
+
const cleanup = () => {
|
|
744
|
+
dest.off('drain', onDrain);
|
|
745
|
+
dest.off('error', onError);
|
|
746
|
+
};
|
|
747
|
+
const onDrain = () => {
|
|
748
|
+
cleanup();
|
|
749
|
+
resolvePromise();
|
|
750
|
+
};
|
|
751
|
+
const onError = (error) => {
|
|
752
|
+
cleanup();
|
|
753
|
+
reject(error);
|
|
754
|
+
};
|
|
755
|
+
dest.once('drain', onDrain);
|
|
756
|
+
dest.once('error', onError);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Append every blob file under the given blob roots to `pack` as `blobs/<rootIndex>/<relpath>`
|
|
761
|
+
* entries. Size is captured at open time and exactly that many bytes are streamed; a file that
|
|
762
|
+
* vanished before it could be opened (a concurrent blob delete) is skipped.
|
|
763
|
+
*/
|
|
764
|
+
async function appendBlobEntries(pack, blobRoots) {
|
|
765
|
+
for (let index = 0; index < blobRoots.length; index++) {
|
|
766
|
+
const root = blobRoots[index];
|
|
767
|
+
if (!(0, node_fs_1.existsSync)(root))
|
|
768
|
+
continue;
|
|
769
|
+
const stack = [root];
|
|
770
|
+
while (stack.length > 0) {
|
|
771
|
+
const dir = stack.pop();
|
|
772
|
+
let entries;
|
|
773
|
+
try {
|
|
774
|
+
entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
if (error.code === 'ENOENT')
|
|
778
|
+
continue;
|
|
779
|
+
throw error;
|
|
780
|
+
}
|
|
781
|
+
for (const entry of entries) {
|
|
782
|
+
const filePath = (0, node_path_1.join)(dir, entry.name);
|
|
783
|
+
if (entry.isDirectory()) {
|
|
784
|
+
stack.push(filePath);
|
|
785
|
+
}
|
|
786
|
+
else if (entry.isFile()) {
|
|
787
|
+
// tar entry names are always POSIX-separated; relative() yields `\` on Windows, which
|
|
788
|
+
// would otherwise become literal filename characters when extracted on POSIX
|
|
789
|
+
const relativePath = (0, node_path_1.relative)(root, filePath).split(node_path_1.sep).join('/');
|
|
790
|
+
await appendBlobFile(pack, filePath, `blobs/${index}/${relativePath}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/** Add a single file to the pack, streaming exactly the byte count captured at open time. */
|
|
797
|
+
async function appendBlobFile(pack, filePath, name) {
|
|
798
|
+
let handle;
|
|
799
|
+
try {
|
|
800
|
+
handle = await (0, promises_1.open)(filePath, 'r');
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
if (error.code === 'ENOENT')
|
|
804
|
+
return; // deleted mid-walk
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
try {
|
|
808
|
+
const { size } = await handle.stat();
|
|
809
|
+
await new Promise((resolvePromise, reject) => {
|
|
810
|
+
const entry = pack.entry({ name, size }, (error) => (error ? reject(error) : resolvePromise()));
|
|
811
|
+
if (size === 0) {
|
|
812
|
+
entry.end();
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
// stream exactly the bytes present at open time (end is inclusive); pin the fd open
|
|
816
|
+
// (autoClose:false) since the finally below closes the handle
|
|
817
|
+
const source = (0, node_fs_1.createReadStream)('', { fd: handle.fd, autoClose: false, start: 0, end: size - 1 });
|
|
818
|
+
source.on('error', (error) => entry.destroy(error));
|
|
819
|
+
source.pipe(entry);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
await handle.close();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
/** Add an in-memory string to the pack as a single tar entry (used for the generated READMEs). */
|
|
827
|
+
async function addTextEntry(pack, name, content) {
|
|
828
|
+
const buffer = Buffer.from(content, 'utf8');
|
|
829
|
+
await new Promise((resolvePromise, reject) => {
|
|
830
|
+
const entry = pack.entry({ name, size: buffer.length }, (error) => (error ? reject(error) : resolvePromise()));
|
|
831
|
+
entry.end(buffer);
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* The top-level `README.md` embedded in a downloaded `get_backup` archive. Unlike a managed backup
|
|
836
|
+
* repository (which is restored in place via `restore_backup`), this is a raw snapshot tar restored
|
|
837
|
+
* by extracting its files back into the database directory and blob roots.
|
|
838
|
+
*/
|
|
839
|
+
function streamedBackupReadme(databaseName) {
|
|
840
|
+
return `# Harper backup archive — database "${databaseName}"
|
|
841
|
+
|
|
842
|
+
A full point-in-time snapshot of the "${databaseName}" database, produced by \`get_backup\`:
|
|
843
|
+
- the RocksDB data and manifest at the archive root (CURRENT, MANIFEST-*, *.sst, OPTIONS-*)
|
|
844
|
+
- transaction_logs/ — the transaction log snapshot
|
|
845
|
+
- blobs/ — the database's file-backed blobs, unless this archive was created with exclude_blobs
|
|
846
|
+
(see blobs/README.md for the layout and the root-index mapping)
|
|
847
|
+
|
|
848
|
+
## Restoring
|
|
849
|
+
|
|
850
|
+
This is a raw snapshot archive, not a managed backup repository. To restore it, stop Harper and lay
|
|
851
|
+
the files back down in two places:
|
|
852
|
+
1. The RocksDB files — everything except blobs/ — go into the database's directory
|
|
853
|
+
(typically <rootPath>/database/${databaseName}).
|
|
854
|
+
2. Each blobs/<rootIndex>/ tree goes into the matching blob root — the index maps to
|
|
855
|
+
storage.blobPaths[n], or <rootPath>/blobs/${databaseName} when blobPaths is not configured
|
|
856
|
+
(see blobs/README.md). Then start Harper.
|
|
857
|
+
|
|
858
|
+
For a server-managed, in-place restore instead, use the managed backup workflow (create_backup /
|
|
859
|
+
restore_backup): https://docs.harperdb.io/reference/v5/operations-api/operations
|
|
860
|
+
`;
|
|
861
|
+
}
|
|
862
|
+
// --- offline CLI paths (server stopped) ---
|
|
863
|
+
/**
|
|
864
|
+
* Offline create: open the RocksDatabase directly, run an ordinary incremental directory backup
|
|
865
|
+
* into the configured backup root, and close. RocksDB is single-writer, so this collides on the
|
|
866
|
+
* database lock if the server is running — callers guard on the server being stopped.
|
|
867
|
+
*/
|
|
868
|
+
async function createBackupOffline(databaseName, excludeBlobs = false) {
|
|
869
|
+
validateDatabaseName(databaseName);
|
|
870
|
+
const databaseDir = (0, databases_ts_1.resolveDatabasePath)(databaseName);
|
|
871
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(databaseDir, 'CURRENT'))) {
|
|
872
|
+
throw new BackupNotFoundError(`No RocksDB database found at ${databaseDir}`);
|
|
873
|
+
}
|
|
874
|
+
const restoreState = (0, restoreMarker_ts_1.checkRestoreState)(databaseDir);
|
|
875
|
+
if (restoreState !== 'clear') {
|
|
876
|
+
throw new BackupInProgressError(`Database '${databaseName}' has an ${restoreState === 'in-progress' ? 'active' : 'incomplete'} restore; rerun restore_backup before backing up`);
|
|
877
|
+
}
|
|
878
|
+
const database = rocksdb_js_1.RocksDatabase.open(databaseDir);
|
|
879
|
+
try {
|
|
880
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
881
|
+
let backupId;
|
|
882
|
+
try {
|
|
883
|
+
backupId = await database.backup(backupDir, { transactionLogs: true });
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
throw mapLockedError(error, databaseName);
|
|
887
|
+
}
|
|
888
|
+
await finalizeBackup(backupDir, backupId, databaseName, !excludeBlobs);
|
|
889
|
+
await writeBackupReadme(backupDir, databaseName);
|
|
890
|
+
return {
|
|
891
|
+
database: databaseName,
|
|
892
|
+
backup_id: backupId,
|
|
893
|
+
blobs: !excludeBlobs,
|
|
894
|
+
...(await describeBackup(backupDir, backupId)),
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
finally {
|
|
898
|
+
database.close();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Offline restore (required for the `system` database; works for any database). Runs the same
|
|
903
|
+
* lock + marker protocol as the online operation so a crashed CLI restore is detected at next
|
|
904
|
+
* server start. `targetDatabase` restores into a different database directory (non-destructive
|
|
905
|
+
* for the source database); the server picks it up on next start via normal engine detection.
|
|
906
|
+
*/
|
|
907
|
+
async function restoreBackupOffline(databaseName, backupId, targetDatabase) {
|
|
908
|
+
validateDatabaseName(databaseName);
|
|
909
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
910
|
+
// resolve to the latest complete backup (or the requested id, rejected if incomplete)
|
|
911
|
+
const resolved = await resolveCompleteBackup(backupDir, backupId, databaseName);
|
|
912
|
+
backupId = resolved.backupId;
|
|
913
|
+
const manifest = resolved.manifest;
|
|
914
|
+
if (targetDatabase !== undefined)
|
|
915
|
+
validateDatabaseName(targetDatabase);
|
|
916
|
+
const databaseDir = (0, databases_ts_1.resolveDatabasePath)(targetDatabase ?? databaseName);
|
|
917
|
+
if (targetDatabase !== undefined && targetDatabase !== databaseName && !isMissingOrEmptyDir(databaseDir)) {
|
|
918
|
+
// target_database is documented as non-destructive: never purge an existing database of
|
|
919
|
+
// that name out from under the operator
|
|
920
|
+
throw new hdbError_ts_1.ClientError(`target_database '${targetDatabase}' already exists at ${databaseDir}; restoring into it would destroy it — choose a new name, or restore in place by omitting target_database`);
|
|
921
|
+
}
|
|
922
|
+
// reject a backup with more blob roots than the target's current config before anything
|
|
923
|
+
// destructive (records persist their root index, so collapsing would mis-address blobs)
|
|
924
|
+
await (0, blobBackup_ts_1.assertBlobSnapshotRestorable)(backupDir, backupId, (0, blob_ts_1.getBlobPathsForDatabaseName)(targetDatabase ?? databaseName));
|
|
925
|
+
// Take the restore lock + marker BEFORE probing so a server that starts after this point sees the
|
|
926
|
+
// marker and refuses to load the database (closing the window between the probe and the purge).
|
|
927
|
+
const lock = beginRestoreForDatabase(databaseDir, targetDatabase ?? databaseName);
|
|
928
|
+
let destructionStarted = false;
|
|
929
|
+
try {
|
|
930
|
+
// The offline path is entered only when the CLI sees no running server (getHdbPid), but that is
|
|
931
|
+
// a heuristic: the PID file is briefly absent mid-`harper restart`, and backups.restore's
|
|
932
|
+
// purgeAllFiles never takes RocksDB's own lock. Probe that lock by opening the database — a live
|
|
933
|
+
// holder makes open throw its LOCK-file error (isRocksDbLockError) — so we fail closed rather
|
|
934
|
+
// than purge a database another process still has open. A directory that fails to open for any
|
|
935
|
+
// *other* reason (corrupt or half-restored) is exactly what restore recovers, so only a lock
|
|
936
|
+
// conflict aborts.
|
|
937
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(databaseDir, 'CURRENT'))) {
|
|
938
|
+
let handle;
|
|
939
|
+
try {
|
|
940
|
+
handle = rocksdb_js_1.RocksDatabase.open(databaseDir);
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
if (isRocksDbLockError(error)) {
|
|
944
|
+
throw new BackupInProgressError(`Cannot restore database '${databaseName}': it is open by a running Harper process — stop Harper before restoring offline`);
|
|
945
|
+
}
|
|
946
|
+
// otherwise corrupt/half-restored — fall through and let restore recover it
|
|
947
|
+
}
|
|
948
|
+
handle?.close();
|
|
949
|
+
}
|
|
950
|
+
destructionStarted = true;
|
|
951
|
+
await rocksdb_js_1.backups.restore(backupDir, databaseDir, { backupId, mode: 'purgeAllFiles' });
|
|
952
|
+
// restore blobs only for a backup that captured them (per the manifest, not snapshot presence)
|
|
953
|
+
if (manifest.blobs) {
|
|
954
|
+
await (0, blobBackup_ts_1.restoreBlobSnapshot)(backupDir, backupId, databaseName, (0, blob_ts_1.getBlobPathsForDatabaseName)(targetDatabase ?? databaseName));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
// Preserve the marker on a destructive failure or a recovery over a pre-existing marker (see
|
|
959
|
+
// the online restoreBackup for the rationale); otherwise clear the fresh marker so an intact,
|
|
960
|
+
// merely-locked database is not left flagged as an incomplete restore.
|
|
961
|
+
if (destructionStarted || lock.preexisting)
|
|
962
|
+
(0, restoreMarker_ts_1.abandonRestore)(lock);
|
|
963
|
+
else
|
|
964
|
+
(0, restoreMarker_ts_1.completeRestore)(lock);
|
|
965
|
+
// preserve typed client errors (e.g. the 409 lock probe) unwrapped; only wrap an opaque restore
|
|
966
|
+
// failure after destruction has begun
|
|
967
|
+
if (destructionStarted && !(error instanceof hdbError_ts_1.ClientError)) {
|
|
968
|
+
throw new Error(`Restore of database '${databaseName}' from backup ${backupId} failed (rerun restore_backup to recover): ${error.message}`, { cause: error });
|
|
969
|
+
}
|
|
970
|
+
throw error;
|
|
971
|
+
}
|
|
972
|
+
(0, restoreMarker_ts_1.completeRestore)(lock);
|
|
973
|
+
return { database: databaseName, backup_id: backupId, restored_to: databaseDir };
|
|
974
|
+
}
|
|
975
|
+
function isMissingOrEmptyDir(path) {
|
|
976
|
+
try {
|
|
977
|
+
return (0, node_fs_1.readdirSync)(path).length === 0;
|
|
978
|
+
}
|
|
979
|
+
catch (error) {
|
|
980
|
+
if (error.code === 'ENOENT')
|
|
981
|
+
return true;
|
|
982
|
+
throw error;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
// --- offline management wrappers (no engine validation: they operate on the directory only) ---
|
|
986
|
+
async function listBackupsOffline(databaseName) {
|
|
987
|
+
validateDatabaseName(databaseName);
|
|
988
|
+
// map to the same snake_case response shape as the online list_backups operation
|
|
989
|
+
return (await listCompleteBackups(backupDirForDatabase(databaseName))).map((backup) => toBackupResponse(backup, backup.blobs));
|
|
990
|
+
}
|
|
991
|
+
async function verifyBackupOffline(databaseName, backupId, verifyChecksum) {
|
|
992
|
+
validateDatabaseName(databaseName);
|
|
993
|
+
const verifyWithChecksum = requireBooleanOption(verifyChecksum, 'verify_checksum');
|
|
994
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
995
|
+
await findBackup(backupDir, backupId, databaseName);
|
|
996
|
+
const manifest = await requireBackupComplete(backupDir, backupId, databaseName);
|
|
997
|
+
await rocksdb_js_1.backups.verify(backupDir, backupId, { verifyWithChecksum });
|
|
998
|
+
if (manifest.blobs && !(0, node_fs_1.existsSync)((0, blobBackup_ts_1.blobSnapshotDir)(backupDir, backupId))) {
|
|
999
|
+
throw new hdbError_ts_1.ClientError(`Backup ${backupId} of database '${databaseName}' declares captured blobs but its blob snapshot is missing (corrupt backup)`);
|
|
1000
|
+
}
|
|
1001
|
+
return { database: databaseName, backup_id: backupId, ok: true, blobs: manifest.blobs };
|
|
1002
|
+
}
|
|
1003
|
+
async function deleteBackupOffline(databaseName, backupId) {
|
|
1004
|
+
validateDatabaseName(databaseName);
|
|
1005
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
1006
|
+
await findBackup(backupDir, backupId, databaseName);
|
|
1007
|
+
try {
|
|
1008
|
+
await rocksdb_js_1.backups.delete(backupDir, backupId);
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
throw mapLockedError(error, databaseName);
|
|
1012
|
+
}
|
|
1013
|
+
await (0, blobBackup_ts_1.deleteBlobSnapshot)(backupDir, backupId);
|
|
1014
|
+
await (0, backupManifest_ts_1.deleteBackupManifest)(backupDir, backupId);
|
|
1015
|
+
return { ok: true };
|
|
1016
|
+
}
|
|
1017
|
+
async function purgeBackupsOffline(databaseName, keepCount) {
|
|
1018
|
+
validateDatabaseName(databaseName);
|
|
1019
|
+
if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
|
|
1020
|
+
throw new hdbError_ts_1.ClientError(`'keep_count' must be a non-negative integer`);
|
|
1021
|
+
}
|
|
1022
|
+
const backupDir = backupDirForDatabase(databaseName);
|
|
1023
|
+
const before = await listBackupsInDir(backupDir);
|
|
1024
|
+
if (before.length === 0) {
|
|
1025
|
+
throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
await rocksdb_js_1.backups.purge(backupDir, keepCount);
|
|
1029
|
+
}
|
|
1030
|
+
catch (error) {
|
|
1031
|
+
throw mapLockedError(error, databaseName);
|
|
1032
|
+
}
|
|
1033
|
+
const remainingBackups = await listBackupsInDir(backupDir);
|
|
1034
|
+
const keepIds = new Set(remainingBackups.map((backup) => backup.backupId));
|
|
1035
|
+
await (0, blobBackup_ts_1.purgeBlobSnapshots)(backupDir, keepIds);
|
|
1036
|
+
await (0, backupManifest_ts_1.purgeBackupManifests)(backupDir, keepIds);
|
|
1037
|
+
return { deleted: Math.max(0, before.length - remainingBackups.length), remaining: remainingBackups.length };
|
|
1038
|
+
}
|
|
1039
|
+
//# sourceMappingURL=rocksdbBackup.js.map
|