@camstack/system 1.2.424 → 1.2.426

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 (85) 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-databases.d.ts +51 -0
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +19 -0
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +268 -17
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +268 -17
  13. package/dist/builtins/backup-orchestrator/manifest-store.d.ts +23 -3
  14. package/dist/builtins/backup-orchestrator/run-queue.d.ts +3 -0
  15. package/dist/builtins/backup-orchestrator/schedule-store.d.ts +7 -1
  16. package/dist/builtins/backup-orchestrator/seed-marker-store.d.ts +34 -0
  17. package/dist/builtins/calendars/calendars.addon.js +1 -1
  18. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  19. package/dist/builtins/camera-grid/index.js +1 -1
  20. package/dist/builtins/camera-grid/index.mjs +1 -1
  21. package/dist/builtins/composer/composer.addon.js +2 -2
  22. package/dist/builtins/composer/composer.addon.mjs +2 -2
  23. package/dist/builtins/console-logging/index.js +1 -1
  24. package/dist/builtins/console-logging/index.mjs +1 -1
  25. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  26. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  27. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  28. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  29. package/dist/builtins/hub-forwarder/index.js +1 -1
  30. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  31. package/dist/builtins/keychain/index.js +3 -3
  32. package/dist/builtins/keychain/index.mjs +3 -3
  33. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  34. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  35. package/dist/builtins/local-auth/local-auth.addon.js +2 -2
  36. package/dist/builtins/local-auth/local-auth.addon.mjs +2 -2
  37. package/dist/builtins/local-network/address-book.d.ts +21 -0
  38. package/dist/builtins/local-network/local-network.addon.d.ts +6 -0
  39. package/dist/builtins/local-network/local-network.addon.js +48 -7
  40. package/dist/builtins/local-network/local-network.addon.mjs +48 -7
  41. package/dist/builtins/loki-logging/index.js +1 -1
  42. package/dist/builtins/loki-logging/index.mjs +1 -1
  43. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  44. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  45. package/dist/builtins/platform-probe/index.js +1 -1
  46. package/dist/builtins/platform-probe/index.mjs +1 -1
  47. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  48. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  49. package/dist/builtins/snapshot/index.js +1 -1
  50. package/dist/builtins/snapshot/index.mjs +1 -1
  51. package/dist/builtins/sqlite-storage/database-snapshot-provider.d.ts +18 -0
  52. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  53. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  54. package/dist/builtins/sqlite-storage/index.js +1 -1
  55. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  56. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +49 -7
  57. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +49 -7
  58. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  59. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  60. package/dist/builtins/system-backup/system-backup.service.d.ts +48 -15
  61. package/dist/builtins/system-config/system-config.addon.js +2 -2
  62. package/dist/builtins/system-config/system-config.addon.mjs +2 -2
  63. package/dist/builtins/winston-logging/index.js +1 -1
  64. package/dist/builtins/winston-logging/index.mjs +1 -1
  65. package/dist/{composition-sources-BWNs-ZeH.js → composition-sources-60tuHQ9N.js} +1 -1
  66. package/dist/{composition-sources-tRG7nKWK.mjs → composition-sources-gxVpghXh.mjs} +1 -1
  67. package/dist/{constants-DHhhVjkt.mjs → constants-B6YTf0v2.mjs} +1 -1
  68. package/dist/{constants-BBWZlnpQ.js → constants-CO5zpJQY.js} +1 -1
  69. package/dist/{dist-B-NmfvVC.mjs → dist-CGz9u2HD.mjs} +869 -800
  70. package/dist/{dist-CiUKtVkX.js → dist-K3WFop9Q.js} +869 -800
  71. package/dist/file-host-worker.js +1 -1
  72. package/dist/file-host-worker.mjs +1 -1
  73. package/dist/index.js +272 -198
  74. package/dist/index.mjs +272 -198
  75. package/dist/{jwt-signing-key-nwRpRgf_.js → jwt-signing-key-BdDy9ZJN.js} +1 -1
  76. package/dist/{jwt-signing-key-CUpweD22.mjs → jwt-signing-key-C_w5ielw.mjs} +1 -1
  77. package/dist/kernel/moleculer/process-service.d.ts +1 -110
  78. package/dist/kernel/moleculer/runner-heap-flags.d.ts +228 -0
  79. package/dist/{restore-archive-DA-aJtg7.js → restore-archive-BJp4Llea.js} +156 -65
  80. package/dist/{restore-archive-CFmPVlWQ.mjs → restore-archive-CZyUM8Qf.mjs} +151 -66
  81. package/dist/{retired-settings-keys-D62kN_hT.js → retired-settings-keys-Dr2AyhsD.js} +1 -1
  82. package/dist/{retired-settings-keys-DCGEI4Vi.mjs → retired-settings-keys-Dut6QWh6.mjs} +1 -1
  83. package/dist/{wal-maintenance-BlqNqaQ3.mjs → wal-maintenance-BZhzoxqA.mjs} +0 -0
  84. package/dist/{wal-maintenance-BzdCQGjH.js → wal-maintenance-Bzm3O2Nj.js} +0 -0
  85. package/package.json +1 -1
