@camstack/system 1.2.328 → 1.2.330

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/autotrack/index.js +1 -1
  8. package/dist/builtins/autotrack/index.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +7 -0
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +294 -79
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +294 -79
  12. package/dist/builtins/camera-grid/index.js +1 -1
  13. package/dist/builtins/camera-grid/index.mjs +1 -1
  14. package/dist/builtins/composer/composer.addon.js +2 -2
  15. package/dist/builtins/composer/composer.addon.mjs +2 -2
  16. package/dist/builtins/console-logging/index.js +1 -1
  17. package/dist/builtins/console-logging/index.mjs +1 -1
  18. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  19. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  20. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  21. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  23. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  24. package/dist/builtins/hub-forwarder/index.js +1 -1
  25. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  26. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  27. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  28. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  29. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  30. package/dist/builtins/local-network/local-network.addon.js +1 -1
  31. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  32. package/dist/builtins/loki-logging/index.js +1 -1
  33. package/dist/builtins/loki-logging/index.mjs +1 -1
  34. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  35. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  36. package/dist/builtins/platform-probe/index.js +1 -1
  37. package/dist/builtins/platform-probe/index.mjs +1 -1
  38. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  39. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  40. package/dist/builtins/snapshot/index.js +1 -1
  41. package/dist/builtins/snapshot/index.mjs +1 -1
  42. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  43. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  44. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +4 -0
  45. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  46. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  47. package/dist/builtins/sqlite-storage/sqlite-snapshot-protocol.d.ts +49 -0
  48. package/dist/builtins/sqlite-storage/sqlite-snapshot-worker.d.ts +33 -0
  49. package/dist/builtins/sqlite-storage/sqlite-snapshotter.d.ts +98 -0
  50. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  51. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  52. package/dist/builtins/system-backup/system-backup.service.d.ts +74 -20
  53. package/dist/builtins/system-config/system-config.addon.js +1 -1
  54. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  55. package/dist/builtins/winston-logging/index.js +1 -1
  56. package/dist/builtins/winston-logging/index.mjs +1 -1
  57. package/dist/{composition-sources-GkgXF6B9.js → composition-sources-2XxNEet3.js} +1 -1
  58. package/dist/{composition-sources-I8Rx7woY.mjs → composition-sources-Wj2kWund.mjs} +1 -1
  59. package/dist/{dist-BUejKKBm.js → dist-Bx4zxM9N.js} +534 -341
  60. package/dist/{dist-DWq17vC8.mjs → dist-Dp043AI0.mjs} +505 -342
  61. package/dist/index.js +1 -1
  62. package/dist/index.mjs +1 -1
  63. package/dist/{retired-settings-keys-DBPEzGop.js → retired-settings-keys-DR2WMe5d.js} +1 -1
  64. package/dist/{retired-settings-keys-BfHclEyd.mjs → retired-settings-keys-Dxf9vELs.mjs} +1 -1
  65. package/dist/sqlite-snapshot-protocol-2QaTouWV.mjs +34 -0
  66. package/dist/sqlite-snapshot-protocol-B9OndqZv.js +51 -0
  67. package/dist/sqlite-snapshot-worker.js +185 -0
  68. package/dist/sqlite-snapshot-worker.mjs +179 -0
  69. package/package.json +6 -3
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BUejKKBm.js");
6
+ const require_dist = require("../../dist-Bx4zxM9N.js");
7
7
  const require_settle_sources = require("../../settle-sources-Bhsy57y-.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { Kn as errMsg, R as addonPagesCapability, _r as EventCategory, qn as BaseAddon } from "../../dist-DWq17vC8.mjs";
1
+ import { Sr as EventCategory, V as addonPagesCapability, Xn as errMsg, Zn as BaseAddon } from "../../dist-Dp043AI0.mjs";
2
2
  import { t as settleSourcesWithTimeout } from "../../settle-sources-CDtNC8ub.mjs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BUejKKBm.js");
