@camstack/system 1.0.2
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/dist/addon/addon-api-factory.d.ts +35 -0
- package/dist/addon-routes/addon-route-registry.d.ts +37 -0
- package/dist/addon-runner.js +599 -0
- package/dist/addon-runner.mjs +597 -0
- package/dist/auth/api-key-manager.d.ts +26 -0
- package/dist/auth/auth-manager.d.ts +109 -0
- package/dist/auth/parse-record.d.ts +18 -0
- package/dist/auth/scope-matcher.d.ts +7 -0
- package/dist/auth/scoped-token-manager.d.ts +40 -0
- package/dist/auth/totp-manager.d.ts +51 -0
- package/dist/auth/user-manager.d.ts +34 -0
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.d.ts +53 -0
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +259 -0
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +251 -0
- package/dist/builtins/addon-pages-aggregator/dedupe-pages.d.ts +6 -0
- package/dist/builtins/addon-pages-aggregator/index.d.ts +1 -0
- package/dist/builtins/addon-pages-aggregator/index.js +8 -0
- package/dist/builtins/addon-pages-aggregator/index.mjs +2 -0
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.d.ts +47 -0
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +228 -0
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +220 -0
- package/dist/builtins/addon-widgets-aggregator/index.d.ts +1 -0
- package/dist/builtins/addon-widgets-aggregator/index.js +8 -0
- package/dist/builtins/addon-widgets-aggregator/index.mjs +2 -0
- package/dist/builtins/alerts/alerts.addon.d.ts +81 -0
- package/dist/builtins/alerts/alerts.addon.js +601 -0
- package/dist/builtins/alerts/alerts.addon.mjs +595 -0
- package/dist/builtins/alerts/index.d.ts +1 -0
- package/dist/builtins/alerts/index.js +4 -0
- package/dist/builtins/alerts/index.mjs +2 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +147 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2229 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2220 -0
- package/dist/builtins/backup-orchestrator/cron-helpers.d.ts +23 -0
- package/dist/builtins/backup-orchestrator/destination-policy.d.ts +72 -0
- package/dist/builtins/backup-orchestrator/download-helpers.d.ts +12 -0
- package/dist/builtins/backup-orchestrator/index.d.ts +2 -0
- package/dist/builtins/backup-orchestrator/index.js +8 -0
- package/dist/builtins/backup-orchestrator/index.mjs +2 -0
- package/dist/builtins/backup-orchestrator/manifest-store.d.ts +77 -0
- package/dist/builtins/console-logging/console-destination.d.ts +13 -0
- package/dist/builtins/console-logging/console-logging.addon.d.ts +25 -0
- package/dist/builtins/console-logging/index.d.ts +3 -0
- package/dist/builtins/console-logging/index.js +104 -0
- package/dist/builtins/console-logging/index.mjs +95 -0
- package/dist/builtins/device-manager/device-config-contribution.d.ts +32 -0
- package/dist/builtins/device-manager/device-event-propagator.d.ts +26 -0
- package/dist/builtins/device-manager/device-link-overlay.d.ts +23 -0
- package/dist/builtins/device-manager/device-link-resolver.d.ts +15 -0
- package/dist/builtins/device-manager/device-manager.addon.d.ts +452 -0
- package/dist/builtins/device-manager/device-manager.addon.js +3299 -0
- package/dist/builtins/device-manager/device-manager.addon.mjs +3292 -0
- package/dist/builtins/device-manager/index.d.ts +2 -0
- package/dist/builtins/device-manager/index.js +8 -0
- package/dist/builtins/device-manager/index.mjs +2 -0
- package/dist/builtins/hub-forwarder/hub-forwarder-destination.d.ts +44 -0
- package/dist/builtins/hub-forwarder/hub-forwarder.addon.d.ts +15 -0
- package/dist/builtins/hub-forwarder/index.d.ts +3 -0
- package/dist/builtins/hub-forwarder/index.js +154 -0
- package/dist/builtins/hub-forwarder/index.mjs +145 -0
- package/dist/builtins/local-auth/auth-schema.d.ts +26 -0
- package/dist/builtins/local-auth/index.d.ts +1 -0
- package/dist/builtins/local-auth/index.js +4 -0
- package/dist/builtins/local-auth/index.mjs +2 -0
- package/dist/builtins/local-auth/local-auth.addon.d.ts +18 -0
- package/dist/builtins/local-auth/local-auth.addon.js +8094 -0
- package/dist/builtins/local-auth/local-auth.addon.mjs +8063 -0
- package/dist/builtins/local-auth/oauth-grants.d.ts +45 -0
- package/dist/builtins/local-auth/oauth-session-manager.d.ts +50 -0
- package/dist/builtins/local-network/index.d.ts +2 -0
- package/dist/builtins/local-network/index.js +10 -0
- package/dist/builtins/local-network/index.mjs +2 -0
- package/dist/builtins/local-network/local-network.addon.d.ts +150 -0
- package/dist/builtins/local-network/local-network.addon.js +489 -0
- package/dist/builtins/local-network/local-network.addon.mjs +477 -0
- package/dist/builtins/native-metrics/index.d.ts +2 -0
- package/dist/builtins/native-metrics/native-metrics-provider.d.ts +48 -0
- package/dist/builtins/native-metrics/native-metrics.addon.d.ts +73 -0
- package/dist/builtins/native-metrics/native-metrics.addon.js +922 -0
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +914 -0
- package/dist/builtins/platform-probe/hardware-decode-accel-probe.d.ts +37 -0
- package/dist/builtins/platform-probe/hardware-encoder-probe.d.ts +13 -0
- package/dist/builtins/platform-probe/index.d.ts +22 -0
- package/dist/builtins/platform-probe/index.js +834 -0
- package/dist/builtins/platform-probe/index.mjs +822 -0
- package/dist/builtins/platform-probe/inference-config-resolver.d.ts +29 -0
- package/dist/builtins/platform-probe/intel-accelerators.d.ts +11 -0
- package/dist/builtins/platform-probe/platform-scorer.d.ts +30 -0
- package/dist/builtins/platform-probe/runtime-packages.d.ts +6 -0
- package/dist/builtins/remote-access-orchestrator/enabled-providers-reconcile.d.ts +96 -0
- package/dist/builtins/remote-access-orchestrator/index.d.ts +1 -0
- package/dist/builtins/remote-access-orchestrator/index.js +8 -0
- package/dist/builtins/remote-access-orchestrator/index.mjs +2 -0
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.d.ts +40 -0
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +214 -0
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +208 -0
- package/dist/builtins/shared/settle-sources.d.ts +22 -0
- package/dist/builtins/snapshot/index.d.ts +2 -0
- package/dist/builtins/snapshot/index.js +494 -0
- package/dist/builtins/snapshot/index.mjs +488 -0
- package/dist/builtins/snapshot/snapshot.addon.d.ts +120 -0
- package/dist/builtins/sqlite-storage/config-store.d.ts +8 -0
- package/dist/builtins/sqlite-storage/device-store.d.ts +23 -0
- package/dist/builtins/sqlite-storage/filesystem-browse-provider.d.ts +25 -0
- package/dist/builtins/sqlite-storage/filesystem-storage-provider.d.ts +83 -0
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.d.ts +32 -0
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +396 -0
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +388 -0
- package/dist/builtins/sqlite-storage/index.d.ts +8 -0
- package/dist/builtins/sqlite-storage/index.js +62 -0
- package/dist/builtins/sqlite-storage/index.mjs +49 -0
- package/dist/builtins/sqlite-storage/integration-registry.d.ts +27 -0
- package/dist/builtins/sqlite-storage/path-guard.d.ts +4 -0
- package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +102 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +14 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +644 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +636 -0
- package/dist/builtins/storage-orchestrator/index.d.ts +6 -0
- package/dist/builtins/storage-orchestrator/index.js +10 -0
- package/dist/builtins/storage-orchestrator/index.mjs +2 -0
- package/dist/builtins/storage-orchestrator/location-store.d.ts +49 -0
- package/dist/builtins/storage-orchestrator/provider-discovery.d.ts +10 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +103 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1138 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1128 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +236 -0
- package/dist/builtins/storage-orchestrator/storage-pressure-manager.d.ts +38 -0
- package/dist/builtins/system-backup/system-backup.service.d.ts +137 -0
- package/dist/builtins/system-config/index.d.ts +1 -0
- package/dist/builtins/system-config/index.js +8 -0
- package/dist/builtins/system-config/index.mjs +2 -0
- package/dist/builtins/system-config/system-config.addon.d.ts +10 -0
- package/dist/builtins/system-config/system-config.addon.js +232 -0
- package/dist/builtins/system-config/system-config.addon.mjs +226 -0
- package/dist/builtins/winston-logging/index.d.ts +3 -0
- package/dist/builtins/winston-logging/index.js +156 -0
- package/dist/builtins/winston-logging/index.mjs +144 -0
- package/dist/builtins/winston-logging/winston-destination.d.ts +21 -0
- package/dist/builtins/winston-logging/winston-logging.addon.d.ts +19 -0
- package/dist/chunk-CNf5ZN-e.mjs +37 -0
- package/dist/chunk-Cek0wNdY.js +64 -0
- package/dist/download/model-download-service.d.ts +41 -0
- package/dist/download/model-downloader.d.ts +31 -0
- package/dist/events/event-bus.d.ts +10 -0
- package/dist/events/system-event-bus.d.ts +14 -0
- package/dist/feature/feature-manager.d.ts +11 -0
- package/dist/formatter-B7qW8bPJ.mjs +162 -0
- package/dist/formatter-DqAKDlvN.js +167 -0
- package/dist/http/authenticated-file-server.d.ts +53 -0
- package/dist/http/data-plane-registry.d.ts +23 -0
- package/dist/http/file-data-plane.d.ts +10 -0
- package/dist/http/reverse-proxy.d.ts +15 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +93485 -0
- package/dist/index.mjs +93179 -0
- package/dist/intel-accelerators-Gg0P5mnl.js +20 -0
- package/dist/intel-accelerators-hGgpZ0pX.mjs +19 -0
- package/dist/kernel/addon-class-resolver.d.ts +4 -0
- package/dist/kernel/addon-engine-manager.d.ts +22 -0
- package/dist/kernel/addon-health-monitor.d.ts +154 -0
- package/dist/kernel/addon-installer.d.ts +208 -0
- package/dist/kernel/addon-loader.d.ts +106 -0
- package/dist/kernel/addon-manifest.d.ts +77 -0
- package/dist/kernel/capability-handle.d.ts +46 -0
- package/dist/kernel/capability-registry.d.ts +412 -0
- package/dist/kernel/config-manager.d.ts +212 -0
- package/dist/kernel/config-schema.d.ts +93 -0
- package/dist/kernel/custom-action-registry.d.ts +23 -0
- package/dist/kernel/deps/addon-deps-manager.d.ts +19 -0
- package/dist/kernel/deps/manifest-native-deps.d.ts +25 -0
- package/dist/kernel/deps/manifest-python-deps.d.ts +20 -0
- package/dist/kernel/device-registry.d.ts +29 -0
- package/dist/kernel/fs-utils.d.ts +41 -0
- package/dist/kernel/hwaccel/hwaccel-resolver.d.ts +19 -0
- package/dist/kernel/hwaccel/hwaccel-service.d.ts +4 -0
- package/dist/kernel/index.d.ts +74 -0
- package/dist/kernel/infra-capabilities.d.ts +13 -0
- package/dist/kernel/moleculer/addon-context-factory.d.ts +91 -0
- package/dist/kernel/moleculer/addon-data-plane-facility.d.ts +19 -0
- package/dist/kernel/moleculer/addon-runner.d.ts +1 -0
- package/dist/kernel/moleculer/addon-service-factory.d.ts +50 -0
- package/dist/kernel/moleculer/broker-factory.d.ts +50 -0
- package/dist/kernel/moleculer/cap-usage-registry.d.ts +46 -0
- package/dist/kernel/moleculer/capabilities-access.d.ts +21 -0
- package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +46 -0
- package/dist/kernel/moleculer/child-cap-dispatch.d.ts +20 -0
- package/dist/kernel/moleculer/cluster-secret.d.ts +15 -0
- package/dist/kernel/moleculer/core-cap-service.d.ts +50 -0
- package/dist/kernel/moleculer/crash-supervisor.d.ts +50 -0
- package/dist/kernel/moleculer/device-cap-proxy.d.ts +79 -0
- package/dist/kernel/moleculer/event-bus-core.d.ts +53 -0
- package/dist/kernel/moleculer/event-bus.d.ts +53 -0
- package/dist/kernel/moleculer/hub-log-forwarder.d.ts +36 -0
- package/dist/kernel/moleculer/hub-service.d.ts +35 -0
- package/dist/kernel/moleculer/node-registry.d.ts +126 -0
- package/dist/kernel/moleculer/process-context.d.ts +4 -0
- package/dist/kernel/moleculer/process-service.d.ts +72 -0
- package/dist/kernel/moleculer/provider-registry.d.ts +28 -0
- package/dist/kernel/moleculer/readiness-context.d.ts +62 -0
- package/dist/kernel/moleculer/readiness-service.d.ts +7 -0
- package/dist/kernel/moleculer/register-node-client.d.ts +35 -0
- package/dist/kernel/moleculer/remote-logger.d.ts +43 -0
- package/dist/kernel/moleculer/resilient-cap-call.d.ts +28 -0
- package/dist/kernel/moleculer/stream-probe-service.d.ts +9 -0
- package/dist/kernel/moleculer/trpc-links.d.ts +189 -0
- package/dist/kernel/moleculer/typed-array-serde.d.ts +25 -0
- package/dist/kernel/moleculer/worker-device-restore.d.ts +10 -0
- package/dist/kernel/provider-kind-drift.d.ts +12 -0
- package/dist/kernel/restart-coordinator.d.ts +90 -0
- package/dist/kernel/storage-location-registry.d.ts +40 -0
- package/dist/kernel/transport/cap-action-name.d.ts +100 -0
- package/dist/kernel/transport/cap-route-resolver.d.ts +148 -0
- package/dist/kernel/transport/cap-route.d.ts +148 -0
- package/dist/kernel/transport/child-cap-protocol.d.ts +136 -0
- package/dist/kernel/transport/create-local-transport.d.ts +7 -0
- package/dist/kernel/transport/frame-codec.d.ts +7 -0
- package/dist/kernel/transport/index.d.ts +27 -0
- package/dist/kernel/transport/local-child-client.d.ts +136 -0
- package/dist/kernel/transport/local-child-registry.d.ts +179 -0
- package/dist/kernel/transport/local-endpoint-path.d.ts +6 -0
- package/dist/kernel/transport/local-transport.d.ts +46 -0
- package/dist/kernel/transport/parent-unowned-call.d.ts +75 -0
- package/dist/kernel/transport/socket-channel.d.ts +27 -0
- package/dist/kernel/transport/uds-event-bridge.d.ts +36 -0
- package/dist/kernel/transport/uds-event-bus.d.ts +22 -0
- package/dist/kernel/transport/uds-local-transport.d.ts +18 -0
- package/dist/kernel/transport/uds-log-ingest.d.ts +28 -0
- package/dist/kernel/transport/uds-logger.d.ts +44 -0
- package/dist/kernel/utils/ring-buffer.d.ts +15 -0
- package/dist/kernel/workspace-detect.d.ts +9 -0
- package/dist/lifecycle/lifecycle-state-machine.d.ts +28 -0
- package/dist/logging/formatter.d.ts +30 -0
- package/dist/logging/log-manager.d.ts +54 -0
- package/dist/logging/log-ring-buffer.d.ts +47 -0
- package/dist/logging/partitioned-log-buffer.d.ts +35 -0
- package/dist/logging/scoped-logger.d.ts +17 -0
- package/dist/main-DNnMW7Z2.js +9983 -0
- package/dist/main-rtjOwPBR.mjs +9976 -0
- package/dist/manifest-python-deps-D1DbAQEv.js +6724 -0
- package/dist/manifest-python-deps-DZsKTbs1.mjs +6315 -0
- package/dist/network/network-quality.d.ts +11 -0
- package/dist/notification/notification-service.d.ts +37 -0
- package/dist/notification/toast-service.d.ts +22 -0
- package/dist/pipeline/engine-manager-resolver.d.ts +15 -0
- package/dist/pipeline/pipeline-runner.d.ts +8 -0
- package/dist/pipeline/pipeline-validator.d.ts +13 -0
- package/dist/process/resource-monitor.d.ts +11 -0
- package/dist/python/python-env-manager.d.ts +12 -0
- package/dist/repl/interfaces.d.ts +31 -0
- package/dist/repl/repl-engine.d.ts +8 -0
- package/dist/resource-monitor-ClDGFyf6.mjs +57 -0
- package/dist/resource-monitor-IIEanuJt.js +74 -0
- package/dist/settle-sources-Bhsy57y-.js +38 -0
- package/dist/settle-sources-CDtNC8ub.mjs +33 -0
- package/dist/storage/fs-storage-backend.d.ts +40 -0
- package/dist/storage/storage-location-manager.d.ts +23 -0
- package/dist/storage/storage-manager.d.ts +83 -0
- package/dist/tar-BgAEMRBR.js +5434 -0
- package/dist/tar-ByMOPNM0.mjs +5429 -0
- package/dist/tls/cert-manager.d.ts +26 -0
- package/dist/tls/index.d.ts +1 -0
- package/package.json +343 -0
|
@@ -0,0 +1,2220 @@
|
|
|
1
|
+
import { o as __toESM } from "../../chunk-CNf5ZN-e.mjs";
|
|
2
|
+
import { t as require_tar } from "../../tar-ByMOPNM0.mjs";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path$1 from "node:path";
|
|
5
|
+
import { BaseAddon, EventCategory, backupCapability } from "@camstack/types";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import * as fsp from "node:fs/promises";
|
|
8
|
+
import * as zlib from "node:zlib";
|
|
9
|
+
//#region src/builtins/system-backup/system-backup.service.ts
|
|
10
|
+
var import_tar = /* @__PURE__ */ __toESM(require_tar());
|
|
11
|
+
/**
|
|
12
|
+
* Default set of paths considered part of a "system backup". Includes
|
|
13
|
+
* everything that isn't pure backend code — config, sqlite, addons
|
|
14
|
+
* (runtime overlay), addons-data (per-addon private dir), TLS certs,
|
|
15
|
+
* auto-update marker.
|
|
16
|
+
*
|
|
17
|
+
* Excluded by default: recordings/, media/, models/, logs/, cache/
|
|
18
|
+
* (too large or fully recreatable).
|
|
19
|
+
*/
|
|
20
|
+
var DEFAULT_BACKUP_LOCATIONS = [
|
|
21
|
+
"config.yaml",
|
|
22
|
+
"addons",
|
|
23
|
+
"addons-data",
|
|
24
|
+
"tls",
|
|
25
|
+
"auto-update.json"
|
|
26
|
+
];
|
|
27
|
+
/** Path of the manifest file embedded inside every archive. */
|
|
28
|
+
var ARCHIVE_MANIFEST_NAME = ".camstack-backup-manifest.json";
|
|
29
|
+
/**
|
|
30
|
+
* System-level archive primitives. Construct one per server boot;
|
|
31
|
+
* methods are pure I/O.
|
|
32
|
+
*/
|
|
33
|
+
var SystemBackupService = class {
|
|
34
|
+
dataDir;
|
|
35
|
+
logger;
|
|
36
|
+
constructor(dataDir, logger) {
|
|
37
|
+
this.dataDir = dataDir;
|
|
38
|
+
this.logger = logger;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Snapshot the current size + file count of each well-known
|
|
42
|
+
* location. Used by the admin UI to render an opt-in checklist
|
|
43
|
+
* before triggering a backup, and by `createArchive` to derive the
|
|
44
|
+
* embedded manifest.
|
|
45
|
+
*/
|
|
46
|
+
statLocations(locations = DEFAULT_BACKUP_LOCATIONS) {
|
|
47
|
+
return locations.map((loc) => {
|
|
48
|
+
const abs = path$1.isAbsolute(loc) ? loc : path$1.join(this.dataDir, loc);
|
|
49
|
+
if (!fs.existsSync(abs)) return {
|
|
50
|
+
name: loc,
|
|
51
|
+
sizeBytes: 0,
|
|
52
|
+
fileCount: 0,
|
|
53
|
+
present: false
|
|
54
|
+
};
|
|
55
|
+
const walked = walkAbs(abs, abs);
|
|
56
|
+
return {
|
|
57
|
+
name: loc,
|
|
58
|
+
sizeBytes: walked.reduce((acc, e) => acc + e.sizeBytes, 0),
|
|
59
|
+
fileCount: walked.filter((e) => e.kind === "file").length,
|
|
60
|
+
present: true
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Create a tar.gz of the given locations. Uses the `tar` npm package
|
|
66
|
+
* (used by npm itself) instead of shelling out — gives us:
|
|
67
|
+
*
|
|
68
|
+
* - Symlink dereference (`follow: true`) so dev mode's
|
|
69
|
+
* `addons/<name> → packages/<name>` overlay archives content,
|
|
70
|
+
* not just link stubs.
|
|
71
|
+
* - Programmatic walk → we build a manifest of every entry while
|
|
72
|
+
* copying so the archive can self-describe.
|
|
73
|
+
*
|
|
74
|
+
* Locations that don't exist are silently skipped. Throws if zero
|
|
75
|
+
* locations would be included.
|
|
76
|
+
*/
|
|
77
|
+
async createArchive(opts) {
|
|
78
|
+
const requested = opts.locations ?? DEFAULT_BACKUP_LOCATIONS;
|
|
79
|
+
const present = [];
|
|
80
|
+
for (const loc of requested) {
|
|
81
|
+
const abs = path$1.isAbsolute(loc) ? loc : path$1.join(this.dataDir, loc);
|
|
82
|
+
if (fs.existsSync(abs)) present.push(loc);
|
|
83
|
+
else this.logger.debug("createArchive: skipping missing location", { meta: { location: loc } });
|
|
84
|
+
}
|
|
85
|
+
if (present.length === 0) throw new Error(`createArchive: no locations from {${requested.join(", ")}} exist under ${this.dataDir}`);
|
|
86
|
+
fs.mkdirSync(path$1.dirname(opts.archivePath), { recursive: true });
|
|
87
|
+
const entries = [];
|
|
88
|
+
for (const loc of present) {
|
|
89
|
+
const abs = path$1.join(this.dataDir, loc);
|
|
90
|
+
const stat = fs.statSync(abs);
|
|
91
|
+
if (stat.isDirectory()) for (const e of walkAbs(abs, this.dataDir)) entries.push(e);
|
|
92
|
+
else entries.push(toArchiveEntry(abs, this.dataDir, stat));
|
|
93
|
+
}
|
|
94
|
+
const manifest = {
|
|
95
|
+
archiveVersion: 1,
|
|
96
|
+
createdAt: Date.now(),
|
|
97
|
+
dataDir: this.dataDir,
|
|
98
|
+
locations: present,
|
|
99
|
+
entries,
|
|
100
|
+
totalBytes: entries.reduce((acc, e) => acc + e.sizeBytes, 0),
|
|
101
|
+
totalFiles: entries.filter((e) => e.kind === "file").length
|
|
102
|
+
};
|
|
103
|
+
const manifestStagePath = `${opts.archivePath}.${process.pid}.manifest.json`;
|
|
104
|
+
fs.writeFileSync(manifestStagePath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
105
|
+
this.logger.info("createArchive: tarring", { meta: {
|
|
106
|
+
archivePath: opts.archivePath,
|
|
107
|
+
locations: present,
|
|
108
|
+
totalFiles: manifest.totalFiles,
|
|
109
|
+
totalBytes: manifest.totalBytes
|
|
110
|
+
} });
|
|
111
|
+
try {
|
|
112
|
+
const stagedInDataDir = path$1.join(this.dataDir, ARCHIVE_MANIFEST_NAME);
|
|
113
|
+
fs.copyFileSync(manifestStagePath, stagedInDataDir);
|
|
114
|
+
try {
|
|
115
|
+
await import_tar.create({
|
|
116
|
+
file: opts.archivePath,
|
|
117
|
+
gzip: true,
|
|
118
|
+
cwd: this.dataDir,
|
|
119
|
+
follow: true,
|
|
120
|
+
portable: true
|
|
121
|
+
}, [ARCHIVE_MANIFEST_NAME, ...present]);
|
|
122
|
+
} finally {
|
|
123
|
+
try {
|
|
124
|
+
fs.rmSync(stagedInDataDir, { force: true });
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
try {
|
|
129
|
+
fs.rmSync(manifestStagePath, { force: true });
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
const sizeBytes = fs.statSync(opts.archivePath).size;
|
|
133
|
+
return {
|
|
134
|
+
archivePath: opts.archivePath,
|
|
135
|
+
includedLocations: present,
|
|
136
|
+
sizeBytes,
|
|
137
|
+
manifest
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Read the embedded manifest from a previously-created archive
|
|
142
|
+
* without extracting payload files. Streams the tar.gz, parses the
|
|
143
|
+
* one entry we care about, then aborts.
|
|
144
|
+
*/
|
|
145
|
+
async readArchiveManifest(archivePath) {
|
|
146
|
+
if (!fs.existsSync(archivePath)) throw new Error(`readArchiveManifest: ${archivePath} does not exist`);
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const chunks = [];
|
|
149
|
+
let found = false;
|
|
150
|
+
let resolved = false;
|
|
151
|
+
const parser = new import_tar.Parse({ onentry: (entry) => {
|
|
152
|
+
if (entry.path === ".camstack-backup-manifest.json" && !found) {
|
|
153
|
+
found = true;
|
|
154
|
+
entry.on("data", (chunk) => chunks.push(chunk));
|
|
155
|
+
entry.on("end", () => {
|
|
156
|
+
try {
|
|
157
|
+
const json = Buffer.concat(chunks).toString("utf-8");
|
|
158
|
+
const parsed = JSON.parse(json);
|
|
159
|
+
resolved = true;
|
|
160
|
+
resolve(parsed);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
resolved = true;
|
|
163
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
} else entry.resume();
|
|
167
|
+
} });
|
|
168
|
+
parser.on("end", () => {
|
|
169
|
+
if (!resolved) resolve(found ? null : null);
|
|
170
|
+
});
|
|
171
|
+
const gunzip = zlib.createGunzip();
|
|
172
|
+
gunzip.on("error", (err) => {
|
|
173
|
+
if (!resolved) {
|
|
174
|
+
resolved = true;
|
|
175
|
+
reject(err);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const reader = fs.createReadStream(archivePath);
|
|
179
|
+
reader.on("error", (err) => {
|
|
180
|
+
if (!resolved) {
|
|
181
|
+
resolved = true;
|
|
182
|
+
reject(err);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
reader.pipe(gunzip).pipe(parser);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Extract a previously-created archive over `targetDir`. Used at boot
|
|
190
|
+
* by the restore hook — never call from a running server (would
|
|
191
|
+
* corrupt sqlite mid-run).
|
|
192
|
+
*
|
|
193
|
+
* `locations` is an optional whitelist: only entries whose path
|
|
194
|
+
* starts with one of the locations get extracted. Used by the
|
|
195
|
+
* partial-restore flow where the operator picks a subset of the
|
|
196
|
+
* archive contents to apply (e.g. "restore my devices but keep
|
|
197
|
+
* my current TLS certs").
|
|
198
|
+
*/
|
|
199
|
+
async extractArchive(opts) {
|
|
200
|
+
if (!fs.existsSync(opts.archivePath)) throw new Error(`extractArchive: ${opts.archivePath} does not exist`);
|
|
201
|
+
fs.mkdirSync(opts.targetDir, { recursive: true });
|
|
202
|
+
this.logger.info("extractArchive: untarring", { meta: {
|
|
203
|
+
archivePath: opts.archivePath,
|
|
204
|
+
targetDir: opts.targetDir,
|
|
205
|
+
locations: opts.locations
|
|
206
|
+
} });
|
|
207
|
+
const allowedPrefixes = opts.locations ? opts.locations.map(normalizeLocation) : null;
|
|
208
|
+
await import_tar.extract({
|
|
209
|
+
file: opts.archivePath,
|
|
210
|
+
cwd: opts.targetDir,
|
|
211
|
+
filter: (p) => {
|
|
212
|
+
if (p === ".camstack-backup-manifest.json" || p === `./.camstack-backup-manifest.json`) return false;
|
|
213
|
+
if (!allowedPrefixes) return true;
|
|
214
|
+
const normalized = p.replace(/^\.\//, "");
|
|
215
|
+
return allowedPrefixes.some((prefix) => entryMatchesLocation(normalized, prefix));
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Schedule a restore for the next boot by writing a marker file.
|
|
221
|
+
* `locations` (optional) carries through to the boot-time
|
|
222
|
+
* extractArchive call so the operator's "restore only X and Y"
|
|
223
|
+
* choice survives the process restart.
|
|
224
|
+
*/
|
|
225
|
+
scheduleRestoreMarker(opts) {
|
|
226
|
+
if (!fs.existsSync(opts.archivePath)) throw new Error(`scheduleRestoreMarker: ${opts.archivePath} missing`);
|
|
227
|
+
const markerPath = this.getRestoreMarkerPath();
|
|
228
|
+
const marker = {
|
|
229
|
+
archivePath: opts.archivePath,
|
|
230
|
+
requestedAt: Date.now(),
|
|
231
|
+
source: opts.source,
|
|
232
|
+
...opts.locations ? { locations: [...opts.locations] } : {}
|
|
233
|
+
};
|
|
234
|
+
fs.mkdirSync(path$1.dirname(markerPath), { recursive: true });
|
|
235
|
+
fs.writeFileSync(markerPath, JSON.stringify(marker, null, 2), { mode: 384 });
|
|
236
|
+
return markerPath;
|
|
237
|
+
}
|
|
238
|
+
/** Read the pending-restore marker if one is present. */
|
|
239
|
+
readPendingMarker() {
|
|
240
|
+
const markerPath = this.getRestoreMarkerPath();
|
|
241
|
+
if (!fs.existsSync(markerPath)) return null;
|
|
242
|
+
try {
|
|
243
|
+
const raw = fs.readFileSync(markerPath, "utf-8");
|
|
244
|
+
const parsed = JSON.parse(raw);
|
|
245
|
+
if (typeof parsed === "object" && parsed != null && typeof parsed.archivePath === "string") return parsed;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
this.logger.warn("readPendingMarker: malformed marker — ignoring", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
/** Drop the marker after a successful boot-time restore. */
|
|
252
|
+
clearPendingMarker() {
|
|
253
|
+
const markerPath = this.getRestoreMarkerPath();
|
|
254
|
+
if (fs.existsSync(markerPath)) fs.rmSync(markerPath, { force: true });
|
|
255
|
+
}
|
|
256
|
+
getRestoreMarkerPath() {
|
|
257
|
+
return path$1.join(this.dataDir, ".pending-restore.json");
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Recursively walk a directory and produce one `ArchiveEntry` per
|
|
262
|
+
* filesystem node. `dataDir` is used to compute the archive-relative
|
|
263
|
+
* path. Symlinks are dereferenced (matches the `follow: true` flag we
|
|
264
|
+
* pass to tar.create).
|
|
265
|
+
*/
|
|
266
|
+
function walkAbs(absRoot, dataDir) {
|
|
267
|
+
const out = [];
|
|
268
|
+
walkRec(absRoot, dataDir, out);
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
function walkRec(abs, dataDir, out) {
|
|
272
|
+
let stat;
|
|
273
|
+
try {
|
|
274
|
+
stat = fs.statSync(abs);
|
|
275
|
+
} catch {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
out.push(toArchiveEntry(abs, dataDir, stat));
|
|
279
|
+
if (stat.isDirectory()) {
|
|
280
|
+
let children;
|
|
281
|
+
try {
|
|
282
|
+
children = fs.readdirSync(abs);
|
|
283
|
+
} catch {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
for (const child of children) walkRec(path$1.join(abs, child), dataDir, out);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function toArchiveEntry(abs, dataDir, stat) {
|
|
290
|
+
const rel = path$1.relative(dataDir, abs).replace(/\\/g, "/");
|
|
291
|
+
const kind = stat.isDirectory() ? "dir" : stat.isSymbolicLink() ? "symlink" : "file";
|
|
292
|
+
return {
|
|
293
|
+
path: rel || path$1.basename(abs),
|
|
294
|
+
kind,
|
|
295
|
+
sizeBytes: kind === "file" ? stat.size : 0,
|
|
296
|
+
mtime: stat.mtimeMs
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Strip leading `./` and trailing `/` so location prefix matching is
|
|
301
|
+
* order-of-comparison invariant. Same shape on both sides of the
|
|
302
|
+
* `entry.path.startsWith(prefix)` test in the extract filter.
|
|
303
|
+
*/
|
|
304
|
+
function normalizeLocation(loc) {
|
|
305
|
+
return loc.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Match an archive entry path against a location whitelist prefix.
|
|
309
|
+
* `addons` matches `addons` (file) and `addons/foo/bar.txt` (path
|
|
310
|
+
* inside the dir). The trailing `/` test is what prevents `addons`
|
|
311
|
+
* from spuriously matching `addons-data`.
|
|
312
|
+
*/
|
|
313
|
+
function entryMatchesLocation(entryPath, prefix) {
|
|
314
|
+
return entryPath === prefix || entryPath.startsWith(`${prefix}/`);
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/builtins/backup-orchestrator/destination-policy.ts
|
|
318
|
+
var TABLE_NAME = "backup_destination_policies";
|
|
319
|
+
var BACKUP_DESTINATION_POLICIES_SCHEMA = { columns: [
|
|
320
|
+
{
|
|
321
|
+
name: "location_id",
|
|
322
|
+
type: "TEXT",
|
|
323
|
+
primaryKey: true
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: "enabled",
|
|
327
|
+
type: "INTEGER",
|
|
328
|
+
notNull: true,
|
|
329
|
+
defaultValue: 1
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: "retention_count",
|
|
333
|
+
type: "INTEGER",
|
|
334
|
+
notNull: true,
|
|
335
|
+
defaultValue: 7
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: "label",
|
|
339
|
+
type: "TEXT"
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
name: "cron",
|
|
343
|
+
type: "TEXT"
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "last_run_at",
|
|
347
|
+
type: "INTEGER"
|
|
348
|
+
}
|
|
349
|
+
] };
|
|
350
|
+
var BackupDestinationPolicyService = class {
|
|
351
|
+
backend;
|
|
352
|
+
tableEnsured = false;
|
|
353
|
+
constructor(backend) {
|
|
354
|
+
this.backend = backend;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Lazy `ensureTable` — called from every read/write path. The
|
|
358
|
+
* settings backend's own `ensureTable` is idempotent (additive
|
|
359
|
+
* migration), so repeated calls collapse to a no-op after the first.
|
|
360
|
+
* This keeps the constructor synchronous and avoids a separate
|
|
361
|
+
* `initialize()` step that callers could forget.
|
|
362
|
+
*/
|
|
363
|
+
async initialize() {
|
|
364
|
+
await this.ensureTable();
|
|
365
|
+
}
|
|
366
|
+
async ensureTable() {
|
|
367
|
+
if (this.tableEnsured) return;
|
|
368
|
+
if (!this.backend.ensureTable) throw new Error("BackupDestinationPolicyService: settings backend does not implement ensureTable — expected the SQLite settings backend, got a backend without structured-table support");
|
|
369
|
+
await this.backend.ensureTable(TABLE_NAME, BACKUP_DESTINATION_POLICIES_SCHEMA);
|
|
370
|
+
this.tableEnsured = true;
|
|
371
|
+
}
|
|
372
|
+
async list() {
|
|
373
|
+
await this.ensureTable();
|
|
374
|
+
if (!this.backend.tableQuery) return [];
|
|
375
|
+
const rows = await this.backend.tableQuery(TABLE_NAME, { orderBy: {
|
|
376
|
+
field: "location_id",
|
|
377
|
+
direction: "asc"
|
|
378
|
+
} });
|
|
379
|
+
const out = [];
|
|
380
|
+
for (const row of rows) {
|
|
381
|
+
const mapped = mapRowToPolicy(row);
|
|
382
|
+
if (mapped) out.push(mapped);
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
async get(locationId) {
|
|
387
|
+
await this.ensureTable();
|
|
388
|
+
if (!this.backend.tableGet) return null;
|
|
389
|
+
const row = await this.backend.tableGet(TABLE_NAME, { location_id: locationId });
|
|
390
|
+
return row ? mapRowToPolicy(row) : null;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Replace-or-insert. Implemented as `delete` + `insert` (mirrors the
|
|
394
|
+
* `SqliteLocationStore` contract) so backends that don't surface
|
|
395
|
+
* `tableUpdate` still work.
|
|
396
|
+
*/
|
|
397
|
+
async upsert(p) {
|
|
398
|
+
await this.ensureTable();
|
|
399
|
+
if (!this.backend.tableDelete || !this.backend.tableInsert) throw new Error("BackupDestinationPolicyService: backend missing tableDelete/tableInsert");
|
|
400
|
+
await this.backend.tableDelete(TABLE_NAME, { location_id: p.locationId });
|
|
401
|
+
await this.backend.tableInsert(TABLE_NAME, mapPolicyToRow(p));
|
|
402
|
+
}
|
|
403
|
+
async delete(locationId) {
|
|
404
|
+
await this.ensureTable();
|
|
405
|
+
if (!this.backend.tableDelete) throw new Error("BackupDestinationPolicyService: backend missing tableDelete");
|
|
406
|
+
await this.backend.tableDelete(TABLE_NAME, { location_id: locationId });
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Auto-create a default policy for a `backups` location seen for the
|
|
410
|
+
* first time (returns the existing policy unchanged when present).
|
|
411
|
+
* The orchestrator calls this lazily on the first `listDestinations`
|
|
412
|
+
* pass so newly-added storage locations are surfaced as enabled
|
|
413
|
+
* destinations without an explicit configuration step.
|
|
414
|
+
*
|
|
415
|
+
* Defaults are passed by the orchestrator (currently:
|
|
416
|
+
* `retentionCount=7` + `cron='0 3 * * *'`) so a fresh destination
|
|
417
|
+
* starts running daily at 03:00 with 7 archives kept. Operator can
|
|
418
|
+
* tune both inline on the destinations table.
|
|
419
|
+
*/
|
|
420
|
+
async ensureDefault(locationId, defaults) {
|
|
421
|
+
const existing = await this.get(locationId);
|
|
422
|
+
if (existing) return existing;
|
|
423
|
+
const fresh = {
|
|
424
|
+
locationId,
|
|
425
|
+
enabled: true,
|
|
426
|
+
retentionCount: defaults.retentionCount,
|
|
427
|
+
...defaults.cron !== void 0 ? { cron: defaults.cron } : {}
|
|
428
|
+
};
|
|
429
|
+
await this.upsert(fresh);
|
|
430
|
+
return fresh;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
function mapPolicyToRow(p) {
|
|
434
|
+
return {
|
|
435
|
+
location_id: p.locationId,
|
|
436
|
+
enabled: p.enabled ? 1 : 0,
|
|
437
|
+
retention_count: p.retentionCount,
|
|
438
|
+
label: p.label ?? null,
|
|
439
|
+
cron: p.cron ?? null,
|
|
440
|
+
last_run_at: p.lastRunAt ?? null
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Decode a row pulled from the structured table. Defensive against
|
|
445
|
+
* schema drift / partial migrations: returns `null` on missing
|
|
446
|
+
* `location_id` so corrupt rows skip the result list rather than
|
|
447
|
+
* throwing the whole orchestrator down at boot.
|
|
448
|
+
*/
|
|
449
|
+
function mapRowToPolicy(row) {
|
|
450
|
+
const locationId = String(row["location_id"] ?? "");
|
|
451
|
+
if (!locationId) return null;
|
|
452
|
+
const labelRaw = row["label"];
|
|
453
|
+
const label = typeof labelRaw === "string" && labelRaw.length > 0 ? labelRaw : void 0;
|
|
454
|
+
const cronRaw = row["cron"];
|
|
455
|
+
const cron = typeof cronRaw === "string" && cronRaw.length > 0 ? cronRaw : void 0;
|
|
456
|
+
const lastRunRaw = row["last_run_at"];
|
|
457
|
+
const lastRunAt = typeof lastRunRaw === "number" && lastRunRaw > 0 ? lastRunRaw : void 0;
|
|
458
|
+
return {
|
|
459
|
+
locationId,
|
|
460
|
+
enabled: row["enabled"] === 1 || row["enabled"] === true,
|
|
461
|
+
retentionCount: Number(row["retention_count"] ?? 0),
|
|
462
|
+
...label !== void 0 ? { label } : {},
|
|
463
|
+
...cron !== void 0 ? { cron } : {},
|
|
464
|
+
...lastRunAt !== void 0 ? { lastRunAt } : {}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region src/builtins/backup-orchestrator/manifest-store.ts
|
|
469
|
+
/** File path (relative to the location root) where the index lives. */
|
|
470
|
+
var MANIFESTS_FILENAME = "manifests.json";
|
|
471
|
+
/**
|
|
472
|
+
* Read the per-location manifest. Returns an empty manifest on first
|
|
473
|
+
* use (when `manifests.json` doesn't exist) or when the file is
|
|
474
|
+
* unreadable / corrupt — operators see destination state degrade
|
|
475
|
+
* gracefully rather than the orchestrator crashing.
|
|
476
|
+
*/
|
|
477
|
+
async function readLocationManifest(api, locationId) {
|
|
478
|
+
if (!await api.exists.query({
|
|
479
|
+
location: locationId,
|
|
480
|
+
relativePath: "manifests.json"
|
|
481
|
+
}).catch(() => false)) return EMPTY_MANIFEST;
|
|
482
|
+
let bytes;
|
|
483
|
+
try {
|
|
484
|
+
bytes = await api.read.query({
|
|
485
|
+
location: locationId,
|
|
486
|
+
relativePath: MANIFESTS_FILENAME
|
|
487
|
+
});
|
|
488
|
+
} catch {
|
|
489
|
+
return EMPTY_MANIFEST;
|
|
490
|
+
}
|
|
491
|
+
return parseManifestBytes(bytes);
|
|
492
|
+
}
|
|
493
|
+
async function writeLocationManifest(api, locationId, manifest) {
|
|
494
|
+
const json = JSON.stringify(manifest, null, 2);
|
|
495
|
+
const bytes = new TextEncoder().encode(json);
|
|
496
|
+
await api.write.mutate({
|
|
497
|
+
location: locationId,
|
|
498
|
+
relativePath: MANIFESTS_FILENAME,
|
|
499
|
+
data: bytes
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Decode the raw bytes of `manifests.json`. Falls back to an empty
|
|
504
|
+
* manifest on JSON-parse / shape-validation failure — same defensive
|
|
505
|
+
* stance as the legacy `LocalBackupService.ensureManifestsLoaded`.
|
|
506
|
+
*/
|
|
507
|
+
function parseManifestBytes(bytes) {
|
|
508
|
+
try {
|
|
509
|
+
const text = new TextDecoder().decode(bytes);
|
|
510
|
+
if (text.trim().length === 0) return EMPTY_MANIFEST;
|
|
511
|
+
return normalizeManifest(JSON.parse(text));
|
|
512
|
+
} catch {
|
|
513
|
+
return EMPTY_MANIFEST;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Coerce an arbitrary parsed JSON shape into the canonical manifest
|
|
518
|
+
* type. Bogus archive entries are silently dropped (a corrupt row
|
|
519
|
+
* shouldn't lock the whole location out of operator visibility).
|
|
520
|
+
*/
|
|
521
|
+
function normalizeManifest(raw) {
|
|
522
|
+
if (typeof raw !== "object" || raw == null) return EMPTY_MANIFEST;
|
|
523
|
+
const obj = raw;
|
|
524
|
+
const archives = [];
|
|
525
|
+
if (Array.isArray(obj.archives)) for (const a of obj.archives) {
|
|
526
|
+
if (typeof a !== "object" || a == null) continue;
|
|
527
|
+
const cand = a;
|
|
528
|
+
if (typeof cand["id"] !== "string" || typeof cand["filename"] !== "string") continue;
|
|
529
|
+
if (typeof cand["createdAt"] !== "number" || typeof cand["sizeBytes"] !== "number") continue;
|
|
530
|
+
const manifest = cand["manifest"];
|
|
531
|
+
if (typeof manifest !== "object" || manifest == null) continue;
|
|
532
|
+
const entry = {
|
|
533
|
+
id: String(cand["id"]),
|
|
534
|
+
filename: String(cand["filename"]),
|
|
535
|
+
createdAt: Number(cand["createdAt"]),
|
|
536
|
+
sizeBytes: Number(cand["sizeBytes"]),
|
|
537
|
+
...typeof cand["label"] === "string" ? { label: cand["label"] } : {},
|
|
538
|
+
manifest
|
|
539
|
+
};
|
|
540
|
+
archives.push(entry);
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
version: 1,
|
|
544
|
+
archives
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
var EMPTY_MANIFEST = {
|
|
548
|
+
version: 1,
|
|
549
|
+
archives: []
|
|
550
|
+
};
|
|
551
|
+
/**
|
|
552
|
+
* Build a `ManifestArchiveEntry` from the raw inputs collected during
|
|
553
|
+
* a successful upload. Pure transform — no I/O — kept here so the
|
|
554
|
+
* triggerBackup path stays terse.
|
|
555
|
+
*/
|
|
556
|
+
function buildArchiveEntry(input) {
|
|
557
|
+
return {
|
|
558
|
+
id: input.id,
|
|
559
|
+
filename: input.filename,
|
|
560
|
+
createdAt: input.createdAt,
|
|
561
|
+
sizeBytes: input.sizeBytes,
|
|
562
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
563
|
+
manifest: input.manifest
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Pure retention split: given an archive list and the operator's
|
|
568
|
+
* `retentionCount`, return `{ keep, drop }` where `keep` is the most-
|
|
569
|
+
* recent N archives (newest first) and `drop` is the older overflow.
|
|
570
|
+
*
|
|
571
|
+
* `retentionCount <= 0` is treated as "no pruning" — defensive against
|
|
572
|
+
* a config value that would otherwise wipe every archive on the next
|
|
573
|
+
* write.
|
|
574
|
+
*/
|
|
575
|
+
function applyRetention(archives, retentionCount) {
|
|
576
|
+
if (retentionCount <= 0) return {
|
|
577
|
+
keep: archives,
|
|
578
|
+
drop: []
|
|
579
|
+
};
|
|
580
|
+
const sorted = [...archives].toSorted((a, b) => b.createdAt - a.createdAt);
|
|
581
|
+
if (sorted.length <= retentionCount) return {
|
|
582
|
+
keep: sorted,
|
|
583
|
+
drop: []
|
|
584
|
+
};
|
|
585
|
+
return {
|
|
586
|
+
keep: sorted.slice(0, retentionCount),
|
|
587
|
+
drop: sorted.slice(retentionCount)
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Per-location async mutex. The orchestrator owns one instance and
|
|
592
|
+
* funnels every read-modify-write of `manifests.json` through it so
|
|
593
|
+
* parallel destination uploads (within the same location) commit in
|
|
594
|
+
* arrival order. Across locations the operations run in parallel —
|
|
595
|
+
* each location keys its own promise chain.
|
|
596
|
+
*/
|
|
597
|
+
var LocationManifestLock = class {
|
|
598
|
+
chains = /* @__PURE__ */ new Map();
|
|
599
|
+
async run(locationId, fn) {
|
|
600
|
+
const previous = this.chains.get(locationId) ?? Promise.resolve();
|
|
601
|
+
let resolveNext;
|
|
602
|
+
const next = new Promise((res) => {
|
|
603
|
+
resolveNext = res;
|
|
604
|
+
});
|
|
605
|
+
this.chains.set(locationId, previous.then(() => next));
|
|
606
|
+
try {
|
|
607
|
+
await previous;
|
|
608
|
+
return await fn();
|
|
609
|
+
} finally {
|
|
610
|
+
resolveNext();
|
|
611
|
+
const tail = this.chains.get(locationId);
|
|
612
|
+
if (tail) Promise.resolve(tail).then(() => {
|
|
613
|
+
if (this.chains.get(locationId) === tail) this.chains.delete(locationId);
|
|
614
|
+
}).catch(() => {});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
//#endregion
|
|
619
|
+
//#region src/builtins/backup-orchestrator/download-helpers.ts
|
|
620
|
+
/**
|
|
621
|
+
* Chunked-download helper for the backup orchestrator (Task 12 of
|
|
622
|
+
* the storage-unification refactor).
|
|
623
|
+
*
|
|
624
|
+
* Streams an archive from a destination `StorageLocation` into a
|
|
625
|
+
* local filesystem path via the consumer-facing `storage` cap. Pulled
|
|
626
|
+
* out of `backup-orchestrator.addon.ts` so it can be unit-tested with
|
|
627
|
+
* an in-memory fake `AddonApi['storage']` slice — the addon is hard
|
|
628
|
+
* to bootstrap in vitest because of the BaseAddon ceremony.
|
|
629
|
+
*
|
|
630
|
+
* Cleanup contract:
|
|
631
|
+
* - Successful run leaves the cache file in place + calls
|
|
632
|
+
* `endDownload`.
|
|
633
|
+
* - Mid-stream failure destroys the writeStream, removes the
|
|
634
|
+
* partial cache file, and best-effort calls `endDownload` so the
|
|
635
|
+
* storage provider's session bookkeeping doesn't leak.
|
|
636
|
+
*/
|
|
637
|
+
async function downloadArchiveToCache(input) {
|
|
638
|
+
const { downloadId, sizeBytes } = await input.api.beginDownload.mutate({
|
|
639
|
+
location: input.locationId,
|
|
640
|
+
relativePath: input.filename
|
|
641
|
+
});
|
|
642
|
+
const writeStream = fs.createWriteStream(input.cachePath);
|
|
643
|
+
let offset = 0;
|
|
644
|
+
try {
|
|
645
|
+
while (offset < sizeBytes) {
|
|
646
|
+
const remaining = sizeBytes - offset;
|
|
647
|
+
const length = Math.min(input.chunkBytes, remaining);
|
|
648
|
+
const chunk = await input.api.readChunk.query({
|
|
649
|
+
downloadId,
|
|
650
|
+
offset,
|
|
651
|
+
length
|
|
652
|
+
});
|
|
653
|
+
if (chunk.byteLength === 0) throw new Error(`backup restore: empty chunk at offset ${offset}/${sizeBytes} from "${input.locationId}/${input.filename}"`);
|
|
654
|
+
writeStream.write(chunk);
|
|
655
|
+
offset += chunk.byteLength;
|
|
656
|
+
}
|
|
657
|
+
await new Promise((resolve, reject) => {
|
|
658
|
+
writeStream.end((err) => {
|
|
659
|
+
if (err) reject(err);
|
|
660
|
+
else resolve();
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
try {
|
|
664
|
+
await input.api.endDownload.mutate({ downloadId });
|
|
665
|
+
} catch (err) {
|
|
666
|
+
input.logger.warn("backup restore: endDownload failed (ignoring)", { meta: {
|
|
667
|
+
downloadId,
|
|
668
|
+
error: err instanceof Error ? err.message : String(err)
|
|
669
|
+
} });
|
|
670
|
+
}
|
|
671
|
+
} catch (err) {
|
|
672
|
+
await new Promise((resolve) => {
|
|
673
|
+
writeStream.once("close", () => resolve());
|
|
674
|
+
writeStream.destroy();
|
|
675
|
+
});
|
|
676
|
+
try {
|
|
677
|
+
await fsp.rm(input.cachePath, { force: true });
|
|
678
|
+
} catch {}
|
|
679
|
+
try {
|
|
680
|
+
await input.api.endDownload.mutate({ downloadId });
|
|
681
|
+
} catch {}
|
|
682
|
+
throw err;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region ../../node_modules/croner/dist/croner.js
|
|
687
|
+
function T(s) {
|
|
688
|
+
return Date.UTC(s.y, s.m - 1, s.d, s.h, s.i, s.s);
|
|
689
|
+
}
|
|
690
|
+
function D(s, e) {
|
|
691
|
+
return s.y === e.y && s.m === e.m && s.d === e.d && s.h === e.h && s.i === e.i && s.s === e.s;
|
|
692
|
+
}
|
|
693
|
+
function A(s, e) {
|
|
694
|
+
let t = new Date(Date.parse(s));
|
|
695
|
+
if (isNaN(t)) throw new Error("Invalid ISO8601 passed to timezone parser.");
|
|
696
|
+
let r = s.substring(9);
|
|
697
|
+
return r.includes("Z") || r.includes("+") || r.includes("-") ? b(t.getUTCFullYear(), t.getUTCMonth() + 1, t.getUTCDate(), t.getUTCHours(), t.getUTCMinutes(), t.getUTCSeconds(), "Etc/UTC") : b(t.getFullYear(), t.getMonth() + 1, t.getDate(), t.getHours(), t.getMinutes(), t.getSeconds(), e);
|
|
698
|
+
}
|
|
699
|
+
function v(s, e, t) {
|
|
700
|
+
return k(A(s, e), t);
|
|
701
|
+
}
|
|
702
|
+
function k(s, e) {
|
|
703
|
+
let t = new Date(T(s)), r = g(t, s.tz), a = T(s) - T(r), o = new Date(t.getTime() + a), h = g(o, s.tz);
|
|
704
|
+
if (D(h, s)) {
|
|
705
|
+
let u = /* @__PURE__ */ new Date(o.getTime() - 36e5);
|
|
706
|
+
return D(g(u, s.tz), s) ? u : o;
|
|
707
|
+
}
|
|
708
|
+
let l = new Date(o.getTime() + T(s) - T(h));
|
|
709
|
+
if (D(g(l, s.tz), s)) return l;
|
|
710
|
+
if (e) throw new Error("Invalid date passed to fromTZ()");
|
|
711
|
+
return o.getTime() > l.getTime() ? o : l;
|
|
712
|
+
}
|
|
713
|
+
function g(s, e) {
|
|
714
|
+
let t, r;
|
|
715
|
+
try {
|
|
716
|
+
t = new Intl.DateTimeFormat("en-US", {
|
|
717
|
+
timeZone: e,
|
|
718
|
+
year: "numeric",
|
|
719
|
+
month: "numeric",
|
|
720
|
+
day: "numeric",
|
|
721
|
+
hour: "numeric",
|
|
722
|
+
minute: "numeric",
|
|
723
|
+
second: "numeric",
|
|
724
|
+
hour12: !1
|
|
725
|
+
}), r = t.formatToParts(s);
|
|
726
|
+
} catch (i) {
|
|
727
|
+
let a = i instanceof Error ? i.message : String(i);
|
|
728
|
+
throw new RangeError(`toTZ: Invalid timezone '${e}' or date. Please provide a valid IANA timezone (e.g., 'America/New_York', 'Europe/Stockholm'). Original error: ${a}`);
|
|
729
|
+
}
|
|
730
|
+
let n = {
|
|
731
|
+
year: 0,
|
|
732
|
+
month: 0,
|
|
733
|
+
day: 0,
|
|
734
|
+
hour: 0,
|
|
735
|
+
minute: 0,
|
|
736
|
+
second: 0
|
|
737
|
+
};
|
|
738
|
+
for (let i of r) (i.type === "year" || i.type === "month" || i.type === "day" || i.type === "hour" || i.type === "minute" || i.type === "second") && (n[i.type] = parseInt(i.value, 10));
|
|
739
|
+
if (isNaN(n.year) || isNaN(n.month) || isNaN(n.day) || isNaN(n.hour) || isNaN(n.minute) || isNaN(n.second)) throw new Error(`toTZ: Failed to parse all date components from timezone '${e}'. This may indicate an invalid date or timezone configuration. Parsed components: ${JSON.stringify(n)}`);
|
|
740
|
+
return n.hour === 24 && (n.hour = 0), {
|
|
741
|
+
y: n.year,
|
|
742
|
+
m: n.month,
|
|
743
|
+
d: n.day,
|
|
744
|
+
h: n.hour,
|
|
745
|
+
i: n.minute,
|
|
746
|
+
s: n.second,
|
|
747
|
+
tz: e
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
function b(s, e, t, r, n, i, a) {
|
|
751
|
+
return {
|
|
752
|
+
y: s,
|
|
753
|
+
m: e,
|
|
754
|
+
d: t,
|
|
755
|
+
h: r,
|
|
756
|
+
i: n,
|
|
757
|
+
s: i,
|
|
758
|
+
tz: a
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
var O = [
|
|
762
|
+
1,
|
|
763
|
+
2,
|
|
764
|
+
4,
|
|
765
|
+
8,
|
|
766
|
+
16
|
|
767
|
+
], C = class {
|
|
768
|
+
pattern;
|
|
769
|
+
timezone;
|
|
770
|
+
mode;
|
|
771
|
+
alternativeWeekdays;
|
|
772
|
+
sloppyRanges;
|
|
773
|
+
second;
|
|
774
|
+
minute;
|
|
775
|
+
hour;
|
|
776
|
+
day;
|
|
777
|
+
month;
|
|
778
|
+
dayOfWeek;
|
|
779
|
+
year;
|
|
780
|
+
lastDayOfMonth;
|
|
781
|
+
lastWeekday;
|
|
782
|
+
nearestWeekdays;
|
|
783
|
+
starDOM;
|
|
784
|
+
starDOW;
|
|
785
|
+
starYear;
|
|
786
|
+
useAndLogic;
|
|
787
|
+
constructor(e, t, r) {
|
|
788
|
+
this.pattern = e, this.timezone = t, this.mode = r?.mode ?? "auto", this.alternativeWeekdays = r?.alternativeWeekdays ?? !1, this.sloppyRanges = r?.sloppyRanges ?? !1, this.second = Array(60).fill(0), this.minute = Array(60).fill(0), this.hour = Array(24).fill(0), this.day = Array(31).fill(0), this.month = Array(12).fill(0), this.dayOfWeek = Array(7).fill(0), this.year = Array(1e4).fill(0), this.lastDayOfMonth = !1, this.lastWeekday = !1, this.nearestWeekdays = Array(31).fill(0), this.starDOM = !1, this.starDOW = !1, this.starYear = !1, this.useAndLogic = !1, this.parse();
|
|
789
|
+
}
|
|
790
|
+
parse() {
|
|
791
|
+
if (!(typeof this.pattern == "string" || this.pattern instanceof String)) throw new TypeError("CronPattern: Pattern has to be of type string.");
|
|
792
|
+
this.pattern.indexOf("@") >= 0 && (this.pattern = this.handleNicknames(this.pattern).trim());
|
|
793
|
+
let e = this.pattern.match(/\S+/g) || [""], t = e.length;
|
|
794
|
+
if (e.length < 5 || e.length > 7) throw new TypeError("CronPattern: invalid configuration format ('" + this.pattern + "'), exactly five, six, or seven space separated parts are required.");
|
|
795
|
+
if (this.mode !== "auto") {
|
|
796
|
+
let n;
|
|
797
|
+
switch (this.mode) {
|
|
798
|
+
case "5-part":
|
|
799
|
+
n = 5;
|
|
800
|
+
break;
|
|
801
|
+
case "6-part":
|
|
802
|
+
n = 6;
|
|
803
|
+
break;
|
|
804
|
+
case "7-part":
|
|
805
|
+
n = 7;
|
|
806
|
+
break;
|
|
807
|
+
case "5-or-6-parts":
|
|
808
|
+
n = [5, 6];
|
|
809
|
+
break;
|
|
810
|
+
case "6-or-7-parts":
|
|
811
|
+
n = [6, 7];
|
|
812
|
+
break;
|
|
813
|
+
default: n = 0;
|
|
814
|
+
}
|
|
815
|
+
if (!(Array.isArray(n) ? n.includes(t) : t === n)) {
|
|
816
|
+
let a = Array.isArray(n) ? n.join(" or ") : n.toString();
|
|
817
|
+
throw new TypeError(`CronPattern: mode '${this.mode}' requires exactly ${a} parts, but pattern '${this.pattern}' has ${t} parts.`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (e.length === 5 && e.unshift("0"), e.length === 6 && e.push("*"), e[3].toUpperCase() === "LW" ? (this.lastWeekday = !0, e[3] = "") : e[3].toUpperCase().indexOf("L") >= 0 && (e[3] = e[3].replace(/L/gi, ""), this.lastDayOfMonth = !0), e[3] == "*" && (this.starDOM = !0), e[6] == "*" && (this.starYear = !0), e[4].length >= 3 && (e[4] = this.replaceAlphaMonths(e[4])), e[5].length >= 3 && (e[5] = this.alternativeWeekdays ? this.replaceAlphaDaysQuartz(e[5]) : this.replaceAlphaDays(e[5])), e[5].startsWith("+") && (this.useAndLogic = !0, e[5] = e[5].substring(1), e[5] === "")) throw new TypeError("CronPattern: Day-of-week field cannot be empty after '+' modifier.");
|
|
821
|
+
switch (e[5] == "*" && (this.starDOW = !0), this.pattern.indexOf("?") >= 0 && (e[0] = e[0].replace(/\?/g, "*"), e[1] = e[1].replace(/\?/g, "*"), e[2] = e[2].replace(/\?/g, "*"), e[3] = e[3].replace(/\?/g, "*"), e[4] = e[4].replace(/\?/g, "*"), e[5] = e[5].replace(/\?/g, "*"), e[6] && (e[6] = e[6].replace(/\?/g, "*"))), this.mode) {
|
|
822
|
+
case "5-part":
|
|
823
|
+
e[0] = "0", e[6] = "*";
|
|
824
|
+
break;
|
|
825
|
+
case "6-part":
|
|
826
|
+
e[6] = "*";
|
|
827
|
+
break;
|
|
828
|
+
case "5-or-6-parts":
|
|
829
|
+
e[6] = "*";
|
|
830
|
+
break;
|
|
831
|
+
case "6-or-7-parts": break;
|
|
832
|
+
case "7-part":
|
|
833
|
+
case "auto": break;
|
|
834
|
+
}
|
|
835
|
+
this.throwAtIllegalCharacters(e), this.partToArray("second", e[0], 0, 1), this.partToArray("minute", e[1], 0, 1), this.partToArray("hour", e[2], 0, 1), this.partToArray("day", e[3], -1, 1), this.partToArray("month", e[4], -1, 1);
|
|
836
|
+
let r = this.alternativeWeekdays ? -1 : 0;
|
|
837
|
+
this.partToArray("dayOfWeek", e[5], r, 63), this.partToArray("year", e[6], 0, 1), !this.alternativeWeekdays && this.dayOfWeek[7] && (this.dayOfWeek[0] = this.dayOfWeek[7]);
|
|
838
|
+
}
|
|
839
|
+
partToArray(e, t, r, n) {
|
|
840
|
+
let i = this[e], a = e === "day" && this.lastDayOfMonth, o = e === "day" && this.lastWeekday;
|
|
841
|
+
if (t === "" && !a && !o) throw new TypeError("CronPattern: configuration entry " + e + " (" + t + ") is empty, check for trailing spaces.");
|
|
842
|
+
if (t === "*") return i.fill(n);
|
|
843
|
+
let h = t.split(",");
|
|
844
|
+
if (h.length > 1) for (let l = 0; l < h.length; l++) this.partToArray(e, h[l], r, n);
|
|
845
|
+
else t.indexOf("-") !== -1 && t.indexOf("/") !== -1 ? this.handleRangeWithStepping(t, e, r, n) : t.indexOf("-") !== -1 ? this.handleRange(t, e, r, n) : t.indexOf("/") !== -1 ? this.handleStepping(t, e, r, n) : t !== "" && this.handleNumber(t, e, r, n);
|
|
846
|
+
}
|
|
847
|
+
throwAtIllegalCharacters(e) {
|
|
848
|
+
for (let t = 0; t < e.length; t++) if ((t === 3 ? /[^/*0-9,\-WwLl]+/ : t === 5 ? /[^/*0-9,\-#Ll]+/ : /[^/*0-9,\-]+/).test(e[t])) throw new TypeError("CronPattern: configuration entry " + t + " (" + e[t] + ") contains illegal characters.");
|
|
849
|
+
}
|
|
850
|
+
handleNumber(e, t, r, n) {
|
|
851
|
+
let i = this.extractNth(e, t), a = e.toUpperCase().includes("W");
|
|
852
|
+
if (t !== "day" && a) throw new TypeError("CronPattern: Nearest weekday modifier (W) only allowed in day-of-month.");
|
|
853
|
+
a && (t = "nearestWeekdays");
|
|
854
|
+
let o = parseInt(i[0], 10) + r;
|
|
855
|
+
if (isNaN(o)) throw new TypeError("CronPattern: " + t + " is not a number: '" + e + "'");
|
|
856
|
+
this.setPart(t, o, i[1] || n);
|
|
857
|
+
}
|
|
858
|
+
setPart(e, t, r) {
|
|
859
|
+
if (!Object.prototype.hasOwnProperty.call(this, e)) throw new TypeError("CronPattern: Invalid part specified: " + e);
|
|
860
|
+
if (e === "dayOfWeek") {
|
|
861
|
+
if (t === 7 && (t = 0), t < 0 || t > 6) throw new RangeError("CronPattern: Invalid value for dayOfWeek: " + t);
|
|
862
|
+
this.setNthWeekdayOfMonth(t, r);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (e === "second" || e === "minute") {
|
|
866
|
+
if (t < 0 || t >= 60) throw new RangeError("CronPattern: Invalid value for " + e + ": " + t);
|
|
867
|
+
} else if (e === "hour") {
|
|
868
|
+
if (t < 0 || t >= 24) throw new RangeError("CronPattern: Invalid value for " + e + ": " + t);
|
|
869
|
+
} else if (e === "day" || e === "nearestWeekdays") {
|
|
870
|
+
if (t < 0 || t >= 31) throw new RangeError("CronPattern: Invalid value for " + e + ": " + t);
|
|
871
|
+
} else if (e === "month") {
|
|
872
|
+
if (t < 0 || t >= 12) throw new RangeError("CronPattern: Invalid value for " + e + ": " + t);
|
|
873
|
+
} else if (e === "year" && (t < 1 || t >= 1e4)) throw new RangeError("CronPattern: Invalid value for " + e + ": " + t + " (supported range: 1-9999)");
|
|
874
|
+
this[e][t] = r;
|
|
875
|
+
}
|
|
876
|
+
validateNotNaN(e, t) {
|
|
877
|
+
if (isNaN(e)) throw new TypeError(t);
|
|
878
|
+
}
|
|
879
|
+
validateRange(e, t, r, n, i) {
|
|
880
|
+
if (e > t) throw new TypeError("CronPattern: From value is larger than to value: '" + i + "'");
|
|
881
|
+
if (r !== void 0) {
|
|
882
|
+
if (r === 0) throw new TypeError("CronPattern: Syntax error, illegal stepping: 0");
|
|
883
|
+
if (r > this[n].length) throw new TypeError("CronPattern: Syntax error, steps cannot be greater than maximum value of part (" + this[n].length + ")");
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
handleRangeWithStepping(e, t, r, n) {
|
|
887
|
+
if (e.toUpperCase().includes("W")) throw new TypeError("CronPattern: Syntax error, W is not allowed in ranges with stepping.");
|
|
888
|
+
let i = this.extractNth(e, t), a = i[0].match(/^(\d+)-(\d+)\/(\d+)$/);
|
|
889
|
+
if (a === null) throw new TypeError("CronPattern: Syntax error, illegal range with stepping: '" + e + "'");
|
|
890
|
+
let [, o, h, l] = a, y = parseInt(o, 10) + r, u = parseInt(h, 10) + r, d = parseInt(l, 10);
|
|
891
|
+
this.validateNotNaN(y, "CronPattern: Syntax error, illegal lower range (NaN)"), this.validateNotNaN(u, "CronPattern: Syntax error, illegal upper range (NaN)"), this.validateNotNaN(d, "CronPattern: Syntax error, illegal stepping: (NaN)"), this.validateRange(y, u, d, t, e);
|
|
892
|
+
for (let c = y; c <= u; c += d) this.setPart(t, c, i[1] || n);
|
|
893
|
+
}
|
|
894
|
+
extractNth(e, t) {
|
|
895
|
+
let r = e, n;
|
|
896
|
+
if (r.includes("#")) {
|
|
897
|
+
if (t !== "dayOfWeek") throw new Error("CronPattern: nth (#) only allowed in day-of-week field");
|
|
898
|
+
n = r.split("#")[1], r = r.split("#")[0];
|
|
899
|
+
} else if (r.toUpperCase().endsWith("L")) {
|
|
900
|
+
if (t !== "dayOfWeek") throw new Error("CronPattern: L modifier only allowed in day-of-week field (use L alone for day-of-month)");
|
|
901
|
+
n = "L", r = r.slice(0, -1);
|
|
902
|
+
}
|
|
903
|
+
return [r, n];
|
|
904
|
+
}
|
|
905
|
+
handleRange(e, t, r, n) {
|
|
906
|
+
if (e.toUpperCase().includes("W")) throw new TypeError("CronPattern: Syntax error, W is not allowed in a range.");
|
|
907
|
+
let i = this.extractNth(e, t), a = i[0].split("-");
|
|
908
|
+
if (a.length !== 2) throw new TypeError("CronPattern: Syntax error, illegal range: '" + e + "'");
|
|
909
|
+
let o = parseInt(a[0], 10) + r, h = parseInt(a[1], 10) + r;
|
|
910
|
+
this.validateNotNaN(o, "CronPattern: Syntax error, illegal lower range (NaN)"), this.validateNotNaN(h, "CronPattern: Syntax error, illegal upper range (NaN)"), this.validateRange(o, h, void 0, t, e);
|
|
911
|
+
for (let l = o; l <= h; l++) this.setPart(t, l, i[1] || n);
|
|
912
|
+
}
|
|
913
|
+
handleStepping(e, t, r, n) {
|
|
914
|
+
if (e.toUpperCase().includes("W")) throw new TypeError("CronPattern: Syntax error, W is not allowed in parts with stepping.");
|
|
915
|
+
let i = this.extractNth(e, t), a = i[0].split("/");
|
|
916
|
+
if (a.length !== 2) throw new TypeError("CronPattern: Syntax error, illegal stepping: '" + e + "'");
|
|
917
|
+
if (this.sloppyRanges) a[0] === "" && (a[0] = "*");
|
|
918
|
+
else {
|
|
919
|
+
if (a[0] === "") throw new TypeError("CronPattern: Syntax error, stepping with missing prefix ('" + e + "') is not allowed. Use wildcard (*/step) or range (min-max/step) instead.");
|
|
920
|
+
if (a[0] !== "*") throw new TypeError("CronPattern: Syntax error, stepping with numeric prefix ('" + e + "') is not allowed. Use wildcard (*/step) or range (min-max/step) instead.");
|
|
921
|
+
}
|
|
922
|
+
let o = 0;
|
|
923
|
+
a[0] !== "*" && (o = parseInt(a[0], 10) + r);
|
|
924
|
+
let h = parseInt(a[1], 10);
|
|
925
|
+
this.validateNotNaN(h, "CronPattern: Syntax error, illegal stepping: (NaN)"), this.validateRange(0, this[t].length - 1, h, t, e);
|
|
926
|
+
for (let l = o; l < this[t].length; l += h) this.setPart(t, l, i[1] || n);
|
|
927
|
+
}
|
|
928
|
+
replaceAlphaDays(e) {
|
|
929
|
+
return e.replace(/-sun/gi, "-7").replace(/sun/gi, "0").replace(/mon/gi, "1").replace(/tue/gi, "2").replace(/wed/gi, "3").replace(/thu/gi, "4").replace(/fri/gi, "5").replace(/sat/gi, "6");
|
|
930
|
+
}
|
|
931
|
+
replaceAlphaDaysQuartz(e) {
|
|
932
|
+
return e.replace(/sun/gi, "1").replace(/mon/gi, "2").replace(/tue/gi, "3").replace(/wed/gi, "4").replace(/thu/gi, "5").replace(/fri/gi, "6").replace(/sat/gi, "7");
|
|
933
|
+
}
|
|
934
|
+
replaceAlphaMonths(e) {
|
|
935
|
+
return e.replace(/jan/gi, "1").replace(/feb/gi, "2").replace(/mar/gi, "3").replace(/apr/gi, "4").replace(/may/gi, "5").replace(/jun/gi, "6").replace(/jul/gi, "7").replace(/aug/gi, "8").replace(/sep/gi, "9").replace(/oct/gi, "10").replace(/nov/gi, "11").replace(/dec/gi, "12");
|
|
936
|
+
}
|
|
937
|
+
handleNicknames(e) {
|
|
938
|
+
let t = e.trim().toLowerCase();
|
|
939
|
+
if (t === "@yearly" || t === "@annually") return "0 0 1 1 *";
|
|
940
|
+
if (t === "@monthly") return "0 0 1 * *";
|
|
941
|
+
if (t === "@weekly") return "0 0 * * 0";
|
|
942
|
+
if (t === "@daily" || t === "@midnight") return "0 0 * * *";
|
|
943
|
+
if (t === "@hourly") return "0 * * * *";
|
|
944
|
+
if (t === "@reboot") throw new TypeError("CronPattern: @reboot is not supported in this environment. This is an event-based trigger that requires system startup detection.");
|
|
945
|
+
return e;
|
|
946
|
+
}
|
|
947
|
+
setNthWeekdayOfMonth(e, t) {
|
|
948
|
+
if (typeof t != "number" && t.toUpperCase() === "L") this.dayOfWeek[e] = this.dayOfWeek[e] | 32;
|
|
949
|
+
else if (t === 63) this.dayOfWeek[e] = 63;
|
|
950
|
+
else if (t < 6 && t > 0) this.dayOfWeek[e] = this.dayOfWeek[e] | O[t - 1];
|
|
951
|
+
else throw new TypeError(`CronPattern: nth weekday out of range, should be 1-5 or L. Value: ${t}, Type: ${typeof t}`);
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
var P = [
|
|
955
|
+
31,
|
|
956
|
+
28,
|
|
957
|
+
31,
|
|
958
|
+
30,
|
|
959
|
+
31,
|
|
960
|
+
30,
|
|
961
|
+
31,
|
|
962
|
+
31,
|
|
963
|
+
30,
|
|
964
|
+
31,
|
|
965
|
+
30,
|
|
966
|
+
31
|
|
967
|
+
], f = [
|
|
968
|
+
[
|
|
969
|
+
"month",
|
|
970
|
+
"year",
|
|
971
|
+
0
|
|
972
|
+
],
|
|
973
|
+
[
|
|
974
|
+
"day",
|
|
975
|
+
"month",
|
|
976
|
+
-1
|
|
977
|
+
],
|
|
978
|
+
[
|
|
979
|
+
"hour",
|
|
980
|
+
"day",
|
|
981
|
+
0
|
|
982
|
+
],
|
|
983
|
+
[
|
|
984
|
+
"minute",
|
|
985
|
+
"hour",
|
|
986
|
+
0
|
|
987
|
+
],
|
|
988
|
+
[
|
|
989
|
+
"second",
|
|
990
|
+
"minute",
|
|
991
|
+
0
|
|
992
|
+
]
|
|
993
|
+
], m = class s {
|
|
994
|
+
tz;
|
|
995
|
+
ms;
|
|
996
|
+
second;
|
|
997
|
+
minute;
|
|
998
|
+
hour;
|
|
999
|
+
day;
|
|
1000
|
+
month;
|
|
1001
|
+
year;
|
|
1002
|
+
constructor(e, t) {
|
|
1003
|
+
if (this.tz = t, e && e instanceof Date) if (!isNaN(e)) this.fromDate(e);
|
|
1004
|
+
else throw new TypeError("CronDate: Invalid date passed to CronDate constructor");
|
|
1005
|
+
else if (e == null) this.fromDate(/* @__PURE__ */ new Date());
|
|
1006
|
+
else if (e && typeof e == "string") this.fromString(e);
|
|
1007
|
+
else if (e instanceof s) this.fromCronDate(e);
|
|
1008
|
+
else throw new TypeError("CronDate: Invalid type (" + typeof e + ") passed to CronDate constructor");
|
|
1009
|
+
}
|
|
1010
|
+
getLastDayOfMonth(e, t) {
|
|
1011
|
+
return t !== 1 ? P[t] : new Date(Date.UTC(e, t + 1, 0)).getUTCDate();
|
|
1012
|
+
}
|
|
1013
|
+
getLastWeekday(e, t) {
|
|
1014
|
+
let r = this.getLastDayOfMonth(e, t), i = new Date(Date.UTC(e, t, r)).getUTCDay();
|
|
1015
|
+
return i === 0 ? r - 2 : i === 6 ? r - 1 : r;
|
|
1016
|
+
}
|
|
1017
|
+
getNearestWeekday(e, t, r) {
|
|
1018
|
+
let n = this.getLastDayOfMonth(e, t);
|
|
1019
|
+
if (r > n) return -1;
|
|
1020
|
+
let a = new Date(Date.UTC(e, t, r)).getUTCDay();
|
|
1021
|
+
return a === 0 ? r === n ? r - 2 : r + 1 : a === 6 ? r === 1 ? r + 2 : r - 1 : r;
|
|
1022
|
+
}
|
|
1023
|
+
isNthWeekdayOfMonth(e, t, r, n) {
|
|
1024
|
+
let a = new Date(Date.UTC(e, t, r)).getUTCDay(), o = 0;
|
|
1025
|
+
for (let h = 1; h <= r; h++) new Date(Date.UTC(e, t, h)).getUTCDay() === a && o++;
|
|
1026
|
+
if (n & 63 && O[o - 1] & n) return !0;
|
|
1027
|
+
if (n & 32) {
|
|
1028
|
+
let h = this.getLastDayOfMonth(e, t);
|
|
1029
|
+
for (let l = r + 1; l <= h; l++) if (new Date(Date.UTC(e, t, l)).getUTCDay() === a) return !1;
|
|
1030
|
+
return !0;
|
|
1031
|
+
}
|
|
1032
|
+
return !1;
|
|
1033
|
+
}
|
|
1034
|
+
fromDate(e) {
|
|
1035
|
+
if (this.tz !== void 0) if (typeof this.tz == "number") this.ms = e.getUTCMilliseconds(), this.second = e.getUTCSeconds(), this.minute = e.getUTCMinutes() + this.tz, this.hour = e.getUTCHours(), this.day = e.getUTCDate(), this.month = e.getUTCMonth(), this.year = e.getUTCFullYear(), this.apply();
|
|
1036
|
+
else try {
|
|
1037
|
+
let t = g(e, this.tz);
|
|
1038
|
+
this.ms = e.getMilliseconds(), this.second = t.s, this.minute = t.i, this.hour = t.h, this.day = t.d, this.month = t.m - 1, this.year = t.y;
|
|
1039
|
+
} catch (t) {
|
|
1040
|
+
let r = t instanceof Error ? t.message : String(t);
|
|
1041
|
+
throw new TypeError(`CronDate: Failed to convert date to timezone '${this.tz}'. This may happen with invalid timezone names or dates. Original error: ${r}`);
|
|
1042
|
+
}
|
|
1043
|
+
else this.ms = e.getMilliseconds(), this.second = e.getSeconds(), this.minute = e.getMinutes(), this.hour = e.getHours(), this.day = e.getDate(), this.month = e.getMonth(), this.year = e.getFullYear();
|
|
1044
|
+
}
|
|
1045
|
+
fromCronDate(e) {
|
|
1046
|
+
this.tz = e.tz, this.year = e.year, this.month = e.month, this.day = e.day, this.hour = e.hour, this.minute = e.minute, this.second = e.second, this.ms = e.ms;
|
|
1047
|
+
}
|
|
1048
|
+
apply() {
|
|
1049
|
+
if (this.month > 11 || this.month < 0 || this.day > P[this.month] || this.day < 1 || this.hour > 59 || this.minute > 59 || this.second > 59 || this.hour < 0 || this.minute < 0 || this.second < 0) {
|
|
1050
|
+
let e = new Date(Date.UTC(this.year, this.month, this.day, this.hour, this.minute, this.second, this.ms));
|
|
1051
|
+
return this.ms = e.getUTCMilliseconds(), this.second = e.getUTCSeconds(), this.minute = e.getUTCMinutes(), this.hour = e.getUTCHours(), this.day = e.getUTCDate(), this.month = e.getUTCMonth(), this.year = e.getUTCFullYear(), !0;
|
|
1052
|
+
} else return !1;
|
|
1053
|
+
}
|
|
1054
|
+
fromString(e) {
|
|
1055
|
+
if (typeof this.tz == "number") {
|
|
1056
|
+
let t = v(e);
|
|
1057
|
+
this.ms = t.getUTCMilliseconds(), this.second = t.getUTCSeconds(), this.minute = t.getUTCMinutes(), this.hour = t.getUTCHours(), this.day = t.getUTCDate(), this.month = t.getUTCMonth(), this.year = t.getUTCFullYear(), this.apply();
|
|
1058
|
+
} else return this.fromDate(v(e, this.tz));
|
|
1059
|
+
}
|
|
1060
|
+
findNext(e, t, r, n) {
|
|
1061
|
+
return this._findMatch(e, t, r, n, 1);
|
|
1062
|
+
}
|
|
1063
|
+
_findMatch(e, t, r, n, i) {
|
|
1064
|
+
let a = this[t], o;
|
|
1065
|
+
r.lastDayOfMonth && (o = this.getLastDayOfMonth(this.year, this.month));
|
|
1066
|
+
let h = !r.starDOW && t == "day" ? new Date(Date.UTC(this.year, this.month, 1, 0, 0, 0, 0)).getUTCDay() : void 0, l = this[t] + n, y = i === 1 ? (u) => u < r[t].length : (u) => u >= 0;
|
|
1067
|
+
for (let u = l; y(u); u += i) {
|
|
1068
|
+
let d = r[t][u];
|
|
1069
|
+
if (t === "day" && !d) {
|
|
1070
|
+
for (let c = 0; c < r.nearestWeekdays.length; c++) if (r.nearestWeekdays[c]) {
|
|
1071
|
+
let M = this.getNearestWeekday(this.year, this.month, c - n);
|
|
1072
|
+
if (M === -1) continue;
|
|
1073
|
+
if (M === u - n) {
|
|
1074
|
+
d = 1;
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (t === "day" && r.lastWeekday) {
|
|
1080
|
+
let c = this.getLastWeekday(this.year, this.month);
|
|
1081
|
+
u - n === c && (d = 1);
|
|
1082
|
+
}
|
|
1083
|
+
if (t === "day" && r.lastDayOfMonth && u - n == o && (d = 1), t === "day" && !r.starDOW) {
|
|
1084
|
+
let c = r.dayOfWeek[(h + (u - n - 1)) % 7];
|
|
1085
|
+
if (c && c & 63) c = this.isNthWeekdayOfMonth(this.year, this.month, u - n, c) ? 1 : 0;
|
|
1086
|
+
else if (c) throw new Error(`CronDate: Invalid value for dayOfWeek encountered. ${c}`);
|
|
1087
|
+
r.useAndLogic ? d = d && c : !e.domAndDow && !r.starDOM ? d = d || c : d = d && c;
|
|
1088
|
+
}
|
|
1089
|
+
if (d) return this[t] = u - n, a !== this[t] ? 2 : 1;
|
|
1090
|
+
}
|
|
1091
|
+
return 3;
|
|
1092
|
+
}
|
|
1093
|
+
recurse(e, t, r) {
|
|
1094
|
+
if (r === 0 && !e.starYear) {
|
|
1095
|
+
if (this.year >= 0 && this.year < e.year.length && e.year[this.year] === 0) {
|
|
1096
|
+
let i = -1;
|
|
1097
|
+
for (let a = this.year + 1; a < e.year.length && a < 1e4; a++) if (e.year[a] === 1) {
|
|
1098
|
+
i = a;
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
if (i === -1) return null;
|
|
1102
|
+
this.year = i, this.month = 0, this.day = 1, this.hour = 0, this.minute = 0, this.second = 0, this.ms = 0;
|
|
1103
|
+
}
|
|
1104
|
+
if (this.year >= 1e4) return null;
|
|
1105
|
+
}
|
|
1106
|
+
let n = this.findNext(t, f[r][0], e, f[r][2]);
|
|
1107
|
+
if (n > 1) {
|
|
1108
|
+
let i = r + 1;
|
|
1109
|
+
for (; i < f.length;) this[f[i][0]] = -f[i][2], i++;
|
|
1110
|
+
if (n === 3) {
|
|
1111
|
+
if (this[f[r][1]]++, this[f[r][0]] = -f[r][2], this.apply(), r === 0 && !e.starYear) {
|
|
1112
|
+
for (; this.year >= 0 && this.year < e.year.length && e.year[this.year] === 0 && this.year < 1e4;) this.year++;
|
|
1113
|
+
if (this.year >= 1e4 || this.year >= e.year.length) return null;
|
|
1114
|
+
}
|
|
1115
|
+
return this.recurse(e, t, 0);
|
|
1116
|
+
} else if (this.apply()) return this.recurse(e, t, r - 1);
|
|
1117
|
+
}
|
|
1118
|
+
return r += 1, r >= f.length ? this : (e.starYear ? this.year >= 3e3 : this.year >= 1e4) ? null : this.recurse(e, t, r);
|
|
1119
|
+
}
|
|
1120
|
+
increment(e, t, r) {
|
|
1121
|
+
return this.second += t.interval !== void 0 && t.interval > 1 && r ? t.interval : 1, this.ms = 0, this.apply(), this.recurse(e, t, 0);
|
|
1122
|
+
}
|
|
1123
|
+
decrement(e, t) {
|
|
1124
|
+
return this.second -= t.interval !== void 0 && t.interval > 1 ? t.interval : 1, this.ms = 0, this.apply(), this.recurseBackward(e, t, 0, 0);
|
|
1125
|
+
}
|
|
1126
|
+
recurseBackward(e, t, r, n = 0) {
|
|
1127
|
+
if (n > 1e4) return null;
|
|
1128
|
+
if (r === 0 && !e.starYear) {
|
|
1129
|
+
if (this.year >= 0 && this.year < e.year.length && e.year[this.year] === 0) {
|
|
1130
|
+
let a = -1;
|
|
1131
|
+
for (let o = this.year - 1; o >= 0; o--) if (e.year[o] === 1) {
|
|
1132
|
+
a = o;
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
if (a === -1) return null;
|
|
1136
|
+
this.year = a, this.month = 11, this.day = 31, this.hour = 23, this.minute = 59, this.second = 59, this.ms = 0;
|
|
1137
|
+
}
|
|
1138
|
+
if (this.year < 0) return null;
|
|
1139
|
+
}
|
|
1140
|
+
let i = this.findPrevious(t, f[r][0], e, f[r][2]);
|
|
1141
|
+
if (i > 1) {
|
|
1142
|
+
let a = r + 1;
|
|
1143
|
+
for (; a < f.length;) {
|
|
1144
|
+
let o = f[a][0], h = f[a][2], l = this.getMaxPatternValue(o, e, h);
|
|
1145
|
+
this[o] = l, a++;
|
|
1146
|
+
}
|
|
1147
|
+
if (i === 3) {
|
|
1148
|
+
if (this[f[r][1]]--, r === 0) {
|
|
1149
|
+
let y = this.getLastDayOfMonth(this.year, this.month);
|
|
1150
|
+
this.day > y && (this.day = y);
|
|
1151
|
+
}
|
|
1152
|
+
if (r === 1) if (this.day <= 0) this.day = 1;
|
|
1153
|
+
else {
|
|
1154
|
+
let y = this.year, u = this.month;
|
|
1155
|
+
for (; u < 0;) u += 12, y--;
|
|
1156
|
+
for (; u > 11;) u -= 12, y++;
|
|
1157
|
+
let d = u !== 1 ? P[u] : new Date(Date.UTC(y, u + 1, 0)).getUTCDate();
|
|
1158
|
+
this.day > d && (this.day = d);
|
|
1159
|
+
}
|
|
1160
|
+
this.apply();
|
|
1161
|
+
let o = f[r][0], h = f[r][2], l = this.getMaxPatternValue(o, e, h);
|
|
1162
|
+
if (o === "day") {
|
|
1163
|
+
let y = this.getLastDayOfMonth(this.year, this.month);
|
|
1164
|
+
this[o] = Math.min(l, y);
|
|
1165
|
+
} else this[o] = l;
|
|
1166
|
+
if (this.apply(), r === 0) {
|
|
1167
|
+
let y = f[1][2], u = this.getMaxPatternValue("day", e, y), d = this.getLastDayOfMonth(this.year, this.month), c = Math.min(u, d);
|
|
1168
|
+
c !== this.day && (this.day = c, this.hour = this.getMaxPatternValue("hour", e, f[2][2]), this.minute = this.getMaxPatternValue("minute", e, f[3][2]), this.second = this.getMaxPatternValue("second", e, f[4][2]));
|
|
1169
|
+
}
|
|
1170
|
+
if (r === 0 && !e.starYear) {
|
|
1171
|
+
for (; this.year >= 0 && this.year < e.year.length && e.year[this.year] === 0;) this.year--;
|
|
1172
|
+
if (this.year < 0) return null;
|
|
1173
|
+
}
|
|
1174
|
+
return this.recurseBackward(e, t, 0, n + 1);
|
|
1175
|
+
} else if (this.apply()) return this.recurseBackward(e, t, r - 1, n + 1);
|
|
1176
|
+
}
|
|
1177
|
+
return r += 1, r >= f.length ? this : this.year < 0 ? null : this.recurseBackward(e, t, r, n + 1);
|
|
1178
|
+
}
|
|
1179
|
+
getMaxPatternValue(e, t, r) {
|
|
1180
|
+
if (e === "day" && t.lastDayOfMonth) return this.getLastDayOfMonth(this.year, this.month);
|
|
1181
|
+
if (e === "day" && !t.starDOW) return this.getLastDayOfMonth(this.year, this.month);
|
|
1182
|
+
for (let n = t[e].length - 1; n >= 0; n--) if (t[e][n]) return n - r;
|
|
1183
|
+
return t[e].length - 1 - r;
|
|
1184
|
+
}
|
|
1185
|
+
findPrevious(e, t, r, n) {
|
|
1186
|
+
return this._findMatch(e, t, r, n, -1);
|
|
1187
|
+
}
|
|
1188
|
+
getDate(e) {
|
|
1189
|
+
return e || this.tz === void 0 ? new Date(this.year, this.month, this.day, this.hour, this.minute, this.second, this.ms) : typeof this.tz == "number" ? new Date(Date.UTC(this.year, this.month, this.day, this.hour, this.minute - this.tz, this.second, this.ms)) : k(b(this.year, this.month + 1, this.day, this.hour, this.minute, this.second, this.tz), !1);
|
|
1190
|
+
}
|
|
1191
|
+
getTime() {
|
|
1192
|
+
return this.getDate(!1).getTime();
|
|
1193
|
+
}
|
|
1194
|
+
match(e, t) {
|
|
1195
|
+
if (!e.starYear && (this.year < 0 || this.year >= e.year.length || e.year[this.year] === 0)) return !1;
|
|
1196
|
+
for (let r = 0; r < f.length; r++) {
|
|
1197
|
+
let n = f[r][0], i = f[r][2], a = this[n];
|
|
1198
|
+
if (a + i < 0 || a + i >= e[n].length) return !1;
|
|
1199
|
+
let o = e[n][a + i];
|
|
1200
|
+
if (n === "day") {
|
|
1201
|
+
if (!o) {
|
|
1202
|
+
for (let h = 0; h < e.nearestWeekdays.length; h++) if (e.nearestWeekdays[h]) {
|
|
1203
|
+
let l = this.getNearestWeekday(this.year, this.month, h - i);
|
|
1204
|
+
if (l !== -1 && l === a) {
|
|
1205
|
+
o = 1;
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
if (e.lastWeekday) a === this.getLastWeekday(this.year, this.month) && (o = 1);
|
|
1211
|
+
if (e.lastDayOfMonth) a === this.getLastDayOfMonth(this.year, this.month) && (o = 1);
|
|
1212
|
+
if (!e.starDOW) {
|
|
1213
|
+
let h = new Date(Date.UTC(this.year, this.month, 1, 0, 0, 0, 0)).getUTCDay(), l = e.dayOfWeek[(h + (a - 1)) % 7];
|
|
1214
|
+
l && l & 63 && (l = this.isNthWeekdayOfMonth(this.year, this.month, a, l) ? 1 : 0), e.useAndLogic ? o = o && l : !t.domAndDow && !e.starDOM ? o = o || l : o = o && l;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (!o) return !1;
|
|
1218
|
+
}
|
|
1219
|
+
return !0;
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
function R(s) {
|
|
1223
|
+
if (s === void 0 && (s = {}), delete s.name, s.legacyMode !== void 0 && s.domAndDow === void 0 ? s.domAndDow = !s.legacyMode : s.domAndDow === void 0 && (s.domAndDow = !1), s.legacyMode = !s.domAndDow, s.paused = s.paused === void 0 ? !1 : s.paused, s.maxRuns = s.maxRuns === void 0 ? Infinity : s.maxRuns, s.catch = s.catch === void 0 ? !1 : s.catch, s.interval = s.interval === void 0 ? 0 : parseInt(s.interval.toString(), 10), s.utcOffset = s.utcOffset === void 0 ? void 0 : parseInt(s.utcOffset.toString(), 10), s.dayOffset = s.dayOffset === void 0 ? 0 : parseInt(s.dayOffset.toString(), 10), s.unref = s.unref === void 0 ? !1 : s.unref, s.mode = s.mode === void 0 ? "auto" : s.mode, s.alternativeWeekdays = s.alternativeWeekdays === void 0 ? !1 : s.alternativeWeekdays, s.sloppyRanges = s.sloppyRanges === void 0 ? !1 : s.sloppyRanges, ![
|
|
1224
|
+
"auto",
|
|
1225
|
+
"5-part",
|
|
1226
|
+
"6-part",
|
|
1227
|
+
"7-part",
|
|
1228
|
+
"5-or-6-parts",
|
|
1229
|
+
"6-or-7-parts"
|
|
1230
|
+
].includes(s.mode)) throw new Error("CronOptions: mode must be one of 'auto', '5-part', '6-part', '7-part', '5-or-6-parts', or '6-or-7-parts'.");
|
|
1231
|
+
if (s.startAt && (s.startAt = new m(s.startAt, s.timezone)), s.stopAt && (s.stopAt = new m(s.stopAt, s.timezone)), s.interval !== null) {
|
|
1232
|
+
if (isNaN(s.interval)) throw new Error("CronOptions: Supplied value for interval is not a number");
|
|
1233
|
+
if (s.interval < 0) throw new Error("CronOptions: Supplied value for interval can not be negative");
|
|
1234
|
+
}
|
|
1235
|
+
if (s.utcOffset !== void 0) {
|
|
1236
|
+
if (isNaN(s.utcOffset)) throw new Error("CronOptions: Invalid value passed for utcOffset, should be number representing minutes offset from UTC.");
|
|
1237
|
+
if (s.utcOffset < -870 || s.utcOffset > 870) throw new Error("CronOptions: utcOffset out of bounds.");
|
|
1238
|
+
if (s.utcOffset !== void 0 && s.timezone) throw new Error("CronOptions: Combining 'utcOffset' with 'timezone' is not allowed.");
|
|
1239
|
+
}
|
|
1240
|
+
if (s.unref !== !0 && s.unref !== !1) throw new Error("CronOptions: Unref should be either true, false or undefined(false).");
|
|
1241
|
+
if (s.dayOffset !== void 0 && s.dayOffset !== 0 && isNaN(s.dayOffset)) throw new Error("CronOptions: Invalid value passed for dayOffset, should be a number representing days to offset.");
|
|
1242
|
+
return s;
|
|
1243
|
+
}
|
|
1244
|
+
function p(s) {
|
|
1245
|
+
return Object.prototype.toString.call(s) === "[object Function]" || typeof s == "function" || s instanceof Function;
|
|
1246
|
+
}
|
|
1247
|
+
function _(s) {
|
|
1248
|
+
return p(s);
|
|
1249
|
+
}
|
|
1250
|
+
function x(s) {
|
|
1251
|
+
typeof Deno < "u" && typeof Deno.unrefTimer < "u" ? Deno.unrefTimer(s) : s && typeof s.unref < "u" && s.unref();
|
|
1252
|
+
}
|
|
1253
|
+
var W = 30 * 1e3, w = [], E = class {
|
|
1254
|
+
name;
|
|
1255
|
+
options;
|
|
1256
|
+
_states;
|
|
1257
|
+
fn;
|
|
1258
|
+
getTz() {
|
|
1259
|
+
return this.options.timezone || this.options.utcOffset;
|
|
1260
|
+
}
|
|
1261
|
+
applyDayOffset(e) {
|
|
1262
|
+
if (this.options.dayOffset !== void 0 && this.options.dayOffset !== 0) {
|
|
1263
|
+
let t = this.options.dayOffset * 24 * 60 * 60 * 1e3;
|
|
1264
|
+
return new Date(e.getTime() + t);
|
|
1265
|
+
}
|
|
1266
|
+
return e;
|
|
1267
|
+
}
|
|
1268
|
+
constructor(e, t, r) {
|
|
1269
|
+
let n, i;
|
|
1270
|
+
if (p(t)) i = t;
|
|
1271
|
+
else if (typeof t == "object") n = t;
|
|
1272
|
+
else if (t !== void 0) throw new Error("Cron: Invalid argument passed for optionsIn. Should be one of function, or object (options).");
|
|
1273
|
+
if (p(r)) i = r;
|
|
1274
|
+
else if (typeof r == "object") n = r;
|
|
1275
|
+
else if (r !== void 0) throw new Error("Cron: Invalid argument passed for funcIn. Should be one of function, or object (options).");
|
|
1276
|
+
if (this.name = n?.name, this.options = R(n), this._states = {
|
|
1277
|
+
kill: !1,
|
|
1278
|
+
blocking: !1,
|
|
1279
|
+
previousRun: void 0,
|
|
1280
|
+
currentRun: void 0,
|
|
1281
|
+
once: void 0,
|
|
1282
|
+
currentTimeout: void 0,
|
|
1283
|
+
maxRuns: n ? n.maxRuns : void 0,
|
|
1284
|
+
paused: n ? n.paused : !1,
|
|
1285
|
+
pattern: new C("* * * * *", void 0, { mode: "auto" })
|
|
1286
|
+
}, e && (e instanceof Date || typeof e == "string" && e.indexOf(":") > 0) ? this._states.once = new m(e, this.getTz()) : this._states.pattern = new C(e, this.options.timezone, {
|
|
1287
|
+
mode: this.options.mode,
|
|
1288
|
+
alternativeWeekdays: this.options.alternativeWeekdays,
|
|
1289
|
+
sloppyRanges: this.options.sloppyRanges
|
|
1290
|
+
}), this.name) {
|
|
1291
|
+
if (w.find((o) => o.name === this.name)) throw new Error("Cron: Tried to initialize new named job '" + this.name + "', but name already taken.");
|
|
1292
|
+
w.push(this);
|
|
1293
|
+
}
|
|
1294
|
+
return i !== void 0 && _(i) && (this.fn = i, this.schedule()), this;
|
|
1295
|
+
}
|
|
1296
|
+
nextRun(e) {
|
|
1297
|
+
let t = this._next(e);
|
|
1298
|
+
return t ? this.applyDayOffset(t.getDate(!1)) : null;
|
|
1299
|
+
}
|
|
1300
|
+
nextRuns(e, t) {
|
|
1301
|
+
this._states.maxRuns !== void 0 && e > this._states.maxRuns && (e = this._states.maxRuns);
|
|
1302
|
+
let r = t || this._states.currentRun || void 0;
|
|
1303
|
+
return this._enumerateRuns(e, r, "next");
|
|
1304
|
+
}
|
|
1305
|
+
previousRuns(e, t) {
|
|
1306
|
+
return this._enumerateRuns(e, t || void 0, "previous");
|
|
1307
|
+
}
|
|
1308
|
+
_enumerateRuns(e, t, r) {
|
|
1309
|
+
let n = [], i = t ? new m(t, this.getTz()) : null, a = r === "next" ? this._next : this._previous;
|
|
1310
|
+
for (; e--;) {
|
|
1311
|
+
let o = a.call(this, i);
|
|
1312
|
+
if (!o) break;
|
|
1313
|
+
let h = o.getDate(!1);
|
|
1314
|
+
n.push(this.applyDayOffset(h)), i = o;
|
|
1315
|
+
}
|
|
1316
|
+
return n;
|
|
1317
|
+
}
|
|
1318
|
+
match(e) {
|
|
1319
|
+
if (this._states.once) {
|
|
1320
|
+
let r = new m(e, this.getTz());
|
|
1321
|
+
r.ms = 0;
|
|
1322
|
+
let n = new m(this._states.once, this.getTz());
|
|
1323
|
+
return n.ms = 0, r.getTime() === n.getTime();
|
|
1324
|
+
}
|
|
1325
|
+
let t = new m(e, this.getTz());
|
|
1326
|
+
return t.ms = 0, t.match(this._states.pattern, this.options);
|
|
1327
|
+
}
|
|
1328
|
+
getPattern() {
|
|
1329
|
+
if (!this._states.once) return this._states.pattern ? this._states.pattern.pattern : void 0;
|
|
1330
|
+
}
|
|
1331
|
+
getOnce() {
|
|
1332
|
+
return this._states.once ? this._states.once.getDate() : null;
|
|
1333
|
+
}
|
|
1334
|
+
isRunning() {
|
|
1335
|
+
let e = this.nextRun(this._states.currentRun), t = !this._states.paused, r = this.fn !== void 0, n = !this._states.kill;
|
|
1336
|
+
return t && r && n && e !== null;
|
|
1337
|
+
}
|
|
1338
|
+
isStopped() {
|
|
1339
|
+
return this._states.kill;
|
|
1340
|
+
}
|
|
1341
|
+
isBusy() {
|
|
1342
|
+
return this._states.blocking;
|
|
1343
|
+
}
|
|
1344
|
+
currentRun() {
|
|
1345
|
+
return this._states.currentRun ? this._states.currentRun.getDate() : null;
|
|
1346
|
+
}
|
|
1347
|
+
previousRun() {
|
|
1348
|
+
return this._states.previousRun ? this._states.previousRun.getDate() : null;
|
|
1349
|
+
}
|
|
1350
|
+
msToNext(e) {
|
|
1351
|
+
let t = this._next(e);
|
|
1352
|
+
return t ? e instanceof m || e instanceof Date ? t.getTime() - e.getTime() : t.getTime() - new m(e).getTime() : null;
|
|
1353
|
+
}
|
|
1354
|
+
stop() {
|
|
1355
|
+
this._states.kill = !0, this._states.currentTimeout && clearTimeout(this._states.currentTimeout);
|
|
1356
|
+
let e = w.indexOf(this);
|
|
1357
|
+
e >= 0 && w.splice(e, 1);
|
|
1358
|
+
}
|
|
1359
|
+
pause() {
|
|
1360
|
+
return this._states.paused = !0, !this._states.kill;
|
|
1361
|
+
}
|
|
1362
|
+
resume() {
|
|
1363
|
+
return this._states.paused = !1, !this._states.kill;
|
|
1364
|
+
}
|
|
1365
|
+
schedule(e) {
|
|
1366
|
+
if (e && this.fn) throw new Error("Cron: It is not allowed to schedule two functions using the same Croner instance.");
|
|
1367
|
+
e && (this.fn = e);
|
|
1368
|
+
let t = this.msToNext(), r = this.nextRun(this._states.currentRun);
|
|
1369
|
+
return t == null || isNaN(t) || r === null ? this : (t > W && (t = W), this._states.currentTimeout = setTimeout(() => this._checkTrigger(r), t), this._states.currentTimeout && this.options.unref && x(this._states.currentTimeout), this);
|
|
1370
|
+
}
|
|
1371
|
+
async _trigger(e) {
|
|
1372
|
+
this._states.blocking = !0, this._states.currentRun = new m(void 0, this.getTz());
|
|
1373
|
+
try {
|
|
1374
|
+
if (this.options.catch) try {
|
|
1375
|
+
this.fn !== void 0 && await this.fn(this, this.options.context);
|
|
1376
|
+
} catch (t) {
|
|
1377
|
+
if (p(this.options.catch)) try {
|
|
1378
|
+
this.options.catch(t, this);
|
|
1379
|
+
} catch {}
|
|
1380
|
+
}
|
|
1381
|
+
else this.fn !== void 0 && await this.fn(this, this.options.context);
|
|
1382
|
+
} finally {
|
|
1383
|
+
this._states.previousRun = new m(e, this.getTz()), this._states.blocking = !1;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
async trigger() {
|
|
1387
|
+
await this._trigger();
|
|
1388
|
+
}
|
|
1389
|
+
runsLeft() {
|
|
1390
|
+
return this._states.maxRuns;
|
|
1391
|
+
}
|
|
1392
|
+
_checkTrigger(e) {
|
|
1393
|
+
let t = /* @__PURE__ */ new Date(), r = !this._states.paused && t.getTime() >= e.getTime(), n = this._states.blocking && this.options.protect;
|
|
1394
|
+
r && !n ? (this._states.maxRuns !== void 0 && this._states.maxRuns--, this._trigger()) : r && n && p(this.options.protect) && setTimeout(() => this.options.protect(this), 0), this.schedule();
|
|
1395
|
+
}
|
|
1396
|
+
_next(e) {
|
|
1397
|
+
let t = !!(e || this._states.currentRun), r = !1;
|
|
1398
|
+
!e && this.options.startAt && this.options.interval && ([e, t] = this._calculatePreviousRun(e, t), r = !e), e = new m(e, this.getTz()), this.options.startAt && e && e.getTime() < this.options.startAt.getTime() && (e = this.options.startAt);
|
|
1399
|
+
let n = this._states.once || new m(e, this.getTz());
|
|
1400
|
+
return !r && n !== this._states.once && (n = n.increment(this._states.pattern, this.options, t)), this._states.once && this._states.once.getTime() <= e.getTime() || n === null || this._states.maxRuns !== void 0 && this._states.maxRuns <= 0 || this._states.kill || this.options.stopAt && n.getTime() >= this.options.stopAt.getTime() ? null : n;
|
|
1401
|
+
}
|
|
1402
|
+
_previous(e) {
|
|
1403
|
+
let t = new m(e, this.getTz());
|
|
1404
|
+
this.options.stopAt && t.getTime() > this.options.stopAt.getTime() && (t = this.options.stopAt);
|
|
1405
|
+
let r = new m(t, this.getTz());
|
|
1406
|
+
return this._states.once ? this._states.once.getTime() < t.getTime() ? this._states.once : null : (r = r.decrement(this._states.pattern, this.options), r === null || this.options.startAt && r.getTime() < this.options.startAt.getTime() ? null : r);
|
|
1407
|
+
}
|
|
1408
|
+
_calculatePreviousRun(e, t) {
|
|
1409
|
+
let r = new m(void 0, this.getTz()), n = e;
|
|
1410
|
+
if (this.options.startAt.getTime() <= r.getTime()) {
|
|
1411
|
+
n = this.options.startAt;
|
|
1412
|
+
let i = n.getTime() + this.options.interval * 1e3;
|
|
1413
|
+
for (; i <= r.getTime();) n = new m(n, this.getTz()).increment(this._states.pattern, this.options, !0), i = n.getTime() + this.options.interval * 1e3;
|
|
1414
|
+
t = !0;
|
|
1415
|
+
}
|
|
1416
|
+
return n === null && (n = void 0), [n, t];
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
//#endregion
|
|
1420
|
+
//#region src/builtins/backup-orchestrator/cron-helpers.ts
|
|
1421
|
+
/**
|
|
1422
|
+
* Cron helpers — wraps `croner` so the orchestrator + the `previewNextRuns`
|
|
1423
|
+
* cap method share one validation + next-run-computation surface.
|
|
1424
|
+
*
|
|
1425
|
+
* `croner` is the opinionated node cron library we standardize on
|
|
1426
|
+
* server-side: pure JS, native TypeScript types, supports the full
|
|
1427
|
+
* 5-field POSIX cron grammar plus seconds (6-field), DST-aware, and
|
|
1428
|
+
* exposes `nextRun()` / `nextRuns(N)` for peeking without scheduling.
|
|
1429
|
+
* The orchestrator uses the peeking API (it has its own polling
|
|
1430
|
+
* loop); future surfaces (e.g. event-driven retention sweeps) can
|
|
1431
|
+
* register an actual scheduled `Cron(pattern, fn)` and let croner
|
|
1432
|
+
* own the timer.
|
|
1433
|
+
*/
|
|
1434
|
+
/**
|
|
1435
|
+
* Validate a cron expression and return its next firing time strictly
|
|
1436
|
+
* after `after`, expressed as ms-epoch. Returns `null` when the
|
|
1437
|
+
* expression has no future runs (croner returns null e.g. for
|
|
1438
|
+
* fully-bounded patterns that have already elapsed). Throws when the
|
|
1439
|
+
* expression is syntactically invalid — callers are expected to
|
|
1440
|
+
* surface that to the operator.
|
|
1441
|
+
*/
|
|
1442
|
+
function computeNextDue(cron, after) {
|
|
1443
|
+
const next = new E(cron, { paused: true }).nextRun(new Date(after));
|
|
1444
|
+
return next ? next.getTime() : null;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Validate without computing — returns true when `cron` parses, false
|
|
1448
|
+
* otherwise. Used by the cap-router input zod schema's `.refine`
|
|
1449
|
+
* callback to reject malformed expressions at the boundary instead
|
|
1450
|
+
* of at scheduler-tick time.
|
|
1451
|
+
*/
|
|
1452
|
+
function isValidCron(cron) {
|
|
1453
|
+
try {
|
|
1454
|
+
new E(cron, { paused: true });
|
|
1455
|
+
return true;
|
|
1456
|
+
} catch {
|
|
1457
|
+
return false;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Peek the next N firing times after `after` (default: now). Used by
|
|
1462
|
+
* the admin UI's CronEditor preview pane — operators see the next
|
|
1463
|
+
* 3–5 runs as they edit the expression so a wrong pattern is
|
|
1464
|
+
* obvious before save.
|
|
1465
|
+
*/
|
|
1466
|
+
function previewNextRuns(cron, count, after) {
|
|
1467
|
+
const job = new E(cron, { paused: true });
|
|
1468
|
+
const baseline = after !== void 0 ? new Date(after) : /* @__PURE__ */ new Date();
|
|
1469
|
+
const out = [];
|
|
1470
|
+
let cursor = baseline;
|
|
1471
|
+
for (let i = 0; i < count; i++) {
|
|
1472
|
+
cursor = job.nextRun(cursor);
|
|
1473
|
+
if (!cursor) break;
|
|
1474
|
+
out.push(cursor.getTime());
|
|
1475
|
+
}
|
|
1476
|
+
return out;
|
|
1477
|
+
}
|
|
1478
|
+
//#endregion
|
|
1479
|
+
//#region src/builtins/backup-orchestrator/backup-orchestrator.addon.ts
|
|
1480
|
+
/**
|
|
1481
|
+
* `backup-orchestrator` builtin — singleton owner of the `backup` cap.
|
|
1482
|
+
*
|
|
1483
|
+
* Responsibilities:
|
|
1484
|
+
* - Schedule (cron-like timer at top of hour, daily/weekly cadence)
|
|
1485
|
+
* - Per-destination policy (enable/disable, retention count, label)
|
|
1486
|
+
* persisted in `backup_destination_policies` (this builtin owns
|
|
1487
|
+
* the table).
|
|
1488
|
+
* - Archive build (one tar.gz with embedded manifest, fanned out to
|
|
1489
|
+
* every selected `backups`-typed `StorageLocation` via the
|
|
1490
|
+
* consumer-facing `storage` cap's chunked upload protocol).
|
|
1491
|
+
* - Aggregate listing/restore/delete across destinations — operates
|
|
1492
|
+
* on the per-location `manifests.json` index file, not the
|
|
1493
|
+
* archives directly.
|
|
1494
|
+
*
|
|
1495
|
+
* Storage routing: destinations are
|
|
1496
|
+
* `api.storage.listLocations({ type: 'backups' })` — every operator-
|
|
1497
|
+
* visible `backups` location IS a destination. The orchestrator joins
|
|
1498
|
+
* that list with the `backup_destination_policies` table to surface
|
|
1499
|
+
* `enabled` / `retentionCount` / `label`. The actual archive bytes
|
|
1500
|
+
* travel through `api.storage.beginUpload` / `writeChunk` /
|
|
1501
|
+
* `finalizeUpload`, with the staging archive built into the `cache`
|
|
1502
|
+
* location.
|
|
1503
|
+
*/
|
|
1504
|
+
/**
|
|
1505
|
+
* Hardcoded seed for newly-discovered `backups` locations. Operators
|
|
1506
|
+
* tune retention per-destination after creation via the destinations
|
|
1507
|
+
* table. Promoted from a configurable global to a constant when the
|
|
1508
|
+
* orchestrator's settings panel was retired (per-destination cron +
|
|
1509
|
+
* inline retention input on the destinations row replaces every knob
|
|
1510
|
+
* the panel used to expose).
|
|
1511
|
+
*/
|
|
1512
|
+
var DEFAULT_RETENTION_COUNT = 7;
|
|
1513
|
+
/**
|
|
1514
|
+
* Default cron stamped on a freshly-discovered `backups` location.
|
|
1515
|
+
* Operators can edit it from the destination row; setting empty in
|
|
1516
|
+
* the editor flips the destination back to manual-only. Daily 03:00
|
|
1517
|
+
* server-local mirrors the previous global default.
|
|
1518
|
+
*/
|
|
1519
|
+
var DEFAULT_CRON = "0 3 * * *";
|
|
1520
|
+
var SCHEDULE_TICK_MS = 6e4;
|
|
1521
|
+
/**
|
|
1522
|
+
* Build the human-facing archive label. Every archive always carries
|
|
1523
|
+
* the destination id and the creation date — useful in restore wizards
|
|
1524
|
+
* + cross-destination listings where UUIDs are unreadable.
|
|
1525
|
+
*
|
|
1526
|
+
* Format: `${destinationId} · ${YYYY-MM-DD HH:mm}[${suffix}]`. Suffix is
|
|
1527
|
+
* an optional operator-provided tag (e.g. "before-migration") appended
|
|
1528
|
+
* after another middot. UTC is intentional — labels are also written
|
|
1529
|
+
* into the per-location manifest which travels across timezones with
|
|
1530
|
+
* the backup.
|
|
1531
|
+
*/
|
|
1532
|
+
function formatArchiveLabel(destinationId, createdAt, suffix) {
|
|
1533
|
+
const d = new Date(createdAt);
|
|
1534
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1535
|
+
const stamp = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
|
1536
|
+
const trimmed = suffix?.trim();
|
|
1537
|
+
return trimmed ? `${destinationId} · ${stamp} · ${trimmed}` : `${destinationId} · ${stamp}`;
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Chunk size for the read-stream that feeds the chunked upload pipe.
|
|
1541
|
+
* 8 MiB is well under the WS frame limit (16 MiB on tRPC) with headroom.
|
|
1542
|
+
*/
|
|
1543
|
+
var UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
1544
|
+
/** Manifest id this addon registers under (must match `package.json`). */
|
|
1545
|
+
var ORCHESTRATOR_ADDON_ID = "backup-orchestrator";
|
|
1546
|
+
var BackupOrchestratorAddon = class extends BaseAddon {
|
|
1547
|
+
systemBackup = null;
|
|
1548
|
+
policies = null;
|
|
1549
|
+
dataDir = "";
|
|
1550
|
+
scheduleTimer = null;
|
|
1551
|
+
/**
|
|
1552
|
+
* Baseline used as the "after" anchor when a policy has a cron but
|
|
1553
|
+
* has never run yet (no `lastRunAt`). Set on orchestrator boot so a
|
|
1554
|
+
* pattern that fires every minute doesn't immediately replay every
|
|
1555
|
+
* historical "missed" run from epoch 0.
|
|
1556
|
+
*/
|
|
1557
|
+
scheduleBaselineAt = Date.now();
|
|
1558
|
+
manifestLock = new LocationManifestLock();
|
|
1559
|
+
constructor() {
|
|
1560
|
+
super({});
|
|
1561
|
+
}
|
|
1562
|
+
async onInitialize() {
|
|
1563
|
+
const envDataPath = process.env["CAMSTACK_DATA"];
|
|
1564
|
+
const fallbackLocation = await this.ctx.api.storage.resolve.query({
|
|
1565
|
+
location: "data",
|
|
1566
|
+
relativePath: ""
|
|
1567
|
+
}).catch(() => null);
|
|
1568
|
+
this.dataDir = envDataPath ?? (fallbackLocation ? parentDir(fallbackLocation) : "camstack-data");
|
|
1569
|
+
this.systemBackup = new SystemBackupService(this.dataDir, this.ctx.logger);
|
|
1570
|
+
this.policies = this.resolvePolicyService();
|
|
1571
|
+
if (this.policies) try {
|
|
1572
|
+
await this.policies.initialize();
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
this.ctx.logger.error("backup-orchestrator: policy table init failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
1575
|
+
this.policies = null;
|
|
1576
|
+
}
|
|
1577
|
+
const provider = {
|
|
1578
|
+
listDestinations: () => this.listDestinations(),
|
|
1579
|
+
list: () => this.aggregatedList(),
|
|
1580
|
+
listLocations: () => this.systemBackup.statLocations(),
|
|
1581
|
+
trigger: (input) => this.triggerBackup(input),
|
|
1582
|
+
getEntries: (input) => this.getEntries(input),
|
|
1583
|
+
restore: (input) => this.scheduleRestore(input),
|
|
1584
|
+
delete: (input) => this.deleteArchive(input),
|
|
1585
|
+
listArchives: (input) => this.listArchives(input),
|
|
1586
|
+
upsertDestinationPolicy: (input) => this.upsertDestinationPolicy(input),
|
|
1587
|
+
previewSchedule: (input) => this.previewSchedule(input)
|
|
1588
|
+
};
|
|
1589
|
+
this.startScheduleTimer();
|
|
1590
|
+
this.ctx.logger.info("Backup orchestrator initialized", { meta: {
|
|
1591
|
+
dataDir: this.dataDir,
|
|
1592
|
+
policies: this.policies !== null
|
|
1593
|
+
} });
|
|
1594
|
+
return [{
|
|
1595
|
+
capability: backupCapability,
|
|
1596
|
+
provider
|
|
1597
|
+
}];
|
|
1598
|
+
}
|
|
1599
|
+
async onShutdown() {
|
|
1600
|
+
this.stopScheduleTimer();
|
|
1601
|
+
this.systemBackup = null;
|
|
1602
|
+
this.policies = null;
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Aggregate destinations from `api.storage.listLocations({ type: 'backups' })`
|
|
1606
|
+
* joined with the per-destination policy table. New locations get an
|
|
1607
|
+
* `ensureDefault` policy seeded transparently (enabled=true,
|
|
1608
|
+
* retentionCount=defaultRetentionCount). Pre-existing operator-edited
|
|
1609
|
+
* policies are preserved by the `ensureDefault` idempotency.
|
|
1610
|
+
*/
|
|
1611
|
+
async listDestinations() {
|
|
1612
|
+
const locations = await this.fetchBackupLocations();
|
|
1613
|
+
const policies = await this.loadPoliciesIndexed(locations.map((l) => l.id));
|
|
1614
|
+
const lastSuccess = await Promise.all(locations.map(async (loc) => {
|
|
1615
|
+
try {
|
|
1616
|
+
const manifest = await readLocationManifest(this.storageApi(), loc.id);
|
|
1617
|
+
return [loc.id, latestArchiveOf(manifest)];
|
|
1618
|
+
} catch {
|
|
1619
|
+
return [loc.id, void 0];
|
|
1620
|
+
}
|
|
1621
|
+
}));
|
|
1622
|
+
const lastSuccessByLoc = new Map(lastSuccess);
|
|
1623
|
+
return locations.map((loc) => this.toDestinationWire(loc, policies.get(loc.id), lastSuccessByLoc.get(loc.id)));
|
|
1624
|
+
}
|
|
1625
|
+
async fetchBackupLocations() {
|
|
1626
|
+
try {
|
|
1627
|
+
return await this.ctx.api.storage.listLocations.query({ type: "backups" });
|
|
1628
|
+
} catch (err) {
|
|
1629
|
+
this.ctx.logger.error("backup-orchestrator: listLocations failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
1630
|
+
return [];
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Load every persisted policy and key it by `locationId`, while
|
|
1635
|
+
* lazily seeding a default for any `locationId` we haven't seen
|
|
1636
|
+
* before. Idempotent — `ensureDefault` no-ops on already-persisted
|
|
1637
|
+
* rows. Falls back to the in-memory default policy when the policy
|
|
1638
|
+
* service isn't wired (no settings-store backend available).
|
|
1639
|
+
*/
|
|
1640
|
+
async loadPoliciesIndexed(locationIds) {
|
|
1641
|
+
const out = /* @__PURE__ */ new Map();
|
|
1642
|
+
if (!this.policies) {
|
|
1643
|
+
for (const id of locationIds) out.set(id, this.fallbackPolicy(id));
|
|
1644
|
+
return out;
|
|
1645
|
+
}
|
|
1646
|
+
const persisted = await this.policies.list().catch((err) => {
|
|
1647
|
+
this.ctx.logger.warn("backup-orchestrator: policy list failed — using defaults", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
1648
|
+
return [];
|
|
1649
|
+
});
|
|
1650
|
+
for (const p of persisted) out.set(p.locationId, p);
|
|
1651
|
+
for (const id of locationIds) {
|
|
1652
|
+
if (out.has(id)) continue;
|
|
1653
|
+
try {
|
|
1654
|
+
const fresh = await this.policies.ensureDefault(id, {
|
|
1655
|
+
retentionCount: DEFAULT_RETENTION_COUNT,
|
|
1656
|
+
cron: DEFAULT_CRON
|
|
1657
|
+
});
|
|
1658
|
+
out.set(id, fresh);
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
this.ctx.logger.warn("backup-orchestrator: ensureDefault failed", { meta: {
|
|
1661
|
+
locationId: id,
|
|
1662
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1663
|
+
} });
|
|
1664
|
+
out.set(id, this.fallbackPolicy(id));
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return out;
|
|
1668
|
+
}
|
|
1669
|
+
fallbackPolicy(locationId) {
|
|
1670
|
+
return {
|
|
1671
|
+
locationId,
|
|
1672
|
+
enabled: true,
|
|
1673
|
+
retentionCount: DEFAULT_RETENTION_COUNT,
|
|
1674
|
+
cron: DEFAULT_CRON
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
toDestinationWire(loc, policy, latest) {
|
|
1678
|
+
const effective = policy ?? this.fallbackPolicy(loc.id);
|
|
1679
|
+
const subId = extractSlug(loc.id);
|
|
1680
|
+
const displayName = effective.label && effective.label.length > 0 ? effective.label : loc.displayName;
|
|
1681
|
+
return {
|
|
1682
|
+
id: loc.id,
|
|
1683
|
+
addonId: ORCHESTRATOR_ADDON_ID,
|
|
1684
|
+
subId,
|
|
1685
|
+
displayName,
|
|
1686
|
+
kind: kindFromProviderId(loc.providerId),
|
|
1687
|
+
triggerSupported: effective.enabled,
|
|
1688
|
+
restoreSupported: true,
|
|
1689
|
+
enabled: effective.enabled,
|
|
1690
|
+
retentionCount: effective.retentionCount,
|
|
1691
|
+
...effective.label !== void 0 ? { label: effective.label } : {},
|
|
1692
|
+
...latest !== void 0 ? { lastSuccessAt: latest.createdAt } : {},
|
|
1693
|
+
...latest !== void 0 ? { lastSuccessSizeBytes: latest.sizeBytes } : {},
|
|
1694
|
+
...effective.cron !== void 0 ? { cron: effective.cron } : {},
|
|
1695
|
+
...effective.lastRunAt !== void 0 ? { lastRunAt: effective.lastRunAt } : {},
|
|
1696
|
+
...effective.cron !== void 0 && effective.cron.trim().length > 0 ? { nextRunAt: this.safeNextDue(effective.cron, effective.lastRunAt) } : {}
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Compute next-due ms-epoch for a cron, or `undefined` on parse error.
|
|
1701
|
+
* Catches so a single malformed cron doesn't blank out the whole
|
|
1702
|
+
* destinations list at the cap surface.
|
|
1703
|
+
*/
|
|
1704
|
+
safeNextDue(cron, after) {
|
|
1705
|
+
try {
|
|
1706
|
+
return computeNextDue(cron, after ?? this.scheduleBaselineAt) ?? void 0;
|
|
1707
|
+
} catch {
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Aggregate listing — read every `backups` location's `manifests.json`
|
|
1713
|
+
* and return one wire entry per archive, tagged with `destinationId`.
|
|
1714
|
+
* Failures on individual locations are logged and skipped so a single
|
|
1715
|
+
* unreachable destination doesn't blank out the whole UI.
|
|
1716
|
+
*/
|
|
1717
|
+
async aggregatedList() {
|
|
1718
|
+
const locations = await this.fetchBackupLocations();
|
|
1719
|
+
const out = [];
|
|
1720
|
+
for (const loc of locations) {
|
|
1721
|
+
let manifest;
|
|
1722
|
+
try {
|
|
1723
|
+
manifest = await readLocationManifest(this.storageApi(), loc.id);
|
|
1724
|
+
} catch (err) {
|
|
1725
|
+
this.ctx.logger.warn("backup list: manifest read failed", { meta: {
|
|
1726
|
+
locationId: loc.id,
|
|
1727
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1728
|
+
} });
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
for (const a of manifest.archives) {
|
|
1732
|
+
const entry = {
|
|
1733
|
+
id: a.id,
|
|
1734
|
+
destinationId: loc.id,
|
|
1735
|
+
createdAt: a.createdAt,
|
|
1736
|
+
sizeBytes: a.sizeBytes,
|
|
1737
|
+
...a.label !== void 0 ? { label: a.label } : {},
|
|
1738
|
+
locations: [...a.manifest.locations]
|
|
1739
|
+
};
|
|
1740
|
+
out.push(entry);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
return out;
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Build a single archive in the `cache` storage location, then push
|
|
1747
|
+
* it to every selected destination via the storage cap's chunked
|
|
1748
|
+
* upload protocol. Per-destination errors are logged and counted
|
|
1749
|
+
* via allSettled semantics — one bad sink doesn't kill the run. The
|
|
1750
|
+
* cache file is deleted in `finally` so a crash mid-fanout doesn't
|
|
1751
|
+
* leak.
|
|
1752
|
+
*/
|
|
1753
|
+
async triggerBackup(input) {
|
|
1754
|
+
if (!this.systemBackup) throw new Error("orchestrator not initialized");
|
|
1755
|
+
const targets = await this.resolveTargets(input?.destinations);
|
|
1756
|
+
if (targets.length === 0) {
|
|
1757
|
+
this.ctx.logger.warn("backup trigger: no destinations selected");
|
|
1758
|
+
return [];
|
|
1759
|
+
}
|
|
1760
|
+
const policies = await this.loadPoliciesIndexed(targets.map((t) => t.id));
|
|
1761
|
+
const archiveId = randomUUID();
|
|
1762
|
+
const archiveFilename = `${archiveId}.tar.gz`;
|
|
1763
|
+
const cacheRelativePath = `${archiveId}.tar.gz`;
|
|
1764
|
+
const cachePath = await this.ctx.api.storage.resolve.query({
|
|
1765
|
+
location: "cache",
|
|
1766
|
+
relativePath: cacheRelativePath
|
|
1767
|
+
});
|
|
1768
|
+
const result = await this.systemBackup.createArchive({
|
|
1769
|
+
archivePath: cachePath,
|
|
1770
|
+
...input?.locations ? { locations: input.locations } : {}
|
|
1771
|
+
});
|
|
1772
|
+
try {
|
|
1773
|
+
const entries = [];
|
|
1774
|
+
const createdAt = Date.now();
|
|
1775
|
+
for (const dest of targets) try {
|
|
1776
|
+
const policy = policies.get(dest.id) ?? this.fallbackPolicy(dest.id);
|
|
1777
|
+
const stored = await this.uploadAndIndex({
|
|
1778
|
+
locationId: dest.id,
|
|
1779
|
+
archiveId,
|
|
1780
|
+
archiveFilename,
|
|
1781
|
+
cachePath,
|
|
1782
|
+
sizeBytes: result.sizeBytes,
|
|
1783
|
+
archiveManifest: result.manifest,
|
|
1784
|
+
retentionCount: policy.retentionCount,
|
|
1785
|
+
createdAt,
|
|
1786
|
+
label: formatArchiveLabel(dest.id, createdAt, input?.label)
|
|
1787
|
+
});
|
|
1788
|
+
entries.push(stored);
|
|
1789
|
+
this.ctx.eventBus.emit({
|
|
1790
|
+
id: randomUUID(),
|
|
1791
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1792
|
+
source: {
|
|
1793
|
+
type: "addon",
|
|
1794
|
+
id: "backup-orchestrator"
|
|
1795
|
+
},
|
|
1796
|
+
category: EventCategory.BackupCompleted,
|
|
1797
|
+
data: {
|
|
1798
|
+
destinationId: dest.id,
|
|
1799
|
+
backupId: stored.id,
|
|
1800
|
+
sizeMB: Math.round((stored.sizeBytes ?? 0) / (1024 * 1024))
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
} catch (err) {
|
|
1804
|
+
this.ctx.logger.error("backup trigger: destination failed", { meta: {
|
|
1805
|
+
destinationId: dest.id,
|
|
1806
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1807
|
+
} });
|
|
1808
|
+
}
|
|
1809
|
+
return entries;
|
|
1810
|
+
} finally {
|
|
1811
|
+
try {
|
|
1812
|
+
await fsp.rm(cachePath, { force: true });
|
|
1813
|
+
} catch {}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Push the staged archive to a single destination via chunked
|
|
1818
|
+
* upload, then update the per-location manifest. Applies the
|
|
1819
|
+
* destination's retention policy as part of the same lock-protected
|
|
1820
|
+
* manifest mutation so the deletes never race with a concurrent
|
|
1821
|
+
* append.
|
|
1822
|
+
*
|
|
1823
|
+
* Throws on upload / manifest failure — caller logs and continues to
|
|
1824
|
+
* the next destination so a single bad sink doesn't fail the run.
|
|
1825
|
+
*/
|
|
1826
|
+
async uploadAndIndex(input) {
|
|
1827
|
+
const { uploadId } = await this.ctx.api.storage.beginUpload.mutate({
|
|
1828
|
+
location: input.locationId,
|
|
1829
|
+
relativePath: input.archiveFilename,
|
|
1830
|
+
sizeBytes: input.sizeBytes
|
|
1831
|
+
});
|
|
1832
|
+
try {
|
|
1833
|
+
let offset = 0;
|
|
1834
|
+
const stream = fs.createReadStream(input.cachePath, { highWaterMark: UPLOAD_CHUNK_BYTES });
|
|
1835
|
+
for await (const rawChunk of stream) {
|
|
1836
|
+
const chunk = toBufferChunk(rawChunk);
|
|
1837
|
+
const data = new Uint8Array(chunk.byteLength);
|
|
1838
|
+
data.set(chunk);
|
|
1839
|
+
await this.ctx.api.storage.writeChunk.mutate({
|
|
1840
|
+
uploadId,
|
|
1841
|
+
offset,
|
|
1842
|
+
data
|
|
1843
|
+
});
|
|
1844
|
+
offset += data.byteLength;
|
|
1845
|
+
}
|
|
1846
|
+
await this.ctx.api.storage.finalizeUpload.mutate({ uploadId });
|
|
1847
|
+
} catch (err) {
|
|
1848
|
+
try {
|
|
1849
|
+
await this.ctx.api.storage.abortUpload.mutate({ uploadId });
|
|
1850
|
+
} catch {}
|
|
1851
|
+
throw err;
|
|
1852
|
+
}
|
|
1853
|
+
const entry = buildArchiveEntry({
|
|
1854
|
+
id: input.archiveId,
|
|
1855
|
+
filename: input.archiveFilename,
|
|
1856
|
+
createdAt: input.createdAt,
|
|
1857
|
+
sizeBytes: input.sizeBytes,
|
|
1858
|
+
label: input.label,
|
|
1859
|
+
manifest: input.archiveManifest
|
|
1860
|
+
});
|
|
1861
|
+
await this.manifestLock.run(input.locationId, async () => {
|
|
1862
|
+
const { keep, drop } = applyRetention([...(await readLocationManifest(this.storageApi(), input.locationId)).archives, entry], input.retentionCount);
|
|
1863
|
+
for (const stale of drop) try {
|
|
1864
|
+
await this.ctx.api.storage.delete.mutate({
|
|
1865
|
+
location: input.locationId,
|
|
1866
|
+
relativePath: stale.filename
|
|
1867
|
+
});
|
|
1868
|
+
} catch (err) {
|
|
1869
|
+
this.ctx.logger.warn("backup retention: storage delete failed (continuing)", { meta: {
|
|
1870
|
+
destinationId: input.locationId,
|
|
1871
|
+
backupId: stale.id,
|
|
1872
|
+
filename: stale.filename,
|
|
1873
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1874
|
+
} });
|
|
1875
|
+
}
|
|
1876
|
+
const next = {
|
|
1877
|
+
version: 1,
|
|
1878
|
+
archives: keep
|
|
1879
|
+
};
|
|
1880
|
+
await writeLocationManifest(this.storageApi(), input.locationId, next);
|
|
1881
|
+
});
|
|
1882
|
+
return {
|
|
1883
|
+
id: input.archiveId,
|
|
1884
|
+
destinationId: input.locationId,
|
|
1885
|
+
createdAt: input.createdAt,
|
|
1886
|
+
sizeBytes: input.sizeBytes,
|
|
1887
|
+
label: input.label,
|
|
1888
|
+
locations: [...input.archiveManifest.locations]
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* List archives at a single destination — newest-first. Reads only
|
|
1893
|
+
* the per-location `manifests.json` (no per-archive tarball read);
|
|
1894
|
+
* the orchestrator already snapshots each archive's locations into
|
|
1895
|
+
* the manifest at upload time.
|
|
1896
|
+
*/
|
|
1897
|
+
async listArchives(input) {
|
|
1898
|
+
let manifest;
|
|
1899
|
+
try {
|
|
1900
|
+
manifest = await readLocationManifest(this.storageApi(), input.destinationId);
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
this.ctx.logger.warn("backup listArchives: manifest read failed", { meta: {
|
|
1903
|
+
destinationId: input.destinationId,
|
|
1904
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1905
|
+
} });
|
|
1906
|
+
return [];
|
|
1907
|
+
}
|
|
1908
|
+
return [...manifest.archives].toSorted((a, b) => b.createdAt - a.createdAt).map((a) => ({
|
|
1909
|
+
id: a.id,
|
|
1910
|
+
filename: a.filename,
|
|
1911
|
+
createdAt: a.createdAt,
|
|
1912
|
+
sizeBytes: a.sizeBytes,
|
|
1913
|
+
...a.label !== void 0 ? { label: a.label } : {},
|
|
1914
|
+
locations: [...a.manifest.locations]
|
|
1915
|
+
}));
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Upsert the per-destination policy row. Used by the admin-UI
|
|
1919
|
+
* destinations table — toggle enabled / change retention / set
|
|
1920
|
+
* label / set cron schedule. No-ops with a logged warning if the
|
|
1921
|
+
* settings backend lacks structured-table support (no persistence
|
|
1922
|
+
* available).
|
|
1923
|
+
*
|
|
1924
|
+
* Validates `cron` server-side: a malformed expression rejects the
|
|
1925
|
+
* upsert with an actionable error so the operator sees the problem
|
|
1926
|
+
* at save time instead of at scheduler-tick time. Empty / undefined
|
|
1927
|
+
* `cron` clears any prior schedule (manual-only).
|
|
1928
|
+
*/
|
|
1929
|
+
async upsertDestinationPolicy(input) {
|
|
1930
|
+
if (!this.policies) {
|
|
1931
|
+
this.ctx.logger.warn("backup upsertDestinationPolicy: no settings-store backend — change will not persist", { meta: { locationId: input.locationId } });
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
const cron = typeof input.cron === "string" ? input.cron.trim() : "";
|
|
1935
|
+
if (cron.length > 0 && !isValidCron(cron)) throw new Error(`Invalid cron expression: "${cron}"`);
|
|
1936
|
+
const existing = await this.policies.get(input.locationId).catch(() => null);
|
|
1937
|
+
await this.policies.upsert({
|
|
1938
|
+
locationId: input.locationId,
|
|
1939
|
+
enabled: input.enabled,
|
|
1940
|
+
retentionCount: input.retentionCount,
|
|
1941
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
1942
|
+
...cron.length > 0 ? { cron } : {},
|
|
1943
|
+
...existing?.lastRunAt !== void 0 ? { lastRunAt: existing.lastRunAt } : {}
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* Validate a cron expression and peek the next N firing times. The
|
|
1948
|
+
* admin UI's CronEditor calls this on every keystroke (debounced)
|
|
1949
|
+
* to render a live "next-run" preview — invalid expressions surface
|
|
1950
|
+
* the parse error inline instead of waiting for save.
|
|
1951
|
+
*/
|
|
1952
|
+
async previewSchedule(input) {
|
|
1953
|
+
const trimmed = input.cron.trim();
|
|
1954
|
+
if (trimmed.length === 0) return {
|
|
1955
|
+
ok: false,
|
|
1956
|
+
error: "Cron expression is empty",
|
|
1957
|
+
nextRuns: []
|
|
1958
|
+
};
|
|
1959
|
+
try {
|
|
1960
|
+
return {
|
|
1961
|
+
ok: true,
|
|
1962
|
+
nextRuns: previewNextRuns(trimmed, input.count ?? 5)
|
|
1963
|
+
};
|
|
1964
|
+
} catch (err) {
|
|
1965
|
+
return {
|
|
1966
|
+
ok: false,
|
|
1967
|
+
error: err instanceof Error ? err.message : "Invalid cron expression",
|
|
1968
|
+
nextRuns: []
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
async getEntries(input) {
|
|
1973
|
+
try {
|
|
1974
|
+
return (await readLocationManifest(this.storageApi(), input.destinationId)).archives.find((a) => a.id === input.backupId)?.manifest ?? null;
|
|
1975
|
+
} catch (err) {
|
|
1976
|
+
this.ctx.logger.warn("backup getEntries: manifest read failed", { meta: {
|
|
1977
|
+
destinationId: input.destinationId,
|
|
1978
|
+
backupId: input.backupId,
|
|
1979
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1980
|
+
} });
|
|
1981
|
+
return null;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
async deleteArchive(input) {
|
|
1985
|
+
await this.manifestLock.run(input.destinationId, async () => {
|
|
1986
|
+
const manifest = await readLocationManifest(this.storageApi(), input.destinationId);
|
|
1987
|
+
const target = manifest.archives.find((a) => a.id === input.backupId);
|
|
1988
|
+
if (!target) throw new Error(`backup delete: backup "${input.backupId}" not found at "${input.destinationId}"`);
|
|
1989
|
+
try {
|
|
1990
|
+
await this.ctx.api.storage.delete.mutate({
|
|
1991
|
+
location: input.destinationId,
|
|
1992
|
+
relativePath: target.filename
|
|
1993
|
+
});
|
|
1994
|
+
} catch (err) {
|
|
1995
|
+
this.ctx.logger.warn("backup delete: storage delete failed (continuing manifest cleanup)", { meta: {
|
|
1996
|
+
destinationId: input.destinationId,
|
|
1997
|
+
filename: target.filename,
|
|
1998
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1999
|
+
} });
|
|
2000
|
+
}
|
|
2001
|
+
const next = {
|
|
2002
|
+
version: 1,
|
|
2003
|
+
archives: manifest.archives.filter((a) => a.id !== input.backupId)
|
|
2004
|
+
};
|
|
2005
|
+
await writeLocationManifest(this.storageApi(), input.destinationId, next);
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Schedule a restore for the next boot. Pulls the archive bytes
|
|
2010
|
+
* down from the destination's storage location via the chunked-
|
|
2011
|
+
* download protocol into the `cache` location, then writes the
|
|
2012
|
+
* usual restore marker against the cached path so the boot-time
|
|
2013
|
+
* apply hook (in server-backend launcher) extracts before any
|
|
2014
|
+
* addon starts.
|
|
2015
|
+
*
|
|
2016
|
+
* `locations` (optional) is propagated into the marker as a
|
|
2017
|
+
* whitelist — only those top-level locations get extracted.
|
|
2018
|
+
*/
|
|
2019
|
+
async scheduleRestore(input) {
|
|
2020
|
+
if (!this.systemBackup) throw new Error("orchestrator not initialized");
|
|
2021
|
+
const entry = (await readLocationManifest(this.storageApi(), input.destinationId)).archives.find((a) => a.id === input.backupId);
|
|
2022
|
+
if (!entry) throw new Error(`backup restore: backup "${input.backupId}" not found at location "${input.destinationId}"`);
|
|
2023
|
+
const cachePath = await this.ctx.api.storage.resolve.query({
|
|
2024
|
+
location: "cache",
|
|
2025
|
+
relativePath: `restore-${input.backupId}.tar.gz`
|
|
2026
|
+
});
|
|
2027
|
+
await downloadArchiveToCache({
|
|
2028
|
+
api: this.ctx.api.storage,
|
|
2029
|
+
locationId: input.destinationId,
|
|
2030
|
+
filename: entry.filename,
|
|
2031
|
+
cachePath,
|
|
2032
|
+
chunkBytes: UPLOAD_CHUNK_BYTES,
|
|
2033
|
+
logger: this.ctx.logger
|
|
2034
|
+
});
|
|
2035
|
+
this.systemBackup.scheduleRestoreMarker({
|
|
2036
|
+
archivePath: cachePath,
|
|
2037
|
+
source: input.backupId,
|
|
2038
|
+
...input.locations ? { locations: input.locations } : {}
|
|
2039
|
+
});
|
|
2040
|
+
this.ctx.eventBus.emit({
|
|
2041
|
+
id: randomUUID(),
|
|
2042
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2043
|
+
source: {
|
|
2044
|
+
type: "addon",
|
|
2045
|
+
id: "backup-orchestrator"
|
|
2046
|
+
},
|
|
2047
|
+
category: EventCategory.BackupRestored,
|
|
2048
|
+
data: {
|
|
2049
|
+
backupId: input.backupId,
|
|
2050
|
+
destinationId: input.destinationId
|
|
2051
|
+
}
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Resolve a target destination set: caller-provided list wins;
|
|
2056
|
+
* otherwise fall back to every destination flagged as enabled in
|
|
2057
|
+
* its policy row (`triggerSupported !== false` after the policy
|
|
2058
|
+
* join). The old "global enabledDestinations override" was retired
|
|
2059
|
+
* along with the orchestrator settings panel — per-destination
|
|
2060
|
+
* `enabled` toggles in the destinations table are now the only
|
|
2061
|
+
* routing knob.
|
|
2062
|
+
*/
|
|
2063
|
+
async resolveTargets(requested) {
|
|
2064
|
+
const all = await this.listDestinations();
|
|
2065
|
+
if (requested && requested.length > 0) return all.filter((d) => requested.includes(d.id));
|
|
2066
|
+
return all.filter((d) => d.triggerSupported !== false);
|
|
2067
|
+
}
|
|
2068
|
+
startScheduleTimer() {
|
|
2069
|
+
if (this.scheduleTimer) return;
|
|
2070
|
+
this.scheduleTimer = setInterval(() => {
|
|
2071
|
+
this.onScheduleTick();
|
|
2072
|
+
}, SCHEDULE_TICK_MS);
|
|
2073
|
+
}
|
|
2074
|
+
stopScheduleTimer() {
|
|
2075
|
+
if (!this.scheduleTimer) return;
|
|
2076
|
+
clearInterval(this.scheduleTimer);
|
|
2077
|
+
this.scheduleTimer = null;
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Per-destination cron-based scheduling. Walks every enabled
|
|
2081
|
+
* destination policy that carries a `cron` expression, computes the
|
|
2082
|
+
* next due time after `lastRunAt` (or the orchestrator's first-tick
|
|
2083
|
+
* baseline if never run), and fires a backup targeting that single
|
|
2084
|
+
* destination when due. Each destination keeps its own cadence —
|
|
2085
|
+
* an offsite SFTP can run weekly while local stays nightly without
|
|
2086
|
+
* any global setting.
|
|
2087
|
+
*
|
|
2088
|
+
* Errors per destination are isolated; one misconfigured cron
|
|
2089
|
+
* doesn't block the others. The policy's `lastRunAt` is updated
|
|
2090
|
+
* BEFORE the trigger to dedupe (the tick runs every
|
|
2091
|
+
* SCHEDULE_TICK_MS — without the up-front update a long-running
|
|
2092
|
+
* upload could overlap with the next tick's same-due check).
|
|
2093
|
+
*/
|
|
2094
|
+
async onScheduleTick() {
|
|
2095
|
+
if (!this.policies) return;
|
|
2096
|
+
const policies = await this.policies.list();
|
|
2097
|
+
const now = Date.now();
|
|
2098
|
+
for (const p of policies) {
|
|
2099
|
+
if (!p.enabled || !p.cron || p.cron.trim().length === 0) continue;
|
|
2100
|
+
let due;
|
|
2101
|
+
try {
|
|
2102
|
+
due = computeNextDue(p.cron, p.lastRunAt ?? this.scheduleBaselineAt);
|
|
2103
|
+
} catch (err) {
|
|
2104
|
+
this.ctx.logger.warn("Invalid cron — skipping destination", { meta: {
|
|
2105
|
+
locationId: p.locationId,
|
|
2106
|
+
cron: p.cron,
|
|
2107
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2108
|
+
} });
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
if (due === null || due > now) continue;
|
|
2112
|
+
const stamped = {
|
|
2113
|
+
...p,
|
|
2114
|
+
lastRunAt: now
|
|
2115
|
+
};
|
|
2116
|
+
try {
|
|
2117
|
+
await this.policies.upsert(stamped);
|
|
2118
|
+
} catch (err) {
|
|
2119
|
+
this.ctx.logger.warn("Failed to persist scheduled-run timestamp", { meta: {
|
|
2120
|
+
locationId: p.locationId,
|
|
2121
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2122
|
+
} });
|
|
2123
|
+
}
|
|
2124
|
+
try {
|
|
2125
|
+
this.ctx.logger.info("Scheduled backup firing for destination", { meta: {
|
|
2126
|
+
locationId: p.locationId,
|
|
2127
|
+
cron: p.cron,
|
|
2128
|
+
dueAt: due
|
|
2129
|
+
} });
|
|
2130
|
+
await this.triggerBackup({ destinations: [p.locationId] });
|
|
2131
|
+
} catch (err) {
|
|
2132
|
+
this.ctx.logger.error("Scheduled backup failed", { meta: {
|
|
2133
|
+
locationId: p.locationId,
|
|
2134
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2135
|
+
} });
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
resolvePolicyService() {
|
|
2140
|
+
const reg = this.ctx.kernel.capabilityRegistry;
|
|
2141
|
+
if (!reg) return null;
|
|
2142
|
+
const backend = reg.getSingleton("settings-store");
|
|
2143
|
+
if (!backend) {
|
|
2144
|
+
this.ctx.logger.warn("backup-orchestrator: no settings-store provider — destination policies will not persist");
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
if (!backend.ensureTable || !backend.tableInsert) {
|
|
2148
|
+
this.ctx.logger.warn("backup-orchestrator: settings backend lacks structured-table support — destination policies will not persist");
|
|
2149
|
+
return null;
|
|
2150
|
+
}
|
|
2151
|
+
return new BackupDestinationPolicyService(backend);
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Narrow `ctx.api.storage` to the subset the manifest helpers need.
|
|
2155
|
+
* Lets the helpers be unit-tested with a Map-backed fake without
|
|
2156
|
+
* pulling in the full `AddonApi` shape.
|
|
2157
|
+
*/
|
|
2158
|
+
storageApi() {
|
|
2159
|
+
return this.ctx.api.storage;
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2162
|
+
/**
|
|
2163
|
+
* Map a `storage-provider` providerId to an operator-friendly `kind`
|
|
2164
|
+
* the admin UI uses for destination cards. Falls back to the raw
|
|
2165
|
+
* provider id so unknown providers still surface (operator sees
|
|
2166
|
+
* `webdav-storage` instead of a blank slot).
|
|
2167
|
+
*/
|
|
2168
|
+
function kindFromProviderId(providerId) {
|
|
2169
|
+
switch (providerId) {
|
|
2170
|
+
case "filesystem-storage": return "local";
|
|
2171
|
+
case "s3-storage": return "s3";
|
|
2172
|
+
case "sftp-storage": return "sftp";
|
|
2173
|
+
case "webdav-storage": return "webdav";
|
|
2174
|
+
default: return providerId;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Extract the slug part of a `<type>:<slug>` storage-location id.
|
|
2179
|
+
* Defensive: if the id doesn't contain a colon (shouldn't happen given
|
|
2180
|
+
* the regex on `StorageLocationSchema.id`) we return the whole string.
|
|
2181
|
+
*/
|
|
2182
|
+
function extractSlug(id) {
|
|
2183
|
+
const colon = id.indexOf(":");
|
|
2184
|
+
return colon < 0 ? id : id.slice(colon + 1);
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Narrow a `for await` chunk from `fs.createReadStream` to a
|
|
2188
|
+
* `Buffer`. The stream yields `string | Buffer` per its public type
|
|
2189
|
+
* even though the no-encoding overload always emits `Buffer`. The
|
|
2190
|
+
* runtime check + clear error keeps eslint's no-unsafe-* rules happy
|
|
2191
|
+
* without resorting to `as Buffer`.
|
|
2192
|
+
*/
|
|
2193
|
+
function toBufferChunk(value) {
|
|
2194
|
+
if (Buffer.isBuffer(value)) return value;
|
|
2195
|
+
if (value instanceof Uint8Array) return value;
|
|
2196
|
+
throw new Error("backup upload: unexpected non-binary chunk from read stream");
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Return the most-recent archive in a location manifest, or undefined
|
|
2200
|
+
* when the location has no archives yet. Pure transform — no I/O.
|
|
2201
|
+
*/
|
|
2202
|
+
function latestArchiveOf(manifest) {
|
|
2203
|
+
if (manifest.archives.length === 0) return void 0;
|
|
2204
|
+
let newest = manifest.archives[0];
|
|
2205
|
+
for (const a of manifest.archives) if (a.createdAt > newest.createdAt) newest = a;
|
|
2206
|
+
return newest;
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Tiny `path.dirname`-equivalent that only depends on `/` and `\`.
|
|
2210
|
+
* Avoids a `node:path` import in this module purely so the
|
|
2211
|
+
* destination-policy.ts and manifest-store.ts neighbours stay node-
|
|
2212
|
+
* builtin-free at the import surface.
|
|
2213
|
+
*/
|
|
2214
|
+
function parentDir(p) {
|
|
2215
|
+
const sep = p.includes("/") ? "/" : "\\";
|
|
2216
|
+
const i = p.lastIndexOf(sep);
|
|
2217
|
+
return i <= 0 ? "" : p.slice(0, i);
|
|
2218
|
+
}
|
|
2219
|
+
//#endregion
|
|
2220
|
+
export { BackupOrchestratorAddon, BackupOrchestratorAddon as default };
|