@@ -0,0 +1,228 @@
1
+ import type { AddonHeapProfile } from "@camstack/types";
2
+ /** One addon hosted inside a runner subprocess. */
3
+ export interface RunnerAddonSpec {
4
+ readonly addonId: string;
5
+ readonly addonDir: string;
6
+ }
7
+ /** The memory declarations one addon's manifest makes about its own runner. */
8
+ export interface AddonHeapDeclaration {
9
+ readonly profile: AddonHeapProfile | undefined;
10
+ /** `execution.maxOldSpaceMb`, when declared. `0` means "this addon opts out of a ceiling". */
11
+ readonly maxOldSpaceMb: number | undefined;
12
+ /**
13
+ * `execution.rssBudgetMb`, when declared — the whole-process footprint the addon's role
14
+ * considers legitimate. `undefined` means the addon declares no budget, which is NOT a
15
+ * request for a default: a runner where nobody declares one reports itself UNWATCHED.
16
+ */
17
+ readonly rssBudgetMb: number | undefined;
18
+ /**
19
+ * `execution.semiSpaceMb`, when declared and inside {@link SEMI_SPACE_MB_RANGE} — the
20
+ * young-generation semi-space the addon measured its runner needs. See `AddonExecution`.
21
+ */
22
+ readonly semiSpaceMb: number | undefined;
23
+ /**
24
+ * `execution.initialOldSpaceMb`, when declared and inside {@link INITIAL_OLD_SPACE_MB_RANGE}
25
+ * — the old-space headroom the runner starts with. See `AddonExecution`.
26
+ */
27
+ readonly initialOldSpaceMb: number | undefined;
28
+ }
29
+ /** Inclusive MB bounds a manifest value must sit in to be a declaration at all. */
30
+ export interface ManifestMbRange {
31
+ readonly min: number;
32
+ readonly max: number;
33
+ }
34
+ /**
35
+ * `execution.semiSpaceMb` is accepted in `[1, 256]` MB. V8 itself clamps the flag to its
36
+ * own limits (the 64-bit default is 64 MB after Node 24's scavenger change, a 256 MB
37
+ * semi-space is three times anything measured useful here); below 1 MB the flag is
38
+ * meaningless. Outside the range the declaration is IGNORED (the runner keeps its class
39
+ * default) rather than clamped: a clamped number silently becomes a different setting from
40
+ * the one in the file, and the spawn log would then say something the manifest does not.
41
+ */
42
+ export declare const SEMI_SPACE_MB_RANGE: ManifestMbRange;
43
+ /**
44
+ * `execution.initialOldSpaceMb` is accepted in `[16, 65536]` MB. Below 16 MB V8's default
45
+ * already starts larger; 64 GB clears any runner in this fleet. The accepted value is then
46
+ * clamped at spawn to the runner's old-space CEILING (`runnerHeapFlags`) — V8 refuses an
47
+ * initial size above `--max-old-space-size`, and the ceiling is the one number that belongs
48
+ * to the runner as a whole.
49
+ */
50
+ export declare const INITIAL_OLD_SPACE_MB_RANGE: ManifestMbRange;
51
+ export declare function readAddonHeapDeclaration(spec: RunnerAddonSpec): AddonHeapDeclaration;
52
+ /**
53
+ * Drop the cached heap declaration for each addon so the NEXT `readAddonHeapDeclaration`
54
+ * re-reads the manifest from disk. Called on operator-driven spawns / restarts
55
+ * (a deploy may have changed `execution.heapProfile` / `execution.maxOldSpaceMb`); the
56
+ * crash-respawn fast path deliberately does NOT invalidate — a crashing runner
57
+ * has the same on-disk manifest, so re-reading it is pure overhead.
58
+ */
59
+ export declare function invalidateHeapProfiles(addons: readonly RunnerAddonSpec[]): void;
60
+ /**
61
+ * A runner is heavy if ANY co-located addon declares `heapProfile: 'heavy'` in its manifest.
62
+ * Everything else (the un-annotated default) is a light control-plane runner. No hard-coded names.
63
+ */
64
+ export declare function isHeavyRunner(addons: readonly RunnerAddonSpec[]): boolean;
65
+ /**
66
+ * Default old-space ceiling for a HEAVY runner, in MB.
67
+ *
68
+ * ## Why a heavy runner needs a ceiling at all
69
+ *
70
+ * `heapProfile: 'heavy'` used to mean "V8 defaults", i.e. no `--max-old-space-size` — which on this
71
+ * host is V8's own memory-derived default (multiple GB). Nothing then bounds a burst, and V8 does
72
+ * not hand committed pages back (the long argument for that is in `kernel/heap-watch.ts`), so a
73
+ * one-off burst becomes the runner's permanent RSS.
74
+ *
75
+ * Measured on the live hub, 2026-08-17: the `recorder` runner reaches **2122 MB RSS** and stays
76
+ * there. The same addon on two agent nodes carrying the same code and a smaller archive sits at
77
+ * **58 MB**.
78
+ *
79
+ * ⚠ This paragraph used to attribute that ~2 GB to "walk garbage" from the deferred archive walk,
80
+ * and to argue from a ~21 MB `RecordingIndex`. Both were wrong, and the error cost a session on
81
+ * 2026-08-27. The deferred walk had NO CALLER and never ran (it has since been deleted). And the
82
+ * index is not 21 MB: a 2026-08-27 profile of the live recorder read `heapUsed = 1606 MB` against
83
+ * `external = 8 MB`, with an `smaps` census putting 1510 MB in V8's 256 KB page ranges against
84
+ * 54 MB of native heap — LIVE JavaScript retainers, not high-water garbage. The retained set is
85
+ * ~7.1 M `SegmentRow`s plus their path strings and Map entries (13 cameras x 2 profiles x 8 640
86
+ * segments/day x ~30 days), reseeded at boot by `SegmentHourLedger.hydrateIndex`.
87
+ *
88
+ * The lesson for anyone sizing this flag: a heap number needs a RETAINER, and "garbage the GC has
89
+ * no reason to collect" is the explanation to reach for last, not first.
90
+ *
91
+ * ## Why 1024
92
+ *
93
+ * From the same live topology snapshot, every heavy runner's FULL RSS — which is a strict
94
+ * over-estimate of its old space, since it also contains native allocations, `external` and
95
+ * `arrayBuffers`, none of which `--max-old-space-size` bounds:
96
+ *
97
+ * detection 471 MB · provider-reolink 507 MB · pipeline-orchestrator 238 MB · ml 218 MB ·
98
+ * snapshot 185 MB · decoder-nodeav 174 MB · audio-analyzer 170 MB
99
+ *
100
+ * 1024 MB clears the largest of those by ~2x while cutting the recorder's observed ratchet by
101
+ * more than half. It is a CEILING, not a target: a runner that fits today keeps behaving exactly
102
+ * as it does now, because V8 only collects harder as it approaches the limit.
103
+ *
104
+ * A runner whose working set genuinely does not fit declares its own number in its manifest
105
+ * (`execution.maxOldSpaceMb`) — the number belongs next to the addon that knows its working set.
106
+ * `recorder` (3072 MB; peak 2341 MB heapTotal) is the one shipped addon that does. `stream-broker` does NOT, despite 1585 MB peak RSS: ~1 GB of that is
107
+ * `arrayBuffers` (47 RTP rings), which this flag does not bound and never will — its actual old
108
+ * space peaks at 224 MB. Size the declaration from `heapTotal` in the `[mem]` heartbeat, never
109
+ * from RSS.
110
+ *
111
+ * ## What this flag CANNOT do
112
+ *
113
+ * It bounds old space and nothing else — not `external`, not `arrayBuffers`, not what a native
114
+ * addon mallocs. `hub/pipeline-analytics` reached 7334 MB RSS with this flag set to 1024 the
115
+ * whole time, because 96% of it was native. That half is bounded in `runner-native-allocator.ts`.
116
+ * Reaching for a bigger number here when RSS is the complaint is the standard wrong move.
117
+ */
118
+ export declare const HEAVY_MAX_OLD_MB_DEFAULT = 1024;
119
+ /**
120
+ * V8 platform worker threads for a LIGHT runner (`--v8-pool-size`).
121
+ *
122
+ * ## What this bounds
123
+ *
124
+ * Node gives every process a V8 platform thread pool — background compilation,
125
+ * parallel marking and sweeping — defaulted to 4. It is not the libuv pool
126
+ * (`UV_THREADPOOL_SIZE`, which serves `fs`/`dns`/`zlib`/`crypto`) and it is
127
+ * deliberately not touched here; see below.
128
+ *
129
+ * ## Why it is worth a flag
130
+ *
131
+ * A container cgroup counts THREADS, not processes, and this cluster forks a
132
+ * runner per addon (D2). Measured on the live hub 2026-09-18 (20 cores,
133
+ * `pids.max=2048`), after 22 fork refusals in 45 minutes earlier that day:
134
+ *
135
+ * pids.current 991 · 47 node processes holding 469 threads
136
+ * of those, 29 LIGHT runners holding 274 (mean 9.4 threads each)
137
+ *
138
+ * A light runner is a control-plane addon with a 2 MB young generation. It does
139
+ * not generate the background GC work four marking threads exist for, and it
140
+ * pays 4 threads of the cgroup's pid budget for them anyway. Two returns ~58
141
+ * threads — ~6% of the container's live thread count — for background GC that
142
+ * is, on these heaps, a few milliseconds a cycle.
143
+ *
144
+ * ## Why TWO and never ZERO
145
+ *
146
+ * `--v8-pool-size=0` does not disable the pool: it tells V8 to size it from the
147
+ * detected parallelism. Measured locally 2026-09-18 on a node process holding
148
+ * the libuv pool open:
149
+ *
150
+ * default 11 threads
151
+ * --v8-pool-size=2 9 threads
152
+ * --v8-pool-size=0 18 threads
153
+ *
154
+ * So the flag is only ever passed with a value of at least 1 — the same trap as
155
+ * `MALLOC_ARENA_MAX=0` in `runner-native-allocator.ts`.
156
+ *
157
+ * ## What is deliberately NOT bounded
158
+ *
159
+ * HEAVY runners keep V8's default: they hold real heaps (the recorder declares
160
+ * 3072 MB) where parallel marking is the difference between a pause and a stall.
161
+ *
162
+ * `UV_THREADPOOL_SIZE` is left alone on every runner. Its 4 threads are lazily
163
+ * spawned, and the measurement above says most light runners have spawned all
164
+ * four — i.e. they really do run concurrent `fs` work. Halving that would trade
165
+ * ~58 more threads for serialized filesystem I/O in runners whose I/O profile
166
+ * has not been measured, on a media system. A thread saving is not worth a
167
+ * media gap.
168
+ */
169
+ export declare const LIGHT_V8_POOL_SIZE_DEFAULT = 2;
170
+ /** Light runners' young-generation semi-space, in MB. */
171
+ export declare const LIGHT_SEMI_SPACE_MB = 2;
172
+ /**
173
+ * The runner's old-space ceiling in MB, or 0 for "uncapped".
174
+ *
175
+ * The runner takes the MAXIMUM ceiling any co-located addon declares — co-location must never
176
+ * silence the hungriest member (D2/D29: a runner is respawned as a whole, so an OOM caused by
177
+ * under-sizing one member kills every addon in the group). An addon declaring `0` therefore lifts
178
+ * the cap for the whole runner, which is the intended escape hatch.
179
+ */
180
+ export declare function runnerMaxOldSpaceMb(addons: readonly RunnerAddonSpec[], heavy: boolean): number;
181
+ /**
182
+ * The runner's declared RSS budget in MB, or `undefined` when NOBODY declared one.
183
+ *
184
+ * Same rule as the old-space ceiling, for the same reason (D2/D29): the runner takes the MAXIMUM
185
+ * any co-located addon declares, because a runner is respawned as a whole and co-location must
186
+ * never silence the hungriest member. It would be strictly worse here than for a heap ceiling —
187
+ * an under-sized budget does not kill anything, it just cries wolf, and a watchdog that cries
188
+ * wolf is a watchdog that gets muted.
189
+ *
190
+ * `undefined` propagates as an ABSENT env var, and the child then announces itself unwatched.
191
+ * There is deliberately no class default: see `AddonExecution.rssBudgetMb`.
192
+ */
193
+ export declare function runnerRssBudgetMb(addons: readonly RunnerAddonSpec[]): number | undefined;
194
+ /**
195
+ * Per-placement V8 flags (Block D). LIGHT runners cap their young generation
196
+ * (`--max-semi-space-size`) to reclaim V8's over-reservation — a measured ~15 MB/runner win — bound
197
+ * V8's platform thread pool (`--v8-pool-size`, see {@link LIGHT_V8_POOL_SIZE_DEFAULT}: a measured
198
+ * ~58 threads across the hub's 29 light runners, against a cgroup `pids.max` this host has already
199
+ * hit) and carry no old-space cap unless a manifest declares one. HEAVY
200
+ * runners keep V8's default young generation but ALWAYS carry an old-space ceiling; see
201
+ * {@link HEAVY_MAX_OLD_MB_DEFAULT} for why, and how a runner declares its own.
202
+ *
203
+ * The per-addon overrides are the manifest's declared `maxOldSpaceMb` (`0` = uncapped),
204
+ * `semiSpaceMb` (`--max-semi-space-size`, replacing the class default) and
205
+ * `initialOldSpaceMb` (`--initial-old-space-size`, clamped to the ceiling) — each the MAXIMUM
206
+ * across a runner's co-located addons. Shipped for `stream-broker` alone (8 / 512): with the
207
+ * heavy class default the hub's broker ran ~166 major GCs a minute and not one scavenge, at
208
+ * 45–60 % of a core. The environment overrides and the `CAMSTACK_RUNNER_HEAP_TUNING=off` kill
209
+ * switch were removed on 2026-10-06 — nothing had set them since the rollout. The flags must
210
+ * be on the spawn command line: `v8.setFlagsFromString` at runtime changes neither space.
211
+ */
212
+ export declare function runnerHeapFlags(addons: readonly RunnerAddonSpec[]): string[];
213
+ /**
214
+ * The runner's declared young-generation semi-space in MB, or `undefined` when no
215
+ * co-located addon declares one (→ the class default applies).
216
+ *
217
+ * The MAXIMUM across the group, like every other declaration here (D2/D29): the
218
+ * member that measured its churn away must not be handed a smaller young
219
+ * generation because a quieter co-tenant declared one.
220
+ */
221
+ export declare function runnerSemiSpaceMb(addons: readonly RunnerAddonSpec[]): number | undefined;
222
+ /**
223
+ * The runner's declared initial old space in MB, or `undefined` when nobody declares
224
+ * one (→ V8 grows it from the live set). MAXIMUM across the group, same rule and same
225
+ * reason as {@link runnerSemiSpaceMb}; the clamp to the runner's ceiling happens in
226
+ * {@link runnerHeapFlags}, where the ceiling is known.
227
+ */
228
+ export declare function runnerInitialOldSpaceMb(addons: readonly RunnerAddonSpec[]): number | undefined;
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-Cek0wNdY.js");
2
- const require_dist = require("./dist-CiUKtVkX.js");
2
+ const require_dist = require("./dist-K3WFop9Q.js");
3
3
  let node_crypto = require("node:crypto");
