@camstack/system 1.2.168 → 1.2.170

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 (60) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  14. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  15. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  16. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  19. package/dist/builtins/hub-forwarder/index.js +1 -1
  20. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  22. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  24. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  25. package/dist/builtins/local-network/local-network.addon.js +1 -1
  26. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  27. package/dist/builtins/loki-logging/index.js +1 -1
  28. package/dist/builtins/loki-logging/index.mjs +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  31. package/dist/builtins/platform-probe/index.js +1 -1
  32. package/dist/builtins/platform-probe/index.mjs +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  35. package/dist/builtins/snapshot/index.js +1 -1
  36. package/dist/builtins/snapshot/index.mjs +1 -1
  37. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  41. package/dist/builtins/storage-orchestrator/data-store-dispatch.d.ts +18 -6
  42. package/dist/builtins/storage-orchestrator/hub-settings-forward.d.ts +68 -0
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +27 -4
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +236 -60
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +236 -60
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-DwaeuknI.mjs → dist-1DEQFmTq.mjs} +167 -6
  51. package/dist/{dist-DCdtLXgx.js → dist-DsoVfJSR.js} +167 -6
  52. package/dist/hub-cap-forward-BdT320dq.mjs +53 -0
  53. package/dist/hub-cap-forward-DmHNjbB0.js +70 -0
  54. package/dist/index.js +6 -5
  55. package/dist/index.mjs +3 -2
  56. package/dist/{manifest-system-deps-D52_8SN1.mjs → manifest-system-deps-Da6sme9C.mjs} +3 -54
  57. package/dist/{manifest-system-deps-Bi7oyXcN.js → manifest-system-deps-ir_XTNpe.js} +2 -71
  58. package/dist/{retired-settings-keys-CB3wWpuC.mjs → retired-settings-keys-ChS1Q2Kv.mjs} +1 -1
  59. package/dist/{retired-settings-keys-DobfRRgq.js → retired-settings-keys-DTE5k4lU.js} +1 -1
  60. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
- import { $t as parseJsonObject, C as StorageMigrationJobSchema, Ct as settingsStoreCapability, Et as storageMigrationCapability, Nt as BaseAddon, P as collectSecretConfigKeys, S as StorageLocationTypeSchema, Tt as storageCapability, m as REDACTED_SECRET, x as StorageCleanupJobSchema } from "../../dist-DwaeuknI.mjs";
1
+ import { $t as parseJsonObject, C as StorageMigrationJobSchema, Ct as settingsStoreCapability, Et as storageMigrationCapability, Nt as BaseAddon, P as collectSecretConfigKeys, S as StorageLocationTypeSchema, Tt as storageCapability, m as REDACTED_SECRET, x as StorageCleanupJobSchema } from "../../dist-1DEQFmTq.mjs";
2
+ import { t as HUB_CAP_FWD_ACTION } from "../../hub-cap-forward-BdT320dq.mjs";
2
3
  import { z } from "zod";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import * as fs from "node:fs/promises";