6
+ const require_dist = require("../../dist-Bx4zxM9N.js");
7
7
  const require_settle_sources = require("../../settle-sources-Bhsy57y-.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { B as addonWidgetsCapability, Kn as errMsg, _r as EventCategory, qn as BaseAddon } from "../../dist-DWq17vC8.mjs";
1
+ import { Sr as EventCategory, U as addonWidgetsCapability, Xn as errMsg, Zn as BaseAddon } from "../../dist-Dp043AI0.mjs";
2
2
  import { t as settleSourcesWithTimeout } from "../../settle-sources-CDtNC8ub.mjs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BUejKKBm.js");
6
+ const require_dist = require("../../dist-Bx4zxM9N.js");
7
7
  //#region src/builtins/alerts/prune-policy.ts
8
8
  /**
9
9
  * Ids older than `cutoffMs`, oldest first.
@@ -1,4 +1,4 @@
1
- import { Kn as errMsg, V as alertsCapability, _r as EventCategory, n as AlertSchema, qn as BaseAddon, rr as createEvent } from "../../dist-DWq17vC8.mjs";
1
+ import { Sr as EventCategory, W as alertsCapability, Xn as errMsg, Zn as BaseAddon, n as AlertSchema, sr as createEvent } from "../../dist-Dp043AI0.mjs";
2
2
  //#region src/builtins/alerts/prune-policy.ts
3
3
  /**
4
4
  * Ids older than `cutoffMs`, oldest first.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BUejKKBm.js");
6
+ const require_dist = require("../../dist-Bx4zxM9N.js");
7
7
  //#region src/builtins/autotrack/autotrack-config.ts
8
8
  var DEFAULT_AUTOTRACK_LOOP_CONFIG = {
9
9
  deadZoneFraction: .5,
@@ -1,4 +1,4 @@
1
- import { Kn as errMsg, W as bareAddonId, _ as FIRST_LEVEL_MACRO_CLASSES, _r as EventCategory, lr as isEvent, qn as BaseAddon, rn as ptzAutotrackCapability } from "../../dist-DWq17vC8.mjs";
1
+ import { Sr as EventCategory, Xn as errMsg, Zn as BaseAddon, b as FIRST_LEVEL_MACRO_CLASSES, pr as isEvent, q as bareAddonId, sn as ptzAutotrackCapability } from "../../dist-Dp043AI0.mjs";
2
2
  //#region src/builtins/autotrack/autotrack-config.ts
3
3
  var DEFAULT_AUTOTRACK_LOOP_CONFIG = {
4
4
  deadZoneFraction: .5,
@@ -134,6 +134,13 @@ export declare class BackupOrchestratorAddon extends BaseAddon<Record<string, ne
134
134
  * `cleanStagingArea`.
135
135
  */
136
136
  private runBackupBuild;
