@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
@@ -1,4 +1,4 @@
1
- import { Gt as mayWriteToLocation, U as backupCapability, _r as EventCategory, qn as BaseAddon, vn as storageOccupancyCapability } from "../../dist-DWq17vC8.mjs";
1
+ import { K as backupCapability, Sn as storageOccupancyCapability, Sr as EventCategory, Yt as mayWriteToLocation, Zn as BaseAddon, m as DATABASE_SNAPSHOT_SUFFIX, mr as nodePin, xr as toNodeId } from "../../dist-Dp043AI0.mjs";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { Buffer as Buffer$1 } from "buffer";
4
4
  import * as fsp from "node:fs/promises";
@@ -2859,8 +2859,7 @@ var Tr = Symbol("onEntry"), gs = Symbol("checkFs"), xr = Symbol("checkFs2"), bs
2859
2859
  return this[O$1](o, t);
2860
2860
  }
2861
2861
  }
2862
- };
2863
- var co = (s) => {
2862
+ }, co = (s) => {
2864
2863
  let t = new Te(s), e = s.file, i = Kt.statSync(e);
2865
2864
  new Me(e, {
2866
2865
  readSize: s.maxReadSize || 16 * 1024 * 1024,
@@ -2880,7 +2879,8 @@ var co = (s) => {
2880
2879
  }
2881
2880
  });
2882
2881
  });
2883
- }, uo = K(co, fo, (s) => new Te(s), (s) => new Xt(s), (s, t) => {
2882
+ };
2883
+ K(co, fo, (s) => new Te(s), (s) => new Xt(s), (s, t) => {
2884
2884
  t?.length && Xi(s, t);
2885
2885
  });
2886
2886
  var mo = (s, t) => {
@@ -3001,6 +3001,44 @@ var BackupCancelledError = class extends Error {
3001
3001
  this.name = "BackupCancelledError";
3002
3002
  }
3003
3003
  };
3004
+ /**
3005
+ * Thrown when an archive would carry a live database and its owner could not
3006
+ * produce a verified snapshot of it (D693). Nothing was built: a run that
3007
+ * "succeeded" without the database — or with a torn copy of it — would push
3008
+ * the oldest good archive out of every destination's retention.
3009
+ */
3010
+ var BackupSnapshotRefusedError = class extends Error {
3011
+ constructor(reason) {
3012
+ super(`settings database snapshot failed — no archive built, nothing uploaded, retention untouched: ${reason}`);
3013
+ this.name = "BackupSnapshotRefusedError";
3014
+ }
3015
+ };
3016
+ /**
3017
+ * Settle with `pending`, or reject with {@link BackupCancelledError} as soon as
3018
+ * `signal` fires. A pending call abandoned by the cancel still settles later:
3019
+ * `onAbandoned` runs then, to clean up what it produced.
3020
+ */
3021
+ function raceAbort(pending, signal, onAbandoned) {
3022
+ if (signal === void 0) return pending;
3023
+ return new Promise((resolve, reject) => {
3024
+ let abandoned = false;
3025
+ const onAbort = () => {
3026
+ abandoned = true;
3027
+ reject(new BackupCancelledError());
3028
+ };
3029
+ if (signal.aborted) onAbort();
3030
+ else signal.addEventListener("abort", onAbort, { once: true });
3031
+ pending.then((value) => {
3032
+ signal.removeEventListener("abort", onAbort);
3033
+ if (abandoned) onAbandoned();
3034
+ else resolve(value);
3035
+ }, (err) => {
3036
+ signal.removeEventListener("abort", onAbort);
3037
+ if (abandoned) onAbandoned();
3038
+ else reject(err);
3039
+ });
3040
+ });
3041
+ }
3004
3042
  /** Throw `BackupCancelledError` when the signal has fired. */
3005
3043
  function throwIfCancelled(signal) {
3006
3044
  if (signal?.aborted) throw new BackupCancelledError();
@@ -3053,11 +3091,48 @@ var EXCLUDED_DIR_NAME = "node_modules";
3053
3091
  * trailing `/`) and backslash separators.
3054
3092
  */
3055
3093
  function isExcludedFromBackup(relPath) {
3056
- const norm = relPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
3094
+ const norm = normalizeArchivePath(relPath);
3057
3095
  if (norm.length === 0) return false;
3058
- if (norm.split("/").includes(EXCLUDED_DIR_NAME)) return true;
3096
+ const segments = norm.split("/");
3097
+ if (segments.includes(EXCLUDED_DIR_NAME)) return true;
3098
+ if (segments.some((seg) => seg.endsWith(".backup-snapshot-partial"))) return true;
3099
+ if (norm.endsWith(".backup-snapshot")) return true;
3059
3100
  return DEFAULT_BACKUP_EXCLUDES.some((prefix) => norm === prefix || norm.startsWith(`${prefix}/`));
3060
3101
  }
3102
+ /** `./a/b/` and `a\\b` → `a/b`: the one spelling every archive-path comparison uses. */
3103
+ function normalizeArchivePath(relPath) {
3104
+ return relPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
3105
+ }
3106
+ /** The files SQLite keeps beside a database. A live one is never archived. */
3107
+ var SQLITE_SIDECAR_SUFFIXES = [
3108
+ "-wal",
3109
+ "-shm",
3110
+ "-journal"
3111
+ ];
3112
+ var SQLITE_DATABASE_SUFFIXES = [
3113
+ ".db",
3114
+ ".sqlite",
3115
+ ".sqlite3"
3116
+ ];
3117
+ /**
3118
+ * The database a `-wal` / `-shm` / `-journal` belongs to, or null for any other
3119
+ * path. The base must name a database (`.db` / `.sqlite` / `.sqlite3`): a
3120
+ * `build-journal` is not a sidecar, and must not stop every backup as a "live
3121
+ * database" that does not exist.
3122
+ */
3123
+ function databaseOfSidecar(relPath) {
3124
+ for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
3125
+ if (!relPath.endsWith(suffix)) continue;
3126
+ const db = relPath.slice(0, -suffix.length);
3127
+ return SQLITE_DATABASE_SUFFIXES.some((dbSuffix) => db.endsWith(dbSuffix)) ? db : null;
3128
+ }
3129
+ return null;
3130
+ }
3131
+ /** A SQLite database file, or a sidecar of one. */
3132
+ function isSqlitePath(relPath) {
3133
+ const db = databaseOfSidecar(relPath) ?? relPath;
3134
+ return SQLITE_DATABASE_SUFFIXES.some((suffix) => db.endsWith(suffix));
3135
+ }
3061
3136
  /** Path of the manifest file embedded inside every archive. */
