@harperfast/harper 5.2.0-beta.4 → 5.2.0
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/harper.ts +25 -6
- package/components/Scope.ts +52 -6
- package/components/componentLoader.ts +107 -9
- package/components/mcp/toolRegistry.ts +10 -0
- package/components/mcp/tools/application.ts +12 -5
- package/components/mcp/tools/operations.ts +3 -0
- package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
- package/components/mcp/tools/schemas/operations.ts +9 -0
- package/components/operations.js +1 -0
- package/components/operationsValidation.js +32 -2
- package/components/scopeMount.ts +150 -0
- package/config-root.schema.json +4 -0
- package/dataLayer/backupManifest.ts +102 -0
- package/dataLayer/blobBackup.ts +286 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +43 -0
- package/dataLayer/restoreMarker.ts +276 -0
- package/dataLayer/rocksdbBackup.ts +1100 -0
- 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/harper.d.ts +7 -0
- package/dist/bin/harper.js +27 -6
- package/dist/bin/harper.js.map +1 -1
- package/dist/components/Scope.d.ts +37 -1
- package/dist/components/Scope.js +48 -6
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.d.ts +2 -6
- package/dist/components/componentLoader.js +91 -8
- package/dist/components/componentLoader.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 +3 -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 +2 -0
- 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/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 +33 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.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/resources/DatabaseTransaction.js +0 -6
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +0 -3
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/Table.js +22 -22
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/blob.d.ts +8 -0
- package/dist/resources/blob.js +16 -7
- package/dist/resources/blob.js.map +1 -1
- package/dist/resources/databases.d.ts +42 -1
- package/dist/resources/databases.js +276 -40
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/transaction.js +0 -3
- package/dist/resources/transaction.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/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/serverHelpers/serverHandlers.js +8 -4
- package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +11 -0
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/static.js +11 -3
- package/dist/server/static.js.map +1 -1
- package/dist/utility/OperationFunctionCaller.js +25 -6
- package/dist/utility/OperationFunctionCaller.js.map +1 -1
- package/dist/utility/hdbTerms.d.ts +11 -1
- package/dist/utility/hdbTerms.js +11 -1
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/logging/harper_logger.d.ts +40 -0
- package/dist/utility/logging/harper_logger.js +746 -14
- 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 +3 -2
- package/package.json +4 -1
- package/resources/DESIGN.md +2 -0
- package/resources/DatabaseTransaction.ts +0 -3
- package/resources/ResourceInterface.ts +0 -3
- package/resources/Table.ts +20 -20
- package/resources/blob.ts +16 -6
- package/resources/databases.ts +272 -42
- package/resources/transaction.ts +0 -3
- package/server/DESIGN.md +16 -0
- package/server/REST.ts +25 -9
- package/server/fastifyRoutes.ts +20 -1
- 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/serverHelpers/serverHandlers.js +8 -4
- package/server/serverHelpers/serverUtilities.ts +19 -0
- package/server/static.ts +12 -3
- package/static/defaultConfig.yaml +1 -0
- package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-DoVWScmq.js} +2 -2
- package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-DoVWScmq.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-UZ2NsUOZ.js} +4 -4
- package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-UZ2NsUOZ.js.map} +1 -1
- package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-BUI_04o7.js} +2 -2
- package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-BUI_04o7.js.map} +1 -1
- package/studio/web/assets/{applications-BDfH8urd.js → applications-D03NA7wW.js} +2 -2
- package/studio/web/assets/{applications-BDfH8urd.js.map → applications-D03NA7wW.js.map} +1 -1
- package/studio/web/assets/{index-BHo3c2Gk.js → index-Bh_CNAHr.js} +6 -6
- package/studio/web/assets/index-Bh_CNAHr.js.map +1 -0
- package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-Dx3MpyDC.js} +4 -4
- package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-Dx3MpyDC.js.map} +1 -1
- package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-0edoFTsb.js} +2 -2
- package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-0edoFTsb.js.map} +1 -1
- package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CwKhipK7.js} +2 -2
- package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CwKhipK7.js.map} +1 -1
- package/studio/web/assets/{profile-Doj5FVDE.js → profile-DUfEPQtx.js} +2 -2
- package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-DUfEPQtx.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-DMPo4UjC.js} +2 -2
- package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-DMPo4UjC.js.map} +1 -1
- package/studio/web/assets/{setup-DJwR0BHd.js → setup-B56Oz1_u.js} +2 -2
- package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-B56Oz1_u.js.map} +1 -1
- package/studio/web/assets/{status-Br_AbsJs.js → status-BAod7p3o.js} +2 -2
- package/studio/web/assets/{status-Br_AbsJs.js.map → status-BAod7p3o.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-lQrBxfwM.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-lQrBxfwM.js.map} +1 -1
- package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-CrHCRjTK.js} +2 -2
- package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-CrHCRjTK.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-DoaBMEvU.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-DoaBMEvU.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/OperationFunctionCaller.ts +24 -3
- package/utility/hdbTerms.ts +11 -1
- package/utility/logging/harper_logger.ts +729 -15
- package/utility/operation_authorization.ts +31 -3
- package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
|
@@ -29,6 +29,7 @@ import * as scheduler from '../resources/scheduler/scheduler.ts';
|
|
|
29
29
|
import { restartWorkers, getWorkerIndex } from '../server/threads/manageThreads.js';
|
|
30
30
|
import { resetRestartNeeded, subscribeToRestartRequests } from './requestRestart.ts';
|
|
31
31
|
import { trackScopeClose } from './scopeShutdown.ts';
|
|
32
|
+
import { toScopeMount, nestScopeMount, type ScopeMount } from './scopeMount.ts';
|
|
32
33
|
import { scopedImport } from '../security/jsLoader.ts';
|
|
33
34
|
import { server } from '../server/Server.ts';
|
|
34
35
|
import { Resources } from '../resources/Resources.ts';
|
|
@@ -63,6 +64,51 @@ let resources;
|
|
|
63
64
|
* @param loadedPluginModules
|
|
64
65
|
* @param loadedResources
|
|
65
66
|
*/
|
|
67
|
+
/**
|
|
68
|
+
* The application mount an operator declared for `appName` in the root config.
|
|
69
|
+
*
|
|
70
|
+
* Applications in the components root are loaded by directory scan, not from a root-config
|
|
71
|
+
* entry, so this is what makes a root-config entry authoritative for where an app is served:
|
|
72
|
+
*
|
|
73
|
+
* ```yaml
|
|
74
|
+
* my-app:
|
|
75
|
+
* host: api.example.com
|
|
76
|
+
* urlPath: /v1
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* Works whether or not the entry also carries `package` — a payload-deployed app is mounted the
|
|
80
|
+
* same way as an installed one. A built-in plugin's config block is never an application mount;
|
|
81
|
+
* those keys (`http`, `mqtt`, …) configure the plugin itself.
|
|
82
|
+
*/
|
|
83
|
+
function rootConfigMount(appName: string): ScopeMount | undefined {
|
|
84
|
+
if (Object.hasOwn(TRUSTED_RESOURCE_PLUGINS, appName)) return undefined;
|
|
85
|
+
return toScopeMount(getConfigObj()?.[appName]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolves the mount for `appName`, or reports the failure and returns `undefined` if the
|
|
90
|
+
* configured `host`/`urlPath` is invalid. The caller must skip loading the application in that
|
|
91
|
+
* case rather than loading it anyway: an invalid mount is a request to CONSTRAIN where the app is
|
|
92
|
+
* served, so loading it unconstrained would silently drop the isolation the operator configured
|
|
93
|
+
* (review finding) — worse than not loading it at all. Isolated per-app so one bad mount doesn't
|
|
94
|
+
* take down every other application's load.
|
|
95
|
+
*/
|
|
96
|
+
function tryRootConfigMount(appName: string): { ok: true; mount: ScopeMount | undefined } | { ok: false } {
|
|
97
|
+
try {
|
|
98
|
+
return { ok: true, mount: rootConfigMount(appName) };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
(error as Error).message = `Not loading '${appName}': invalid routing configured: ${(error as Error).message}`;
|
|
101
|
+
errorReporter?.(error);
|
|
102
|
+
(getWorkerIndex() === 0 ? console : harperLogger).error(errorForLog(error as Error));
|
|
103
|
+
componentLifecycle.failed(
|
|
104
|
+
appName,
|
|
105
|
+
error as Error,
|
|
106
|
+
`Component '${appName}' failed to load due to invalid routing configuration`
|
|
107
|
+
);
|
|
108
|
+
return { ok: false };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
66
112
|
export async function loadComponentDirectories(loadedPluginModules?: Map<any, any>, loadedResources?: Resources) {
|
|
67
113
|
if (loadedResources) resources = loadedResources;
|
|
68
114
|
if (loadedPluginModules) loadedComponents = loadedPluginModules;
|
|
@@ -81,21 +127,32 @@ export async function loadComponentDirectories(loadedPluginModules?: Map<any, an
|
|
|
81
127
|
if (appEntry.name.startsWith('.')) continue;
|
|
82
128
|
const appName = appEntry.name;
|
|
83
129
|
const appFolder = join(CF_ROUTES_DIR, appName);
|
|
130
|
+
const mountResult = tryRootConfigMount(appName);
|
|
131
|
+
if (!mountResult.ok) continue;
|
|
84
132
|
cfsLoaded.push(
|
|
85
|
-
loadComponent(appFolder, resources, HDB_ROOT_DIR_NAME, {
|
|
133
|
+
loadComponent(appFolder, resources, HDB_ROOT_DIR_NAME, {
|
|
134
|
+
isRoot: false,
|
|
135
|
+
autoReload: false,
|
|
136
|
+
appName,
|
|
137
|
+
mount: mountResult.mount,
|
|
138
|
+
})
|
|
86
139
|
);
|
|
87
140
|
}
|
|
88
141
|
}
|
|
89
142
|
const hdbAppFolder = process.env.RUN_HDB_APP;
|
|
90
143
|
if (hdbAppFolder) {
|
|
91
144
|
if (getWorkerIndex() === 0) harperLogger.info?.('Loading application from ' + hdbAppFolder);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
145
|
+
const mountResult = tryRootConfigMount(basename(hdbAppFolder));
|
|
146
|
+
if (mountResult.ok) {
|
|
147
|
+
cfsLoaded.push(
|
|
148
|
+
loadComponent(hdbAppFolder, resources, hdbAppFolder, {
|
|
149
|
+
isRoot: false,
|
|
150
|
+
autoReload: Boolean(process.env.DEV_MODE),
|
|
151
|
+
appName: hdbAppFolder,
|
|
152
|
+
mount: mountResult.mount,
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
}
|
|
99
156
|
}
|
|
100
157
|
return Promise.all(cfsLoaded).then(() => {
|
|
101
158
|
watchesSetup = true;
|
|
@@ -316,6 +373,10 @@ export interface LoadComponentOptions {
|
|
|
316
373
|
// (e.g. the deploy pre-flight validation) so their deploy-lifecycle listeners don't accumulate
|
|
317
374
|
// across deploys (#1462).
|
|
318
375
|
collectScopes?: Set<Scope>;
|
|
376
|
+
// Routing the operator declared for this application in the root config (`host`/`urlPath` on
|
|
377
|
+
// the application's entry). Applied to every plugin scope this load creates, and inherited by
|
|
378
|
+
// components the application itself declares, so the whole subtree moves together.
|
|
379
|
+
mount?: ScopeMount;
|
|
319
380
|
}
|
|
320
381
|
|
|
321
382
|
/**
|
|
@@ -342,6 +403,7 @@ export async function loadComponent(
|
|
|
342
403
|
isRoot,
|
|
343
404
|
autoReload,
|
|
344
405
|
appName,
|
|
406
|
+
mount,
|
|
345
407
|
} = options;
|
|
346
408
|
applicationScope.allowedPath ??= realpathSync(componentDirectory);
|
|
347
409
|
if (providedLoadedComponents) loadedComponents = providedLoadedComponents;
|
|
@@ -485,6 +547,13 @@ export async function loadComponent(
|
|
|
485
547
|
autoReload: false,
|
|
486
548
|
appName: appName || componentName,
|
|
487
549
|
collectScopes: options.collectScopes,
|
|
550
|
+
// `host`/`urlPath` on this entry route the component being loaded. For an
|
|
551
|
+
// application (no plugin module of its own) that entry is the only place an
|
|
552
|
+
// operator can say where the app is served — its own config.yaml declares the
|
|
553
|
+
// plugins, not the deployment. A nested component's own mount nests inside
|
|
554
|
+
// the parent's rather than replacing it, and the parent keeps hostname
|
|
555
|
+
// authority, so a child can't escape the host it is served on.
|
|
556
|
+
mount: nestScopeMount(mount, toScopeMount(componentConfig)),
|
|
488
557
|
});
|
|
489
558
|
componentFunctionality[componentName] = true;
|
|
490
559
|
}
|
|
@@ -554,7 +623,10 @@ export async function loadComponent(
|
|
|
554
623
|
// authoritative root-ness: only root-load scopes watch THE root config and
|
|
555
624
|
// get the runtime env-config overlay (#1618) — an app component that happens
|
|
556
625
|
// to ship a root-named config file does not
|
|
557
|
-
isRoot
|
|
626
|
+
isRoot,
|
|
627
|
+
// A root-declared plugin reads its own `host`/`urlPath` straight from this
|
|
628
|
+
// config, so only an inherited application mount applies here.
|
|
629
|
+
mount
|
|
558
630
|
);
|
|
559
631
|
|
|
560
632
|
if (options.collectScopes) {
|
|
@@ -597,6 +669,32 @@ export async function loadComponent(
|
|
|
597
669
|
);
|
|
598
670
|
}
|
|
599
671
|
|
|
672
|
+
// `start`/`startOnMainThread` hand the plugin the raw, unmounted `server` directly —
|
|
673
|
+
// unlike the new Plugin API's `handleApplication(scope)`, which receives the mount-aware
|
|
674
|
+
// scope — so routes registered there would silently escape a configured host/urlPath
|
|
675
|
+
// mount and stay reachable unconstrained (review finding). Same isolation gap
|
|
676
|
+
// fastifyRoutes.ts already closes for its own legacy fallback (host-only there, since
|
|
677
|
+
// urlPath composes into its route prefix; here neither composes, so both are rejected).
|
|
678
|
+
// Refuse rather than imply an isolation the hook can't honor; contained to this
|
|
679
|
+
// component by the loader's per-component try/catch.
|
|
680
|
+
if (
|
|
681
|
+
mount &&
|
|
682
|
+
(typeof extensionModule.start === 'function' || typeof extensionModule.startOnMainThread === 'function')
|
|
683
|
+
) {
|
|
684
|
+
const error = new Error(
|
|
685
|
+
`Component '${componentName}' is mounted (${[
|
|
686
|
+
mount.host && `host '${mount.host}'`,
|
|
687
|
+
mount.urlPath && `urlPath '${mount.urlPath}'`,
|
|
688
|
+
]
|
|
689
|
+
.filter(Boolean)
|
|
690
|
+
.join(
|
|
691
|
+
', '
|
|
692
|
+
)}), but its deprecated 'start'/'startOnMainThread' extension API receives the bare, unmounted server — routes registered there would stay reachable unconstrained. Upgrade to the new Plugin API (handleApplication(scope)) or drop the mount.`
|
|
693
|
+
);
|
|
694
|
+
componentLifecycle.failed(componentStatusName, error, `Component '${componentStatusName}' failed to load`);
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
|
|
600
698
|
if (isMainThread) {
|
|
601
699
|
// `startOnMainThread` is one-time main-thread init: run it at most once per component
|
|
602
700
|
// for the life of the process (first load / first deploy). On a reload of an
|
|
@@ -362,6 +362,16 @@ export function isSuperUser(user: AuthedUser | undefined): boolean {
|
|
|
362
362
|
return user?.role?.permission?.super_user === true;
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
/**
|
|
366
|
+
* True if the request carries an authenticated principal. Anonymous MCP sessions are a supported
|
|
367
|
+
* deployment (the public-docs case, #1609) — the adapter hands them through as `{ username: '' }`
|
|
368
|
+
* rather than rejecting them — so a listing filter that means "any real user" has to test for this
|
|
369
|
+
* explicitly. `user?.role` is not a proxy: an authenticated user may legitimately carry no role.
|
|
370
|
+
*/
|
|
371
|
+
export function isAuthenticated(user: AuthedUser | undefined): boolean {
|
|
372
|
+
return typeof user?.username === 'string' && user.username.length > 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
365
375
|
/**
|
|
366
376
|
* Class-level verb introspection. Mirrors `resources/openApi.ts:149-153` so
|
|
367
377
|
* a class is considered to "have" a verb only if its prototype overrides the
|
|
@@ -27,6 +27,7 @@ import harperLogger from '../../../utility/logging/harper_logger.ts';
|
|
|
27
27
|
import {
|
|
28
28
|
addTool,
|
|
29
29
|
clearProfileTools,
|
|
30
|
+
isAuthenticated,
|
|
30
31
|
snapshotProfileTools,
|
|
31
32
|
type AuthedUser,
|
|
32
33
|
type ToolCallContext,
|
|
@@ -708,11 +709,17 @@ function makeVisibleTo(
|
|
|
708
709
|
return function visibleTo(user: AuthedUser): boolean {
|
|
709
710
|
// Super-user sees everything.
|
|
710
711
|
if (user?.role?.permission?.super_user === true) return true;
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
|
|
712
|
+
// A Resource with no backing table has no static permission gate to consult, so listing is
|
|
713
|
+
// open to any authenticated caller and the Resource's own `allow*` predicates enforce at call
|
|
714
|
+
// time — hiding these while still accepting `tools/call` on them bought no security, only
|
|
715
|
+
// undiscoverable tools (#1940).
|
|
716
|
+
//
|
|
717
|
+
// Anonymous sessions stay excluded. They are a supported deployment (#1609) and reach here as
|
|
718
|
+
// `{ username: '' }`, so this must be tested explicitly. Custom `mcpTools` DO list to them, but
|
|
719
|
+
// that is an author opt-in — declaring `static mcpTools` is a deliberate act of publishing.
|
|
720
|
+
// Verb tools are generated for every exported Resource with no author action, so publishing an
|
|
721
|
+
// app's field names and docstrings to unauthenticated callers by default is not equivalent.
|
|
722
|
+
if (!databaseName || !tableName) return isAuthenticated(user);
|
|
716
723
|
const perm = getUserTablePermissions(user, databaseName, tableName);
|
|
717
724
|
if (!perm) return false;
|
|
718
725
|
if (mode === 'read') return perm.read === true || perm.describe === true;
|
|
@@ -60,6 +60,9 @@ export const OPERATION_DESCRIPTIONS: Record<string, string> = {
|
|
|
60
60
|
// list_deployments: components/deploymentOperations.ts:50 — Deployment history with filtering and pagination.
|
|
61
61
|
list_deployments:
|
|
62
62
|
'Lists deployments from system.hdb_deployment with optional filtering by project, status, and date range, plus pagination.',
|
|
63
|
+
// list_backups: dataLayer/rocksdbBackup.ts (listBackups) — Managed RocksDB backup metadata for a database.
|
|
64
|
+
list_backups:
|
|
65
|
+
'Lists the managed RocksDB directory backups for a database (id, timestamp, size, file count). Requires super_user; RocksDB databases only.',
|
|
63
66
|
|
|
64
67
|
// ─── DEFAULT_ALLOW: search_* ──────────────────────────────────────────
|
|
65
68
|
// search_by_conditions: dataLayer/search.ts:6 — Multi-condition search with comparators.
|
|
@@ -101,6 +101,15 @@ export const OPERATION_INPUT_SCHEMAS: Record<string, object> = {
|
|
|
101
101
|
properties: {},
|
|
102
102
|
description: 'Lists deployed component versions in this Harper instance.',
|
|
103
103
|
},
|
|
104
|
+
list_backups: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
database: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: 'Database name whose managed RocksDB backups to list. Defaults to `data`.',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
104
113
|
|
|
105
114
|
// ─── search_* ─────────────────────────────────────────────────────────
|
|
106
115
|
search_by_hash: {
|
package/components/operations.js
CHANGED
|
@@ -410,6 +410,7 @@ async function deployComponent(req) {
|
|
|
410
410
|
};
|
|
411
411
|
}
|
|
412
412
|
if (req.urlPath !== undefined) applicationConfig.urlPath = req.urlPath;
|
|
413
|
+
if (req.host !== undefined) applicationConfig.host = req.host;
|
|
413
414
|
// Persist credential references (never tokens) so every cold install of this component —
|
|
414
415
|
// reboot, new peer, rollback — re-resolves the credential from the store.
|
|
415
416
|
if (credentialReferences.length) applicationConfig.credentials = credentialReferences;
|
|
@@ -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-root.schema.json
CHANGED
|
@@ -486,6 +486,10 @@
|
|
|
486
486
|
},
|
|
487
487
|
"prefetchWrites": { "type": "boolean", "description": "Load data prior to write transactions. Default: true" },
|
|
488
488
|
"path": { "type": "string", "description": "Directory for all database files. Default: <rootPath>/database" },
|
|
489
|
+
"backupPath": {
|
|
490
|
+
"type": "string",
|
|
491
|
+
"description": "Directory for managed database backups (RocksDB directory backups created by create_backup), one subdirectory per database. Default: <rootPath>/backup"
|
|
492
|
+
},
|
|
489
493
|
"blobPaths": {
|
|
490
494
|
"oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }],
|
|
491
495
|
"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
|
+
}
|