@camstack/system 1.2.85 → 1.2.87

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 (54) 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/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-manager.addon.js +155 -18
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +155 -18
  15. package/dist/builtins/device-manager/device-state-mirror.d.ts +51 -6
  16. package/dist/builtins/device-manager/runtime-state-persist-gate.d.ts +32 -0
  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-backend.d.ts +15 -0
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +37 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +37 -1
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/system-config/system-config.addon.js +1 -1
  45. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  46. package/dist/builtins/winston-logging/index.js +1 -1
  47. package/dist/builtins/winston-logging/index.mjs +1 -1
  48. package/dist/{dist-DWuNENzg.mjs → dist-CuAaqtdt.mjs} +996 -109
  49. package/dist/{dist-DiScsb8j.js → dist-D4nuaPdd.js} +1001 -108
  50. package/dist/index.js +38 -20
  51. package/dist/index.mjs +38 -20
  52. package/dist/kernel/addon-installer.d.ts +7 -15
  53. package/dist/kernel/config-manager.d.ts +32 -0
  54. package/package.json +1 -1
@@ -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-DiScsb8j.js");
6
+ const require_dist = require("../../dist-D4nuaPdd.js");
7
7
  const require_settle_sources = require("../../settle-sources-Bhsy57y-.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { Ht as EventCategory, gt as BaseAddon, ht as errMsg, v as addonPagesCapability } from "../../dist-DWuNENzg.mjs";
1
+ import { Ut as EventCategory, _t as BaseAddon, gt as errMsg, v as addonPagesCapability } from "../../dist-CuAaqtdt.mjs";
2
2
  import { t as settleSourcesWithTimeout } from "../../settle-sources-CDtNC8ub.mjs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DiScsb8j.js");
6
+ const require_dist = require("../../dist-D4nuaPdd.js");
7
7
  const require_settle_sources = require("../../settle-sources-Bhsy57y-.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { Ht as EventCategory, b as addonWidgetsCapability, gt as BaseAddon, ht as errMsg } from "../../dist-DWuNENzg.mjs";
1
+ import { Ut as EventCategory, _t as BaseAddon, b as addonWidgetsCapability, gt as errMsg } from "../../dist-CuAaqtdt.mjs";
2
2
  import { t as settleSourcesWithTimeout } from "../../settle-sources-CDtNC8ub.mjs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DiScsb8j.js");
6
+ const require_dist = require("../../dist-D4nuaPdd.js");
7
7
  //#region src/builtins/alerts/prune-policy.ts
