@camstack/system 1.2.316 → 1.2.317

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 (87) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/auth/auth-manager.d.ts +11 -0
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/alerts/alerts.addon.js +1 -1
  9. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  10. package/dist/builtins/autotrack/index.js +1 -1
  11. package/dist/builtins/autotrack/index.mjs +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  13. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  14. package/dist/builtins/camera-grid/index.js +1 -1
  15. package/dist/builtins/camera-grid/index.mjs +1 -1
  16. package/dist/builtins/composer/composer.addon.js +347 -181
  17. package/dist/builtins/composer/composer.addon.mjs +347 -181
  18. package/dist/builtins/composer/composition-evaluation.d.ts +37 -0
  19. package/dist/builtins/composer/composition-runtime.d.ts +11 -52
  20. package/dist/builtins/composer/composition-target.d.ts +50 -0
  21. package/dist/builtins/composer/existing-target.d.ts +19 -1
  22. package/dist/builtins/composer/own-field-reads.d.ts +19 -0
  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 +265 -29
  28. package/dist/builtins/device-manager/device-manager.addon.mjs +265 -29
  29. package/dist/builtins/device-manager/device-provider-context.d.ts +6 -0
  30. package/dist/builtins/device-manager/device-source-activation.d.ts +26 -1
  31. package/dist/builtins/device-manager/device-state-mirror.d.ts +41 -9
  32. package/dist/builtins/device-manager/mirror-row-writer.d.ts +59 -1
  33. package/dist/builtins/doorbell/binding-mirror.d.ts +45 -81
  34. package/dist/builtins/doorbell/doorbell-extension-slot.d.ts +4 -3
  35. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +53 -11
  36. package/dist/builtins/doorbell/virtual-doorbell.addon.js +207 -82
  37. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +207 -82
  38. package/dist/builtins/hub-forwarder/index.js +1 -1
  39. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  40. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  41. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  42. package/dist/builtins/local-auth/local-auth.addon.js +47 -3
  43. package/dist/builtins/local-auth/local-auth.addon.mjs +46 -4
  44. package/dist/builtins/local-network/local-network.addon.js +1 -1
  45. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  46. package/dist/builtins/loki-logging/index.js +1 -1
  47. package/dist/builtins/loki-logging/index.mjs +1 -1
  48. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  49. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  50. package/dist/builtins/platform-probe/index.js +1 -1
  51. package/dist/builtins/platform-probe/index.mjs +1 -1
  52. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  53. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  54. package/dist/builtins/snapshot/index.js +1 -1
  55. package/dist/builtins/snapshot/index.mjs +1 -1
  56. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  57. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  58. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  59. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  60. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  61. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  62. package/dist/builtins/system-config/system-config.addon.js +4 -4
  63. package/dist/builtins/system-config/system-config.addon.mjs +4 -4
  64. package/dist/builtins/winston-logging/index.js +1 -1
  65. package/dist/builtins/winston-logging/index.mjs +1 -1
  66. package/dist/{child-cap-dispatch-AAltfBUH.mjs → child-cap-dispatch-C2VINHu1.mjs} +70 -18
  67. package/dist/{child-cap-dispatch-DzcUd_ct.js → child-cap-dispatch-C8uts1P4.js} +70 -18
  68. package/dist/{composition-sources-fUyF4z39.mjs → composition-sources-DVClu006.mjs} +1 -1
  69. package/dist/{composition-sources-BwE1POnF.js → composition-sources-Dep7nBwu.js} +1 -1
  70. package/dist/{dist-BPvku43i.mjs → dist-Dori4pf3.mjs} +85 -6
  71. package/dist/{dist-RAl10EfG.js → dist-a6p0V-IL.js} +85 -6
  72. package/dist/index.js +739 -350
  73. package/dist/index.mjs +737 -352
  74. package/dist/kernel/bootstrap-config.d.ts +8 -0
  75. package/dist/kernel/capability-registry.d.ts +30 -0
  76. package/dist/kernel/config-manager.d.ts +75 -129
  77. package/dist/kernel/index.d.ts +5 -2
  78. package/dist/kernel/moleculer/runtime-state-load.d.ts +19 -0
  79. package/dist/kernel/settings-door-view.d.ts +24 -1
  80. package/dist/kernel/settings-read-error.d.ts +37 -0
  81. package/dist/kernel/settings-view-types.d.ts +105 -0
  82. package/dist/kernel/store-settings-view.d.ts +17 -0
  83. package/dist/kernel/system-settings-mirror.d.ts +34 -0
  84. package/dist/kernel/system-settings-policy.d.ts +47 -0
  85. package/dist/{retired-settings-keys-D3rGTGq5.js → retired-settings-keys-DC1uHWPZ.js} +1 -1
  86. package/dist/{retired-settings-keys-DqVWSrVw.mjs → retired-settings-keys-DVcKRgzm.mjs} +1 -1
  87. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- import { Gn as BaseAddon, Hn as DeviceRole, Kt as normalizeUnit, Lt as locationIconKey, Nt as legacyAutoLinkIds, S as LocationIconIdSchema, Un as DeviceType, Vn as DeviceFeature, Wn as errMsg, Yt as parseStreamParamsFormPatch, Zn as WELL_KNOWN_TAB_MAP, _t as enumerateSchemaFields, an as runtimeStatePolicyFor, bt as expandContainer, ct as defineExtensionSlotProvider, dt as deviceManagerCapability, ft as deviceStateCapability, g as DeviceStatusSchema, gt as enumerateItemArrayFields, hr as EventCategory, it as createDeviceExtensionProvider, j as STREAM_PROFILE_META, mr as sleep$1, o as CAMERA_SWITCH_ORDER, or as isDeviceConfigCap, p as DAY_NAMES, pt as deviceStatusCapability, q as buildStreamParamsConfigSchema, s as CAP_NAMES_WITH_STATUS, t as ALL_CAPABILITY_DEFINITIONS, un as siteZoneKey, ut as deviceExtensionCapability, v as FieldClaimModeSchema, w as MINUTES_PER_DAY, wt as formatMinutes, y as FieldClaimSchema } from "../../dist-BPvku43i.mjs";