137
+ /**
138
+ * THIS node's database owner (D693), reached through `database-snapshot`
139
+ * pinned to the local node: the paths it answers must be on this host,
140
+ * because this process archives them. No local node id means no pin, and an
141
+ * unpinned singleton call could be answered by another host — refuse.
142
+ */
143
+ private databaseOwner;
137
144
  /**
138
145
  * Push the staged archive to a single destination via chunked
139
146
  * upload, then update the per-location manifest. Applies the
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BUejKKBm.js");
6
+ const require_dist = require("../../dist-Bx4zxM9N.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let buffer = require("buffer");
9
9
  let node_fs_promises = require("node:fs/promises");
@@ -2874,8 +2874,7 @@ var Tr = Symbol("onEntry"), gs = Symbol("checkFs"), xr = Symbol("checkFs2"), bs
2874
2874
  return this[O$1](o, t);
2875
2875
  }
2876
2876
  }
2877
- };
2878
- var co = (s) => {
2877
+ }, co = (s) => {
2879
2878
  let t = new Te(s), e = s.file, i = node_fs$1.default.statSync(e);
2880
2879
  new Me(e, {
2881
2880
  readSize: s.maxReadSize || 16 * 1024 * 1024,
@@ -2895,7 +2894,8 @@ var co = (s) => {
2895
2894
  }
2896
2895
  });
2897
2896
  });
2898
- }, uo = K(co, fo, (s) => new Te(s), (s) => new Xt(s), (s, t) => {
2897
+ };
2898
+ K(co, fo, (s) => new Te(s), (s) => new Xt(s), (s, t) => {
2899
2899
  t?.length && Xi(s, t);
2900
2900
  });
2901
2901
  var mo = (s, t) => {
@@ -3016,6 +3016,44 @@ var BackupCancelledError = class extends Error {
3016
3016
  this.name = "BackupCancelledError";
3017
3017
  }
3018
3018
  };
3019
+ /**
3020
+ * Thrown when an archive would carry a live database and its owner could not
3021
+ * produce a verified snapshot of it (D693). Nothing was built: a run that
3022
+ * "succeeded" without the database — or with a torn copy of it — would push
3023
+ * the oldest good archive out of every destination's retention.
3024
+ */
3025
+ var BackupSnapshotRefusedError = class extends Error {
3026
+ constructor(reason) {
3027
+ super(`settings database snapshot failed — no archive built, nothing uploaded, retention untouched: ${reason}`);
3028
+ this.name = "BackupSnapshotRefusedError";
3029
+ }
3030
+ };
3031
+ /**
3032
+ * Settle with `pending`, or reject with {@link BackupCancelledError} as soon as
3033
+ * `signal` fires. A pending call abandoned by the cancel still settles later:
3034
+ * `onAbandoned` runs then, to clean up what it produced.
3035
+ */
3036
+ function raceAbort(pending, signal, onAbandoned) {
3037
+ if (signal === void 0) return pending;
3038
+ return new Promise((resolve, reject) => {
3039
+ let abandoned = false;
3040
+ const onAbort = () => {
3041
+ abandoned = true;
3042
+ reject(new BackupCancelledError());
3043
+ };
3044
+ if (signal.aborted) onAbort();
3045
+ else signal.addEventListener("abort", onAbort, { once: true });
3046
+ pending.then((value) => {
3047
+ signal.removeEventListener("abort", onAbort);
3048
+ if (abandoned) onAbandoned();
3049
+ else resolve(value);
3050
+ }, (err) => {
3051
+ signal.removeEventListener("abort", onAbort);
3052
+ if (abandoned) onAbandoned();
3053
+ else reject(err);
3054
+ });
3055
+ });
3056
+ }
3019
3057
  /** Throw `BackupCancelledError` when the signal has fired. */
3020
3058
  function throwIfCancelled(signal) {
3021
3059
  if (signal?.aborted) throw new BackupCancelledError();
@@ -3068,11 +3106,48 @@ var EXCLUDED_DIR_NAME = "node_modules";
3068
3106
  * trailing `/`) and backslash separators.
3069
3107
  */
3070
3108
  function isExcludedFromBackup(relPath) {
3071
- const norm = relPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
3109
+ const norm = normalizeArchivePath(relPath);
3072
3110
  if (norm.length === 0) return false;
3073
- if (norm.split("/").includes(EXCLUDED_DIR_NAME)) return true;
3111
+ const segments = norm.split("/");
3112
+ if (segments.includes(EXCLUDED_DIR_NAME)) return true;
3113
+ if (segments.some((seg) => seg.endsWith(".backup-snapshot-partial"))) return true;
3114
+ if (norm.endsWith(".backup-snapshot")) return true;
3074
3115
  return DEFAULT_BACKUP_EXCLUDES.some((prefix) => norm === prefix || norm.startsWith(`${prefix}/`));
3075
3116
  }
