@camstack/system 1.2.157 → 1.2.160

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 (77) hide show
  1. package/dist/addon-runner.js +17 -16
  2. package/dist/addon-runner.mjs +2 -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.d.ts +10 -7
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +50 -12
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +50 -12
  12. package/dist/builtins/backup-orchestrator/write-targets.d.ts +63 -0
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  17. package/dist/builtins/device-manager/device-bindings-store.d.ts +56 -10
  18. package/dist/builtins/device-manager/device-manager.addon.d.ts +11 -0
  19. package/dist/builtins/device-manager/device-manager.addon.js +216 -81
  20. package/dist/builtins/device-manager/device-manager.addon.mjs +216 -81
  21. package/dist/builtins/device-manager/device-meta-actions.d.ts +9 -3
  22. package/dist/builtins/device-manager/device-meta-store.d.ts +32 -5
  23. package/dist/builtins/device-manager/device-queries.d.ts +7 -3
  24. package/dist/builtins/device-manager/write-lock.d.ts +17 -0
  25. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  26. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  27. package/dist/builtins/hub-forwarder/index.js +1 -1
  28. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  29. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  30. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  31. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  32. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  33. package/dist/builtins/local-network/local-network.addon.js +1 -1
  34. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  35. package/dist/builtins/loki-logging/index.js +1 -1
  36. package/dist/builtins/loki-logging/index.mjs +1 -1
  37. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  38. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  39. package/dist/builtins/platform-probe/index.js +1 -1
  40. package/dist/builtins/platform-probe/index.mjs +1 -1
  41. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  42. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  43. package/dist/builtins/snapshot/index.js +1 -1
  44. package/dist/builtins/snapshot/index.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  46. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  47. package/dist/builtins/sqlite-storage/filter-compiler.d.ts +41 -2
  48. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +15 -7
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  50. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  51. package/dist/builtins/sqlite-storage/vector-index-shared.d.ts +30 -5
  52. package/dist/builtins/sqlite-storage/vector-index-vec.d.ts +28 -6
  53. package/dist/builtins/storage-orchestrator/access-guards.d.ts +32 -0
  54. package/dist/builtins/storage-orchestrator/location-secrets.d.ts +33 -0
  55. package/dist/builtins/storage-orchestrator/storage-migration-coordinator.d.ts +74 -3
  56. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +26 -0
  57. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +552 -15
  58. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +552 -15
  59. package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +56 -1
  60. package/dist/builtins/system-config/system-config.addon.js +1 -1
  61. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  62. package/dist/builtins/winston-logging/index.js +1 -1
  63. package/dist/builtins/winston-logging/index.mjs +1 -1
  64. package/dist/{dist-CDgIzo82.js → dist-CcvXUhHK.js} +537 -17
  65. package/dist/{dist-LvKus-mT.mjs → dist-DKs---Vx.mjs} +520 -18
  66. package/dist/index.js +141 -139
  67. package/dist/index.mjs +3 -3
  68. package/dist/kernel/deps/manifest-system-deps.d.ts +15 -0
  69. package/dist/kernel/index.d.ts +4 -2
  70. package/dist/kernel/moleculer/cap-usage-registry.d.ts +132 -7
  71. package/dist/kernel/storage-location-registry.d.ts +16 -1
  72. package/dist/kernel/transport/local-child-registry.d.ts +57 -0
  73. package/dist/{manifest-python-deps-CD14Pv0-.mjs → manifest-system-deps-DnoPpLGp.mjs} +466 -83
  74. package/dist/{manifest-python-deps-DVODn-qc.js → manifest-system-deps-oJMkWSX-.js} +477 -82
  75. package/dist/{retired-settings-keys-CV4QVdXj.mjs → retired-settings-keys-C3eONTvV.mjs} +1 -1
  76. package/dist/{retired-settings-keys-BfAzWvPC.js → retired-settings-keys-DXZ2xe7C.js} +1 -1
  77. package/package.json +2 -1
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-CDgIzo82.js");
7
- const require_retired_settings_keys = require("../../retired-settings-keys-BfAzWvPC.js");
6
+ const require_dist = require("../../dist-CcvXUhHK.js");
7
+ const require_retired_settings_keys = require("../../retired-settings-keys-DXZ2xe7C.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let _camstack_types_node = require("@camstack/types/node");
10
10
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -569,11 +569,31 @@ function resolveDeviceType(deps, row, deviceId) {
569
569
  * same reason `getAllBindings` warns instead of reporting zero devices. That is
570
570
  * the only reason this is async: the `COUNT(*)` runs solely on the miss path,
571
571
  * where the alternative is to call a device gone because the store is empty.
572
+ *
573
+ * `ledgerNonEmpty` is passed IN rather than defaulted, deliberately. A default
574
+ * that issued its own `rows.count()` would be a per-device read hiding behind
575
+ * an optional argument — the exact shape this file just removed from the
576
+ * activations blob — and nothing would fail when a future caller took it.
572
577
  */
573
- async function resolveDevicePresence(deps, row, deviceId) {
578
+ async function resolveDevicePresence(deps, row, deviceId, ledgerNonEmpty) {
574
579
  if (deps.ctx.kernel?.deviceRegistry?.getById(deviceId)) return "present";
575
580
  if (row !== null) return "present";
576
- return await deps.rows.count() > 0 ? "absent" : "unknown";
581
+ return await ledgerNonEmpty() ? "absent" : "unknown";
582
+ }
583
+ /**
584
+ * `deps.rows.count()`, collapsed to at most one call per pass.
585
+ *
586
+ * The count answers a FLEET question ("has device restore run at all?"), not a
587
+ * per-device one, and it is reached only when a device misses both the live
588
+ * registry and the ledger. A fleet sweep over a store that is genuinely empty
589
+ * hits that path once per device.
590
+ */
591
+ function onceLedgerNonEmpty(deps) {
592
+ let pending = null;
593
+ return () => {
594
+ if (pending === null) pending = deps.rows.count().then((n) => n > 0);
595
+ return pending;
596
+ };
577
597
  }
578
598
  /**
579
599
  * Does a capability apply to a device of type `deviceType`?
@@ -594,8 +614,11 @@ function capAppliesToDeviceType(def, deviceType) {
594
614
  return declared.some((t) => t === deviceType);
595
615
  }
596
616
  async function getBindings(deps, input) {
597
- const row = await deps.rows.get(input.deviceId);
598
- return resolveBindingsForDevice(deps, input.deviceId, row);
617
+ const [row, store] = await Promise.all([deps.rows.get(input.deviceId), readBindingsStore(deps)]);
618
+ return resolveBindingsForDevice(deps, input.deviceId, row, {
619
+ store,
620
+ ledgerNonEmpty: onceLedgerNonEmpty(deps)
621
+ });
599
622
  }
600
623
  /**
601
624
  * The `getBindings` resolver, taking the device's row as a PARAMETER instead
@@ -610,12 +633,22 @@ async function getBindings(deps, input) {
610
633
  * iteration shape measured on the live hub 2026-08-25: `get
611
634
  * device-manager:devices` at 1 656 calls/min steady-state and 17 687 during
612
635
  * boot, one row per call.
636
+ *
637
+ * The {@link BindingsPass} is the SECOND half of that same split, and it is
638
+ * the one that was missing. This function used to issue its own
639
+ * `readBindingsStore(deps)` — one settings-door round trip per device, for a
640
+ * blob that is identical for every device in the pass. `getAllBindings` on the
641
+ * live 1 019-device hub therefore cost 1 020 round trips; it never answered
642
+ * inside `SystemMirror.init`'s 15 s budget, and the admin UI refused to boot.
643
+ * Now every entry point resolves the pass once and hands it down — so this
644
+ * function performs NO store reads of its own, and every device in a sweep
645
+ * sees the same version of the activations.
613
646
  */
614
- async function resolveBindingsForDevice(deps, deviceId, row) {
647
+ async function resolveBindingsForDevice(deps, deviceId, row, pass) {
615
648
  const storeKey = String(deviceId);
616
- const perDevice = (await readBindingsStore(deps)).deviceBindings[storeKey] ?? {};
649
+ const perDevice = pass.store.deviceBindings[storeKey] ?? {};
617
650
  const deviceType = resolveDeviceType(deps, row, deviceId);
618
- const presence = await resolveDevicePresence(deps, row, deviceId);
651
+ const presence = await resolveDevicePresence(deps, row, deviceId, pass.ledgerNonEmpty);
619
652
  const entries = [];
620
653
  const seenCaps = /* @__PURE__ */ new Set();
621
654
  const resolveRemote = (capName) => deps.remoteNativeCaps.get(deviceId)?.get(capName) ?? resolveRemoteNativeCapFromRegistry(deps, capName, deviceId);
@@ -723,21 +756,33 @@ async function resolveBindingsForDevice(deps, deviceId, row) {
723
756
  * `getAllBindings` (511 ms / 679 KB of mostly-unwanted rows).
724
757
  *
725
758
  * Sequential on purpose, like `getAllBindings`: `resolveBindingsForDevice`'s
726
- * remaining per-device work (the bindings-store read, the routing rules) runs
727
- * on this process's own event loop, so concurrency buys nothing there and a
728
- * fan-out of `Promise.all` over hundreds of ids only makes the queue less fair
729
- * to everything else on it. The one part that DOES amortise across the whole
730
- * set — the device row lookup — is fetched ONCE, ahead of the loop
731
- * ({@link DeviceRowStore.getMany}), rather than once per iteration: measured
732
- * on the live hub 2026-08-25, `get device-manager:devices` ran 1 656
733
- * calls/min steady-state, one row per call, which is exactly what looping
734
- * `getBindings`'s own `rows.get(deviceId)` here produced.
759
+ * remaining per-device work is the routing rules, which run on this process's
760
+ * own event loop, so concurrency buys nothing there and a fan-out of
761
+ * `Promise.all` over hundreds of ids only makes the queue less fair to
762
+ * everything else on it. Both parts that DO amortise across the whole set are
763
+ * fetched ONCE, ahead of the loop, and in parallel with each other:
764
+ *
765
+ * - the device rows ({@link DeviceRowStore.getMany}) rather than once per
766
+ * iteration measured on the live hub 2026-08-25, `get
767
+ * device-manager:devices` ran 1 656 calls/min steady-state, one row per
768
+ * call, which is exactly what looping `getBindings`'s own
769
+ * `rows.get(deviceId)` here produced;
770
+ * - the wrapper-activation blob, which the per-device resolver used to read
771
+ * itself — a second settings-door round trip per id, on the call that
772
+ * exists precisely so a caller needing many devices does not pay per
773
+ * device. Measured on the live hub 2026-08-30, mid-drain:
774
+ * `getBindingsBatch` of 1 id = 5.78 s, of 8 ids = 17.16 s — a slope of
775
+ * 1.63 s PER ADDITIONAL DEVICE, on a call that ships 4.5 KB a row.
735
776
  */
736
777
  async function getBindingsBatch(deps, input) {
737
778
  const ids = [...new Set(input.deviceIds)];
738
- const rows = await deps.rows.getMany(ids);
779
+ const [rows, store] = await Promise.all([deps.rows.getMany(ids), readBindingsStore(deps)]);
780
+ const pass = {
781
+ store,
782
+ ledgerNonEmpty: onceLedgerNonEmpty(deps)
783
+ };
739
784
  const out = [];
740
- for (const deviceId of ids) out.push(await resolveBindingsForDevice(deps, deviceId, rows.get(deviceId) ?? null));
785
+ for (const deviceId of ids) out.push(await resolveBindingsForDevice(deps, deviceId, rows.get(deviceId) ?? null, pass));
741
786
  return out;
742
787
  }
743
788
  /**
@@ -750,9 +795,18 @@ async function getBindingsBatch(deps, input) {
750
795
  * clients invalidate via the existing
751
796
  * `capability.binding-changed` event rather than re-fetching this
752
797
  * payload periodically.
798
+ *
799
+ * **"Single round-trip" was a claim, not a fact, until 2026-08-30.** The
800
+ * per-device resolver read the wrapper-activation blob itself, so this call
801
+ * issued one settings-door RPC per device — 1 020 of them on the live
802
+ * 1 019-device hub, at ~2.9 s each while the storage drain ran. It did not
803
+ * return in 240 s, and `SystemMirror.init` gives it 15 s, so the admin UI
804
+ * could not boot at all. Now the whole pass costs exactly TWO reads (the fleet
805
+ * listing and the blob), issued in parallel, and nothing in the loop awaits a
806
+ * store.
753
807
  */
754
808
  async function getAllBindings(deps) {
755
- const fleet = await deps.rows.listAll();
809
+ const [fleet, store] = await Promise.all([deps.rows.listAll(), readBindingsStore(deps)]);
756
810
  const rowById = /* @__PURE__ */ new Map();
757
811
  const ids = /* @__PURE__ */ new Set();
758
812
  for (const row of fleet) {
@@ -765,8 +819,12 @@ async function getAllBindings(deps) {
765
819
  deps.ctx.logger.warn("getAllBindings found no devices — warm boot will see nothing", { meta: { hasRegistry: deps.ctx.kernel?.deviceRegistry !== void 0 } });
766
820
  return [];
767
821
  }
822
+ const pass = {
823
+ store,
824
+ ledgerNonEmpty: onceLedgerNonEmpty(deps)
825
+ };
768
826
  const out = [];
769
- for (const deviceId of [...ids].sort((a, b) => a - b)) out.push(await resolveBindingsForDevice(deps, deviceId, rowById.get(deviceId) ?? null));
827
+ for (const deviceId of [...ids].sort((a, b) => a - b)) out.push(await resolveBindingsForDevice(deps, deviceId, rowById.get(deviceId) ?? null, pass));
770
828
  return out;
771
829
  }
772
830
  /**
@@ -1466,15 +1524,39 @@ async function getDevice(pctx, input) {
1466
1524
  };
1467
1525
  }
1468
1526
  /**
1469
- * The children of ONE parent, projected from rows and live devices the caller
1470
- * has ALREADY read.
1527
+ * The child ids whose config comes from the SETTINGS STORE the rows the live
1528
+ * registry does not answer for.
1529
+ *
1530
+ * The one place the "persisted fills in what the registry does not hold" rule
1531
+ * is spelled, so the pre-read and the projection cannot disagree about which
1532
+ * children need a store value. On the live hub 2026-08-30 this is every child
1533
+ * there is: all 896 belong to forked provider addons, so hub-main's registry
1534
+ * holds none of them.
1535
+ */
1536
+ function persistedChildIds(childRows, liveChildren) {
1537
+ const live = /* @__PURE__ */ new Set();
1538
+ for (const device of liveChildren) live.add(device.id);
1539
+ return childRows.filter((row) => !live.has(row.meta.id)).map((row) => row.meta.id);
1540
+ }
1541
+ /**
1542
+ * The children of ONE parent, projected from rows, live devices AND configs the
1543
+ * caller has ALREADY read.
1471
1544
  *
1472
1545
  * Extracted so `getChildren` and `getChildrenBatch` share one projection: the
1473
1546
  * batch differs only in HOW the rows were fetched (one `listByParentMany`
1474
1547
  * instead of N `listByParent`), never in what a parent's answer looks like.
1475
- * Byte-for-byte the body `getChildren` had.
1548
+ *
1549
+ * **Not async, and that is the contract.** This function used to `await
1550
+ * pctx.settings.readDeviceStore(m.id)` inside its own loop — one settings-door
1551
+ * round trip per persisted child, sequentially, on hub-main's event loop. The
1552
+ * caller above (`getChildrenBatch`, coalescing every accessory reconcile in
1553
+ * flight) had already removed the ledger N+1 and left this one: 896 door reads
1554
+ * per boot reconcile and per device page, at ~2.9 s each mid-drain. `configs`
1555
+ * arrives resolved through `readDeviceConfigs` — one round trip for the whole
1556
+ * pass — and a signature with no `Promise` in it means a future read cannot be
1557
+ * put back here without the type saying so.
1476
1558
  */
1477
- async function projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, liveChildren) {
1559
+ function projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, liveChildren, configs) {
1478
1560
  const results = [];
1479
1561
  const seen = /* @__PURE__ */ new Set();
1480
1562
  const rowById = /* @__PURE__ */ new Map();
@@ -1490,9 +1572,9 @@ async function projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, li
1490
1572
  const childStableId = m.stableId;
1491
1573
  const key = String(m.id);
1492
1574
  if (seen.has(key)) continue;
1493
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1575
+ const persistedConfig = configs.get(m.id) ?? {};
1494
1576
  const metadata = row.metadata;
1495
- const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
1577
+ const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig, childStableId, ownerAddonId);
1496
1578
  results.push({
1497
1579
  id: m.id,
1498
1580
  stableId: childStableId,
@@ -1507,7 +1589,7 @@ async function projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, li
1507
1589
  probed: pctx.host.resolveDeviceProbed(m.id),
1508
1590
  features: persistedFeatures(m.features),
1509
1591
  isCamera: false,
1510
- config: persistedConfig ?? {},
1592
+ config: persistedConfig,
1511
1593
  metadata,
1512
1594
  ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1513
1595
  ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
@@ -1541,18 +1623,23 @@ async function getChildren(pctx, input) {
1541
1623
  }
1542
1624
  const childRows = await pctx.metaStore.rows.listByParent(parentDeviceId);
1543
1625
  const liveChildren = pctx.registry?.getChildren(parentDeviceId) ?? [];
1544
- return [...await projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, liveChildren)];
1626
+ const configs = await readDeviceConfigs(pctx, persistedChildIds(childRows, liveChildren));
1627
+ return [...projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, liveChildren, configs)];
1545
1628
  }
1546
1629
  /**
1547
- * `getChildren` for a set of parents — TWO store reads for the whole set, not
1548
- * two per parent.
1630
+ * `getChildren` for a set of parents — THREE store reads for the whole set, not
1631
+ * three per parent (and not one per child).
1549
1632
  *
1550
1633
  * The single-parent path costs one `resolvePersistedById` (a primary-key read)
1551
1634
  * plus one `listByParent` (an indexed scan) EACH. Asked once per device at
1552
1635
  * boot that was 1 024 of each, measured on the live hub 2026-08-27 — see the
1553
1636
  * cap method's note. Here the ownership lookup for every parent the live
1554
1637
  * registry does not hold is ONE `getMany`, and the children of every parent
1555
- * are ONE `listByParentMany`.
1638
+ * are ONE `listByParentMany`. The third read is the settings door, and it is
1639
+ * one for the whole batch: `readDeviceConfigs` over every persisted child of
1640
+ * every parent. That one was the leftover — the ledger N+1 was removed and a
1641
+ * door round trip PER CHILD stayed behind it (896 on the live hub 2026-08-30,
1642
+ * all of them forked-owned and therefore all persisted).
1556
1643
  *
1557
1644
  * The live half is indexed once too: `registry.getChildren` is
1558
1645
  * `getAll().filter(…)`, so calling it per parent would trade an RPC N+1 for an
@@ -1590,11 +1677,14 @@ async function getChildrenBatch(pctx, input) {
1590
1677
  if (bucket === void 0) liveByParent.set(parentId, [device]);
1591
1678
  else bucket.push(device);
1592
1679
  }
1680
+ const configIds = [];
1681
+ for (const parentId of owners.keys()) configIds.push(...persistedChildIds(rowsByParent.get(parentId) ?? [], liveByParent.get(parentId) ?? []));
1682
+ const configs = await readDeviceConfigs(pctx, configIds);
1593
1683
  for (const [parentId, ownerAddonId] of owners) {
1594
1684
  const childRows = rowsByParent.get(parentId) ?? [];
1595
1685
  const liveChildren = liveByParent.get(parentId) ?? [];
1596
1686
  if (childRows.length === 0 && liveChildren.length === 0) continue;
1597
- const projected = await projectChildren(pctx, parentId, ownerAddonId, childRows, liveChildren);
1687
+ const projected = projectChildren(pctx, parentId, ownerAddonId, childRows, liveChildren, configs);
1598
1688
  if (projected.length > 0) out[String(parentId)] = [...projected];
1599
1689
  }
1600
1690
  return out;
@@ -2956,18 +3046,20 @@ async function listBindableCapsForDeviceType(deps, input) {
2956
3046
  }
2957
3047
  async function setWrapperActive(deps, input) {
2958
3048
  const storeKey = String(input.deviceId);
2959
- const store = await readBindingsStore(deps.bindingsDeps);
2960
- const perDevice = { ...store.deviceBindings[storeKey] };
2961
- if (input.active) perDevice[input.capName] = { wrapperAddonId: input.wrapperAddonId };
2962
- else perDevice[input.capName] = { wrapperAddonId: null };
2963
- const nextDeviceBindings = Object.keys(perDevice).length > 0 ? {
2964
- ...store.deviceBindings,
2965
- [storeKey]: perDevice
2966
- } : (() => {
2967
- const { [storeKey]: _drop, ...rest } = store.deviceBindings;
2968
- return rest;
2969
- })();
2970
- await writeBindingsStore(deps.bindingsDeps, { deviceBindings: nextDeviceBindings });
3049
+ await deps.bindingsDeps.withAddonStoreWriteLock(async () => {
3050
+ const store = await readBindingsStore(deps.bindingsDeps);
3051
+ const perDevice = { ...store.deviceBindings[storeKey] };
3052
+ if (input.active) perDevice[input.capName] = { wrapperAddonId: input.wrapperAddonId };
3053
+ else perDevice[input.capName] = { wrapperAddonId: null };
3054
+ const nextDeviceBindings = Object.keys(perDevice).length > 0 ? {
3055
+ ...store.deviceBindings,
3056
+ [storeKey]: perDevice
3057
+ } : (() => {
3058
+ const { [storeKey]: _drop, ...rest } = store.deviceBindings;
3059
+ return rest;
3060
+ })();
3061
+ await writeBindingsStore(deps.bindingsDeps, { deviceBindings: nextDeviceBindings });
3062
+ });
2971
3063
  deps.ctx.eventBus.emit({
2972
3064
  id: (0, node_crypto.randomUUID)(),
2973
3065
  timestamp: /* @__PURE__ */ new Date(),
@@ -3334,12 +3426,13 @@ async function removeDevice(pctx, input) {
3334
3426
  });
3335
3427
  await pctx.settings.clearDeviceStore(deviceId);
3336
3428
  await pctx.settings.clearDeviceRuntimeState(deviceId);
3337
- const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
3338
3429
  const bindingKey = String(deviceId);
3339
- if (bindingsStore.deviceBindings[bindingKey]) {
3430
+ await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
3431
+ const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
3432
+ if (!bindingsStore.deviceBindings[bindingKey]) return;
3340
3433
  const { [bindingKey]: _removedBindings, ...restBindings } = bindingsStore.deviceBindings;
3341
3434
  await writeBindingsStore(pctx.bindingsDeps, { deviceBindings: restBindings });
3342
- }
3435
+ });
3343
3436
  pctx.host.remoteNativeCaps.delete(deviceId);
3344
3437
  pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(deviceId);
3345
3438
  pctx.metaStore.idToAddonId.delete(deviceId);
@@ -3801,16 +3894,24 @@ async function getRoleDisplayDefaults(pctx, _input) {
3801
3894
  /**
3802
3895
  * Replace the per-role display defaults whole-record (full replace). Override
3803
3896
  * units are normalized (`normalizeUnit`) at write so the render path always
3804
- * looks up canonical spellings. Single-writer top-level key no lock, no RMW,
3805
- * no interaction with the device write lock. Not per-device, so no event
3806
- * is emitted; the UI invalidates its own query on mutate.
3897
+ * looks up canonical spellings. Not per-device, so no event is emitted; the UI
3898
+ * invalidates its own query on mutate.
3899
+ *
3900
+ * This key is not itself read-modify-written, but `writeAddonStore` IS — the
3901
+ * door loads this addon's whole key range, merges the patch and re-inserts the
3902
+ * range (`settings-door-view.ts`). An unordered write of ANY key on that range
3903
+ * can therefore carry a stale copy of every other key, so it takes the same
3904
+ * lock as `deviceBindings` and `locations`. The old comment here claimed "no
3905
+ * lock, no RMW", which described the top-level key and not the write.
3807
3906
  */
3808
3907
  async function setRoleDisplayDefaults(pctx, input) {
3809
3908
  const normalized = Object.fromEntries(Object.entries(input.defaults).map(([role, def]) => [role, def.unit !== void 0 ? {
3810
3909
  ...def,
3811
3910
  unit: require_dist.normalizeUnit(def.unit) ?? def.unit
3812
3911
  } : def]));
3813
- await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
3912
+ await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
3913
+ await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
3914
+ });
3814
3915
  }
3815
3916
  /**
3816
3917
  * Batched meta pre-seed. Applies every provided field to the
@@ -4008,9 +4109,11 @@ async function listLocations(pctx) {
4008
4109
  async function addLocation(pctx, input) {
4009
4110
  const trimmed = input.name.trim();
4010
4111
  if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
4011
- const current = (await pctx.metaStore.readStore()).locations ?? [];
4012
- if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
4013
- await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
4112
+ await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
4113
+ const current = (await pctx.metaStore.readStore()).locations ?? [];
4114
+ if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
4115
+ await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
4116
+ });
4014
4117
  }
4015
4118
  /**
4016
4119
  * Remove a label from the curated registry. Match is
@@ -4024,9 +4127,12 @@ async function addLocation(pctx, input) {
4024
4127
  async function removeLocation(pctx, input) {
4025
4128
  const trimmed = input.name.trim();
4026
4129
  if (trimmed.length === 0) return;
4027
- const current = (await pctx.metaStore.readStore()).locations ?? [];
4028
- const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
4029
- if (remaining.length !== current.length) await pctx.settings.writeAddonStore({ locations: remaining });
4130
+ await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
4131
+ const current = (await pctx.metaStore.readStore()).locations ?? [];
4132
+ const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
4133
+ if (remaining.length === current.length) return;
4134
+ await pctx.settings.writeAddonStore({ locations: remaining });
4135
+ });
4030
4136
  if (input.cascade !== true) return;
4031
4137
  const cleared = await pctx.metaStore.withMetaWriteLock(async () => {
4032
4138
  const out = [];
@@ -4077,24 +4183,41 @@ function isRoleDisplayDefaults(value) {
4077
4183
  return value !== null && typeof value === "object" && !Array.isArray(value);
4078
4184
  }
4079
4185
  //#endregion
4186
+ //#region src/builtins/device-manager/write-lock.ts
4187
+ function createWriteLock() {
4188
+ let chain = Promise.resolve();
4189
+ return async (fn) => {
4190
+ const previous = chain;
4191
+ let release = () => {};
4192
+ chain = new Promise((resolve) => {
4193
+ release = resolve;
4194
+ });
4195
+ try {
4196
+ await previous.catch(() => {});
4197
+ return await fn();
4198
+ } finally {
4199
+ release();
4200
+ }
4201
+ };
4202
+ }
4203
+ //#endregion
4080
4204
  //#region src/builtins/device-manager/device-meta-store.ts
4081
4205
  var DeviceMetaStore = class {
4082
4206
  settings;
4083
4207
  registry;
4084
4208
  rows;
4209
+ withAddonStoreWriteLock;
4085
4210
  /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
4086
4211
  * The persisted row store is authoritative but reads are async; hub-side
4087
4212
  * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
4088
4213
  * ownership without awaiting. Kept in sync with every register/remove and
4089
4214
  * warmed from persistence on boot. */
4090
4215
  idToAddonId = /* @__PURE__ */ new Map();
4091
- /** Serialises every read-modify-write of a device row through one promise
4092
- * chain (see `withMetaWriteLock`). Per-instance state. */
4093
- metaWriteChain = Promise.resolve();
4094
- constructor(settings, registry, rows) {
4216
+ constructor(settings, registry, rows, withAddonStoreWriteLock = createWriteLock()) {
4095
4217
  this.settings = settings;
4096
4218
  this.registry = registry;
4097
4219
  this.rows = rows;
4220
+ this.withAddonStoreWriteLock = withAddonStoreWriteLock;
4098
4221
  }
4099
4222
  /** The read currently in flight, or null. Never a settled value — see
4100
4223
  * {@link readStore}. */
@@ -4125,20 +4248,7 @@ var DeviceMetaStore = class {
4125
4248
  this.inFlightRead = read;
4126
4249
  return read;
4127
4250
  };
4128
- withMetaWriteLock = async (fn) => {
4129
- const previous = this.metaWriteChain;
4130
- let release = () => {};
4131
- const next = new Promise((resolve) => {
4132
- release = resolve;
4133
- });
4134
- this.metaWriteChain = next;
4135
- try {
4136
- await previous.catch(() => {});
4137
- return await fn();
4138
- } finally {
4139
- release();
4140
- }
4141
- };
4251
+ withMetaWriteLock = createWriteLock();
4142
4252
  /** The whole persisted row for one device, or `null`. */
4143
4253
  getRow = async (deviceId) => this.rows.get(deviceId);
4144
4254
  /**
@@ -4172,11 +4282,24 @@ var DeviceMetaStore = class {
4172
4282
  ids.delete(parentId);
4173
4283
  return [...ids];
4174
4284
  };
4175
- allocateNextDeviceId = async () => {
4285
+ /**
4286
+ * Mint the next numeric device id.
4287
+ *
4288
+ * A read-modify-write of `nextDeviceId`, and it must hold the addon-store
4289
+ * lock for the whole of it. Serialising the WRITES is not enough and never
4290
+ * was: two callers that both read `N` both write `N + 1` and both return
4291
+ * `N`, so two devices receive the same id. (`writeAddonStore` is itself
4292
+ * read-modify-write over the addon's whole key range, so an unlocked bump
4293
+ * can also be reverted wholesale by a concurrent `setWrapperActive`.)
4294
+ *
4295
+ * Callers hold {@link withMetaWriteLock} — this is the one nesting, and it
4296
+ * only ever goes meta ⊃ addon-store.
4297
+ */
4298
+ allocateNextDeviceId = async () => this.withAddonStoreWriteLock(async () => {
4176
4299
  const current = (await this.readStore()).nextDeviceId ?? 1;
4177
4300
  await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
4178
4301
  return current;
4179
- };
4302
+ });
4180
4303
  };