2
- import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-DqVWSrVw.mjs";
1
+ import { Gn as BaseAddon, Hn as DeviceRole, Kt as normalizeUnit, Lt as locationIconKey, Nt as legacyAutoLinkIds, S as LocationIconIdSchema, Un as DeviceType, Vn as DeviceFeature, Wn as errMsg, Yt as parseStreamParamsFormPatch, Zn as WELL_KNOWN_TAB_MAP, _t as enumerateSchemaFields, an as runtimeStatePolicyFor, bt as expandContainer, ct as defineExtensionSlotProvider, dt as deviceManagerCapability, ft as deviceStateCapability, g as DeviceStatusSchema, gt as enumerateItemArrayFields, hr as EventCategory, it as createDeviceExtensionProvider, j as STREAM_PROFILE_META, mr as sleep$1, o as CAMERA_SWITCH_ORDER, or as isDeviceConfigCap, p as DAY_NAMES, pt as deviceStatusCapability, q as buildStreamParamsConfigSchema, s as CAP_NAMES_WITH_STATUS, t as ALL_CAPABILITY_DEFINITIONS, un as siteZoneKey, ut as deviceExtensionCapability, v as FieldClaimModeSchema, w as MINUTES_PER_DAY, wt as formatMinutes, y as FieldClaimSchema } from "../../dist-Dori4pf3.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-DVcKRgzm.mjs";
3
3
  import { z } from "zod";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
@@ -4136,6 +4136,73 @@ async function setWrapperActive(deps, input) {
4136
4136
  }
4137
4137
  });
4138
4138
  }
4139
+ function isRecord(value) {
4140
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4141
+ }
4142
+ /** The key of this addon's store that holds every wrapper activation. */
4143
+ var DEVICE_BINDINGS_KEY = "deviceBindings";
4144
+ /**
4145
+ * The STORED activation for one `(device, capName)` pair, three-way — the
4146
+ * provider behind `deviceManager.getWrapperActivation` (D675).
4147
+ *
4148
+ * Reads ONE row — this addon's `deviceBindings` key (~1.6 KB live) — through
4149
+ * the settings view's single-key three-way read, never the addon's whole store
4150
+ * (~625 KB) and never `readAddonStore()`, which folds a failed read into `{}`.
4151
+ *
4152
+ * Absence is graded by LEVEL:
4153
+ * - STORE level — the read failed, or the `deviceBindings` row does not exist,
4154
+ * or it is not a map — is `failed`. Every hub that has ever bound anything
4155
+ * holds that row, so its absence is the signature of a store answering from
4156
+ * an empty or unready snapshot (D44), and it must never silence a press
4157
+ * (D49). An install that has never bound anything lands here too; erring
4158
+ * `failed` there costs nothing, because nothing is bound to silence.
4159
+ * - PAIR level — the row was read and has no entry for this device, or none
4160
+ * for this cap, or none with `wrapperAddonId` — is `missing`: the operator
4161
+ * never touched this pair.
4162
+ */
4163
+ async function readWrapperActivation(deps, query) {
4164
+ const settings = deps.ctx.settings;
4165
+ if (settings?.readAddonStoreKeyResult === void 0) return {
4166
+ kind: "failed",
4167
+ error: "this settings view has no single-key three-way read"
4168
+ };
4169
+ const read = await settings.readAddonStoreKeyResult(DEVICE_BINDINGS_KEY);
4170
+ if (read.kind === "unreadable") return {
4171
+ kind: "failed",
4172
+ error: read.error ?? read.reason
4173
+ };
4174
+ if (read.kind === "absent") return {
4175
+ kind: "failed",
4176
+ error: "the store holds no deviceBindings row — an empty snapshot is not evidence of an unbind"
4177
+ };
4178
+ const deviceBindings = read.value;
4179
+ if (!isRecord(deviceBindings)) return {
4180
+ kind: "failed",
4181
+ error: "stored deviceBindings is not a map"
4182
+ };
4183
+ const perDevice = deviceBindings[String(query.deviceId)];
4184
+ if (perDevice === void 0) return { kind: "missing" };
4185
+ if (!isRecord(perDevice)) return {
4186
+ kind: "failed",
4187
+ error: `stored bindings of device ${query.deviceId} are not a map`
4188
+ };
4189
+ const activation = perDevice[query.capName];
4190
+ if (activation === void 0) return { kind: "missing" };
4191
+ if (!isRecord(activation)) return {
4192
+ kind: "failed",
4193
+ error: `stored "${query.capName}" activation is not a record`
4194
+ };
4195
+ const wrapperAddonId = activation["wrapperAddonId"];
4196
+ if (wrapperAddonId === void 0) return { kind: "missing" };
4197
+ if (wrapperAddonId === null || typeof wrapperAddonId === "string") return {
4198
+ kind: "value",
4199
+ wrapperAddonId
4200
+ };
4201
+ return {
4202
+ kind: "failed",
4203
+ error: `stored "${query.capName}" wrapperAddonId is not a string`
4204
+ };
4205
+ }
4139
4206
  //#endregion