8
8
  /**
9
9
  * Ids older than `cutoffMs`, oldest first.
@@ -1,4 +1,4 @@
1
- import { Ht as EventCategory, gt as BaseAddon, ht as errMsg, kt as createEvent, n as AlertSchema, x as alertsCapability } from "../../dist-DWuNENzg.mjs";
1
+ import { At as createEvent, Ut as EventCategory, _t as BaseAddon, gt as errMsg, n as AlertSchema, x as alertsCapability } from "../../dist-CuAaqtdt.mjs";
2
2
  //#region src/builtins/alerts/prune-policy.ts
3
3
  /**
4
4
  * Ids older than `cutoffMs`, oldest first.
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DiScsb8j.js");
6
+ const require_dist = require("../../dist-D4nuaPdd.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let buffer = require("buffer");
9
9
  let node_fs_promises = require("node:fs/promises");
@@ -1,4 +1,4 @@
1
- import { C as backupCapability, Ht as EventCategory, gt as BaseAddon } from "../../dist-DWuNENzg.mjs";
1
+ import { C as backupCapability, Ut as EventCategory, _t as BaseAddon } from "../../dist-CuAaqtdt.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";
@@ -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-DiScsb8j.js");
6
+ const require_dist = require("../../dist-D4nuaPdd.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 { J as logDestinationCapability, gt as BaseAddon } from "../../dist-DWuNENzg.mjs";
1
+ import { J as logDestinationCapability, _t as BaseAddon } from "../../dist-CuAaqtdt.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,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-DiScsb8j.js");
3
+ const require_dist = require("../../dist-D4nuaPdd.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_fs_promises = require("node:fs/promises");
6
6
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -1,4 +1,4 @@
1
- import { D as coreBlockIdFromAddonId, E as coreBlockAddonId, O as coreBlocksCapability, c as CoreBlockSchema, gt as BaseAddon, l as DeclaredDevices, o as CORE_BLOCKS_ADDON_ID, s as CORE_BLOCK_ADDON_PREFIX } from "../../dist-DWuNENzg.mjs";
1
+ import { D as coreBlockIdFromAddonId, E as coreBlockAddonId, O as coreBlocksCapability, _t as BaseAddon, c as CoreBlockSchema, l as DeclaredDevices, o as CORE_BLOCKS_ADDON_ID, s as CORE_BLOCK_ADDON_PREFIX } from "../../dist-CuAaqtdt.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-DiScsb8j.js");
6
+ const require_dist = require("../../dist-D4nuaPdd.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let _camstack_types_node = require("@camstack/types/node");
9
9
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -3942,6 +3942,88 @@ var DeviceMetaStore = class {
3942
3942
  };
3943
3943
  };
3944
3944
  //#endregion
3945
+ //#region src/builtins/device-manager/runtime-state-persist-gate.ts
3946
+ /**
3947
+ * The subset of `blob` that is allowed on disk: slices whose capability
3948
+ * declared `durability: 'restored'`.
3949
+ *
3950
+ * A cap the lookup does not know resolves to `'session'` and is dropped. That
3951
+ * direction is deliberate: the cost of losing a session value is a cold window
3952
+ * until the provider republishes; the cost of persisting an unasked-for one is
3953
+ * a commit per second on the fleet's busiest write path.
3954
+ */
3955
+ function persistableBlob(blob, policyFor) {
3956
+ const out = {};
3957
+ for (const [capName, slice] of Object.entries(blob)) {
3958
+ if (policyFor(capName).durability !== "restored") continue;
3959
+ out[capName] = { ...slice };
3960
+ }
3961
+ return out;
3962
+ }
3963
+ /**
3964
+ * Are two slices the same, ignoring the fields the cap declared volatile?
3965
+ *
3966
+ * A volatile field is still WRITTEN — it simply does not, on its own, justify
3967
+ * the write. A field the cap did NOT declare volatile is content, even when its
3968
+ * name looks like a clock: the capability is the only authority on which of its
3969
+ * fields carry meaning (D4), and the guard makes an undeclared clock visible in
3970
+ * the diff rather than assuming it.
3971
+ */
3972
+ function sliceEffectivelyEqual(a, b, volatileFields) {
3973
+ const skip = new Set(volatileFields);
3974
+ const keys = /* @__PURE__ */ new Set();
3975
+ for (const k of Object.keys(a)) if (!skip.has(k)) keys.add(k);
3976
+ for (const k of Object.keys(b)) if (!skip.has(k)) keys.add(k);
3977
+ for (const k of keys) {
3978
+ if (!deepEqual(a[k], b[k])) return false;
3979
+ if (Object.hasOwn(a, k) !== Object.hasOwn(b, k)) return false;
3980
+ }
3981
+ return true;
3982
+ }
3983
+ /**
3984
+ * Is the whole persistable blob unchanged in every way that matters?
3985
+ *
3986
+ * A cap slice APPEARING or DISAPPEARING is always a change — the write is a
3987
+ * whole-blob replace, so a dropped slice only leaves disk if the write happens.
3988
+ */
3989
+ function effectivelyEqual(before, after, policyFor) {
3990
+ const caps = new Set([...Object.keys(before), ...Object.keys(after)]);
3991
+ for (const capName of caps) {
3992
+ const a = before[capName];
3993
+ const b = after[capName];
3994
+ if (a === void 0 || b === void 0) return false;
3995
+ if (!sliceEffectivelyEqual(a, b, policyFor(capName).volatileFields)) return false;
3996
+ }
3997
+ return true;
3998
+ }
3999
+ /**
4000
+ * Structural equality over the JSON values a runtime-state slice can hold.
4001
+ * `NaN` compares equal to itself so a sensor that reports it does not rewrite
4002
+ * the blob forever.
4003
+ */
4004
+ function deepEqual(a, b) {
4005
+ if (a === b) return true;
4006
+ if (typeof a === "number" && typeof b === "number") return Number.isNaN(a) && Number.isNaN(b);
4007
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
4008
+ const aIsArray = Array.isArray(a);
4009
+ if (aIsArray !== Array.isArray(b)) return false;
4010
+ if (aIsArray) {
4011
+ const bArr = b;
4012
+ if (a.length !== bArr.length) return false;
4013
+ for (let i = 0; i < a.length; i += 1) if (!deepEqual(a[i], bArr[i])) return false;
4014
+ return true;
4015
+ }
4016
+ const aRec = a;
4017
+ const bRec = b;
4018
+ const aKeys = Object.keys(aRec);
4019
+ if (aKeys.length !== Object.keys(bRec).length) return false;
4020
+ for (const k of aKeys) {
4021
+ if (!Object.hasOwn(bRec, k)) return false;
4022
+ if (!deepEqual(aRec[k], bRec[k])) return false;
4023
+ }
4024
+ return true;
4025
+ }
4026
+ //#endregion
3945
4027
  //#region src/builtins/device-manager/device-state-mirror.ts