4181
4304
  //#endregion
4182
4305
  //#region src/builtins/device-manager/fleet-read-census.ts
@@ -5559,6 +5682,17 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
5559
5682
  * it through {@link bindingsDeps} or the `ProviderContext`.
5560
5683
  */
5561
5684
  deviceRows = null;
5685
+ /**
5686
+ * The ONE lock ordering every read-modify-write of this addon's
5687
+ * `addon-settings` key range (`deviceBindings`, `locations`, `nextDeviceId`,
5688
+ * `roleDisplayDefaults`).
5689
+ *
5690
+ * It lives on the addon rather than on `DeviceMetaStore` because the writers
5691
+ * do not all go through that class — `setWrapperActive` and `removeDevice`'s
5692
+ * bindings purge reach the store through `BindingsDeps` — and two locks over
5693
+ * one key range is the same defect one level up. Handed to both.
5694
+ */
5695
+ addonStoreWriteLock = createWriteLock();
5562
5696
  /** Build the dependency context the extracted binding resolvers consume. */
5563
5697
  get bindingsDeps() {
5564
5698
  const rows = this.deviceRows;
@@ -5567,7 +5701,8 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
5567
5701
  ctx: this.ctx,
5568
5702
  capabilityRegistry: this.capabilityRegistry,
5569
5703
  remoteNativeCaps: this.remoteNativeCaps,
5570
- rows
5704
+ rows,
5705
+ withAddonStoreWriteLock: this.addonStoreWriteLock
5571
5706
  };
5572
5707
  }
5573
5708
  async getBindings(input) {
@@ -5636,7 +5771,7 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
5636
5771
  } catch (err) {
5637
5772
  this.ctx.logger.warn("retired-row purge failed", { meta: { error: require_dist.errMsg(err) } });
5638
5773
  }
5639
- const metaStore = new DeviceMetaStore(settings, registry, deviceRows);
5774
+ const metaStore = new DeviceMetaStore(settings, registry, deviceRows, this.addonStoreWriteLock);
5640
5775
  this.stateMirrorImpl = new DeviceStateMirror(this.ctx);
5641
5776
  const stateMirror = this.stateMirrorImpl;
5642
5777
  const resolvePersistedById = metaStore.resolvePersistedById;