4140
4207
  //#region src/builtins/device-manager/device-event-propagator.ts
4141
4208
  /**
@@ -5540,6 +5607,7 @@ async function removeDevice(pctx, input) {
5540
5607
  });
5541
5608
  });
5542
5609
  await pctx.settings.clearDeviceStore(deviceId);
5610
+ await pctx.host.forgetMirrorDevice(deviceId);
5543
5611
  await pctx.settings.clearDeviceRuntimeState(deviceId);
5544
5612
  await pctx.host.fieldClaims.pruneDevice(deviceId, pctx.settings);
5545
5613
  const bindingKey = String(deviceId);
@@ -8061,6 +8129,20 @@ function deepEqual(a, b) {
8061
8129
  }
8062
8130
  //#endregion
8063
8131
  //#region src/builtins/device-manager/mirror-row-writer.ts
8132
+ /**
8133
+ * A write-through refused because the device's row could not be read first
8134
+ * (D677). Nothing was written; the caller rolls its memory change back.
8135
+ */
8136
+ var RuntimeStateRowUnreadError = class extends Error {
8137
+ deviceId;
8138
+ constructor(deviceId) {
8139
+ super(`runtime-state row of device ${deviceId} could not be read — nothing written, a row this process has not read is never replaced (D677)`);
8140
+ this.deviceId = deviceId;
8141
+ this.name = "RuntimeStateRowUnreadError";
8142
+ }
8143
+ };
8144
+ var ROW_UNREAD_REFUSAL_MESSAGE = "runtime-state row write refused — the row could not be read first, nothing written (D677)";
8145
+ var ROW_DROPS_CAPS_MESSAGE = "runtime-state row write DROPS caps the stored row holds (D677)";
8064
8146
  function errorMessage$2(err) {
8065
8147
  return err instanceof Error ? err.message : String(err);
8066
8148
  }