@@ -297,7 +298,7 @@ var StorageMigrationCoordinator = class {
297
298
  if (!target) throw new Error(`Storage location "${targetId}" not found`);
298
299
  if (target.type !== storageClass) throw new Error(`Storage location "${targetId}" is type "${target.type}", expected "${storageClass}"`);
299
300
  if (source.id === target.id) throw new Error(namedSourceId ? `Source and destination are the same location ("${targetId}")` : `Storage location "${targetId}" is already the "${storageClass}" default`);
300
- const freezeSource = defaultLoc !== void 0 && source.id !== defaultLoc.id;
301
+ const freezeSource = defaultLoc === null || source.id !== defaultLoc.id;
301
302
  if (!MOVER_CLASSES.includes(storageClass)) throw new Error(`No mover owns "${storageClass}" — the migration can repoint its default but cannot move its bytes. Move it by hand, then repoint the default with upsertLocation.`);
302
303
  moves.push({
303
304
  storageClass,
@@ -1113,6 +1114,201 @@ function canProbeOccupancyLocally(locality) {
1113
1114
  return locality === true;
1114
1115
  }
1115
1116
  //#endregion
1117
+ //#region src/builtins/storage-orchestrator/data-store-dispatch.ts
1118
+ /**
1119
+ * Pick the target to serve a call.
1120
+ *
1121
+ * A LOCAL engine always wins: the remote is a fallback for a node that has
1122
+ * none, never a policy. Two cases are refused rather than guessed:
1123
+ *
1124
+ * - **none registered, and no remote** — resolving to a no-op would make
1125
+ * every read answer `undefined` and every write disappear, which reads
1126
+ * exactly like an empty database;
1127
+ * - **more than one engine** — there is no routing rule yet. The `engine`
1128
+ * hint on `declareCollection` is what will choose, and until it exists a
1129
+ * silent pick would send a collection to whichever engine happened to
1130
+ * register first. A remote does not rescue this: the ambiguity is local.
1131
+ */
1132
+ function resolveEngine(getEngines, getRemote) {
1133
+ const engines = getEngines();
1134
+ if (engines.length === 0) {
1135
+ const remote = getRemote?.() ?? null;
1136
+ if (remote) return remote;
1137
+ throw new Error("settings-store: no data-store-provider engine is registered — the data door has nothing behind it");
1138
+ }
1139
+ if (engines.length > 1) throw new Error(`settings-store: ${engines.length} data-store-provider engines are registered and there is no routing rule yet — declare the engine on the collection first`);
1140
+ const engine = engines[0];
1141
+ if (!engine) throw new Error("settings-store: data-store-provider collection yielded an empty entry");
1142
+ return engine;
1143
+ }
1144
+ /**
1145
+ * Build the `settings-store` provider that fronts the registered engines,
1146
+ * with an optional remote behind it for a node that registers none.
1147
+ *
1148
+ * `getEngines` and `getRemote` are read on every call, never captured:
1149
+ * engines register during boot and a runner respawn replaces the instance, so
1150
+ * a snapshot taken at construction time would go stale exactly when an addon
1151
+ * crashed and came back.
1152
+ */
1153
+ function createDataStoreDispatch(getEngines, getRemote) {
1154
+ const engine = async () => resolveEngine(getEngines, getRemote);
1155
+ return {
1156
+ get: async (input) => (await engine()).get(input),
1157
+ set: async (input) => (await engine()).set(input),
1158
+ query: async (input) => (await engine()).query(input),
1159
+ insert: async (input) => (await engine()).insert(input),
1160
+ insertMany: async (input) => (await engine()).insertMany(input),
1161
+ update: async (input) => (await engine()).update(input),
1162
+ delete: async (input) => (await engine()).delete(input),
1163
+ deleteWhere: async (input) => (await engine()).deleteWhere(input),
1164
+ updateWhere: async (input) => (await engine()).updateWhere(input),
1165
+ count: async (input) => (await engine()).count(input),
1166
+ aggregate: async (input) => (await engine()).aggregate(input),
1167
+ histogram: async (input) => (await engine()).histogram(input),
1168
+ isEmpty: async (input) => (await engine()).isEmpty(input),
1169
+ declareCollection: async (input) => (await engine()).declareCollection(input)
1170
+ };
1171
+ }
1172
+ //#endregion
1173
+ //#region src/builtins/storage-orchestrator/hub-settings-forward.ts
1174
+ /**
1175
+ * The data door with the HUB behind it — an agent's `settings-store` provider.
1176
+ *
1177
+ * ## Why this exists
1178
+ *
1179
+ * Persistence is the hub's. An agent runs no `data-store-provider` engine
1180
+ * (see `resolveAgentInfraCapabilities` in `server/backend/src/agent/`), so the
1181
+ * `settings-store` door the storage-orchestrator registers on an agent has
1182
+ * nothing local behind it. Rather than refuse — which would strand the four
1183
+ * infra addons that boot inside agent-main (`hub-forwarder`, `native-metrics`,
1184
+ * `platform-probe`, the orchestrator itself) with a `ctx.settings` that throws
1185
+ * on every read — the door FORWARDS into the hub's own routing.
1186
+ *
1187
+ * This is not a new transport. It is the same `$hub-cap-fwd.forward` a forked
1188
+ * addon on an agent has always used to reach a hub-hosted cap
1189
+ * (`kernel/transport/hub-cap-forward.ts` names `settings-store` in its own
1190
+ * header); agent-main's infra addons simply never had it, because they resolve
1191
+ * `ctx.api.settingsStore` through `localProviderLink` against the process's own
1192
+ * provider registry — which is exactly the local door.
1193
+ *
1194
+ * ## The payload crosses untouched
1195
+ *
1196
+ * The engine composes its table name from `namespace` + `collection` **out of
1197
+ * the payload**. A forwarder that injected, defaulted or stripped a namespace
1198
+ * would silently rename every table on the hub. Every method here is the same
1199
+ * forward, and the args are the caller's object verbatim — the same rule
1200
+ * `data-store-dispatch.ts` states for the local path, for the same reason.
1201
+ *
1202
+ * ## No second key space
1203
+ *
1204
+ * Per-node scoping already exists, one layer up, and it is per FIELD:
1205
+ * `BaseAddon` persists a `perNode: true` schema field as `<key>@<nodeId>`
1206
+ * inside the addon's single shared blob (`packages/types/src/addon/
1207
+ * per-node-store.ts`). The agent therefore reads and writes the SAME row the
1208
+ * hub's copy of that addon reads — `<addonId>:addon-settings` / `root` — and
1209
+ * the two agree by construction. Introducing a second, addon-level
1210
+ * `(nodeId, addonId)` row here would be a second authority over one fact,
1211
+ * which is the shape [D224](../../../../../docs/decisions/adr-0224-the-freshness-of-a-camera-fact-lives-in-its-provider.md)
1212
+ * forbids: the hub's copy would keep writing the bare row and nothing would
1213
+ * reconcile them.
1214
+ *
1215
+ * ## A hub NEVER forwards
1216
+ *
1217
+ * {@link nodeOwnsItsDataStore} gates construction. A hub that built one would
1218
+ * route its own door back into itself through `$hub-cap-fwd`, which resolves
1219
+ * `settings-store` to… this. The gate reads the same env var, with the same
1220
+ * default, as `settingsStoreIsAuthoritativeHere` — unset means hub, because
1221
+ * that is what the launcher defaults to.
1222
+ *
1223
+ * ## When the hub is down
1224
+ *
1225
+ * Every method REJECTS. It never answers "empty": `ctx.settings`' read path
1226
+ * (`addon-context-factory.ts`) distinguishes a MISS from a FAILURE precisely
1227
+ * so a failed read cannot make the next read-modify-write persist the patch
1228
+ * alone and destroy every field the patch does not mention. An empty answer
1229
+ * would be indistinguishable from an empty store; a rejection is not. The
1230
+ * operator's rule for this node is the same one that already governs it —
1231
+ * without the hub an agent has no work — so a rejection is the whole of the
1232
+ * degraded behaviour, and it is loud.
1233
+ */
1234
+ /**
1235
+ * Does THIS node hold its own data store?
1236
+ *
1237
+ * Unset or `'hub'` means hub — the launcher's own default, mirrored from
1238
+ * `resolveNodeRole` (`server/backend/src/node-role.ts`) the same way
1239
+ * `settingsStoreIsAuthoritativeHere` mirrors it, and duplicated rather than
1240
+ * imported for the same reason: `@camstack/system` is a dependency OF the
1241
+ * backend, not the other way round.
1242
+ *
1243
+ * Anything else is an agent, and an agent's answer is the hub's store.
1244
+ */
1245
+ function nodeOwnsItsDataStore(env) {
1246
+ const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
1247
+ return raw === "" || raw === "hub";
1248
+ }
1249
+ /**
1250
+ * Build the `settings-store` provider that forwards to the hub.
1251
+ *
1252
+ * Every method is the same forward under the CAP name — never the engine
1253
+ * name. The hub routes `settings-store` to its own door, which then picks its
1254
+ * local engine; asking for `data-store-provider` would step around the door on
1255
+ * the far side and lose whatever the door is for.
1256
+ */
1257
+ function createHubSettingsForward(forward) {
1258
+ const send = (method, args) => forward({
1259
+ capName: settingsStoreCapability.name,
1260
+ method,
1261
+ args
1262
+ });
1263
+ return {
1264
+ get: (input) => send("get", input),
1265
+ set: (input) => send("set", input),
1266
+ query: (input) => send("query", input),
1267
+ insert: (input) => send("insert", input),
1268
+ insertMany: (input) => send("insertMany", input),
1269
+ update: (input) => send("update", input),
1270
+ delete: (input) => send("delete", input),
1271
+ deleteWhere: (input) => send("deleteWhere", input),
1272
+ updateWhere: (input) => send("updateWhere", input),
1273
+ count: (input) => send("count", input),
1274
+ aggregate: (input) => send("aggregate", input),
1275
+ histogram: (input) => send("histogram", input),
1276
+ isEmpty: (input) => send("isEmpty", input),
1277
+ declareCollection: (input) => send("declareCollection", input)
1278
+ };
1279
+ }
1280
+ /**
1281
+ * How long an agent waits for the hub to answer a settings call before the
1282
+ * door rejects. Matches the agent's own `forwardToHub` budget in
1283
+ * `server/backend/src/agent/main.ts` — the same hop, over the same action, so
1284
+ * a shorter one here would only manufacture a timeout the transport did not
1285
+ * have.
1286
+ */
1287
+ var HUB_SETTINGS_FORWARD_TIMEOUT_MS = 6e4;
1288
+ /**
1289
+ * The whole decision, in one place: does this node's data door have the hub
1290
+ * behind it, and how does it get there?
1291
+ *
1292
+ * `null` means "no remote" and the door keeps its old behaviour — it refuses
1293
+ * when no engine is registered. That is the answer on a hub (which IS the
1294
+ * store) and in any process with no cluster broker.
1295
+ *
1296
+ * Extracted from the addon so the gate is unit-testable without booting a
1297
+ * `BaseAddon` — the same reason `data-store-dispatch.ts` and
1298
+ * `provider-discovery.ts` exist.
1299
+ */
1300
+ function resolveHubSettingsForward(options) {
1301
+ if (nodeOwnsItsDataStore(options.env)) return null;
1302
+ const broker = options.broker;
1303
+ if (!broker) return null;
1304
+ options.logger.info("storage-orchestrator: no local data-store engine on this node — the data door forwards to the hub");
1305
+ return createHubSettingsForward((envelope) => broker.call(HUB_CAP_FWD_ACTION, {
1306
+ capName: envelope.capName,
1307
+ method: envelope.method,
1308
+ args: envelope.args
1309
+ }, { timeout: HUB_SETTINGS_FORWARD_TIMEOUT_MS }));
1310
+ }
1311
+ //#endregion
1116
1312
  //#region src/builtins/storage-orchestrator/location-occupancy.ts
1117
1313
  /**
1118
1314
  * Delete-time occupancy of a location's on-disk root.
@@ -1185,57 +1381,6 @@ async function probeLocalDirectoryOccupancy(input) {
1185
1381
  return "empty";
1186
1382
  }
1187
1383
  //#endregion
1188
- //#region src/builtins/storage-orchestrator/data-store-dispatch.ts
1189
- /**
1190
- * Pick the engine to serve a call.
1191
- *
1192
- * Today the rule is "the only one there is". Two cases are refused rather
1193
- * than guessed:
1194
- *
1195
- * - **none registered** — resolving to a no-op would make every read
1196
- * answer `undefined` and every write disappear, which reads exactly
1197
- * like an empty database;
1198
- * - **more than one** — there is no routing rule yet. The `engine` hint
1199
- * on `declareCollection` is what will choose, and until it exists a
1200
- * silent pick would send a collection to whichever engine happened to
1201
- * register first.
1202
- */
1203
- function resolveEngine(getEngines) {
1204
- const engines = getEngines();
1205
- if (engines.length === 0) throw new Error("settings-store: no data-store-provider engine is registered — the data door has nothing behind it");
1206
- if (engines.length > 1) throw new Error(`settings-store: ${engines.length} data-store-provider engines are registered and there is no routing rule yet — declare the engine on the collection first`);
1207
- const engine = engines[0];
1208
- if (!engine) throw new Error("settings-store: data-store-provider collection yielded an empty entry");
1209
- return engine;
1210
- }
1211
- /**
1212
- * Build the `settings-store` provider that fronts the registered engines.
1213
- *
1214
- * `getEngines` is read on every call, never captured: engines register
1215
- * during boot and a runner respawn replaces the instance, so a snapshot
1216
- * taken at construction time would go stale exactly when an addon crashed
1217
- * and came back.
1218
- */
1219
- function createDataStoreDispatch(getEngines) {
1220
- const engine = async () => resolveEngine(getEngines);
1221
- return {
1222
- get: async (input) => (await engine()).get(input),
1223
- set: async (input) => (await engine()).set(input),
1224
- query: async (input) => (await engine()).query(input),
1225
- insert: async (input) => (await engine()).insert(input),
1226
- insertMany: async (input) => (await engine()).insertMany(input),
1227
- update: async (input) => (await engine()).update(input),
1228
- delete: async (input) => (await engine()).delete(input),
1229
- deleteWhere: async (input) => (await engine()).deleteWhere(input),
1230
- updateWhere: async (input) => (await engine()).updateWhere(input),
1231
- count: async (input) => (await engine()).count(input),
1232
- aggregate: async (input) => (await engine()).aggregate(input),
1233
- histogram: async (input) => (await engine()).histogram(input),
1234
- isEmpty: async (input) => (await engine()).isEmpty(input),
1235
- declareCollection: async (input) => (await engine()).declareCollection(input)
1236
- };
1237
- }
1238
- //#endregion
1239
1384
  //#region src/builtins/storage-orchestrator/location-secrets.ts
1240
1385
  /**
1241
1386
  * The config keys the ORCHESTRATOR itself owns, as opposed to the ones a
@@ -2480,7 +2625,6 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2480
2625
  pressureTimer = null;
2481
2626
  pressureSweepInFlight = false;
2482
2627
  migration = null;
2483
- cleanup = null;
2484
2628
  /**
2485
2629
  * Cached `providerId → nodeLocal` snapshot (SP1). Backs the orchestrator's
2486
2630
  * synchronous `NodeLocalResolver` — `getProviderInfo()` is async, so we
@@ -2786,7 +2930,6 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2786
2930
  now: () => Date.now(),
2787
2931
  newId: () => randomUUID()
2788
2932
  });
2789
- this.cleanup = cleanup;
2790
2933
  const migrationProvider = {
2791
2934
  plan: (input) => migration.plan(input),
2792
2935
  start: async (input) => ({ jobId: await migration.start(input) }),
@@ -2835,10 +2978,32 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2835
2978
  },
2836
2979
  {
2837
2980
  capability: settingsStoreCapability,
2838
- provider: createDataStoreDispatch(getEngines)
2981
+ provider: createDataStoreDispatch(getEngines, () => this.hubSettingsForward())
2839
2982
  }
2840
2983
  ];
2841
2984
  }
2985
+ /**
2986
+ * The remote behind the data door on a node that owns no engine.
2987
+ *
2988
+ * `null` on a hub — the hub IS the store, and a hub that forwarded would
2989
+ * route its own door back into itself through `$hub-cap-fwd`. `null` too
2990
+ * when this process has no cluster broker (a forked worker, or a test
2991
+ * harness); the door then behaves exactly as it always did and refuses.
2992
+ *
2993
+ * Built once and memoised. It is only consulted when `getEngines()` is
2994
+ * empty, so on a hub — and on an agent rolled back to a local engine via
2995
+ * `CAMSTACK_AGENT_LOCAL_DATA_STORE` — the resolve never runs a second time.
2996
+ */
2997
+ hubForward = null;
2998
+ hubSettingsForward() {
2999
+ if (this.hubForward) return this.hubForward;
3000
+ this.hubForward = resolveHubSettingsForward({
3001
+ env: process.env,
3002
+ broker: this.ctx.kernel.cluster?.broker,
3003
+ logger: this.ctx.logger
3004
+ });
3005
+ return this.hubForward;
3006
+ }
2842
3007
  async onShutdown() {
2843
3008
  for (const dispose of this.seedSubscriptionDisposers) try {
2844
3009
  dispose();
@@ -2859,9 +3024,19 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2859
3024
  /**
2860
3025
  * Resolve the live engine from the capability registry and wrap it in a
2861
3026
  * `SqliteLocationStore`. Returns `null` when no engine has registered
2862
- * yet — the service falls back to in-memory only. Production boot order
2863
- * (`data-store-provider` precedes `storage` in `INFRA_CAPABILITIES`)
2864
- * makes this the rare path.
3027
+ * yet — the service falls back to in-memory only. On a HUB that is a
3028
+ * boot-order window: `data-store-provider` precedes `storage` in
3029
+ * `INFRA_CAPABILITIES`, so it is the rare path.
3030
+ *
3031
+ * On an AGENT it is permanent and intended, and the line says so. An agent
3032
+ * boots no engine (`resolveAgentInfraCapabilities`), and this store must NOT
3033
+ * be pointed at the hub-forwarding data door: `storage_locations` is keyed by
3034
+ * a bare location id (`recordings:default`, `eventMedia:default`, …) which
3035
+ * every node in the cluster shares, so an agent writing through the door
3036
+ * would land on the HUB's rows. The locations an agent needs are re-seeded
3037
+ * from its addon declarations at every boot; the operator's real, node-pinned
3038
+ * locations live in the hub's own table (`__nodeId` in the config blob) and
3039
+ * are dispatched to the agent's `storage-provider` from there.
2865
3040
  *
2866
3041
  * Reads the ENGINE, not the `settings-store` singleton: since D44 this
2867
3042
  * addon *is* that singleton, so asking the registry for it would hand
@@ -2873,7 +3048,8 @@ var StorageOrchestratorAddon = class extends BaseAddon {
2873
3048
  if (!reg) return null;
2874
3049
  const engine = reg.getCollection(DATA_STORE_PROVIDER_CAP_NAME)[0];
2875
3050
  if (!engine) {
2876
- if (!quiet) this.ctx.logger.warn("storage-orchestrator: no data-store-provider engine yet — running in-memory until one registers");
3051
+ if (!quiet) if (nodeOwnsItsDataStore(process.env)) this.ctx.logger.warn("storage-orchestrator: no data-store-provider engine yet — running in-memory until one registers");
3052
+ else this.ctx.logger.info("storage-orchestrator: this node boots no data-store engine — storage locations are seeded from addon declarations at every boot, and node-pinned locations live on the hub");
2877
3053
  return null;
2878
3054
  }
2879
3055
  return new SqliteLocationStore(engine);
@@ -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-DCdtLXgx.js");
6
+ const require_dist = require("../../dist-DsoVfJSR.js");
7
7
  //#region src/builtins/system-config/system-config.addon.ts
8
8
  /**
9
9
  * Built-in `system-config` addon — Phase 4 of the settings redesign.
@@ -1,4 +1,4 @@
1
- import { Mt as errMsg, Nt as BaseAddon, Xt as hydrateSchema } from "../../dist-DwaeuknI.mjs";
1
+ import { Mt as errMsg, Nt as BaseAddon, Xt as hydrateSchema } from "../../dist-1DEQFmTq.mjs";
2
2
  //#region src/builtins/system-config/system-config.addon.ts
3
3
  /**
4
4
  * Built-in `system-config` addon — Phase 4 of the settings redesign.
@@ -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-DCdtLXgx.js");
6
+ const require_dist = require("../../dist-DsoVfJSR.js");
7
7
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  let node_path = require("node:path");
9
9
  node_path = require_chunk.__toESM(node_path);
@@ -1,4 +1,4 @@
1
- import { Nt as BaseAddon, st as logDestinationCapability } from "../../dist-DwaeuknI.mjs";
1
+ import { Nt as BaseAddon, st as logDestinationCapability } from "../../dist-1DEQFmTq.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  import * as path$1 from "node:path";
4
4
  import path from "node:path";
@@ -14344,6 +14344,8 @@ var NcSystemEventKindSchema = z.enum([
14344
14344
  "device-offline",
14345
14345
  "device-disabled",
14346
14346
  "device-enabled",
14347
+ "device-battery-low",
14348
+ "device-battery-normal",
14347
14349
  "stream-online",
14348
14350
  "stream-offline",
14349
14351
  "node-online",
@@ -17985,6 +17987,34 @@ var KeyEventsForDeviceSchema = z.object({
17985
17987
  deviceId: z.number(),
17986
17988
  events: z.array(KeyEventSchema).readonly()
17987
17989
  });
17990
+ /** One non-empty bucket of the timeline histogram. */
17991
+ var EventDensityBucketSchema = z.object({
17992
+ bucketStart: z.number(),
17993
+ motion: z.number().int(),
17994
+ object: z.number().int(),
17995
+ audio: z.number().int()
17996
+ });
17997
+ /**
17998
+ * One camera's row in a `getEventDensityBatch` answer.
17999
+ *
18000
+ * TWO facts, and the timeline draws them differently:
18001
+ *
18002
+ * - `read: 'read'` — the histogram for this camera. `buckets: []` is a real
18003
+ * claim: read, and nothing happened in the window.
18004
+ * - `read: 'unreadable'` — nobody could answer for this camera. `buckets` is
18005
+ * empty and that emptiness means NOTHING; the timeline must render unknown,
18006
+ * not quiet.
18007
+ *
18008
+ * Fanned out per camera the difference was free — one query rejected while the
18009
+ * others resolved — and collapsing to a batch is the exact moment it is lost
18010
+ * silently. Every requested id gets a row: a caller that asked for twelve and
18011
+ * got eleven cannot tell which one is missing, or that any is.
18012
+ */
18013
+ var EventDensityForDeviceSchema = z.object({
18014
+ deviceId: z.number(),
18015
+ read: z.enum(["read", "unreadable"]),
18016
+ buckets: z.array(EventDensityBucketSchema).readonly()
18017
+ });
17988
18018
  z.object({
17989
18019
  trackId: z.string(),
17990
18020
  className: z.string(),
@@ -18385,12 +18415,31 @@ var pipelineAnalyticsCapability = {
18385
18415
  since: z.number(),
18386
18416
  until: z.number(),
18387
18417
  bucketMs: z.number().int().positive()
18388
- }), z.array(z.object({
18389
- bucketStart: z.number(),
18390
- motion: z.number().int(),
18391
- object: z.number().int(),
18392
- audio: z.number().int()
18393
- })).readonly()),
18418
+ }), z.array(EventDensityBucketSchema).readonly()),
18419
+ /**
18420
+ * The same histogram, for a SET of cameras, in one round trip.
18421
+ *
18422
+ * A multi-camera timeline (the tablet grid) re-asks this of every camera in
18423
+ * the grid on every day change — N browser → hub → post-analysis round
18424
+ * trips for N independent, already-indexed store queries. The queries are
18425
+ * unchanged and still run per camera (`densityByKind`, one per deviceId,
18426
+ * concurrently INSIDE the owner); only the transport collapses.
18427
+ *
18428
+ * Deliberately per-device rather than pre-merged: a timeline lane belongs
18429
+ * to a camera, and a caller that merged would have to un-merge.
18430
+ * `getEventDensity` stays for single-camera callers.
18431
+ *
18432
+ * Every requested id gets a row, marked — see
18433
+ * {@link EventDensityForDeviceSchema}. `deviceIds` is capped at
18434
+ * {@link EVENT_DENSITY_BATCH_MAX}, and an over-cap ask is refused rather
18435
+ * than truncated.
18436
+ */
18437
+ getEventDensityBatch: method(z.object({
18438
+ deviceIds: z.array(z.number()).min(1).max(200),
18439
+ since: z.number(),
18440
+ until: z.number(),
18441
+ bucketMs: z.number().int().positive()
18442
+ }), z.array(EventDensityForDeviceSchema).readonly()),
18394
18443
  /**
18395
18444
  * @deprecated Prefer `pruneTracksBefore` — the track is the ROOT of the
18396
18445
  * analytics model and retention must cascade from it (design §5.1). This
@@ -29825,6 +29874,39 @@ var RecordingDaysSchema = z.object({
29825
29874
  /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
29826
29875
  days: z.array(z.number())
29827
29876
  });
29877
+ /**
29878
+ * One camera's row in a `getAvailabilityBatch` answer.
29879
+ *
29880
+ * `ranges` is EXACTLY what `getAvailability` returns for that camera — the
29881
+ * batch collapses the transport, not the work — plus the one thing the singular
29882
+ * method never had to say:
29883
+ *
29884
+ * - `read: 'read'` — answered. `ranges: []` means "read, and this camera has
29885
+ * no footage in the window", which is a real claim.
29886
+ * - `read: 'unreadable'` — the recorder could not answer for this camera (its
29887
+ * calendar rung threw, its location is unmounted). `ranges` is empty and
29888
+ * that emptiness means NOTHING.
29889
+ *
29890
+ * A timeline that renders the second as the first tells an operator there is no
29891
+ * footage when the truth is that nobody looked.
29892
+ */
29893
+ var RecordingAvailabilityForDeviceSchema = z.object({
29894
+ deviceId: z.number(),
29895
+ read: z.enum(["read", "unreadable"]),
29896
+ ranges: z.array(RecordingRangeSchema).readonly()
29897
+ });
29898
+ /**
29899
+ * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
29900
+ * {@link RecordingAvailabilityForDeviceSchema}: `days: []` on a `'read'` row is
29901
+ * "no footage in this month", `read: 'unreadable'` is "nobody could look", and
29902
+ * the date-picker's day dots must not spell them the same.
29903
+ */
29904
+ var RecordingDaysForDeviceSchema = z.object({
29905
+ deviceId: z.number(),
29906
+ read: z.enum(["read", "unreadable"]),
29907
+ /** Local-midnight epochs (UTC ms) of days that have ≥1 recorded segment. */
29908
+ days: z.array(z.number()).readonly()
29909
+ });
29828
29910
  var RecordingManifestSchema = z.object({
29829
29911
  deviceId: z.number(),
29830
29912
  /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
@@ -30070,6 +30152,32 @@ var recordingCapability = {
30070
30152
  kind: "query",
30071
30153
  auth: "protected"
30072
30154
  }),
30155
+ /**
30156
+ * `getAvailability` for a SET of cameras, in one round trip.
30157
+ *
30158
+ * A multi-camera timeline re-asks availability for every camera in the grid
30159
+ * on every day change; fanned out that is one request per camera for N
30160
+ * independent calendar walks. The per-camera work is IDENTICAL (the same
30161
+ * `availabilityProfileFor` + `rangesIn`, run concurrently inside the
30162
+ * recorder) — only the transport collapses.
30163
+ *
30164
+ * `protected` for the same reason the singular method is: every id in
30165
+ * `deviceIds` is a device reference, so the F1 #3 gate refuses any camera
30166
+ * outside the caller's scope — one id out of scope refuses the CALL, it
30167
+ * does not quietly drop a row.
30168
+ *
30169
+ * Every requested id gets a row, marked — see
30170
+ * {@link RecordingAvailabilityForDeviceSchema}. `deviceIds` is capped at
30171
+ * {@link RECORDING_TIMELINE_BATCH_MAX} and an over-cap ask is refused.
30172
+ */
30173
+ getAvailabilityBatch: method(z.object({
30174
+ deviceIds: z.array(z.number()).min(1).max(200),
30175
+ fromMs: z.number(),
30176
+ toMs: z.number()
30177
+ }), z.array(RecordingAvailabilityForDeviceSchema).readonly(), {
30178
+ kind: "query",
30179
+ auth: "protected"
30180
+ }),
30073
30181
  /** Which calendar days in [fromMs,toMs) have ≥1 recorded segment, bucketed by
30074
30182
  * the client's local day (`tzOffsetMinutes` = minutes to add to UTC). Drives
30075
30183
  * the theater date-picker's day dots. */
@@ -30082,6 +30190,26 @@ var recordingCapability = {
30082
30190
  kind: "query",
30083
30191
  auth: "protected"
30084
30192
  }),
30193
+ /**
30194
+ * `getDaysWithRecordings` for a SET of cameras, in one round trip.
30195
+ *
30196
+ * The cheapest question in the product, asked once per camera per month
30197
+ * change. One directory read per day per camera at the owner, unchanged;
30198
+ * what collapses is the N requests it took to ask.
30199
+ *
30200
+ * Every requested id gets a row, marked — see
30201
+ * {@link RecordingDaysForDeviceSchema}. `deviceIds` is capped at
30202
+ * {@link RECORDING_TIMELINE_BATCH_MAX} and an over-cap ask is refused.
30203
+ */
30204
+ getDaysWithRecordingsBatch: method(z.object({
30205
+ deviceIds: z.array(z.number()).min(1).max(200),
30206
+ fromMs: z.number(),
30207
+ toMs: z.number(),
30208
+ tzOffsetMinutes: z.number()
30209
+ }), z.array(RecordingDaysForDeviceSchema).readonly(), {
30210
+ kind: "query",
30211
+ auth: "protected"
30212
+ }),
30085
30213
  getPlaybackManifest: method(z.object({
30086
30214
  deviceId: z.number(),
30087
30215
  fromMs: z.number(),
@@ -37285,6 +37413,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
37285
37413
  addonId: null,
37286
37414
  access: "view"
37287
37415
  },
37416
+ "pipelineAnalytics.getEventDensityBatch": {
37417
+ capName: "pipeline-analytics",
37418
+ capScope: "device",
37419
+ addonId: null,
37420
+ access: "view"
37421
+ },
37288
37422
  "pipelineAnalytics.getEventMedia": {
37289
37423
  capName: "pipeline-analytics",
37290
37424
  capScope: "device",
@@ -38407,12 +38541,24 @@ var METHOD_ACCESS_MAP = Object.freeze({
38407
38541
  addonId: null,
38408
38542
  access: "view"
38409
38543
  },
38544
+ "recording.getAvailabilityBatch": {
38545
+ capName: "recording",
38546
+ capScope: "system",
38547
+ addonId: null,
38548
+ access: "view"
38549
+ },
38410
38550
  "recording.getDaysWithRecordings": {
38411
38551
  capName: "recording",
38412
38552
  capScope: "system",
38413
38553
  addonId: null,
38414
38554
  access: "view"
38415
38555
  },
38556
+ "recording.getDaysWithRecordingsBatch": {
38557
+ capName: "recording",
38558
+ capScope: "system",
38559
+ addonId: null,
38560
+ access: "view"
38561
+ },
38416
38562
  "recording.getDeviceConfig": {
38417
38563
  capName: "recording",
38418
38564
  capScope: "system",
@@ -41017,6 +41163,11 @@ Object.freeze({
41017
41163
  form: "single",
41018
41164
  optional: false
41019
41165
  }],
41166
+ "pipelineAnalytics.getEventDensityBatch": [{
41167
+ name: "deviceIds",
41168
+ form: "array",
41169
+ optional: false
41170
+ }],
41020
41171
  "pipelineAnalytics.getEventMedia": [{
41021
41172
  name: "deviceId",
41022
41173
  form: "single",
@@ -41432,11 +41583,21 @@ Object.freeze({
41432
41583
  form: "single",
41433
41584
  optional: false
41434
41585
  }],
41586
+ "recording.getAvailabilityBatch": [{
41587
+ name: "deviceIds",
41588
+ form: "array",
41589
+ optional: false
41590
+ }],
41435
41591
  "recording.getDaysWithRecordings": [{
41436
41592
  name: "deviceId",
41437
41593
  form: "single",
41438
41594
  optional: false
41439
41595
  }],
41596
+ "recording.getDaysWithRecordingsBatch": [{
41597
+ name: "deviceIds",
41598
+ form: "array",
41599
+ optional: false
41600
+ }],
41440
41601
  "recording.getDeviceConfig": [{
41441
41602
  name: "deviceId",
41442
41603
  form: "single",