3062
3137
  var ARCHIVE_MANIFEST_NAME = ".camstack-backup-manifest.json";
3063
3138
  var DEFAULT_STAT_CACHE_TTL_MS = 6e4;
@@ -3153,28 +3228,75 @@ var SystemBackupService = class {
3153
3228
  }
3154
3229
  if (present.length === 0) throw new Error(`createArchive: no locations from {${requested.join(", ")}} exist under ${this.dataDir}`);
3155
3230
  fs.mkdirSync(path$1.dirname(opts.archivePath), { recursive: true });
3156
- const entries = [];
3231
+ const walked = [];
3157
3232
  for (const loc of present) {
3158
3233
  throwIfCancelled(opts.signal);
3159
3234
  const abs = path$1.join(this.dataDir, loc);
3160
3235
  const stat = await fsp.stat(abs);
3161
- if (stat.isDirectory()) for (const e of await walkAbs(abs, this.dataDir, this.exclusionPredicate(), opts.signal)) entries.push(e);
3162
- else entries.push(toArchiveEntry(abs, this.dataDir, stat));
3236
+ if (stat.isDirectory()) for (const e of await walkAbs(abs, this.dataDir, this.exclusionPredicate(), opts.signal)) walked.push(e);
3237
+ else if (!isExcludedFromBackup(path$1.relative(this.dataDir, abs))) walked.push(toArchiveEntry(abs, this.dataDir, stat));
3238
+ }
3239
+ const walkedPaths = new Set(walked.map((e) => e.path));
3240
+ const owned = walked.some((e) => isSqlitePath(e.path)) ? await this.ownedDatabasesInScope(opts.owner, walkedPaths) : /* @__PURE__ */ new Set();
3241
+ const unowned = /* @__PURE__ */ new Set();
3242
+ for (const e of walked) {
3243
+ const db = databaseOfSidecar(e.path);
3244
+ if (db !== null && !owned.has(db)) unowned.add(db);
3245
+ }
3246
+ if (unowned.size > 0) {
3247
+ const databases = [...unowned].sort();
3248
+ this.logger.error("createArchive: a live SQLite database has no owner snapshot — no archive built", { meta: {
3249
+ databases,
3250
+ archivePath: opts.archivePath
3251
+ } });
3252
+ 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`);
3163
3253
  }
3164
- const manifest = {
3165
- archiveVersion: 1,
3166
- createdAt: Date.now(),
3167
- dataDir: this.dataDir,
3168
- locations: present,
3169
- entries,
3170
- totalBytes: entries.reduce((acc, e) => acc + e.sizeBytes, 0),
3171
- totalFiles: entries.filter((e) => e.kind === "file").length
3172
- };
3254
+ const snapshot = owned.size > 0 && opts.owner ? await this.takeOwnerSnapshot(opts.owner, owned, opts.signal) : null;
3255
+ try {
3256
+ const liveFiles = /* @__PURE__ */ new Set();
3257
+ for (const db of owned) {
3258
+ liveFiles.add(db);
3259
+ for (const suffix of SQLITE_SIDECAR_SUFFIXES) liveFiles.add(db + suffix);
3260
+ }
3261
+ const entries = walked.filter((e) => !liveFiles.has(e.path));
3262
+ if (snapshot !== null) entries.push(snapshot.entry);
3263
+ const manifest = {
3264
+ archiveVersion: 1,
3265
+ createdAt: Date.now(),
3266
+ dataDir: this.dataDir,
3267
+ locations: present,
3268
+ entries,
3269
+ totalBytes: entries.reduce((acc, e) => acc + e.sizeBytes, 0),
3270
+ totalFiles: entries.filter((e) => e.kind === "file").length,
3271
+ ...snapshot !== null ? { sqliteSnapshots: [snapshot.archived] } : {}
3272
+ };
3273
+ const carriedSnapshot = snapshot?.archived.snapshotPath ?? null;
3274
+ return await this.writeArchive({
3275
+ archivePath: opts.archivePath,
3276
+ present,
3277
+ manifest,
3278
+ excluded: (rel) => {
3279
+ if (rel === carriedSnapshot) return false;
3280
+ return liveFiles.has(rel) || isExcludedFromBackup(rel);
3281
+ },
3282
+ ...opts.signal ? { signal: opts.signal } : {},
3283
+ ...opts.onArchiveBytes ? { onArchiveBytes: opts.onArchiveBytes } : {}
3284
+ });
3285
+ } finally {
3286
+ if (snapshot !== null) await this.removeSnapshot(snapshot.absPath);
3287
+ }
3288
+ }
3289
+ /**
3290
+ * Tar `present` (filtered by `excluded`) plus the staged manifest into
3291
+ * `archivePath`.
3292
+ */
3293
+ async writeArchive(opts) {
3294
+ const { present, manifest } = opts;
3173
3295
  const manifestStagePath = `${opts.archivePath}.${process.pid}.manifest.json`;
3174
3296
  fs.writeFileSync(manifestStagePath, JSON.stringify(manifest, null, 2), "utf-8");
3175
3297
  this.logger.info("createArchive: tarring", { meta: {
3176
3298
  archivePath: opts.archivePath,
3177
- locations: present,
3299
+ locations: [...present],
3178
3300
  totalFiles: manifest.totalFiles,
3179
3301
  totalBytes: manifest.totalBytes
3180
3302
  } });
@@ -3190,8 +3312,8 @@ var SystemBackupService = class {
3190
3312
  follow: true,
3191
3313
  portable: true,
3192
3314
  filter: (entryPath) => {
3193
- const norm = entryPath.replace(/^\.\//, "").replace(/\/+$/, "");
3194
- if (isExcludedFromBackup(norm)) return false;
3315
+ const norm = normalizeArchivePath(entryPath);
3316
+ if (opts.excluded(norm)) return false;
3195
3317
  if (!excludePrefix) return true;
3196
3318
  return norm !== excludePrefix && !norm.startsWith(`${excludePrefix}/`);
3197
3319
  }
@@ -3219,7 +3341,7 @@ var SystemBackupService = class {
3219
3341
  const sizeBytes = fs.statSync(opts.archivePath).size;
3220
3342
  return {
3221
3343
  archivePath: opts.archivePath,
3222
- includedLocations: present,
3344
+ includedLocations: [...present],
3223
3345
  sizeBytes,
3224
3346
  manifest
3225
3347
  };
@@ -3272,41 +3394,11 @@ var SystemBackupService = class {
3272
3394
  });
3273
3395
  }
3274
3396
  /**
3275
- * Extract a previously-created archive over `targetDir`. Used at boot
3276
- * by the restore hook — never call from a running server (would
3277
- * corrupt sqlite mid-run).
3278
- *
3279
- * `locations` is an optional whitelist: only entries whose path
3280
- * starts with one of the locations get extracted. Used by the
3281
- * partial-restore flow where the operator picks a subset of the
3282
- * archive contents to apply (e.g. "restore my devices but keep
3283
- * my current TLS certs").
3284
- */
3285
- async extractArchive(opts) {
3286
- if (!fs.existsSync(opts.archivePath)) throw new Error(`extractArchive: ${opts.archivePath} does not exist`);
3287
- fs.mkdirSync(opts.targetDir, { recursive: true });
3288
- this.logger.info("extractArchive: untarring", { meta: {
3289
- archivePath: opts.archivePath,
3290
- targetDir: opts.targetDir,
3291
- locations: opts.locations
3292
- } });
3293
- const allowedPrefixes = opts.locations ? opts.locations.map(normalizeLocation) : null;
3294
- await uo({
3295
- file: opts.archivePath,
3296
- cwd: opts.targetDir,
3297
- filter: (p) => {
3298
- if (p === ".camstack-backup-manifest.json" || p === `./.camstack-backup-manifest.json`) return false;
3299
- if (!allowedPrefixes) return true;
3300
- const normalized = p.replace(/^\.\//, "");
3301
- return allowedPrefixes.some((prefix) => entryMatchesLocation(normalized, prefix));
3302
- }
3303
- });
3304
- }
3305
- /**
3306
3397
  * Schedule a restore for the next boot by writing a marker file.
3307
- * `locations` (optional) carries through to the boot-time
3308
- * extractArchive call so the operator's "restore only X and Y"
3309
- * choice survives the process restart.
3398
+ * `locations` (optional) carries through to the launcher's boot-time
3399
+ * restore (`server/backend/src/launcher.ts`, the only restore there is)
3400
+ * so the operator's "restore only X and Y" choice survives the process
3401
+ * restart.
3310
3402
  */
3311
3403
  scheduleRestoreMarker(opts) {
3312
3404
  if (!fs.existsSync(opts.archivePath)) throw new Error(`scheduleRestoreMarker: ${opts.archivePath} missing`);
@@ -3339,6 +3431,117 @@ var SystemBackupService = class {
3339
3431
  const markerPath = this.getRestoreMarkerPath();
3340
3432
  if (fs.existsSync(markerPath)) fs.rmSync(markerPath, { force: true });
3341
3433
  }
3434
+ /**
3435
+ * The databases the owner declares, archive-relative, that this walk found.
3436
+ * One outside this data root is not in scope. An owner that cannot answer
3437
+ * stops the archive: without the answer, nothing says which file is live.
3438
+ */
3439
+ async ownedDatabasesInScope(owner, walked) {
3440
+ if (owner === void 0) return /* @__PURE__ */ new Set();
3441
+ let declared;
3442
+ try {
3443
+ declared = await owner.listDatabases();
3444
+ } catch (err) {
3445
+ const reason = err instanceof Error ? err.message : String(err);
3446
+ this.logger.error("createArchive: the database owner did not say what it owns — no archive built", { meta: { error: reason } });
3447
+ throw new BackupSnapshotRefusedError(`the database owner could not be asked: ${reason}`);
3448
+ }
3449
+ const inScope = /* @__PURE__ */ new Set();
3450
+ for (const abs of declared) {
3451
+ const rel = this.archiveRelative(abs);
3452
+ if (rel !== null && walked.has(rel)) inScope.add(rel);
3453
+ }
3454
+ return inScope;
3455
+ }
3456
+ /**
3457
+ * Ask the owner for its snapshot, and place it in this archive. The snapshot
3458
+ * must sit on THIS data root — the call is pinned to this node, so a path
3459
+ * elsewhere means it reached another host, and that file is not ours to
3460
+ * read — beside an owned database in scope, and it must cover EVERY owned
3461
+ * database in scope. Anything else is refused.
3462
+ *
3463
+ * Only ever DELETED here: `<owned db>.backup-snapshot` under this data root,
3464
+ * a name this process derives itself. A path the owner ANSWERS is never
3465
+ * deleted on its word — answering the live database as the snapshot would
3466
+ * otherwise delete the live database. A file at any other path is left to
3467
+ * the owner's own sweep.
3468
+ *
3469
+ * A cancel is honoured while the owner is still copying; the copy it may
3470
+ * finish afterwards is removed when that call settles.
3471
+ */
3472
+ async takeOwnerSnapshot(owner, owned, signal) {
3473
+ const removeOwnedSnapshots = async () => {
3474
+ for (const db of owned) await this.removeSnapshot(path$1.join(this.dataDir, db + DATABASE_SNAPSHOT_SUFFIX));
3475
+ };
3476
+ let result;
3477
+ try {
3478
+ result = await raceAbort(owner.writeSnapshot(), signal, () => {
3479
+ this.logger.info("createArchive: run cancelled while the owner copied — its copy is removed when it lands", { meta: { databases: [...owned] } });
3480
+ return removeOwnedSnapshots();
3481
+ });
3482
+ } catch (err) {
3483
+ if (err instanceof BackupCancelledError) throw err;
3484
+ const reason = err instanceof Error ? err.message : String(err);
3485
+ this.logger.error("createArchive: the owner could not snapshot its live database — no archive built", { meta: {
3486
+ databases: [...owned],
3487
+ error: reason
3488
+ } });
3489
+ await removeOwnedSnapshots();
3490
+ throw new BackupSnapshotRefusedError(reason);
3491
+ }
3492
+ try {
3493
+ const livePath = this.archiveRelative(result.livePath);
3494
+ const snapshotPath = this.archiveRelative(result.snapshotPath);
3495
+ if (livePath === null || snapshotPath === null) {
3496
+ this.logger.warn("createArchive: the owner answered a snapshot outside this data root — not touched, left to its sweep", { meta: {
3497
+ snapshot: result.snapshotPath,
3498
+ dataDir: this.dataDir
3499
+ } });
3500
+ throw new BackupSnapshotRefusedError(`snapshot ${result.snapshotPath} is not under this archive's data root ${this.dataDir}`);
3501
+ }
3502
+ if (snapshotPath !== livePath + ".backup-snapshot") throw new BackupSnapshotRefusedError(`snapshot ${snapshotPath} does not sit beside the database ${livePath} it copies`);
3503
+ const uncovered = [...owned].filter((db) => db !== livePath);
3504
+ if (!owned.has(livePath) || uncovered.length > 0) throw new BackupSnapshotRefusedError(`snapshot copies ${livePath}, but the owned database(s) in this archive are ${[...owned].join(", ")}`);
3505
+ const absPath = path$1.join(this.dataDir, snapshotPath);
3506
+ const stat = await fsp.stat(absPath);
3507
+ this.logger.info("createArchive: carrying the owner snapshot in place of the live database", { meta: {
3508
+ database: livePath,
3509
+ snapshot: snapshotPath,
3510
+ sizeBytes: stat.size,
3511
+ tables: result.tables.length,
3512
+ copyMs: result.copyMs,
3513
+ verifyMs: result.verifyMs
3514
+ } });
3515
+ return {
3516
+ absPath,
3517
+ archived: {
3518
+ ...result,
3519
+ livePath,
3520
+ snapshotPath
3521
+ },
3522
+ entry: toArchiveEntry(absPath, this.dataDir, stat)
3523
+ };
3524
+ } catch (err) {
3525
+ await removeOwnedSnapshots();
3526
+ throw err;
3527
+ }
3528
+ }
3529
+ async removeSnapshot(absPath) {
3530
+ try {
3531
+ await fsp.rm(absPath, { force: true });
3532
+ } catch (err) {
3533
+ this.logger.warn("createArchive: owner snapshot could not be removed", { meta: {
3534
+ snapshot: absPath,
3535
+ error: err instanceof Error ? err.message : String(err)
3536
+ } });
3537
+ }
3538
+ }
3539
+ /** `abs` relative to the data root, or null when it lies outside it. */
3540
+ archiveRelative(abs) {
3541
+ const rel = path$1.relative(path$1.resolve(this.dataDir), path$1.resolve(abs));
3542
+ if (rel.length === 0 || rel.startsWith("..") || path$1.isAbsolute(rel)) return null;
3543
+ return normalizeArchivePath(rel);
3544
+ }
3342
3545
  getRestoreMarkerPath() {
3343
3546
  return path$1.join(this.dataDir, ".pending-restore.json");
3344
3547
  }