@@ -8080,6 +8162,13 @@ var RuntimeStateDrainError = class extends MigrationRefusedError {
8080
8162
  var MirrorRowWriter = class MirrorRowWriter {
8081
8163
  deps;
8082
8164
  static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
8165
+ /** Ceiling of the re-arm backoff while a device's row cannot be read (D677, D3). */
8166
+ static UNREAD_REARM_MAX_MS = 6e4;
8167
+ /** The re-arm delay after `refusals` consecutive unread refusals: 1 s, 2 s, 4 s … capped at 60 s. */
8168
+ static unreadRearmDelayMs(refusals) {
8169
+ const exponent = Math.max(0, refusals - 1);
8170
+ return Math.min(MirrorRowWriter.RUNTIME_STATE_DEBOUNCE_MS * 2 ** exponent, MirrorRowWriter.UNREAD_REARM_MAX_MS);
8171
+ }
8083
8172
  /**
8084
8173
  * Per-device disk-write debouncer for runtime-state. `setCapSlice`
8085
8174
  * updates the in-memory mirror synchronously and emits the change
@@ -8108,6 +8197,8 @@ var MirrorRowWriter = class MirrorRowWriter {
8108
8197
  * that drops work silently reads as "never happened".
8109
8198
  */
8110
8199
  skippedSinceWrite = /* @__PURE__ */ new Map();
8200
+ /** Devices whose unreadable-row refusal is already logged — once per episode. */
8201
+ rowUnreadLogged = /* @__PURE__ */ new Set();
8111
8202
  constructor(deps) {
8112
8203
  this.deps = deps;
8113
8204
  }
@@ -8144,7 +8235,8 @@ var MirrorRowWriter = class MirrorRowWriter {
8144
8235
  slot = {
8145
8236
  timer: null,
8146
8237
  inFlight: null,
8147
- lastWriteFailed: false
8238
+ lastWriteFailed: false,
8239
+ unreadRefusals: 0
8148
8240
  };
8149
8241
  this.runtimeStateDebounce.set(deviceId, slot);
8150
8242
  }
@@ -8179,23 +8271,116 @@ var MirrorRowWriter = class MirrorRowWriter {
8179
8271
  this.armOrHold(deviceId, settings);
8180
8272
  }
8181
8273
  /** Arm the debounce timer; at fire, queue a write of the live blob unless disk already has it. */
8182
- armDiskWrite(deviceId, settings) {
8274
+ armDiskWrite(deviceId, settings, delayMs = MirrorRowWriter.RUNTIME_STATE_DEBOUNCE_MS) {
8183
8275
  const slot = this.slotFor(deviceId);
8184
8276
  if (slot.timer) return;
8185
8277
  slot.timer = setTimeout(() => {
8186
8278
  slot.timer = null;
8187
- this.serialiseWrite(deviceId, () => this.writeLiveBlob(deviceId, settings, slot));
8188
- }, MirrorRowWriter.RUNTIME_STATE_DEBOUNCE_MS);
8279
+ this.serialiseWrite(deviceId, () => this.writeLiveBlob(deviceId, settings, slot, "debounced"));
8280
+ }, delayMs);
8281
+ }
8282
+ /**
8283
+ * Forget a removed device: cancel its armed write, await one in flight, and
8284
+ * drop its baseline and counters — nothing writes its row after this.
8285
+ */
8286
+ async forget(deviceId) {
8287
+ const slot = this.runtimeStateDebounce.get(deviceId);
8288
+ if (slot?.timer) {
8289
+ clearTimeout(slot.timer);
8290
+ slot.timer = null;
8291
+ }
8292
+ if (slot?.inFlight) await slot.inFlight;
8293
+ this.runtimeStateDebounce.delete(deviceId);
8294
+ this.lastPersisted.delete(deviceId);
8295
+ this.skippedSinceWrite.delete(deviceId);
8296
+ this.rowUnreadLogged.delete(deviceId);
8297
+ this.heldDirty.delete(deviceId);
8298
+ }
8299
+ /**
8300
+ * The row is in memory, or is read now (D677). False when the read failed:
8301
+ * the caller writes nothing. Logged once per episode, tagged; the episode
8302
+ * ends at the next successful read.
8303
+ */
8304
+ async rowReadOrRefuse(deviceId, settings, path) {
8305
+ if (!this.deps.isRowRead(deviceId)) try {
8306
+ await this.deps.readRowFirst(deviceId, settings);
8307
+ } catch (err) {
8308
+ if (!this.rowUnreadLogged.has(deviceId)) {
8309
+ this.rowUnreadLogged.add(deviceId);
8310
+ this.deps.logger.warn(ROW_UNREAD_REFUSAL_MESSAGE, {
8311
+ tags: { deviceId },
8312
+ meta: {
8313
+ path,
8314
+ error: errorMessage$2(err)
8315
+ }
8316
+ });
8317
+ }
8318
+ return false;
8319
+ }
8320
+ this.rowUnreadLogged.delete(deviceId);
8321
+ return true;
8189
8322
  }
8190
- /** The debounced write itself: the blob as memory holds it NOW, behind the effective-change gate. Never rejects. */
8191
- async writeLiveBlob(deviceId, settings, slot) {
8323
+ /**
8324
+ * The ONE call of `writeDeviceRuntimeState`. A blob that lacks a cap the
8325
+ * stored row holds is written (memory is the authority once the row was
8326
+ * read) but never silently: the dropped caps are named at warn (D677).
8327
+ * `$owned` is excluded — a release that empties it is the claim path's own
8328
+ * logged business.
8329
+ */
8330
+ async writeRow(deviceId, settings, blob, path) {
8331
+ const onDisk = this.lastPersisted.get(deviceId);
8332
+ if (onDisk) {
8333
+ const dropped = Object.keys(onDisk).filter((cap) => cap !== "$owned" && !Object.hasOwn(blob, cap));
8334
+ if (dropped.length > 0) this.deps.logger.warn(ROW_DROPS_CAPS_MESSAGE, {
8335
+ tags: { deviceId },
8336
+ meta: {
8337
+ path,
8338
+ dropped,
8339
+ storedCaps: Object.keys(onDisk).length,
8340
+ writtenCaps: Object.keys(blob).length
8341
+ }
8342
+ });
8343
+ }
8344
+ await settings.writeDeviceRuntimeState(deviceId, blob);
8345
+ this.lastPersisted.set(deviceId, blob);
8346
+ }
8347
+ /**
8348
+ * A claim's write-through (D663): queued behind the device's writes, the row
8349
+ * read first, the blob taken when it runs. Rejects with
8350
+ * {@link RuntimeStateRowUnreadError} when the row cannot be read, or with the
8351
+ * write's own error; resolves to what landed.
8352
+ */
8353
+ writeThroughRow(deviceId, settings, path) {
8354
+ return this.serialiseWrite(deviceId, async () => {
8355
+ if (!await this.rowReadOrRefuse(deviceId, settings, path)) throw new RuntimeStateRowUnreadError(deviceId);
8356
+ const live = this.deps.persistableFor(deviceId);
8357
+ await this.writeRow(deviceId, settings, live, path);
8358
+ return live;
8359
+ });
8360
+ }
8361
+ /**
8362
+ * The debounced write itself: the row read first (D677), then the blob as
8363
+ * memory holds it NOW, behind the effective-change gate. Never rejects. An
8364
+ * unreadable row writes nothing; the debounced path re-arms with a backoff
8365
+ * (1 s doubling to 60 s — never a 1 Hz loop, D3), and the armed timer is
8366
+ * what a migration drain sees as owed, so a row that has become readable
8367
+ * drains normally. The shutdown flush does not re-arm.
8368
+ */
8369
+ async writeLiveBlob(deviceId, settings, slot, path) {
8370
+ if (!await this.rowReadOrRefuse(deviceId, settings, path)) {
8371
+ if (path === "debounced") {
8372
+ slot.unreadRefusals += 1;
8373
+ this.rearmAfterUnread(deviceId, settings, slot);
8374
+ }
8375
+ return;
8376
+ }
8377
+ slot.unreadRefusals = 0;
8192
8378
  const blob = this.deps.persistableFor(deviceId);
8193
8379
  if (this.isAlreadyPersisted(deviceId, blob)) return;
8194
8380
  const skipped = this.skippedSinceWrite.get(deviceId) ?? 0;
8195
8381
  this.skippedSinceWrite.delete(deviceId);
8196
8382
  try {
8197
- await settings.writeDeviceRuntimeState(deviceId, blob);
8198
- this.lastPersisted.set(deviceId, blob);
8383
+ await this.writeRow(deviceId, settings, blob, path);
8199
8384
  slot.lastWriteFailed = false;
8200
8385
  if (skipped > 0) this.deps.logger.debug("runtime state persisted", {
8201
8386
  tags: { deviceId },
@@ -8212,6 +8397,14 @@ var MirrorRowWriter = class MirrorRowWriter {
8212
8397
  });
8213
8398
  }
8214
8399
  }
8400
+ /** Re-arm an unread refusal with its backoff, or remember it for `release` while held. */
8401
+ rearmAfterUnread(deviceId, settings, slot) {
8402
+ if (this.writesHeld.has(deviceId)) {
8403
+ this.heldDirty.add(deviceId);
8404
+ return;
8405
+ }
8406
+ this.armDiskWrite(deviceId, settings, MirrorRowWriter.unreadRearmDelayMs(slot.unreadRefusals));
8407
+ }
8215
8408
  /**
8216
8409
  * Hold the debounced writer off these devices' rows while a migration swaps
8217
8410
  * them (D663 re-review N-6). Resolves once each device is DRAINED: a write
@@ -8265,6 +8458,7 @@ var MirrorRowWriter = class MirrorRowWriter {
8265
8458
  if (slot?.inFlight) await slot.inFlight;
8266
8459
  const lastFailed = slot?.lastWriteFailed ?? false;
8267
8460
  if (!pending && !lastFailed) return;
8461
+ if (!await this.rowReadOrRefuse(deviceId, settings, "drain")) this.refuseDrain(deviceId, "its runtime-state row could not be read first", null);
8268
8462
  const blob = this.deps.persistableFor(deviceId);
8269
8463
  if (this.isAlreadyPersisted(deviceId, blob)) {
8270
8464
  if (slot) slot.lastWriteFailed = false;
@@ -8272,11 +8466,10 @@ var MirrorRowWriter = class MirrorRowWriter {
8272
8466
  }
8273
8467
  if (lastFailed) this.refuseDrain(deviceId, "its last runtime-state write did not land", null);
8274
8468
  try {
8275
- await this.serialiseWrite(deviceId, () => settings.writeDeviceRuntimeState(deviceId, blob));
8469
+ await this.serialiseWrite(deviceId, () => this.writeRow(deviceId, settings, blob, "drain"));
8276
8470
  } catch (err) {
8277
8471
  this.refuseDrain(deviceId, `the pending runtime-state write failed: ${errorMessage$2(err)}`, err);
8278
8472
  }
8279
- this.lastPersisted.set(deviceId, blob);
8280
8473
  }
8281
8474
  /** A drain that cannot vouch for the row: still owed (re-armed on release), logged, refused. */
8282
8475
  refuseDrain(deviceId, reason, cause) {
@@ -8309,7 +8502,7 @@ var MirrorRowWriter = class MirrorRowWriter {
8309
8502
  clearTimeout(slot.timer);
8310
8503
  slot.timer = null;
8311
8504
  if (settings) {
8312
- pending.push(this.serialiseWrite(deviceId, () => this.writeLiveBlob(deviceId, settings, slot)));
8505
+ pending.push(this.serialiseWrite(deviceId, () => this.writeLiveBlob(deviceId, settings, slot, "shutdown-flush")));
8313
8506
  continue;
8314
8507
  }
8315
8508
  }
@@ -8366,9 +8559,18 @@ var DeviceStateMirror = class {
8366
8559
  stateMirror = /* @__PURE__ */ new Map();
8367
8560
  /** The claims and the merged view of every claimed cap (D663). */
8368
8561
  claims;
8369
- /** Devices whose row has been consulted (or ruled empty by the index). */
8562
+ /**
8563
+ * Devices whose CLAIMS are known: the row was read, or a loaded claims index
8564
+ * vouched that it names none (D447). This is a READ-path fact — what a
8565
+ * status read may answer from. It never licenses a row write (D677).
8566
+ */
8370
8567
  seeded = /* @__PURE__ */ new Set();
8371
- /** Devices whose row was actually READ into memory — a write-through needs this. */
8568
+ /**
8569
+ * Devices whose row was actually READ into memory in THIS process. Every row
8570
+ * write of a device requires it (D677): the write replaces the whole row, so
8571
+ * memory must hold what the row held — an index that vouches for the claims
8572
+ * says nothing about the native slices beside them.
8573
+ */
8372
8574
  rowRead = /* @__PURE__ */ new Set();
8373
8575
  /** One row read per device, however many writes arrive during it. */
8374
8576
  seeding = /* @__PURE__ */ new Map();
@@ -8387,7 +8589,9 @@ var DeviceStateMirror = class {
8387
8589
  this.writer = new MirrorRowWriter({
8388
8590
  logger: ctx.logger,
8389
8591
  policyFor,
8390
- persistableFor: (deviceId) => this.persistableForDevice(deviceId)
8592
+ persistableFor: (deviceId) => this.persistableForDevice(deviceId),
8593
+ isRowRead: (deviceId) => this.rowRead.has(deviceId),
8594
+ readRowFirst: (deviceId, settings) => this.ensureRowRead(deviceId, settings)
8391
8595
  });
8392
8596
  }
8393
8597
  /**
@@ -8587,8 +8791,28 @@ var DeviceStateMirror = class {
8587
8791
  return this.seeded.has(deviceId);
8588
8792
  }
8589
8793
  /**
8794
+ * Forget a removed device (D677): its armed row write is cancelled and one
8795
+ * in flight awaited BEFORE its memory goes, so nothing writes its row back
8796
+ * after `removeDevice` clears it.
8797
+ */
8798
+ async forgetDevice(deviceId) {
8799
+ await this.seeding.get(deviceId)?.catch(() => void 0);
8800
+ await this.writer.forget(deviceId);
8801
+ this.stateMirror.delete(deviceId);
8802
+ this.claims.clearDevice(deviceId);
8803
+ this.seeded.delete(deviceId);
8804
+ this.rowRead.delete(deviceId);
8805
+ this.seedRefusalLogged.delete(deviceId);
8806
+ }
8807
+ /** The device's row was read in this process — the precondition of every row write (D677). */
8808
+ isRowRead(deviceId) {
8809
+ return this.rowRead.has(deviceId);
8810
+ }
8811
+ /**
8590
8812
  * The SYNCHRONOUS half of {@link ensureSeeded}, branch (c): a LOADED claims
8591
- * index that does not name the device vouches for it — seeded, no read.
8813
+ * index that does not name the device vouches for its CLAIMS — seeded, no
8814
+ * read. That is all it vouches for: the device is still not `rowRead`, and
8815
+ * the writer reads the row before its first write (D677).
8592
8816
  * Refused (false) while a read is in flight, because that read may be
8593
8817
  * landing a claim the index does not know yet; refused when the index cannot
8594
8818
  * vouch. The overlay's `peek` calls this instead of `ensureSeeded`: a sync
@@ -8603,14 +8827,21 @@ var DeviceStateMirror = class {
8603
8827
  return true;
8604
8828
  }
8605
8829
  /**
8606
- * Make sure the device's claims are known before a native write is applied
8607
- * (D49). Index first, single-flight (ruling S6):
8830
+ * Make sure the device's CLAIMS are known before a status read is answered
8831
+ * or a native slice applied (D49). Index first, single-flight (ruling S6):
8608
8832
  * (a) already seeded → nothing;
8609
8833
  * (b) a seed in flight → await it;
8610
8834
  * (c) a LOADED index that does not name the device → seeded, no read;
8611
8835
  * (d) otherwise read the row once and seed it.
8612
- * A read that throws propagates — the write is refused — and is logged once
8613
- * per device transition (D391).
8836
+ * A read that throws propagates and is logged once per device transition
8837
+ * (D391).
8838
+ *
8839
+ * Also the native-write RPC's gate: a slice may be APPLIED once its claims
8840
+ * are known. Never the ROW write's: branch (c) knows the claims and nothing
8841
+ * about the native slices on the row; a writer that trusted it replaced a
8842
+ * row it never read with the one slice it held, and every restored cap
8843
+ * nobody re-wrote soon after boot was erased (D677, superseding S6(c) for
8844
+ * writes). The writer calls {@link ensureRowRead}.
8614
8845
  */
8615
8846
  async ensureSeeded(deviceId, settings, index) {
8616
8847
  if (this.seeded.has(deviceId)) return;
@@ -8619,7 +8850,13 @@ var DeviceStateMirror = class {
8619
8850
  if (this.seedIfIndexClears(deviceId, index)) return;
8620
8851
  return this.readRowOnce(deviceId, settings);
8621
8852
  }
8622
- /** A write-through replaces the whole row: memory must hold what the row held. */
8853
+ /**
8854
+ * A row write replaces the whole row: memory must hold what the row held
8855
+ * (D677). Reads the row once, single-flight, and fills memory's gaps from it
8856
+ * (`seedMirror('fill')`: a slice memory already holds is newer and wins).
8857
+ * A read that throws propagates — nothing is written — and is logged once
8858
+ * per device transition (D49, D391).
8859
+ */
8623
8860
  async ensureRowRead(deviceId, settings) {
8624
8861
  if (this.rowRead.has(deviceId)) return;
8625
8862
  const inFlight = this.seeding.get(deviceId);
@@ -8673,13 +8910,8 @@ var DeviceStateMirror = class {
8673
8910
  * `changed: false`, and consumers are handed the truth that stands.
8674
8911
  */
8675
8912
  async writeThrough(deviceId, settings, op, rollback) {
8676
- let blob;
8677
8913
  try {
8678
- blob = await this.writer.serialiseWrite(deviceId, async () => {
8679
- const live = this.persistableForDevice(deviceId);
8680
- await settings.writeDeviceRuntimeState(deviceId, live);
8681
- return live;
8682
- });
8914
+ await this.writer.writeThroughRow(deviceId, settings, op.op);
8683
8915
  } catch (err) {
8684
8916
  const extras = {
8685
8917
  tags: { deviceId },
@@ -8698,7 +8930,6 @@ var DeviceStateMirror = class {
8698
8930
  }
8699
8931
  throw err;
8700
8932
  }
8701
- this.writer.markPersisted(deviceId, blob);
8702
8933
  }
8703
8934
  /** After a rollback, re-emit what is true now: the restored claim's merge, or the native. */
8704
8935
  reemitAfterRollback(deviceId, capName) {
@@ -9326,6 +9557,7 @@ var DeviceManagerAddon = class extends BaseAddon {
9326
9557
  },
9327
9558
  holdMirrorWrites: (deviceIds, settings) => this.stateMirror.holdDiskWrites(deviceIds, settings),
9328
9559
  mirrorOwnedCaps: (deviceId) => this.stateMirror.ownedCaps(deviceId),
9560
+ forgetMirrorDevice: (deviceId) => this.stateMirror.forgetDevice(deviceId),
9329
9561
  resolveDeviceOnline: (deviceId, fallbackOnline) => this.stateMirror.resolveDeviceOnline(deviceId, fallbackOnline),
9330
9562
  resolveDeviceProbed: (deviceId) => this.stateMirror.resolveDeviceProbed(deviceId),
9331
9563
  waitDeviceProvider: (addonId, timeoutMs) => this.waitDeviceProvider(addonId, timeoutMs),
@@ -9668,6 +9900,10 @@ var DeviceManagerAddon = class extends BaseAddon {
9668
9900
  active: input.active
9669
9901
  });
9670
9902
  },
9903
+ getWrapperActivation: (input) => readWrapperActivation(this.wrapperActivationDeps, {
9904
+ deviceId: input.deviceId,
9905
+ capName: input.capName
9906
+ }),
9671
9907
  listWrappersForCap: async (input) => this.listWrappersForCap(input),
9672
9908
  listBindableCapsForDeviceType: async (input) => this.listBindableCapsForDeviceType(input),
9673
9909
  getDeviceSettingsAggregate: async (input) => {
@@ -40,6 +40,12 @@ export interface ProviderHost {
40
40
  * write-through may not have landed yet. A migration's second look (D663).
41
41
  */
42
42
  mirrorOwnedCaps(deviceId: number): OwnedCaps;
43
+ /**
44
+ * Forget a REMOVED device in the mirror: cancel its armed row write, await
45
+ * one in flight, drop its slices, claims and baseline (D677). Called before
46
+ * the row is cleared, so no write lands an orphan row after it.
47
+ */
48
+ forgetMirrorDevice(deviceId: number): Promise<void>;
43
49
  /** Reset a stale per-session `feature-probe` timestamp before mirror seeding. */
44
50
  withResetSessionProbe(blob: Record<string, unknown>): Record<string, unknown>;
45
51
  /** The per-field claims index (D663) — `removeDevice` prunes a removed device from it. */
@@ -1,4 +1,4 @@
1
- import { AddonContext, ICapabilityRegistry } from '@camstack/types';
1
+ import { AddonContext, ICapabilityRegistry, WrapperActivationRead } from '@camstack/types';
2
2
  import { WriteLock } from './write-lock.js';
3
3
  /**
4
4
  * What a wrapper-activation write needs, and nothing more.
@@ -34,3 +34,28 @@ export interface WrapperActivationInput {
34
34
  * this module shipped until 2026-09-20.
35
35
  */
36
36
  export declare function setWrapperActive(deps: WrapperActivationDeps, input: WrapperActivationInput): Promise<void>;
37
+ /** Which activation `getWrapperActivation` is asked about. */
38
+ export interface WrapperActivationQuery {
39
+ readonly deviceId: number;
40
+ readonly capName: string;
41
+ }
42
+ /**
43
+ * The STORED activation for one `(device, capName)` pair, three-way — the
44
+ * provider behind `deviceManager.getWrapperActivation` (D675).
45
+ *
46
+ * Reads ONE row — this addon's `deviceBindings` key (~1.6 KB live) — through
47
+ * the settings view's single-key three-way read, never the addon's whole store
48
+ * (~625 KB) and never `readAddonStore()`, which folds a failed read into `{}`.
49
+ *
50
+ * Absence is graded by LEVEL:
51
+ * - STORE level — the read failed, or the `deviceBindings` row does not exist,
52
+ * or it is not a map — is `failed`. Every hub that has ever bound anything
53
+ * holds that row, so its absence is the signature of a store answering from
54
+ * an empty or unready snapshot (D44), and it must never silence a press
55
+ * (D49). An install that has never bound anything lands here too; erring
56
+ * `failed` there costs nothing, because nothing is bound to silence.
57
+ * - PAIR level — the row was read and has no entry for this device, or none
58
+ * for this cap, or none with `wrapperAddonId` — is `missing`: the operator
59
+ * never touched this pair.
60
+ */
61
+ export declare function readWrapperActivation(deps: Pick<WrapperActivationDeps, 'ctx'>, query: WrapperActivationQuery): Promise<WrapperActivationRead>;
@@ -39,9 +39,18 @@ export declare class DeviceStateMirror {
39
39
  private readonly stateMirror;
40
40
  /** The claims and the merged view of every claimed cap (D663). */
41
41
  private readonly claims;
42
- /** Devices whose row has been consulted (or ruled empty by the index). */
42
+ /**
43
+ * Devices whose CLAIMS are known: the row was read, or a loaded claims index
44
+ * vouched that it names none (D447). This is a READ-path fact — what a
45
+ * status read may answer from. It never licenses a row write (D677).
46
+ */
43
47
  private readonly seeded;
44
- /** Devices whose row was actually READ into memory — a write-through needs this. */
48
+ /**
49
+ * Devices whose row was actually READ into memory in THIS process. Every row
50
+ * write of a device requires it (D677): the write replaces the whole row, so
51
+ * memory must hold what the row held — an index that vouches for the claims
52
+ * says nothing about the native slices beside them.
53
+ */
45
54
  private readonly rowRead;
46
55
  /** One row read per device, however many writes arrive during it. */
47
56
  private readonly seeding;
@@ -103,9 +112,19 @@ export declare class DeviceStateMirror {
103
112
  /** What the merge says for a claimed cap; null when the cap is not claimed. */
104
113
  effectiveIfClaimed(deviceId: number, cap: string): MergedSlice | null;
105
114
  isSeeded(deviceId: number): boolean;
115
+ /**
116
+ * Forget a removed device (D677): its armed row write is cancelled and one
117
+ * in flight awaited BEFORE its memory goes, so nothing writes its row back
118
+ * after `removeDevice` clears it.
119
+ */
120
+ forgetDevice(deviceId: number): Promise<void>;
121
+ /** The device's row was read in this process — the precondition of every row write (D677). */
122
+ isRowRead(deviceId: number): boolean;
106
123
  /**
107
124
  * The SYNCHRONOUS half of {@link ensureSeeded}, branch (c): a LOADED claims
108
- * index that does not name the device vouches for it — seeded, no read.
125
+ * index that does not name the device vouches for its CLAIMS — seeded, no
126
+ * read. That is all it vouches for: the device is still not `rowRead`, and
127
+ * the writer reads the row before its first write (D677).
109
128
  * Refused (false) while a read is in flight, because that read may be
110
129
  * landing a claim the index does not know yet; refused when the index cannot
111
130
  * vouch. The overlay's `peek` calls this instead of `ensureSeeded`: a sync
@@ -114,18 +133,31 @@ export declare class DeviceStateMirror {
114
133
  */
115
134
  seedIfIndexClears(deviceId: number, index: ClaimsIndexView): boolean;
116
135
  /**
117
- * Make sure the device's claims are known before a native write is applied
118
- * (D49). Index first, single-flight (ruling S6):
136
+ * Make sure the device's CLAIMS are known before a status read is answered
137
+ * or a native slice applied (D49). Index first, single-flight (ruling S6):
119
138
  * (a) already seeded → nothing;
120
139
  * (b) a seed in flight → await it;
121
140
  * (c) a LOADED index that does not name the device → seeded, no read;
122
141
  * (d) otherwise read the row once and seed it.
123
- * A read that throws propagates — the write is refused — and is logged once
124
- * per device transition (D391).
142
+ * A read that throws propagates and is logged once per device transition
143
+ * (D391).
144
+ *
145
+ * Also the native-write RPC's gate: a slice may be APPLIED once its claims
146
+ * are known. Never the ROW write's: branch (c) knows the claims and nothing
147
+ * about the native slices on the row; a writer that trusted it replaced a
148
+ * row it never read with the one slice it held, and every restored cap
149
+ * nobody re-wrote soon after boot was erased (D677, superseding S6(c) for
150
+ * writes). The writer calls {@link ensureRowRead}.
125
151
  */
126
152
  ensureSeeded(deviceId: number, settings: DeviceManagerSettings, index: ClaimsIndexView): Promise<void>;
127
- /** A write-through replaces the whole row: memory must hold what the row held. */
128
- private ensureRowRead;
153
+ /**
154
+ * A row write replaces the whole row: memory must hold what the row held
155
+ * (D677). Reads the row once, single-flight, and fills memory's gaps from it
156
+ * (`seedMirror('fill')`: a slice memory already holds is newer and wins).
157
+ * A read that throws propagates — nothing is written — and is logged once
158
+ * per device transition (D49, D391).
159
+ */
160
+ ensureRowRead(deviceId: number, settings: DeviceManagerSettings): Promise<void>;
129
161
  /** An unreadable row is a NAMED refusal (D49): the answer is unknown — a
130
162
  * throw could be read as "gone", `not-claimed` as "never was". Logged by `readRowOnce`. */
131
163
  private readRowOrRefuse;