3946
4028
  /**
3947
4029
  * Hub-side runtime-state mirror for the device-manager addon.
@@ -3960,6 +4042,7 @@ var DeviceMetaStore = class {
3960
4042
  */
3961
4043
  var DeviceStateMirror = class DeviceStateMirror {
3962
4044
  ctx;
4045
+ policyFor;
3963
4046
  /**
3964
4047
  * Hub-side mirror of every device's cap-keyed runtime state.
3965
4048
  * Key: deviceId. Value: per-cap slice map. Empty by default —
@@ -3973,8 +4056,24 @@ var DeviceStateMirror = class DeviceStateMirror {
3973
4056
  */
3974
4057
  runtimeStateDebounce = /* @__PURE__ */ new Map();
3975
4058
  static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
3976
- constructor(ctx) {
4059
+ /**
4060
+ * What is believed to be ON DISK for each device — the persistable projection
4061
+ * of the last blob actually written (or seeded at boot). The effective-change
4062
+ * gate compares against THIS, not against the previous mirror state: two
4063
+ * clock-only ticks in a row must not add up to a write just because each was
4064
+ * compared with its immediate predecessor.
4065
+ */
4066
+ lastPersisted = /* @__PURE__ */ new Map();
4067
+ /**
4068
+ * Per-device count of writes the effective-change gate skipped since the last
4069
+ * real one. Reported on the next write that DOES happen, so the log says how
4070
+ * much churn the gate absorbed instead of saying nothing at all — a branch
4071
+ * that drops work silently reads as "never happened".
4072
+ */
4073
+ skippedSinceWrite = /* @__PURE__ */ new Map();
4074
+ constructor(ctx, policyFor = require_dist.runtimeStatePolicyFor) {
3977
4075
  this.ctx = ctx;
4076
+ this.policyFor = policyFor;
3978
4077
  }
3979
4078
  /**
3980
4079
  * Single-cap mirror update — diff against the current mirror,
@@ -4001,13 +4100,23 @@ var DeviceStateMirror = class DeviceStateMirror {
4001
4100
  return true;
4002
4101
  }
4003
4102
  /**
4004
- * Debounced disk writer. Coalesces frequent writes (motion phase
4005
- * transitions, battery pushes) into one `writeDeviceRuntimeState`
4006
- * per `RUNTIME_STATE_DEBOUNCE_MS` window. Reads the per-device
4007
- * blob from the live mirror at flush time so the disk picture is
4103
+ * Debounced disk writer, behind two gates.
4104
+ *
4105
+ * GATE — DURABILITY: a change to a `durability: 'session'` cap never even
4106
+ * arms the timer. That is where the fleet's write rate goes: `audio-metrics`
4107
+ * and `zone-analytics` are ~90 % of the offered 12–19 writes/s, they change
4108
+ * genuinely every second, and their restored value is worthless.
4109
+ *
4110
+ * GATE — EFFECTIVE CHANGE (at flush): the persistable blob is compared with
4111
+ * what is believed to be on disk, ignoring the clock fields each cap declared
4112
+ * volatile. `battery` wrote its blob 526 times in 25 minutes without one
4113
+ * percentage moving; this is the gate that turns those into one write.
4114
+ *
4115
+ * The blob is read from the live mirror at flush time, so the disk picture is
4008
4116
  * always the latest state — no risk of writing a stale snapshot.
4009
4117
  */
4010
- scheduleRuntimeStateDiskWrite(deviceId, settings) {
4118
+ scheduleRuntimeStateDiskWrite(deviceId, settings, changedCap) {
4119
+ if (this.policyFor(changedCap).durability !== "restored") return;
4011
4120
  let slot = this.runtimeStateDebounce.get(deviceId);
4012
4121
  if (!slot) {
4013
4122
  slot = {
@@ -4019,10 +4128,21 @@ var DeviceStateMirror = class DeviceStateMirror {
4019
4128
  if (slot.timer) return;
4020
4129
  slot.timer = setTimeout(() => {
4021
4130
  slot.timer = null;
4022
- const blob = this.snapshotForDevice(deviceId);
4131
+ const blob = this.persistableForDevice(deviceId);
4132
+ if (this.isAlreadyPersisted(deviceId, blob)) return;
4133
+ const skipped = this.skippedSinceWrite.get(deviceId) ?? 0;
4134
+ this.skippedSinceWrite.delete(deviceId);
4023
4135
  const write = (async () => {
4024
4136
  try {
4025
4137
  await settings.writeDeviceRuntimeState(deviceId, blob);
4138
+ this.lastPersisted.set(deviceId, blob);
4139
+ if (skipped > 0) this.ctx.logger.debug("runtime state persisted", {
4140
+ tags: { deviceId },
4141
+ meta: {
4142
+ caps: Object.keys(blob).length,
4143
+ skippedSinceLastWrite: skipped
4144
+ }
4145
+ });
4026
4146
  } catch (err) {
4027
4147
  this.ctx.logger.warn("writeDeviceRuntimeState failed", {
4028
4148
  tags: { deviceId },
@@ -4036,6 +4156,22 @@ var DeviceStateMirror = class DeviceStateMirror {
4036
4156
  }, DeviceStateMirror.RUNTIME_STATE_DEBOUNCE_MS);
4037
4157
  }
4038
4158
  /**
4159
+ * True when `blob` differs from what is believed to be on disk only in fields
4160
+ * the owning caps declared volatile. Counts the skip so the next real write
4161
+ * can report how much churn was absorbed.
4162
+ */
4163
+ isAlreadyPersisted(deviceId, blob) {
4164
+ const onDisk = this.lastPersisted.get(deviceId);
4165
+ if (!onDisk) return false;
4166
+ if (!effectivelyEqual(onDisk, blob, this.policyFor)) return false;
4167
+ this.skippedSinceWrite.set(deviceId, (this.skippedSinceWrite.get(deviceId) ?? 0) + 1);
4168
+ return true;
4169
+ }
4170
+ /** The device's mirror, restricted to the slices that may reach disk. */
4171
+ persistableForDevice(deviceId) {
4172
+ return persistableBlob(this.snapshotForDevice(deviceId), this.policyFor);
4173
+ }
4174
+ /**
4039
4175
  * One-shot mirror seed used by `loadRuntimeState` at boot so the
4040
4176
  * hub knows about every persisted slice without waiting for the
4041
4177
  * first `setCapSlice` call. No events emitted — this is
@@ -4055,6 +4191,7 @@ var DeviceStateMirror = class DeviceStateMirror {
4055
4191
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
4056
4192
  perCap.set(capName, { ...raw });
4057
4193
  }
4194
+ this.lastPersisted.set(deviceId, persistableBlob(this.snapshotForDevice(deviceId), this.policyFor));
4058
4195
  }
4059
4196
  /**
4060
4197
  * The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
@@ -4168,15 +4305,15 @@ var DeviceStateMirror = class DeviceStateMirror {
4168
4305
  if (slot.timer) {
4169
4306
  clearTimeout(slot.timer);
4170
4307
  slot.timer = null;
4171
- if (settings) {
4172
- const blob = this.snapshotForDevice(deviceId);
4173
- pending.push(settings.writeDeviceRuntimeState(deviceId, blob).catch((err) => {
4174
- this.ctx.logger.warn("shutdown writeDeviceRuntimeState failed", {
4175
- tags: { deviceId },
4176
- meta: { error: err instanceof Error ? err.message : String(err) }
4177
- });
4178
- }));
4179
- }
4308
+ const blob = settings ? this.persistableForDevice(deviceId) : null;
4309
+ if (settings && blob !== null && !this.isAlreadyPersisted(deviceId, blob)) pending.push(settings.writeDeviceRuntimeState(deviceId, blob).then(() => {
4310
+ this.lastPersisted.set(deviceId, blob);
4311
+ }).catch((err) => {
4312
+ this.ctx.logger.warn("shutdown writeDeviceRuntimeState failed", {
4313
+ tags: { deviceId },
4314
+ meta: { error: err instanceof Error ? err.message : String(err) }
4315
+ });
4316
+ }));
4180
4317
  }
4181
4318
  if (slot.inFlight) pending.push(slot.inFlight);
4182
4319
  }
@@ -4642,7 +4779,7 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
4642
4779
  setCapSlice: async (input) => {
4643
4780
  const { deviceId, capName, slice } = input;
4644
4781
  if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] setCapSlice: unknown device id=${deviceId}`);
4645
- if (stateMirror.applySingleCapUpdate(deviceId, capName, slice)) stateMirror.scheduleRuntimeStateDiskWrite(deviceId, settings);
4782
+ if (stateMirror.applySingleCapUpdate(deviceId, capName, slice)) stateMirror.scheduleRuntimeStateDiskWrite(deviceId, settings, capName);
4646
4783
  }
4647
4784
  }
4648
4785
  }];
@@ -1,4 +1,4 @@
1
- import { Ht as EventCategory, I as enumerateItemArrayFields, L as enumerateSchemaFields, M as deviceStateCapability, N as deviceStatusCapability, Pt as isDeviceConfigCap, Q as normalizeUnit, St as DeviceType, T as buildStreamParamsConfigSchema, Tt as WELL_KNOWN_TAB_MAP, Vt as sleep, a as CAP_NAMES_WITH_STATUS, bt as DeviceFeature, et as parseStreamParamsFormPatch, gt as BaseAddon, ht as errMsg, j as deviceManagerCapability, p as STREAM_PROFILE_META, t as ALL_CAPABILITY_DEFINITIONS, u as DeviceStatusSchema, xt as DeviceRole } from "../../dist-DWuNENzg.mjs";
1
+ import { Ct as DeviceType, Et as WELL_KNOWN_TAB_MAP, Ft as isDeviceConfigCap, Ht as sleep, I as enumerateItemArrayFields, L as enumerateSchemaFields, M as deviceStateCapability, N as deviceStatusCapability, Q as normalizeUnit, St as DeviceRole, T as buildStreamParamsConfigSchema, Ut as EventCategory, _t as BaseAddon, a as CAP_NAMES_WITH_STATUS, et as parseStreamParamsFormPatch, gt as errMsg, j as deviceManagerCapability, p as STREAM_PROFILE_META, rt as runtimeStatePolicyFor, t as ALL_CAPABILITY_DEFINITIONS, u as DeviceStatusSchema, xt as DeviceFeature } from "../../dist-CuAaqtdt.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
4
4
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -3937,6 +3937,88 @@ var DeviceMetaStore = class {
3937
3937
  };
3938
3938
  };
3939
3939
  //#endregion
3940
+ //#region src/builtins/device-manager/runtime-state-persist-gate.ts
3941
+ /**
3942
+ * The subset of `blob` that is allowed on disk: slices whose capability
3943
+ * declared `durability: 'restored'`.
3944
+ *
3945
+ * A cap the lookup does not know resolves to `'session'` and is dropped. That
3946
+ * direction is deliberate: the cost of losing a session value is a cold window
3947
+ * until the provider republishes; the cost of persisting an unasked-for one is
3948
+ * a commit per second on the fleet's busiest write path.
3949
+ */
3950
+ function persistableBlob(blob, policyFor) {
3951
+ const out = {};
3952
+ for (const [capName, slice] of Object.entries(blob)) {
3953
+ if (policyFor(capName).durability !== "restored") continue;
3954
+ out[capName] = { ...slice };
3955
+ }
3956
+ return out;
3957
+ }
3958
+ /**
3959
+ * Are two slices the same, ignoring the fields the cap declared volatile?
3960
+ *
3961
+ * A volatile field is still WRITTEN — it simply does not, on its own, justify
3962
+ * the write. A field the cap did NOT declare volatile is content, even when its
3963
+ * name looks like a clock: the capability is the only authority on which of its
3964
+ * fields carry meaning (D4), and the guard makes an undeclared clock visible in
3965
+ * the diff rather than assuming it.
3966
+ */
3967
+ function sliceEffectivelyEqual(a, b, volatileFields) {
3968
+ const skip = new Set(volatileFields);
3969
+ const keys = /* @__PURE__ */ new Set();
3970
+ for (const k of Object.keys(a)) if (!skip.has(k)) keys.add(k);
3971
+ for (const k of Object.keys(b)) if (!skip.has(k)) keys.add(k);
3972
+ for (const k of keys) {
3973
+ if (!deepEqual(a[k], b[k])) return false;
3974
+ if (Object.hasOwn(a, k) !== Object.hasOwn(b, k)) return false;
3975
+ }
3976
+ return true;
3977
+ }
3978
+ /**
3979
+ * Is the whole persistable blob unchanged in every way that matters?
3980
+ *
3981
+ * A cap slice APPEARING or DISAPPEARING is always a change — the write is a
3982
+ * whole-blob replace, so a dropped slice only leaves disk if the write happens.
3983
+ */
3984
+ function effectivelyEqual(before, after, policyFor) {
3985
+ const caps = new Set([...Object.keys(before), ...Object.keys(after)]);
3986
+ for (const capName of caps) {
3987
+ const a = before[capName];
3988
+ const b = after[capName];
3989
+ if (a === void 0 || b === void 0) return false;
3990
+ if (!sliceEffectivelyEqual(a, b, policyFor(capName).volatileFields)) return false;
3991
+ }
3992
+ return true;
3993
+ }
3994
+ /**
3995
+ * Structural equality over the JSON values a runtime-state slice can hold.
3996
+ * `NaN` compares equal to itself so a sensor that reports it does not rewrite
3997
+ * the blob forever.
3998
+ */
3999
+ function deepEqual(a, b) {
4000
+ if (a === b) return true;
4001
+ if (typeof a === "number" && typeof b === "number") return Number.isNaN(a) && Number.isNaN(b);
4002
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
4003
+ const aIsArray = Array.isArray(a);
4004
+ if (aIsArray !== Array.isArray(b)) return false;
4005
+ if (aIsArray) {
4006
+ const bArr = b;
4007
+ if (a.length !== bArr.length) return false;
4008
+ for (let i = 0; i < a.length; i += 1) if (!deepEqual(a[i], bArr[i])) return false;
4009
+ return true;
4010
+ }
4011
+ const aRec = a;
4012
+ const bRec = b;
4013
+ const aKeys = Object.keys(aRec);
4014
+ if (aKeys.length !== Object.keys(bRec).length) return false;
4015
+ for (const k of aKeys) {
4016
+ if (!Object.hasOwn(bRec, k)) return false;
4017
+ if (!deepEqual(aRec[k], bRec[k])) return false;
4018
+ }
4019
+ return true;
4020
+ }
4021
+ //#endregion
3940
4022
  //#region src/builtins/device-manager/device-state-mirror.ts
3941
4023
  /**
3942
4024
  * Hub-side runtime-state mirror for the device-manager addon.
@@ -3955,6 +4037,7 @@ var DeviceMetaStore = class {
3955
4037
  */
3956
4038
  var DeviceStateMirror = class DeviceStateMirror {
3957
4039
  ctx;
4040
+ policyFor;
3958
4041
  /**
3959
4042
  * Hub-side mirror of every device's cap-keyed runtime state.
3960
4043
  * Key: deviceId. Value: per-cap slice map. Empty by default —
@@ -3968,8 +4051,24 @@ var DeviceStateMirror = class DeviceStateMirror {
3968
4051
  */
3969
4052
  runtimeStateDebounce = /* @__PURE__ */ new Map();
3970
4053
  static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
3971
- constructor(ctx) {
4054
+ /**
4055
+ * What is believed to be ON DISK for each device — the persistable projection
4056
+ * of the last blob actually written (or seeded at boot). The effective-change
4057
+ * gate compares against THIS, not against the previous mirror state: two
4058
+ * clock-only ticks in a row must not add up to a write just because each was
4059
+ * compared with its immediate predecessor.
4060
+ */
4061
+ lastPersisted = /* @__PURE__ */ new Map();
4062
+ /**
4063
+ * Per-device count of writes the effective-change gate skipped since the last
4064
+ * real one. Reported on the next write that DOES happen, so the log says how
4065
+ * much churn the gate absorbed instead of saying nothing at all — a branch
4066
+ * that drops work silently reads as "never happened".
4067
+ */
4068
+ skippedSinceWrite = /* @__PURE__ */ new Map();
4069
+ constructor(ctx, policyFor = runtimeStatePolicyFor) {
3972
4070
  this.ctx = ctx;
4071
+ this.policyFor = policyFor;
3973
4072
  }
3974
4073
  /**
3975
4074
  * Single-cap mirror update — diff against the current mirror,
@@ -3996,13 +4095,23 @@ var DeviceStateMirror = class DeviceStateMirror {
3996
4095
  return true;
3997
4096
  }
3998
4097
  /**
3999
- * Debounced disk writer. Coalesces frequent writes (motion phase
4000
- * transitions, battery pushes) into one `writeDeviceRuntimeState`
4001
- * per `RUNTIME_STATE_DEBOUNCE_MS` window. Reads the per-device
4002
- * blob from the live mirror at flush time so the disk picture is
4098
+ * Debounced disk writer, behind two gates.
4099
+ *
4100
+ * GATE — DURABILITY: a change to a `durability: 'session'` cap never even
4101
+ * arms the timer. That is where the fleet's write rate goes: `audio-metrics`
4102
+ * and `zone-analytics` are ~90 % of the offered 12–19 writes/s, they change
4103
+ * genuinely every second, and their restored value is worthless.
4104
+ *
4105
+ * GATE — EFFECTIVE CHANGE (at flush): the persistable blob is compared with
4106
+ * what is believed to be on disk, ignoring the clock fields each cap declared
4107
+ * volatile. `battery` wrote its blob 526 times in 25 minutes without one
4108
+ * percentage moving; this is the gate that turns those into one write.
4109
+ *
4110
+ * The blob is read from the live mirror at flush time, so the disk picture is
4003
4111
  * always the latest state — no risk of writing a stale snapshot.
4004
4112
  */
4005
- scheduleRuntimeStateDiskWrite(deviceId, settings) {
4113
+ scheduleRuntimeStateDiskWrite(deviceId, settings, changedCap) {
4114
+ if (this.policyFor(changedCap).durability !== "restored") return;
4006
4115
  let slot = this.runtimeStateDebounce.get(deviceId);
4007
4116
  if (!slot) {
4008
4117
  slot = {
@@ -4014,10 +4123,21 @@ var DeviceStateMirror = class DeviceStateMirror {
4014
4123
  if (slot.timer) return;
4015
4124
  slot.timer = setTimeout(() => {
4016
4125
  slot.timer = null;
4017
- const blob = this.snapshotForDevice(deviceId);
4126
+ const blob = this.persistableForDevice(deviceId);
4127
+ if (this.isAlreadyPersisted(deviceId, blob)) return;
4128
+ const skipped = this.skippedSinceWrite.get(deviceId) ?? 0;
4129
+ this.skippedSinceWrite.delete(deviceId);
4018
4130
  const write = (async () => {
4019
4131
  try {
4020
4132
  await settings.writeDeviceRuntimeState(deviceId, blob);
4133
+ this.lastPersisted.set(deviceId, blob);
4134
+ if (skipped > 0) this.ctx.logger.debug("runtime state persisted", {
4135
+ tags: { deviceId },
4136
+ meta: {
4137
+ caps: Object.keys(blob).length,
4138
+ skippedSinceLastWrite: skipped
4139
+ }
4140
+ });
4021
4141
  } catch (err) {
4022
4142
  this.ctx.logger.warn("writeDeviceRuntimeState failed", {
4023
4143
  tags: { deviceId },
@@ -4031,6 +4151,22 @@ var DeviceStateMirror = class DeviceStateMirror {
4031
4151
  }, DeviceStateMirror.RUNTIME_STATE_DEBOUNCE_MS);
4032
4152
  }
4033
4153
  /**
4154
+ * True when `blob` differs from what is believed to be on disk only in fields
4155
+ * the owning caps declared volatile. Counts the skip so the next real write
4156
+ * can report how much churn was absorbed.
4157
+ */
4158
+ isAlreadyPersisted(deviceId, blob) {
4159
+ const onDisk = this.lastPersisted.get(deviceId);
4160
+ if (!onDisk) return false;
4161
+ if (!effectivelyEqual(onDisk, blob, this.policyFor)) return false;
4162
+ this.skippedSinceWrite.set(deviceId, (this.skippedSinceWrite.get(deviceId) ?? 0) + 1);
4163
+ return true;
4164
+ }
4165
+ /** The device's mirror, restricted to the slices that may reach disk. */
4166
+ persistableForDevice(deviceId) {
4167
+ return persistableBlob(this.snapshotForDevice(deviceId), this.policyFor);
4168
+ }
4169
+ /**
4034
4170
  * One-shot mirror seed used by `loadRuntimeState` at boot so the
4035
4171
  * hub knows about every persisted slice without waiting for the
4036
4172
  * first `setCapSlice` call. No events emitted — this is
@@ -4050,6 +4186,7 @@ var DeviceStateMirror = class DeviceStateMirror {
4050
4186
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
4051
4187
  perCap.set(capName, { ...raw });
4052
4188
  }
4189
+ this.lastPersisted.set(deviceId, persistableBlob(this.snapshotForDevice(deviceId), this.policyFor));
4053
4190
  }
4054
4191
  /**
4055
4192
  * The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
@@ -4163,15 +4300,15 @@ var DeviceStateMirror = class DeviceStateMirror {
4163
4300
  if (slot.timer) {
4164
4301
  clearTimeout(slot.timer);
4165
4302
  slot.timer = null;
4166
- if (settings) {
4167
- const blob = this.snapshotForDevice(deviceId);
4168
- pending.push(settings.writeDeviceRuntimeState(deviceId, blob).catch((err) => {
4169
- this.ctx.logger.warn("shutdown writeDeviceRuntimeState failed", {
4170
- tags: { deviceId },
4171
- meta: { error: err instanceof Error ? err.message : String(err) }
4172
- });
4173
- }));
4174
- }
4303
+ const blob = settings ? this.persistableForDevice(deviceId) : null;
4304
+ if (settings && blob !== null && !this.isAlreadyPersisted(deviceId, blob)) pending.push(settings.writeDeviceRuntimeState(deviceId, blob).then(() => {
4305
+ this.lastPersisted.set(deviceId, blob);
4306
+ }).catch((err) => {
4307
+ this.ctx.logger.warn("shutdown writeDeviceRuntimeState failed", {
4308
+ tags: { deviceId },
4309
+ meta: { error: err instanceof Error ? err.message : String(err) }
4310
+ });
4311
+ }));
4175
4312
  }
4176
4313
  if (slot.inFlight) pending.push(slot.inFlight);
4177
4314
  }
@@ -4637,7 +4774,7 @@ var DeviceManagerAddon = class extends BaseAddon {
4637
4774
  setCapSlice: async (input) => {
4638
4775
  const { deviceId, capName, slice } = input;
4639
4776
  if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] setCapSlice: unknown device id=${deviceId}`);
4640
- if (stateMirror.applySingleCapUpdate(deviceId, capName, slice)) stateMirror.scheduleRuntimeStateDiskWrite(deviceId, settings);
4777
+ if (stateMirror.applySingleCapUpdate(deviceId, capName, slice)) stateMirror.scheduleRuntimeStateDiskWrite(deviceId, settings, capName);
4641
4778
  }
4642
4779
  }
4643
4780
  }];