3117
+ /** `./a/b/` and `a\\b` → `a/b`: the one spelling every archive-path comparison uses. */
3118
+ function normalizeArchivePath(relPath) {
3119
+ return relPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
3120
+ }
3121
+ /** The files SQLite keeps beside a database. A live one is never archived. */
3122
+ var SQLITE_SIDECAR_SUFFIXES = [
3123
+ "-wal",
3124
+ "-shm",
3125
+ "-journal"
3126
+ ];
3127
+ var SQLITE_DATABASE_SUFFIXES = [
3128
+ ".db",
3129
+ ".sqlite",
3130
+ ".sqlite3"
3131
+ ];
3132
+ /**
3133
+ * The database a `-wal` / `-shm` / `-journal` belongs to, or null for any other
3134
+ * path. The base must name a database (`.db` / `.sqlite` / `.sqlite3`): a
3135
+ * `build-journal` is not a sidecar, and must not stop every backup as a "live
3136
+ * database" that does not exist.
3137
+ */
3138
+ function databaseOfSidecar(relPath) {
3139
+ for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
3140
+ if (!relPath.endsWith(suffix)) continue;
3141
+ const db = relPath.slice(0, -suffix.length);
3142
+ return SQLITE_DATABASE_SUFFIXES.some((dbSuffix) => db.endsWith(dbSuffix)) ? db : null;
3143
+ }
3144
+ return null;
3145
+ }
3146
+ /** A SQLite database file, or a sidecar of one. */
3147
+ function isSqlitePath(relPath) {
3148
+ const db = databaseOfSidecar(relPath) ?? relPath;
3149
+ return SQLITE_DATABASE_SUFFIXES.some((suffix) => db.endsWith(suffix));
3150
+ }
3076
3151
  /** Path of the manifest file embedded inside every archive. */
3077
3152
  var ARCHIVE_MANIFEST_NAME = ".camstack-backup-manifest.json";
3078
3153
  var DEFAULT_STAT_CACHE_TTL_MS = 6e4;
@@ -3168,28 +3243,75 @@ var SystemBackupService = class {
3168
3243
  }
3169
3244
  if (present.length === 0) throw new Error(`createArchive: no locations from {${requested.join(", ")}} exist under ${this.dataDir}`);
3170
3245
  node_fs.mkdirSync(node_path.dirname(opts.archivePath), { recursive: true });
3171
- const entries = [];
3246
+ const walked = [];
3172
3247
  for (const loc of present) {
3173
3248
  throwIfCancelled(opts.signal);
3174
3249
  const abs = node_path.join(this.dataDir, loc);
3175
3250
  const stat = await node_fs_promises.stat(abs);
3176
- if (stat.isDirectory()) for (const e of await walkAbs(abs, this.dataDir, this.exclusionPredicate(), opts.signal)) entries.push(e);
3177
- else entries.push(toArchiveEntry(abs, this.dataDir, stat));
3251
+ if (stat.isDirectory()) for (const e of await walkAbs(abs, this.dataDir, this.exclusionPredicate(), opts.signal)) walked.push(e);
3252
+ else if (!isExcludedFromBackup(node_path.relative(this.dataDir, abs))) walked.push(toArchiveEntry(abs, this.dataDir, stat));
3253
+ }
3254
+ const walkedPaths = new Set(walked.map((e) => e.path));
3255
+ const owned = walked.some((e) => isSqlitePath(e.path)) ? await this.ownedDatabasesInScope(opts.owner, walkedPaths) : /* @__PURE__ */ new Set();
3256
+ const unowned = /* @__PURE__ */ new Set();
3257
+ for (const e of walked) {
3258
+ const db = databaseOfSidecar(e.path);
3259
+ if (db !== null && !owned.has(db)) unowned.add(db);
3260
+ }
3261
+ if (unowned.size > 0) {
3262
+ const databases = [...unowned].sort();
3263
+ this.logger.error("createArchive: a live SQLite database has no owner snapshot — no archive built", { meta: {
3264
+ databases,
3265
+ archivePath: opts.archivePath
3266
+ } });
3267
+ throw new BackupSnapshotRefusedError(`live SQLite database ${databases.join(", ")} has no owner that can snapshot it — a copy of its files would be torn; remove its stale -wal/-shm/-journal if nothing has it open, or leave its location out of the backup`);
3178
3268
  }