4
4
  let buffer = require("buffer");
5
5
  let node_fs_promises = require("node:fs/promises");
@@ -3508,6 +3508,11 @@ function isSqlitePath(relPath) {
3508
3508
  const db = databaseOfSidecar(relPath) ?? relPath;
3509
3509
  return SQLITE_DATABASE_SUFFIXES.some((suffix) => db.endsWith(suffix));
3510
3510
  }
3511
+ /**
3512
+ * The databases a run carries when it states none: the minimum (D382). A
3513
+ * schedule, a manual run and `createArchive` all fall back to this one value.
3514
+ */
3515
+ var DEFAULT_BACKUP_DATABASES = ["settings"];
3511
3516
  /** Path of the manifest file embedded inside every archive. */
3512
3517
  var ARCHIVE_MANIFEST_NAME = ".camstack-backup-manifest.json";
3513
3518
  var DEFAULT_STAT_CACHE_TTL_MS = 6e4;
@@ -3596,6 +3601,8 @@ var SystemBackupService = class {
3596
3601
  throwIfCancelled(opts.signal);
3597
3602
  const recipient = opts.encryptTo !== void 0 ? parseBackupPublicKey(opts.encryptTo) : null;
3598
3603
  const requested = opts.locations ?? DEFAULT_BACKUP_LOCATIONS;
3604
+ const selection = new Set(opts.databases ?? DEFAULT_BACKUP_DATABASES);
3605
+ if (!selection.has("settings")) return this.createSnapshotsOnlyArchive(opts, selection, recipient);
3599
3606
  const present = [];
3600
3607
  for (const loc of requested) {
3601
3608
  const abs = node_path.isAbsolute(loc) ? loc : node_path.join(this.dataDir, loc);
@@ -3613,7 +3620,12 @@ var SystemBackupService = class {
3613
3620
  else if (!isExcludedFromBackup(node_path.relative(this.dataDir, abs))) walked.push(toArchiveEntry(abs, this.dataDir, stat));
3614
3621
  }
3615
3622
  const walkedPaths = new Set(walked.map((e) => e.path));
3616
- const owned = walked.some((e) => isSqlitePath(e.path)) ? await this.ownedDatabasesInScope(opts.owner, walkedPaths) : /* @__PURE__ */ new Set();
3623
+ const ownedById = walked.some((e) => isSqlitePath(e.path)) ? await this.ownedDatabasesInScope(opts.owner, walkedPaths) : /* @__PURE__ */ new Map();
3624
+ const owned = new Set(ownedById.keys());
3625
+ const selected = /* @__PURE__ */ new Map();
3626
+ const omitted = /* @__PURE__ */ new Set();
3627
+ for (const [rel, databaseId] of ownedById) if (selection.has(databaseId)) selected.set(rel, databaseId);
3628
+ else omitted.add(databaseId);
3617
3629
  const unowned = /* @__PURE__ */ new Set();
3618
3630
  for (const e of walked) {
3619
3631
  const db = databaseOfSidecar(e.path);
@@ -3627,7 +3639,7 @@ var SystemBackupService = class {
3627
3639
  } });
3628
3640
  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`);
3629
3641
  }
3630
- const snapshot = owned.size > 0 && opts.owner ? await this.takeOwnerSnapshot(opts.owner, owned, opts.signal) : null;
3642
+ const snapshots = selected.size > 0 && opts.owner ? await this.takeOwnerSnapshots(opts.owner, selected, opts.signal) : [];
3631
3643
  try {
3632
3644
  const liveFiles = /* @__PURE__ */ new Set();
3633
3645
  for (const db of owned) {
@@ -3635,7 +3647,7 @@ var SystemBackupService = class {
3635
3647
  for (const suffix of SQLITE_SIDECAR_SUFFIXES) liveFiles.add(db + suffix);
3636
3648
  }
3637
3649
  const entries = walked.filter((e) => !liveFiles.has(e.path));
3638
- if (snapshot !== null) entries.push(snapshot.entry);
3650
+ for (const snapshot of snapshots) entries.push(snapshot.entry);
3639
3651
  const manifest = {
3640
3652
  archiveVersion: 1,
3641
3653
  createdAt: Date.now(),
@@ -3644,15 +3656,20 @@ var SystemBackupService = class {
3644
3656
  entries,
3645
3657
  totalBytes: entries.reduce((acc, e) => acc + e.sizeBytes, 0),
3646
3658
  totalFiles: entries.filter((e) => e.kind === "file").length,
3647
- ...snapshot !== null ? { sqliteSnapshots: [snapshot.archived] } : {}
3659
+ ...snapshots.length > 0 ? { sqliteSnapshots: snapshots.map((snapshot) => snapshot.archived) } : {},
3660
+ ...omitted.size > 0 ? { omittedDatabases: [...omitted].toSorted() } : {}
3648
3661
  };
3649
- const carriedSnapshot = snapshot?.archived.snapshotPath ?? null;
3662
+ if (omitted.size > 0) this.logger.info("createArchive: owned databases left out of this run", { meta: {
3663
+ omitted: [...omitted].toSorted(),
3664
+ carried: [...selected.values()]
3665
+ } });
3666
+ const carriedSnapshots = new Set(snapshots.map((snapshot) => snapshot.archived.snapshotPath));
3650
3667
  return await this.writeArchive({
3651
3668
  archivePath: opts.archivePath,
3652
3669
  present,
3653
3670
  manifest,
3654
3671
  excluded: (rel) => {
3655
- if (rel === carriedSnapshot) return false;
3672
+ if (carriedSnapshots.has(rel)) return false;
3656
3673
  return liveFiles.has(rel) || isExcludedFromBackup(rel);
3657
3674
  },
3658
3675
  ...opts.signal ? { signal: opts.signal } : {},
@@ -3666,7 +3683,62 @@ var SystemBackupService = class {
3666
3683
  }
3667
3684
  });
3668
3685
  } finally {
3669
- if (snapshot !== null) await this.removeSnapshot(snapshot.absPath);
3686
+ for (const snapshot of snapshots) await this.removeSnapshot(snapshot.absPath);
3687
+ }
3688
+ }
3689
+ /**
3690
+ * An archive of the selected databases' owner snapshots and the manifest —
3691
+ * no data-root walk, no location, no settings (D852). Refused when the owner
3692
+ * has none of the selected files: an archive of nothing must reach no
3693
+ * destination, because its upload prunes an older archive that has them.
3694
+ */
3695
+ async createSnapshotsOnlyArchive(opts, selection, recipient) {
3696
+ if (opts.owner === void 0) throw new BackupSnapshotRefusedError("a snapshots-only archive needs the database owner, and none was given");
3697
+ node_fs.mkdirSync(node_path.dirname(opts.archivePath), { recursive: true });
3698
+ const ownedById = await this.ownedDatabasesInScope(opts.owner, null);
3699
+ const selected = /* @__PURE__ */ new Map();
3700
+ for (const [rel, databaseId] of ownedById) if (selection.has(databaseId)) selected.set(rel, databaseId);
3701
+ if (selected.size === 0) {
3702
+ this.logger.warn("createArchive: the owner has none of the selected databases — no archive built", { meta: {
3703
+ requested: [...selection].toSorted(),
3704
+ archivePath: opts.archivePath
3705
+ } });
3706
+ throw new BackupSnapshotRefusedError(`the owner has none of the selected databases (${[...selection].toSorted().join(", ")})`);
3707
+ }
3708
+ const snapshots = await this.takeOwnerSnapshots(opts.owner, selected, opts.signal);
3709
+ try {
3710
+ const entries = snapshots.map((snapshot) => snapshot.entry);
3711
+ const manifest = {
3712
+ archiveVersion: 1,
3713
+ createdAt: Date.now(),
3714
+ dataDir: this.dataDir,
3715
+ locations: [],
3716
+ entries,
3717
+ totalBytes: entries.reduce((acc, e) => acc + e.sizeBytes, 0),
3718
+ totalFiles: entries.length,
3719
+ sqliteSnapshots: snapshots.map((snapshot) => snapshot.archived)
3720
+ };
3721
+ const carried = snapshots.map((snapshot) => snapshot.archived.snapshotPath);
3722
+ const carriedSet = new Set(carried);
3723
+ const result = await this.writeArchive({
3724
+ archivePath: opts.archivePath,
3725
+ present: [],
3726
+ tarRoots: carried,
3727
+ manifest,
3728
+ excluded: (rel) => rel !== ".camstack-backup-manifest.json" && !carriedSet.has(rel),
3729
+ ...opts.signal ? { signal: opts.signal } : {},
3730
+ ...opts.onArchiveBytes ? { onArchiveBytes: opts.onArchiveBytes } : {},
3731
+ ...opts.encryptTo !== void 0 ? { encryptTo: opts.encryptTo } : {}
3732
+ });
3733
+ return recipient === null ? result : {
3734
+ ...result,
3735
+ encryption: {
3736
+ scheme: BACKUP_ENCRYPTION_SCHEME,
3737
+ keyId: recipient.keyId
3738
+ }
3739
+ };
3740
+ } finally {
3741
+ for (const snapshot of snapshots) await this.removeSnapshot(snapshot.absPath);
3670
3742
  }
3671
3743
  }
3672
3744
  /**
@@ -3700,7 +3772,7 @@ var SystemBackupService = class {
3700
3772
  if (!excludePrefix) return true;
3701
3773
  return norm !== excludePrefix && !norm.startsWith(`${excludePrefix}/`);
3702
3774
  }
3703
- }, [ARCHIVE_MANIFEST_NAME, ...present]);
3775
+ }, [ARCHIVE_MANIFEST_NAME, ...opts.tarRoots ?? present]);
3704
3776
  const sink = node_fs.createWriteStream(opts.archivePath, { mode: 384 });
3705
3777
  try {
3706
3778
  const streamOpts = opts.signal ? { signal: opts.signal } : {};
@@ -3817,12 +3889,13 @@ var SystemBackupService = class {
3817
3889
  if (node_fs.existsSync(markerPath)) node_fs.rmSync(markerPath, { force: true });
3818
3890
  }
3819
3891
  /**
3820
- * The databases the owner declares, archive-relative, that this walk found.
3821
- * One outside this data root is not in scope. An owner that cannot answer
3822
- * stops the archive: without the answer, nothing says which file is live.
3892
+ * The databases the owner declares, archive-relative, that this walk found,
3893
+ * with the id of each. One outside this data root is not in scope. An owner
3894
+ * that cannot answer stops the archive: without the answer, nothing says
3895
+ * which file is live.
3823
3896
  */
3824
3897
  async ownedDatabasesInScope(owner, walked) {
3825
- if (owner === void 0) return /* @__PURE__ */ new Set();
3898
+ if (owner === void 0) return /* @__PURE__ */ new Map();
3826
3899
  let declared;
3827
3900
  try {
3828
3901
  declared = await owner.listDatabases();
@@ -3831,22 +3904,23 @@ var SystemBackupService = class {
3831
3904
  this.logger.error("createArchive: the database owner did not say what it owns — no archive built", { meta: { error: reason } });
3832
3905
  throw new BackupSnapshotRefusedError(`the database owner could not be asked: ${reason}`);
3833
3906
  }
3834
- const inScope = /* @__PURE__ */ new Set();
3835
- for (const abs of declared) {
3836
- const rel = this.archiveRelative(abs);
3837
- if (rel !== null && walked.has(rel)) inScope.add(rel);
3907
+ const inScope = /* @__PURE__ */ new Map();
3908
+ for (const db of declared) {
3909
+ const rel = this.archiveRelative(db.livePath);
3910
+ if (rel !== null && (walked === null || walked.has(rel))) inScope.set(rel, db.databaseId);
3838
3911
  }
3839
3912
  return inScope;
3840
3913
  }
3841
3914
  /**
3842
- * Ask the owner for its snapshot, and place it in this archive. The snapshot
3843
- * must sit on THIS data root — the call is pinned to this node, so a path
3844
- * elsewhere means it reached another host, and that file is not ours to
3845
- * read — beside an owned database in scope, and it must cover EVERY owned
3846
- * database in scope. Anything else is refused.
3915
+ * Ask the owner for one verified snapshot per SELECTED database, one after
3916
+ * the other, and place each in this archive. A snapshot must sit on THIS data
3917
+ * root — the call is pinned to this node, so a path elsewhere means it
3918
+ * reached another host, and that file is not ours to read — beside the very
3919
+ * database it was asked for. Anything else is refused, and the run builds
3920
+ * nothing: the copies already taken are removed.
3847
3921
  *
3848
- * Only ever DELETED here: `<owned db>.backup-snapshot` under this data root,
3849
- * a name this process derives itself. A path the owner ANSWERS is never
3922
+ * Only ever DELETED here: `<selected db>.backup-snapshot` under this data
3923
+ * root, a name this process derives itself. A path the owner ANSWERS is never
3850
3924
  * deleted on its word — answering the live database as the snapshot would
3851
3925
  * otherwise delete the live database. A file at any other path is left to
3852
3926
  * the owner's own sweep.
@@ -3854,62 +3928,73 @@ var SystemBackupService = class {
3854
3928
  * A cancel is honoured while the owner is still copying; the copy it may
3855
3929
  * finish afterwards is removed when that call settles.
3856
3930
  */
3857
- async takeOwnerSnapshot(owner, owned, signal) {
3931
+ async takeOwnerSnapshots(owner, selected, signal) {
3858
3932
  const removeOwnedSnapshots = async () => {
3859
- for (const db of owned) await this.removeSnapshot(node_path.join(this.dataDir, db + require_dist.DATABASE_SNAPSHOT_SUFFIX));
3933
+ for (const db of selected.keys()) await this.removeSnapshot(node_path.join(this.dataDir, db + require_dist.DATABASE_SNAPSHOT_SUFFIX));
3860
3934
  };
3935
+ const taken = [];
3936
+ try {
3937
+ for (const [expectedLive, databaseId] of selected) {
3938
+ throwIfCancelled(signal);
3939
+ taken.push(await this.takeOneSnapshot(owner, databaseId, expectedLive, signal, selected, removeOwnedSnapshots));
3940
+ }
3941
+ } catch (err) {
3942
+ await removeOwnedSnapshots();
3943
+ throw err;
3944
+ }
3945
+ return taken;
3946
+ }
3947
+ async takeOneSnapshot(owner, databaseId, expectedLive, signal, selected, removeOwnedSnapshots) {
3861
3948
  let result;
3862
3949
  try {
3863
- result = await raceAbort(owner.writeSnapshot(), signal, () => {
3864
- this.logger.info("createArchive: run cancelled while the owner copied — its copy is removed when it lands", { meta: { databases: [...owned] } });
3950
+ result = await raceAbort(owner.writeSnapshot(databaseId), signal, () => {
3951
+ this.logger.info("createArchive: run cancelled while the owner copied — its copy is removed when it lands", { meta: {
3952
+ databases: [...selected.values()],
3953
+ database: databaseId
3954
+ } });
3865
3955
  return removeOwnedSnapshots();
3866
3956
  });
3867
3957
  } catch (err) {
3868
3958
  if (err instanceof BackupCancelledError) throw err;
3869
3959
  const reason = err instanceof Error ? err.message : String(err);
3870
3960
  this.logger.error("createArchive: the owner could not snapshot its live database — no archive built", { meta: {
3871
- databases: [...owned],
3961
+ databases: [...selected.values()],
3962
+ database: databaseId,
3872
3963
  error: reason
3873
3964
  } });
3874
- await removeOwnedSnapshots();
3875
- throw new BackupSnapshotRefusedError(reason);
3965
+ throw new BackupSnapshotRefusedError(`${databaseId}: ${reason}`);
3876
3966
  }
3877
- try {
3878
- const livePath = this.archiveRelative(result.livePath);
3879
- const snapshotPath = this.archiveRelative(result.snapshotPath);
3880
- if (livePath === null || snapshotPath === null) {
3881
- this.logger.warn("createArchive: the owner answered a snapshot outside this data root — not touched, left to its sweep", { meta: {
3882
- snapshot: result.snapshotPath,
3883
- dataDir: this.dataDir
3884
- } });
3885
- throw new BackupSnapshotRefusedError(`snapshot ${result.snapshotPath} is not under this archive's data root ${this.dataDir}`);
3886
- }
3887
- if (snapshotPath !== livePath + ".backup-snapshot") throw new BackupSnapshotRefusedError(`snapshot ${snapshotPath} does not sit beside the database ${livePath} it copies`);
3888
- const uncovered = [...owned].filter((db) => db !== livePath);
3889
- if (!owned.has(livePath) || uncovered.length > 0) throw new BackupSnapshotRefusedError(`snapshot copies ${livePath}, but the owned database(s) in this archive are ${[...owned].join(", ")}`);
3890
- const absPath = node_path.join(this.dataDir, snapshotPath);
3891
- const stat = await node_fs_promises.stat(absPath);
3892
- this.logger.info("createArchive: carrying the owner snapshot in place of the live database", { meta: {
3893
- database: livePath,
3894
- snapshot: snapshotPath,
3895
- sizeBytes: stat.size,
3896
- tables: result.tables.length,
3897
- copyMs: result.copyMs,
3898
- verifyMs: result.verifyMs
3967
+ const livePath = this.archiveRelative(result.livePath);
3968
+ const snapshotPath = this.archiveRelative(result.snapshotPath);
3969
+ if (livePath === null || snapshotPath === null) {
3970
+ this.logger.warn("createArchive: the owner answered a snapshot outside this data root — not touched, left to its sweep", { meta: {
3971
+ snapshot: result.snapshotPath,
3972
+ dataDir: this.dataDir
3899
3973
  } });
3900
- return {
3901
- absPath,
3902
- archived: {
3903
- ...result,
3904
- livePath,
3905
- snapshotPath
3906
- },
3907
- entry: toArchiveEntry(absPath, this.dataDir, stat)
3908
- };
3909
- } catch (err) {
3910
- await removeOwnedSnapshots();
3911
- throw err;
3974
+ throw new BackupSnapshotRefusedError(`snapshot ${result.snapshotPath} is not under this archive's data root ${this.dataDir}`);
3912
3975
  }
3976
+ if (snapshotPath !== livePath + ".backup-snapshot") throw new BackupSnapshotRefusedError(`snapshot ${snapshotPath} does not sit beside the database ${livePath} it copies`);
3977
+ if (livePath !== expectedLive || result.databaseId !== databaseId) throw new BackupSnapshotRefusedError(`snapshot copies ${livePath} (${result.databaseId}), but ${databaseId} (${expectedLive}) was asked for`);
3978
+ const absPath = node_path.join(this.dataDir, snapshotPath);
3979
+ const stat = await node_fs_promises.stat(absPath);
3980
+ this.logger.info("createArchive: carrying the owner snapshot in place of the live database", { meta: {
3981
+ database: livePath,
3982
+ databaseId,
3983
+ snapshot: snapshotPath,
3984
+ sizeBytes: stat.size,
3985
+ tables: result.tables.length,
3986
+ copyMs: result.copyMs,
3987
+ verifyMs: result.verifyMs
3988
+ } });
3989
+ return {
3990
+ absPath,
3991
+ archived: {
3992
+ ...result,
3993
+ livePath,
3994
+ snapshotPath
3995
+ },
3996
+ entry: toArchiveEntry(absPath, this.dataDir, stat)
3997
+ };
3913
3998
  }
3914
3999
  async removeSnapshot(absPath) {
3915
4000
  try {
@@ -4089,6 +4174,12 @@ Object.defineProperty(exports, "BackupSnapshotRefusedError", {
4089
4174
  return BackupSnapshotRefusedError;
4090
4175
  }
4091
4176
  });
4177
+ Object.defineProperty(exports, "DEFAULT_BACKUP_DATABASES", {
4178
+ enumerable: true,
4179
+ get: function() {
4180
+ return DEFAULT_BACKUP_DATABASES;
4181
+ }
4182
+ });
4092
4183
  Object.defineProperty(exports, "ENCRYPTED_BACKUP_SUFFIX", {
4093
4184
  enumerable: true,
4094
4185
  get: function() {