@@ -3405,23 +3608,6 @@ function toArchiveEntry(abs, dataDir, stat) {
3405
3608
  mtime: stat.mtimeMs
3406
3609
  };
3407
3610
  }
3408
- /**
3409
- * Strip leading `./` and trailing `/` so location prefix matching is
3410
- * order-of-comparison invariant. Same shape on both sides of the
3411
- * `entry.path.startsWith(prefix)` test in the extract filter.
3412
- */
3413
- function normalizeLocation(loc) {
3414
- return loc.replace(/^\.\//, "").replace(/\/+$/, "");
3415
- }
3416
- /**
3417
- * Match an archive entry path against a location whitelist prefix.
3418
- * `addons` matches `addons` (file) and `addons/foo/bar.txt` (path
3419
- * inside the dir). The trailing `/` test is what prevents `addons`
3420
- * from spuriously matching `addons-data`.
3421
- */
3422
- function entryMatchesLocation(entryPath, prefix) {
3423
- return entryPath === prefix || entryPath.startsWith(`${prefix}/`);
3424
- }
3425
3611
  //#endregion
3426
3612
  //#region ../../node_modules/croner/dist/croner.js
3427
3613
  function T(s) {
@@ -5698,12 +5884,22 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5698
5884
  const archiveId = randomUUID();
5699
5885
  const archiveFilename = `${archiveId}.tar.gz`;
5700
5886
  const cachePath = await this.stagingPath(archiveFilename);
5701
- const result = await this.systemBackup.createArchive({
5702
- archivePath: cachePath,
5703
- ...input.locations ? { locations: [...input.locations] } : {},
5704
- signal,
5705
- onArchiveBytes: (bytes) => progress.setStagedBytes(bytes)
5706
- });
5887
+ let result;
5888
+ try {
5889
+ result = await this.systemBackup.createArchive({
5890
+ archivePath: cachePath,
5891
+ ...input.locations ? { locations: [...input.locations] } : {},
5892
+ signal,
5893
+ onArchiveBytes: (bytes) => progress.setStagedBytes(bytes),
5894
+ owner: this.databaseOwner()
5895
+ });
5896
+ } catch (err) {
5897
+ if (err instanceof BackupSnapshotRefusedError) for (const dest of targets) {
5898
+ progress.destinationFailed(dest.id);
5899
+ await this.recordDestinationOutcome(dest.id, Date.now(), err.message);
5900
+ }
5901
+ throw err;
5902
+ }
5707
5903
  progress.setArchiveSize(result.sizeBytes);
5708
5904
  progress.setPhase("uploading");
5709
5905
  try {
@@ -5784,6 +5980,25 @@ var BackupOrchestratorAddon = class extends BaseAddon {
5784
5980
  }
5785
5981
  }
5786
5982
  /**
5983
+ * THIS node's database owner (D693), reached through `database-snapshot`
5984
+ * pinned to the local node: the paths it answers must be on this host,
5985
+ * because this process archives them. No local node id means no pin, and an
5986
+ * unpinned singleton call could be answered by another host — refuse.
5987
+ */
5988
+ databaseOwner() {
5989
+ const pin = () => {
5990
+ const localNodeId = this.ctx.kernel.localNodeId;
5991
+ if (localNodeId === void 0 || localNodeId.length === 0) throw new Error("no local node id — the database owner cannot be pinned to this host");
5992
+ return nodePin(toNodeId(localNodeId));
5993
+ };
5994
+ return {
5995
+ listDatabases: async () => {
5996
+ return (await this.ctx.api.databaseSnapshot.listDatabases.query({}, pin())).databases.map((d) => d.livePath);
5997
+ },
5998
+ writeSnapshot: async () => this.ctx.api.databaseSnapshot.writeSnapshot.mutate({}, pin())
5999
+ };
6000
+ }
6001
+ /**
5787
6002
  * Push the staged archive to a single destination via chunked
5788
6003
  * upload, then update the per-location manifest. Applies the
5789
6004
  * destination's retention policy as part of the same lock-protected
@@ -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
  let zod = require("zod");
8
8
  let _camstack_types_node = require("@camstack/types/node");
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { Gn as DeviceType, Kn as errMsg, Kt as memoizePinnedFfmpeg, T as MOTION_CLOSE_AFTER_MS, Un as DeviceFeature, Y as cameraGridLayoutCapability, Yt as motionCapability, _r as EventCategory, bn as streamCatalogCapability, h as DeclaredDevices, hn as snapshotCapability, i as BaseDevice, jn as buildFfmpegArgs, m as DERIVED_CAMERA_SILENCED_CAP_NAMES, qn as BaseAddon, rr as createEvent, sr as hydrateSchema, st as declarationOwnerNodeId } from "../../dist-DWq17vC8.mjs";
1
+ import { $t as motionCapability, Fn as buildFfmpegArgs, O as MOTION_CLOSE_AFTER_MS, Q as cameraGridLayoutCapability, Sr as EventCategory, Xn as errMsg, Xt as memoizePinnedFfmpeg, Yn as DeviceType, Zn as BaseAddon, _ as DERIVED_CAMERA_SILENCED_CAP_NAMES, dr as hydrateSchema, dt as declarationOwnerNodeId, i as BaseDevice, qn as DeviceFeature, sr as createEvent, v as DeclaredDevices, wn as streamCatalogCapability, yn as snapshotCapability } from "../../dist-Dp043AI0.mjs";
2
2
  import { z } from "zod";
3
3
  import { FfmpegProcess } from "@camstack/types/node";
4
4
  import { join } from "node:path";
@@ -3,9 +3,9 @@ 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
  const require_device_state_changed = require("../../device-state-changed-Bk7cvTnz.js");
8
- const require_composition_sources = require("../../composition-sources-GkgXF6B9.js");
8
+ const require_composition_sources = require("../../composition-sources-2XxNEet3.js");
9
9
  //#region src/builtins/composer/composed-device.ts
10
10
  /**
11
11
  * A composed device: an ordinary `BaseDevice` whose caps are served by the
@@ -1,6 +1,6 @@
1
- import { Fn as compositionFeatureEnabled, Gn as DeviceType, Hn as toExpressionValue, In as compositionItemFieldPath, K as buildCapabilityLookup, Kn as errMsg, Ln as compositionSliceKey, Mn as cloneStatefulExpressionMemory, Nn as composedDeviceRef, On as COMPOSER_ADDON_ID, Pn as composedDeviceStableId, Q as compositionFieldKey, Rn as compositionSourceKey, Z as composerCapability, _r as EventCategory, c as COMPOSED_ITEM_FIXED_FIELDS, en as planComposition, h as DeclaredDevices, i as BaseDevice, qn as BaseAddon, rr as createEvent, t as ALL_CAPABILITY_DEFINITIONS, xt as evaluateCompositionField, zn as createStatefulExpressionMemory } from "../../dist-DWq17vC8.mjs";
1
+ import { Bn as compositionItemFieldPath, Hn as compositionSourceKey, In as cloneStatefulExpressionMemory, Kn as toExpressionValue, Ln as composedDeviceRef, Mn as COMPOSER_ADDON_ID, Rn as composedDeviceStableId, Sr as EventCategory, Tt as evaluateCompositionField, Un as createStatefulExpressionMemory, Vn as compositionSliceKey, Xn as errMsg, Y as buildCapabilityLookup, Yn as DeviceType, Zn as BaseAddon, c as COMPOSED_ITEM_FIXED_FIELDS, et as composerCapability, i as BaseDevice, in as planComposition, sr as createEvent, t as ALL_CAPABILITY_DEFINITIONS, tt as compositionFieldKey, v as DeclaredDevices, zn as compositionFeatureEnabled } from "../../dist-Dp043AI0.mjs";
2
2
  import { n as parseDeviceStateChanged, t as isRecord$2 } from "../../device-state-changed-DoBbkyEI.mjs";
3
- import { n as normalizedNativeOwner } from "../../composition-sources-I8Rx7woY.mjs";
3
+ import { n as normalizedNativeOwner } from "../../composition-sources-Wj2kWund.mjs";
4
4
  import { z } from "zod";
5
5
  //#region src/builtins/composer/composed-device.ts
6
6
  /**
@@ -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
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  //#region src/builtins/console-logging/console-destination.ts
9
9
  var LEVEL_RANK = {
@@ -1,4 +1,4 @@
1
- import { Vt as logDestinationCapability, qn as BaseAddon } from "../../dist-DWq17vC8.mjs";
1
+ import { Gt as logDestinationCapability, Zn as BaseAddon } from "../../dist-Dp043AI0.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  //#region src/builtins/console-logging/console-destination.ts
4
4
  var LEVEL_RANK = {
@@ -1,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-BUejKKBm.js");
4
- const require_composition_sources = require("../../composition-sources-GkgXF6B9.js");
3
+ const require_dist = require("../../dist-Bx4zxM9N.js");
4
+ const require_composition_sources = require("../../composition-sources-2XxNEet3.js");
5
5
  let node_crypto = require("node:crypto");
6
6
  let _camstack_types_node = require("@camstack/types/node");
7
7
  let node_fs_promises = require("node:fs/promises");
@@ -1,5 +1,5 @@
1
- import { $ as compositionSourceRefs, Cn as validateComposition, K as buildCapabilityLookup, Kn as errMsg, Nn as composedDeviceRef, Rn as compositionSourceKey, an as replacedFieldKeys, d as CORE_BLOCK_ADDON_PREFIX, dt as describeComposableCaps, en as planComposition, et as coreBlockAddonId, f as CoreBlockSchema, h as DeclaredDevices, it as coreBlocksCapability, l as COMPOSER_RUNNER_ID, nt as coreBlockIdFromAddonId, qn as BaseAddon, rt as coreBlockSourceKind, t as ALL_CAPABILITY_DEFINITIONS, tt as coreBlockComposition, u as CORE_BLOCKS_ADDON_ID } from "../../dist-DWq17vC8.mjs";
2
- import { t as describeCompositionSources } from "../../composition-sources-I8Rx7woY.mjs";
1
+ import { Dn as validateComposition, Hn as compositionSourceKey, Ln as composedDeviceRef, Xn as errMsg, Y as buildCapabilityLookup, Zn as BaseAddon, at as coreBlockIdFromAddonId, d as CORE_BLOCK_ADDON_PREFIX, f as CoreBlockSchema, ht as describeComposableCaps, in as planComposition, it as coreBlockComposition, l as COMPOSER_RUNNER_ID, ln as replacedFieldKeys, nt as compositionSourceRefs, ot as coreBlockSourceKind, rt as coreBlockAddonId, st as coreBlocksCapability, t as ALL_CAPABILITY_DEFINITIONS, u as CORE_BLOCKS_ADDON_ID, v as DeclaredDevices } from "../../dist-Dp043AI0.mjs";
2
+ import { t as describeCompositionSources } from "../../composition-sources-Wj2kWund.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { customizationBlockName } from "@camstack/types/node";
5
5
  import * as fs from "node:fs/promises";
@@ -3,8 +3,8 @@ 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");
7
- const require_retired_settings_keys = require("../../retired-settings-keys-DBPEzGop.js");
6
+ const require_dist = require("../../dist-Bx4zxM9N.js");
7
+ const require_retired_settings_keys = require("../../retired-settings-keys-DR2WMe5d.js");
8
8
  let zod = require("zod");
9
9
  let node_crypto = require("node:crypto");
10
10
  let _camstack_types_node = require("@camstack/types/node");
@@ -1,5 +1,5 @@
1
- import { $n as WELL_KNOWN_TAB_MAP, $t as parseStreamParamsFormPatch, Bt as locationIconKey, Ct as expandContainer, Dt as formatMinutes, Gn as DeviceType, It as legacyAutoLinkIds, J as buildStreamParamsConfigSchema, Kn as errMsg, S as LocationIconIdSchema, Un as DeviceFeature, Wn as DeviceRole, Xt as normalizeUnit, _r as EventCategory, at as createDeviceExtensionProvider, bt as enumerateSchemaFields, cr as isDeviceConfigCap, ft as deviceExtensionCapability, g as DeviceStatusSchema, gr as sleep$1, ht as deviceStatusCapability, j as STREAM_PROFILE_META, ln as runtimeStatePolicyFor, lt as defineExtensionSlotProvider, mn as siteZoneKey, mt as deviceStateCapability, o as CAMERA_SWITCH_ORDER, p as DAY_NAMES, pt as deviceManagerCapability, qn as BaseAddon, s as CAP_NAMES_WITH_STATUS, t as ALL_CAPABILITY_DEFINITIONS, v as FieldClaimModeSchema, w as MINUTES_PER_DAY, y as FieldClaimSchema, yt as enumerateItemArrayFields } from "../../dist-DWq17vC8.mjs";
2
- import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-BfHclEyd.mjs";
1
+ import { Bt as legacyAutoLinkIds, Ct as enumerateItemArrayFields, D as MINUTES_PER_DAY, Dt as expandContainer, Jn as DeviceRole, P as STREAM_PROFILE_META, S as FieldClaimSchema, Sr as EventCategory, T as LocationIconIdSchema, Wt as locationIconKey, Xn as errMsg, Yn as DeviceType, Z as buildStreamParamsConfigSchema, Zn as BaseAddon, _t as deviceManagerCapability, br as sleep$1, ct as createDeviceExtensionProvider, en as normalizeUnit, fr as isDeviceConfigCap, g as DAY_NAMES, gt as deviceExtensionCapability, jt as formatMinutes, o as CAMERA_SWITCH_ORDER, pn as runtimeStatePolicyFor, pt as defineExtensionSlotProvider, qn as DeviceFeature, rn as parseStreamParamsFormPatch, rr as WELL_KNOWN_TAB_MAP, s as CAP_NAMES_WITH_STATUS, t as ALL_CAPABILITY_DEFINITIONS, vn as siteZoneKey, vt as deviceStateCapability, wt as enumerateSchemaFields, x as FieldClaimModeSchema, y as DeviceStatusSchema, yt as deviceStatusCapability } from "../../dist-Dp043AI0.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-Dxf9vELs.mjs";
3
3
  import { z } from "zod";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
@@ -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
  const require_device_state_changed = require("../../device-state-changed-Bk7cvTnz.js");
8
8
  let zod = require("zod");
9
9
  //#region src/builtins/doorbell/trigger-engine.ts
@@ -1,4 +1,4 @@
1
- import { An as SOURCE_CAPS, Bn as evaluateSensorEdge, Gn as DeviceType, Kn as errMsg, Nt as isSameAddonId, Vn as isSourceCap, W as bareAddonId, _r as EventCategory, _t as doorbellFeatureSources, at as createDeviceExtensionProvider, ft as deviceExtensionCapability, gt as doorbellCapability, lt as defineExtensionSlotProvider, q as buildDoorbellCompositionFeature, qn as BaseAddon, rr as createEvent } from "../../dist-DWq17vC8.mjs";
1
+ import { Gn as isSourceCap, Lt as isSameAddonId, Pn as SOURCE_CAPS, Sr as EventCategory, Wn as evaluateSensorEdge, X as buildDoorbellCompositionFeature, Xn as errMsg, Yn as DeviceType, Zn as BaseAddon, bt as doorbellCapability, ct as createDeviceExtensionProvider, gt as deviceExtensionCapability, pt as defineExtensionSlotProvider, q as bareAddonId, sr as createEvent, xt as doorbellFeatureSources } from "../../dist-Dp043AI0.mjs";
2
2
  import { n as parseDeviceStateChanged } from "../../device-state-changed-DoBbkyEI.mjs";
3
3
  import { z } from "zod";
4
4
  //#region src/builtins/doorbell/trigger-engine.ts
@@ -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
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  //#region src/builtins/hub-forwarder/hub-forwarder-destination.ts
9
9
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
@@ -1,4 +1,4 @@
1
- import { Vt as logDestinationCapability, qn as BaseAddon } from "../../dist-DWq17vC8.mjs";
1
+ import { Gt as logDestinationCapability, Zn as BaseAddon } from "../../dist-Dp043AI0.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  //#region src/builtins/hub-forwarder/hub-forwarder-destination.ts
4
4
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-BUejKKBm.js");
3
+ const require_dist = require("../../dist-Bx4zxM9N.js");
4
4
  //#region src/builtins/liveness-monitor/liveness-checks.ts
5
5
  var NO_DEVICES = "liveness:no-devices";
6
6
  var ALL_OFFLINE = "liveness:all-devices-offline";
@@ -1,4 +1,4 @@
1
- import { E as RECORDING_SOURCE_CAMSTACK_ADDON, Kn as errMsg, _r as EventCategory, k as RecordingStatusSchema, qn as BaseAddon, rr as createEvent } from "../../dist-DWq17vC8.mjs";
1
+ import { M as RecordingStatusSchema, Sr as EventCategory, Xn as errMsg, Zn as BaseAddon, k as RECORDING_SOURCE_CAMSTACK_ADDON, sr as createEvent } from "../../dist-Dp043AI0.mjs";
2
2
  //#region src/builtins/liveness-monitor/liveness-checks.ts
3
3
  var NO_DEVICES = "liveness:no-devices";
4
4
  var ALL_OFFLINE = "liveness:all-devices-offline";
@@ -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
  node_crypto = require_chunk.__toESM(node_crypto);
9
9
  let crypto$1 = require("crypto");