3179
- const manifest = {
3180
- archiveVersion: 1,
3181
- createdAt: Date.now(),
3182
- dataDir: this.dataDir,
3183
- locations: present,
3184
- entries,
3185
- totalBytes: entries.reduce((acc, e) => acc + e.sizeBytes, 0),
3186
- totalFiles: entries.filter((e) => e.kind === "file").length
3187
- };
3269
+ const snapshot = owned.size > 0 && opts.owner ? await this.takeOwnerSnapshot(opts.owner, owned, opts.signal) : null;
3270
+ try {
3271
+ const liveFiles = /* @__PURE__ */ new Set();
3272
+ for (const db of owned) {
3273
+ liveFiles.add(db);
3274
+ for (const suffix of SQLITE_SIDECAR_SUFFIXES) liveFiles.add(db + suffix);
3275
+ }
3276
+ const entries = walked.filter((e) => !liveFiles.has(e.path));
3277
+ if (snapshot !== null) entries.push(snapshot.entry);
3278
+ const manifest = {
3279
+ archiveVersion: 1,
3280
+ createdAt: Date.now(),
3281
+ dataDir: this.dataDir,
3282
+ locations: present,
3283
+ entries,
3284
+ totalBytes: entries.reduce((acc, e) => acc + e.sizeBytes, 0),
3285
+ totalFiles: entries.filter((e) => e.kind === "file").length,
3286
+ ...snapshot !== null ? { sqliteSnapshots: [snapshot.archived] } : {}
3287
+ };
3288
+ const carriedSnapshot = snapshot?.archived.snapshotPath ?? null;
3289
+ return await this.writeArchive({
3290
+ archivePath: opts.archivePath,
3291
+ present,
3292
+ manifest,
3293
+ excluded: (rel) => {
3294
+ if (rel === carriedSnapshot) return false;
3295
+ return liveFiles.has(rel) || isExcludedFromBackup(rel);
3296
+ },
3297
+ ...opts.signal ? { signal: opts.signal } : {},
3298
+ ...opts.onArchiveBytes ? { onArchiveBytes: opts.onArchiveBytes } : {}
3299
+ });
3300
+ } finally {
3301
+ if (snapshot !== null) await this.removeSnapshot(snapshot.absPath);
3302
+ }
3303
+ }
3304
+ /**
3305
+ * Tar `present` (filtered by `excluded`) plus the staged manifest into
3306
+ * `archivePath`.
3307
+ */
3308
+ async writeArchive(opts) {
3309
+ const { present, manifest } = opts;
3188
3310
  const manifestStagePath = `${opts.archivePath}.${process.pid}.manifest.json`;
3189
3311
  node_fs.writeFileSync(manifestStagePath, JSON.stringify(manifest, null, 2), "utf-8");
3190
3312
  this.logger.info("createArchive: tarring", { meta: {
3191
3313
  archivePath: opts.archivePath,
3192
- locations: present,
3314
+ locations: [...present],
3193
3315
  totalFiles: manifest.totalFiles,
3194
3316
  totalBytes: manifest.totalBytes
3195
3317
  } });
@@ -3205,8 +3327,8 @@ var SystemBackupService = class {
3205
3327
  follow: true,
3206
3328
  portable: true,
3207
3329
  filter: (entryPath) => {
3208
- const norm = entryPath.replace(/^\.\//, "").replace(/\/+$/, "");
3209
- if (isExcludedFromBackup(norm)) return false;
3330
+ const norm = normalizeArchivePath(entryPath);
3331
+ if (opts.excluded(norm)) return false;
3210
3332
  if (!excludePrefix) return true;
3211
3333
  return norm !== excludePrefix && !norm.startsWith(`${excludePrefix}/`);
3212
3334
  }
@@ -3234,7 +3356,7 @@ var SystemBackupService = class {
3234
3356
  const sizeBytes = node_fs.statSync(opts.archivePath).size;
3235
3357
  return {
3236
3358
  archivePath: opts.archivePath,
3237
- includedLocations: present,
3359
+ includedLocations: [...present],
3238
3360
  sizeBytes,
3239
3361
  manifest
3240
3362
  };
@@ -3287,41 +3409,11 @@ var SystemBackupService = class {
3287
3409
  });
3288
3410
  }
3289
3411
  /**
3290
- * Extract a previously-created archive over `targetDir`. Used at boot
3291
- * by the restore hook — never call from a running server (would
3292
- * corrupt sqlite mid-run).
3293
- *
3294
- * `locations` is an optional whitelist: only entries whose path
3295
- * starts with one of the locations get extracted. Used by the
3296
- * partial-restore flow where the operator picks a subset of the
3297
- * archive contents to apply (e.g. "restore my devices but keep
3298
- * my current TLS certs").
3299
- */
3300
- async extractArchive(opts) {
3301
- if (!node_fs.existsSync(opts.archivePath)) throw new Error(`extractArchive: ${opts.archivePath} does not exist`);
3302
- node_fs.mkdirSync(opts.targetDir, { recursive: true });
3303
- this.logger.info("extractArchive: untarring", { meta: {
3304
- archivePath: opts.archivePath,
3305
- targetDir: opts.targetDir,
3306
- locations: opts.locations
3307
- } });
3308
- const allowedPrefixes = opts.locations ? opts.locations.map(normalizeLocation) : null;
3309
- await uo({
3310
- file: opts.archivePath,
3311
- cwd: opts.targetDir,
3312
- filter: (p) => {
3313
- if (p === ".camstack-backup-manifest.json" || p === `./.camstack-backup-manifest.json`) return false;
3314
- if (!allowedPrefixes) return true;
3315
- const normalized = p.replace(/^\.\//, "");
3316
- return allowedPrefixes.some((prefix) => entryMatchesLocation(normalized, prefix));
3317
- }
3318
- });
3319
- }
3320
- /**
3321
3412
  * Schedule a restore for the next boot by writing a marker file.
3322
- * `locations` (optional) carries through to the boot-time
3323
- * extractArchive call so the operator's "restore only X and Y"
3324
- * choice survives the process restart.
3413
+ * `locations` (optional) carries through to the launcher's boot-time
3414
+ * restore (`server/backend/src/launcher.ts`, the only restore there is)
3415
+ * so the operator's "restore only X and Y" choice survives the process
3416
+ * restart.
3325
3417
  */
3326
3418
  scheduleRestoreMarker(opts) {
3327
3419
  if (!node_fs.existsSync(opts.archivePath)) throw new Error(`scheduleRestoreMarker: ${opts.archivePath} missing`);
@@ -3354,6 +3446,117 @@ var SystemBackupService = class {
3354
3446
  const markerPath = this.getRestoreMarkerPath();
3355
3447
  if (node_fs.existsSync(markerPath)) node_fs.rmSync(markerPath, { force: true });
3356
3448
  }
3449
+ /**
3450
+ * The databases the owner declares, archive-relative, that this walk found.
3451
+ * One outside this data root is not in scope. An owner that cannot answer
3452
+ * stops the archive: without the answer, nothing says which file is live.
3453
+ */
3454
+ async ownedDatabasesInScope(owner, walked) {
3455
+ if (owner === void 0) return /* @__PURE__ */ new Set();
3456
+ let declared;
3457
+ try {
3458
+ declared = await owner.listDatabases();
3459
+ } catch (err) {
3460
+ const reason = err instanceof Error ? err.message : String(err);
3461
+ this.logger.error("createArchive: the database owner did not say what it owns — no archive built", { meta: { error: reason } });
3462
+ throw new BackupSnapshotRefusedError(`the database owner could not be asked: ${reason}`);
3463
+ }
3464
+ const inScope = /* @__PURE__ */ new Set();
3465
+ for (const abs of declared) {
3466
+ const rel = this.archiveRelative(abs);
3467
+ if (rel !== null && walked.has(rel)) inScope.add(rel);
3468
+ }
3469
+ return inScope;
3470
+ }
3471
+ /**
3472
+ * Ask the owner for its snapshot, and place it in this archive. The snapshot
3473
+ * must sit on THIS data root — the call is pinned to this node, so a path
3474
+ * elsewhere means it reached another host, and that file is not ours to
3475
+ * read — beside an owned database in scope, and it must cover EVERY owned
3476
+ * database in scope. Anything else is refused.
3477
+ *
3478
+ * Only ever DELETED here: `<owned db>.backup-snapshot` under this data root,
3479
+ * a name this process derives itself. A path the owner ANSWERS is never
3480
+ * deleted on its word — answering the live database as the snapshot would
3481
+ * otherwise delete the live database. A file at any other path is left to
3482
+ * the owner's own sweep.
3483
+ *
3484
+ * A cancel is honoured while the owner is still copying; the copy it may
3485
+ * finish afterwards is removed when that call settles.
3486
+ */
3487
+ async takeOwnerSnapshot(owner, owned, signal) {
3488
+ const removeOwnedSnapshots = async () => {
3489
+ for (const db of owned) await this.removeSnapshot(node_path.join(this.dataDir, db + require_dist.DATABASE_SNAPSHOT_SUFFIX));
3490
+ };
3491
+ let result;
3492
+ try {
3493
+ result = await raceAbort(owner.writeSnapshot(), signal, () => {
3494
+ this.logger.info("createArchive: run cancelled while the owner copied — its copy is removed when it lands", { meta: { databases: [...owned] } });
3495
+ return removeOwnedSnapshots();
3496
+ });
3497
+ } catch (err) {
3498
+ if (err instanceof BackupCancelledError) throw err;
3499
+ const reason = err instanceof Error ? err.message : String(err);
3500
+ this.logger.error("createArchive: the owner could not snapshot its live database — no archive built", { meta: {
3501
+ databases: [...owned],
3502
+ error: reason
3503
+ } });
3504
+ await removeOwnedSnapshots();
3505
+ throw new BackupSnapshotRefusedError(reason);
3506
+ }
3507
+ try {
3508
+ const livePath = this.archiveRelative(result.livePath);
3509
+ const snapshotPath = this.archiveRelative(result.snapshotPath);
3510
+ if (livePath === null || snapshotPath === null) {
3511
+ this.logger.warn("createArchive: the owner answered a snapshot outside this data root — not touched, left to its sweep", { meta: {
3512
+ snapshot: result.snapshotPath,
3513
+ dataDir: this.dataDir
3514
+ } });
3515
+ throw new BackupSnapshotRefusedError(`snapshot ${result.snapshotPath} is not under this archive's data root ${this.dataDir}`);
3516
+ }
3517
+ if (snapshotPath !== livePath + ".backup-snapshot") throw new BackupSnapshotRefusedError(`snapshot ${snapshotPath} does not sit beside the database ${livePath} it copies`);
3518
+ const uncovered = [...owned].filter((db) => db !== livePath);
3519
+ if (!owned.has(livePath) || uncovered.length > 0) throw new BackupSnapshotRefusedError(`snapshot copies ${livePath}, but the owned database(s) in this archive are ${[...owned].join(", ")}`);
3520
+ const absPath = node_path.join(this.dataDir, snapshotPath);
3521
+ const stat = await node_fs_promises.stat(absPath);
3522
+ this.logger.info("createArchive: carrying the owner snapshot in place of the live database", { meta: {
3523
+ database: livePath,
3524
+ snapshot: snapshotPath,
3525
+ sizeBytes: stat.size,
3526
+ tables: result.tables.length,
3527
+ copyMs: result.copyMs,
3528
+ verifyMs: result.verifyMs
3529
+ } });
3530
+ return {
3531
+ absPath,
3532
+ archived: {
3533
+ ...result,
3534
+ livePath,
3535
+ snapshotPath
3536
+ },
3537
+ entry: toArchiveEntry(absPath, this.dataDir, stat)
3538
+ };
3539
+ } catch (err) {
3540
+ await removeOwnedSnapshots();
3541
+ throw err;
3542
+ }
3543
+ }
3544
+ async removeSnapshot(absPath) {
3545
+ try {
3546
+ await node_fs_promises.rm(absPath, { force: true });
3547
+ } catch (err) {
3548
+ this.logger.warn("createArchive: owner snapshot could not be removed", { meta: {
3549
+ snapshot: absPath,
3550
+ error: err instanceof Error ? err.message : String(err)
3551
+ } });
3552
+ }
3553
+ }
3554
+ /** `abs` relative to the data root, or null when it lies outside it. */
3555
+ archiveRelative(abs) {
3556
+ const rel = node_path.relative(node_path.resolve(this.dataDir), node_path.resolve(abs));
3557
+ if (rel.length === 0 || rel.startsWith("..") || node_path.isAbsolute(rel)) return null;
3558
+ return normalizeArchivePath(rel);
3559
+ }
3357
3560
  getRestoreMarkerPath() {
3358
3561
  return node_path.join(this.dataDir, ".pending-restore.json");
3359
3562
  }
@@ -3420,23 +3623,6 @@ function toArchiveEntry(abs, dataDir, stat) {
3420
3623
  mtime: stat.mtimeMs
3421
3624
  };
3422
3625
  }
3423
- /**
3424
- * Strip leading `./` and trailing `/` so location prefix matching is
3425
- * order-of-comparison invariant. Same shape on both sides of the
3426
- * `entry.path.startsWith(prefix)` test in the extract filter.
3427
- */
3428
- function normalizeLocation(loc) {
3429
- return loc.replace(/^\.\//, "").replace(/\/+$/, "");
3430
- }
3431
- /**
3432
- * Match an archive entry path against a location whitelist prefix.
3433
- * `addons` matches `addons` (file) and `addons/foo/bar.txt` (path
3434
- * inside the dir). The trailing `/` test is what prevents `addons`
3435
- * from spuriously matching `addons-data`.
3436
- */
3437
- function entryMatchesLocation(entryPath, prefix) {
3438
- return entryPath === prefix || entryPath.startsWith(`${prefix}/`);
3439
- }
3440
3626
  //#endregion
3441
3627
  //#region ../../node_modules/croner/dist/croner.js
3442
3628
  function T(s) {
@@ -5713,12 +5899,22 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5713
5899
  const archiveId = (0, node_crypto.randomUUID)();
5714
5900
  const archiveFilename = `${archiveId}.tar.gz`;
5715
5901
  const cachePath = await this.stagingPath(archiveFilename);
5716
- const result = await this.systemBackup.createArchive({
5717
- archivePath: cachePath,
5718
- ...input.locations ? { locations: [...input.locations] } : {},
5719
- signal,
5720
- onArchiveBytes: (bytes) => progress.setStagedBytes(bytes)
5721
- });
5902
+ let result;
5903
+ try {
5904
+ result = await this.systemBackup.createArchive({
5905
+ archivePath: cachePath,
5906
+ ...input.locations ? { locations: [...input.locations] } : {},
5907
+ signal,
5908
+ onArchiveBytes: (bytes) => progress.setStagedBytes(bytes),
5909
+ owner: this.databaseOwner()
5910
+ });
5911
+ } catch (err) {
5912
+ if (err instanceof BackupSnapshotRefusedError) for (const dest of targets) {
5913
+ progress.destinationFailed(dest.id);
5914
+ await this.recordDestinationOutcome(dest.id, Date.now(), err.message);
5915
+ }
5916
+ throw err;
5917
+ }
5722
5918
  progress.setArchiveSize(result.sizeBytes);
5723
5919
  progress.setPhase("uploading");
5724
5920
  try {
@@ -5799,6 +5995,25 @@ var BackupOrchestratorAddon = class extends require_dist.BaseAddon {
5799
5995
  }
5800
5996
  }
5801
5997
  /**
5998
+ * THIS node's database owner (D693), reached through `database-snapshot`
5999
+ * pinned to the local node: the paths it answers must be on this host,
6000
+ * because this process archives them. No local node id means no pin, and an
6001
+ * unpinned singleton call could be answered by another host — refuse.
6002
+ */
6003
+ databaseOwner() {
6004
+ const pin = () => {
6005
+ const localNodeId = this.ctx.kernel.localNodeId;
6006
+ if (localNodeId === void 0 || localNodeId.length === 0) throw new Error("no local node id — the database owner cannot be pinned to this host");
6007
+ return require_dist.nodePin(require_dist.toNodeId(localNodeId));
6008
+ };
6009
+ return {
6010
+ listDatabases: async () => {
6011
+ return (await this.ctx.api.databaseSnapshot.listDatabases.query({}, pin())).databases.map((d) => d.livePath);
6012
+ },
6013
+ writeSnapshot: async () => this.ctx.api.databaseSnapshot.writeSnapshot.mutate({}, pin())
6014
+ };
6015
+ }
6016
+ /**
5802
6017
  * Push the staged archive to a single destination via chunked
5803
6018
  * upload, then update the per-location manifest. Applies the
5804
6019
  * destination's retention policy as part of the same lock-protected