@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
@@ -1,5 +1,5 @@
1
- import { A as buildStreamParamsConfigSchema, Ft as DeviceType, H as enumerateItemArrayFields, L as deviceManagerCapability, Nt as DeviceFeature, Ot as errMsg, Pt as DeviceRole, R as deviceStateCapability, Rt as WELL_KNOWN_TAB_MAP, U as enumerateSchemaFields, _ as STREAM_PROFILE_META, a as CAP_NAMES_WITH_STATUS, d as DeviceStatusSchema, en as sleep, ht as runtimeStatePolicyFor, kt as BaseAddon, lt as parseStreamParamsFormPatch, qt as isDeviceConfigCap, st as normalizeUnit, t as ALL_CAPABILITY_DEFINITIONS, tn as EventCategory, z as deviceStatusCapability } from "../../dist-LvKus-mT.mjs";
2
- import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-CV4QVdXj.mjs";
1
+ import { B as deviceManagerCapability, G as enumerateItemArrayFields, H as deviceStatusCapability, It as DeviceFeature, K as enumerateSchemaFields, Lt as DeviceRole, M as buildStreamParamsConfigSchema, Mt as BaseAddon, Rt as DeviceType, V as deviceStateCapability, Vt as WELL_KNOWN_TAB_MAP, Xt as isDeviceConfigCap, a as CAP_NAMES_WITH_STATUS, d as DeviceStatusSchema, ft as parseStreamParamsFormPatch, in as EventCategory, jt as errMsg, rn as sleep, t as ALL_CAPABILITY_DEFINITIONS, ut as normalizeUnit, vt as runtimeStatePolicyFor, y as STREAM_PROFILE_META } from "../../dist-DKs---Vx.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-C3eONTvV.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
5
5
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -564,11 +564,31 @@ function resolveDeviceType(deps, row, deviceId) {
564
564
  * same reason `getAllBindings` warns instead of reporting zero devices. That is
565
565
  * the only reason this is async: the `COUNT(*)` runs solely on the miss path,
566
566
  * where the alternative is to call a device gone because the store is empty.
567
+ *
568
+ * `ledgerNonEmpty` is passed IN rather than defaulted, deliberately. A default
569
+ * that issued its own `rows.count()` would be a per-device read hiding behind
570
+ * an optional argument — the exact shape this file just removed from the
571
+ * activations blob — and nothing would fail when a future caller took it.
567
572
  */
568
- async function resolveDevicePresence(deps, row, deviceId) {
573
+ async function resolveDevicePresence(deps, row, deviceId, ledgerNonEmpty) {
569
574
  if (deps.ctx.kernel?.deviceRegistry?.getById(deviceId)) return "present";
570
575
  if (row !== null) return "present";
571
- return await deps.rows.count() > 0 ? "absent" : "unknown";
576
+ return await ledgerNonEmpty() ? "absent" : "unknown";
577
+ }
578
+ /**
579
+ * `deps.rows.count()`, collapsed to at most one call per pass.
580
+ *
581
+ * The count answers a FLEET question ("has device restore run at all?"), not a
582
+ * per-device one, and it is reached only when a device misses both the live
583
+ * registry and the ledger. A fleet sweep over a store that is genuinely empty
584
+ * hits that path once per device.
585
+ */
586
+ function onceLedgerNonEmpty(deps) {
587
+ let pending = null;
588
+ return () => {
589
+ if (pending === null) pending = deps.rows.count().then((n) => n > 0);
590
+ return pending;
591
+ };
572
592
  }
573
593
  /**
574
594
  * Does a capability apply to a device of type `deviceType`?
@@ -589,8 +609,11 @@ function capAppliesToDeviceType(def, deviceType) {
589
609
  return declared.some((t) => t === deviceType);
590
610
  }
591
611
  async function getBindings(deps, input) {
592
- const row = await deps.rows.get(input.deviceId);
593
- return resolveBindingsForDevice(deps, input.deviceId, row);
612
+ const [row, store] = await Promise.all([deps.rows.get(input.deviceId), readBindingsStore(deps)]);
613
+ return resolveBindingsForDevice(deps, input.deviceId, row, {
614
+ store,
615
+ ledgerNonEmpty: onceLedgerNonEmpty(deps)
616
+ });
594
617
  }
595
618
  /**
596
619
  * The `getBindings` resolver, taking the device's row as a PARAMETER instead
@@ -605,12 +628,22 @@ async function getBindings(deps, input) {
605
628
  * iteration shape measured on the live hub 2026-08-25: `get
606
629
  * device-manager:devices` at 1 656 calls/min steady-state and 17 687 during
607
630
  * boot, one row per call.
631
+ *
632
+ * The {@link BindingsPass} is the SECOND half of that same split, and it is
633
+ * the one that was missing. This function used to issue its own
634
+ * `readBindingsStore(deps)` — one settings-door round trip per device, for a
635
+ * blob that is identical for every device in the pass. `getAllBindings` on the
636
+ * live 1 019-device hub therefore cost 1 020 round trips; it never answered
637
+ * inside `SystemMirror.init`'s 15 s budget, and the admin UI refused to boot.
638
+ * Now every entry point resolves the pass once and hands it down — so this
639
+ * function performs NO store reads of its own, and every device in a sweep
640
+ * sees the same version of the activations.
608
641
  */
609
- async function resolveBindingsForDevice(deps, deviceId, row) {
642
+ async function resolveBindingsForDevice(deps, deviceId, row, pass) {
610
643
  const storeKey = String(deviceId);
611
- const perDevice = (await readBindingsStore(deps)).deviceBindings[storeKey] ?? {};
644
+ const perDevice = pass.store.deviceBindings[storeKey] ?? {};
612
645
  const deviceType = resolveDeviceType(deps, row, deviceId);
613
- const presence = await resolveDevicePresence(deps, row, deviceId);
646
+ const presence = await resolveDevicePresence(deps, row, deviceId, pass.ledgerNonEmpty);
614
647
  const entries = [];
615
648
  const seenCaps = /* @__PURE__ */ new Set();
616
649
  const resolveRemote = (capName) => deps.remoteNativeCaps.get(deviceId)?.get(capName) ?? resolveRemoteNativeCapFromRegistry(deps, capName, deviceId);
@@ -718,21 +751,33 @@ async function resolveBindingsForDevice(deps, deviceId, row) {
718
751
  * `getAllBindings` (511 ms / 679 KB of mostly-unwanted rows).
719
752
  *
720
753
  * Sequential on purpose, like `getAllBindings`: `resolveBindingsForDevice`'s
721
- * remaining per-device work (the bindings-store read, the routing rules) runs
722
- * on this process's own event loop, so concurrency buys nothing there and a
723
- * fan-out of `Promise.all` over hundreds of ids only makes the queue less fair
724
- * to everything else on it. The one part that DOES amortise across the whole
725
- * set — the device row lookup — is fetched ONCE, ahead of the loop
726
- * ({@link DeviceRowStore.getMany}), rather than once per iteration: measured
727
- * on the live hub 2026-08-25, `get device-manager:devices` ran 1 656
728
- * calls/min steady-state, one row per call, which is exactly what looping
729
- * `getBindings`'s own `rows.get(deviceId)` here produced.
754
+ * remaining per-device work is the routing rules, which run on this process's
755
+ * own event loop, so concurrency buys nothing there and a fan-out of
756
+ * `Promise.all` over hundreds of ids only makes the queue less fair to
757
+ * everything else on it. Both parts that DO amortise across the whole set are
758
+ * fetched ONCE, ahead of the loop, and in parallel with each other:
759
+ *
760
+ * - the device rows ({@link DeviceRowStore.getMany}) rather than once per
761
+ * iteration measured on the live hub 2026-08-25, `get
762
+ * device-manager:devices` ran 1 656 calls/min steady-state, one row per
763
+ * call, which is exactly what looping `getBindings`'s own
764
+ * `rows.get(deviceId)` here produced;
765
+ * - the wrapper-activation blob, which the per-device resolver used to read
766
+ * itself — a second settings-door round trip per id, on the call that
767
+ * exists precisely so a caller needing many devices does not pay per
768
+ * device. Measured on the live hub 2026-08-30, mid-drain:
769
+ * `getBindingsBatch` of 1 id = 5.78 s, of 8 ids = 17.16 s — a slope of
770
+ * 1.63 s PER ADDITIONAL DEVICE, on a call that ships 4.5 KB a row.
730
771
  */
731
772
  async function getBindingsBatch(deps, input) {
732
773
  const ids = [...new Set(input.deviceIds)];
733
- const rows = await deps.rows.getMany(ids);
774
+ const [rows, store] = await Promise.all([deps.rows.getMany(ids), readBindingsStore(deps)]);
775
+ const pass = {
776
+ store,
777
+ ledgerNonEmpty: onceLedgerNonEmpty(deps)
778
+ };
734
779
  const out = [];
735
- for (const deviceId of ids) out.push(await resolveBindingsForDevice(deps, deviceId, rows.get(deviceId) ?? null));
780
+ for (const deviceId of ids) out.push(await resolveBindingsForDevice(deps, deviceId, rows.get(deviceId) ?? null, pass));
736
781
  return out;
737
782
  }
738
783
  /**
@@ -745,9 +790,18 @@ async function getBindingsBatch(deps, input) {
745
790
  * clients invalidate via the existing
746
791
  * `capability.binding-changed` event rather than re-fetching this
747
792
  * payload periodically.
793
+ *
794
+ * **"Single round-trip" was a claim, not a fact, until 2026-08-30.** The
795
+ * per-device resolver read the wrapper-activation blob itself, so this call
796
+ * issued one settings-door RPC per device — 1 020 of them on the live
797
+ * 1 019-device hub, at ~2.9 s each while the storage drain ran. It did not
798
+ * return in 240 s, and `SystemMirror.init` gives it 15 s, so the admin UI
799
+ * could not boot at all. Now the whole pass costs exactly TWO reads (the fleet
800
+ * listing and the blob), issued in parallel, and nothing in the loop awaits a
801
+ * store.
748
802
  */
749
803
  async function getAllBindings(deps) {
750
- const fleet = await deps.rows.listAll();
804
+ const [fleet, store] = await Promise.all([deps.rows.listAll(), readBindingsStore(deps)]);
751
805
  const rowById = /* @__PURE__ */ new Map();
752
806
  const ids = /* @__PURE__ */ new Set();
753
807
  for (const row of fleet) {
@@ -760,8 +814,12 @@ async function getAllBindings(deps) {
760
814
  deps.ctx.logger.warn("getAllBindings found no devices — warm boot will see nothing", { meta: { hasRegistry: deps.ctx.kernel?.deviceRegistry !== void 0 } });
761
815
  return [];
762
816
  }
817
+ const pass = {
818
+ store,
819
+ ledgerNonEmpty: onceLedgerNonEmpty(deps)
820
+ };
763
821
  const out = [];
764
- for (const deviceId of [...ids].sort((a, b) => a - b)) out.push(await resolveBindingsForDevice(deps, deviceId, rowById.get(deviceId) ?? null));
822
+ for (const deviceId of [...ids].sort((a, b) => a - b)) out.push(await resolveBindingsForDevice(deps, deviceId, rowById.get(deviceId) ?? null, pass));
765
823
  return out;
766
824
  }
767
825
  /**
@@ -1461,15 +1519,39 @@ async function getDevice(pctx, input) {
1461
1519
  };
1462
1520
  }
1463
1521
  /**
1464
- * The children of ONE parent, projected from rows and live devices the caller
1465
- * has ALREADY read.
1522
+ * The child ids whose config comes from the SETTINGS STORE the rows the live
1523
+ * registry does not answer for.
1524
+ *
1525
+ * The one place the "persisted fills in what the registry does not hold" rule
1526
+ * is spelled, so the pre-read and the projection cannot disagree about which
1527
+ * children need a store value. On the live hub 2026-08-30 this is every child
1528
+ * there is: all 896 belong to forked provider addons, so hub-main's registry
1529
+ * holds none of them.
1530
+ */
1531
+ function persistedChildIds(childRows, liveChildren) {
1532
+ const live = /* @__PURE__ */ new Set();
1533
+ for (const device of liveChildren) live.add(device.id);
1534
+ return childRows.filter((row) => !live.has(row.meta.id)).map((row) => row.meta.id);
1535
+ }
1536
+ /**
1537
+ * The children of ONE parent, projected from rows, live devices AND configs the
1538
+ * caller has ALREADY read.
1466
1539
  *
1467
1540
  * Extracted so `getChildren` and `getChildrenBatch` share one projection: the
1468
1541
  * batch differs only in HOW the rows were fetched (one `listByParentMany`
1469
1542
  * instead of N `listByParent`), never in what a parent's answer looks like.
1470
- * Byte-for-byte the body `getChildren` had.
1543
+ *
1544
+ * **Not async, and that is the contract.** This function used to `await
1545
+ * pctx.settings.readDeviceStore(m.id)` inside its own loop — one settings-door
1546
+ * round trip per persisted child, sequentially, on hub-main's event loop. The
1547
+ * caller above (`getChildrenBatch`, coalescing every accessory reconcile in
1548
+ * flight) had already removed the ledger N+1 and left this one: 896 door reads
1549
+ * per boot reconcile and per device page, at ~2.9 s each mid-drain. `configs`
1550
+ * arrives resolved through `readDeviceConfigs` — one round trip for the whole
1551
+ * pass — and a signature with no `Promise` in it means a future read cannot be
1552
+ * put back here without the type saying so.
1471
1553
  */
1472
- async function projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, liveChildren) {
1554
+ function projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, liveChildren, configs) {
1473
1555
  const results = [];
1474
1556
  const seen = /* @__PURE__ */ new Set();
1475
1557
  const rowById = /* @__PURE__ */ new Map();
@@ -1485,9 +1567,9 @@ async function projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, li
1485
1567
  const childStableId = m.stableId;
1486
1568
  const key = String(m.id);
1487
1569
  if (seen.has(key)) continue;
1488
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1570
+ const persistedConfig = configs.get(m.id) ?? {};
1489
1571
  const metadata = row.metadata;
1490
- const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
1572
+ const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig, childStableId, ownerAddonId);
1491
1573
  results.push({
1492
1574
  id: m.id,
1493
1575
  stableId: childStableId,
@@ -1502,7 +1584,7 @@ async function projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, li
1502
1584
  probed: pctx.host.resolveDeviceProbed(m.id),
1503
1585
  features: persistedFeatures(m.features),
1504
1586
  isCamera: false,
1505
- config: persistedConfig ?? {},
1587
+ config: persistedConfig,
1506
1588
  metadata,
1507
1589
  ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1508
1590
  ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
@@ -1536,18 +1618,23 @@ async function getChildren(pctx, input) {
1536
1618
  }
1537
1619
  const childRows = await pctx.metaStore.rows.listByParent(parentDeviceId);
1538
1620
  const liveChildren = pctx.registry?.getChildren(parentDeviceId) ?? [];
1539
- return [...await projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, liveChildren)];
1621
+ const configs = await readDeviceConfigs(pctx, persistedChildIds(childRows, liveChildren));
1622
+ return [...projectChildren(pctx, parentDeviceId, ownerAddonId, childRows, liveChildren, configs)];
1540
1623
  }
1541
1624
  /**
1542
- * `getChildren` for a set of parents — TWO store reads for the whole set, not
1543
- * two per parent.
1625
+ * `getChildren` for a set of parents — THREE store reads for the whole set, not
1626
+ * three per parent (and not one per child).
1544
1627
  *
1545
1628
  * The single-parent path costs one `resolvePersistedById` (a primary-key read)
1546
1629
  * plus one `listByParent` (an indexed scan) EACH. Asked once per device at
1547
1630
  * boot that was 1 024 of each, measured on the live hub 2026-08-27 — see the
1548
1631
  * cap method's note. Here the ownership lookup for every parent the live
1549
1632
  * registry does not hold is ONE `getMany`, and the children of every parent
1550
- * are ONE `listByParentMany`.
1633
+ * are ONE `listByParentMany`. The third read is the settings door, and it is
1634
+ * one for the whole batch: `readDeviceConfigs` over every persisted child of
1635
+ * every parent. That one was the leftover — the ledger N+1 was removed and a
1636
+ * door round trip PER CHILD stayed behind it (896 on the live hub 2026-08-30,
1637
+ * all of them forked-owned and therefore all persisted).
1551
1638
  *
1552
1639
  * The live half is indexed once too: `registry.getChildren` is
1553
1640
  * `getAll().filter(…)`, so calling it per parent would trade an RPC N+1 for an
@@ -1585,11 +1672,14 @@ async function getChildrenBatch(pctx, input) {
1585
1672
  if (bucket === void 0) liveByParent.set(parentId, [device]);
1586
1673
  else bucket.push(device);
1587
1674
  }
1675
+ const configIds = [];
1676
+ for (const parentId of owners.keys()) configIds.push(...persistedChildIds(rowsByParent.get(parentId) ?? [], liveByParent.get(parentId) ?? []));
1677
+ const configs = await readDeviceConfigs(pctx, configIds);
1588
1678
  for (const [parentId, ownerAddonId] of owners) {
1589
1679
  const childRows = rowsByParent.get(parentId) ?? [];
1590
1680
  const liveChildren = liveByParent.get(parentId) ?? [];
1591
1681
  if (childRows.length === 0 && liveChildren.length === 0) continue;
1592
- const projected = await projectChildren(pctx, parentId, ownerAddonId, childRows, liveChildren);
1682
+ const projected = projectChildren(pctx, parentId, ownerAddonId, childRows, liveChildren, configs);
1593
1683
  if (projected.length > 0) out[String(parentId)] = [...projected];
1594
1684
  }
1595
1685
  return out;
@@ -2951,18 +3041,20 @@ async function listBindableCapsForDeviceType(deps, input) {
2951
3041
  }
2952
3042
  async function setWrapperActive(deps, input) {
2953
3043
  const storeKey = String(input.deviceId);
2954
- const store = await readBindingsStore(deps.bindingsDeps);
2955
- const perDevice = { ...store.deviceBindings[storeKey] };
2956
- if (input.active) perDevice[input.capName] = { wrapperAddonId: input.wrapperAddonId };
2957
- else perDevice[input.capName] = { wrapperAddonId: null };
2958
- const nextDeviceBindings = Object.keys(perDevice).length > 0 ? {
2959
- ...store.deviceBindings,
2960
- [storeKey]: perDevice
2961
- } : (() => {
2962
- const { [storeKey]: _drop, ...rest } = store.deviceBindings;
2963
- return rest;
2964
- })();
2965
- await writeBindingsStore(deps.bindingsDeps, { deviceBindings: nextDeviceBindings });
3044
+ await deps.bindingsDeps.withAddonStoreWriteLock(async () => {
3045
+ const store = await readBindingsStore(deps.bindingsDeps);
3046
+ const perDevice = { ...store.deviceBindings[storeKey] };
3047
+ if (input.active) perDevice[input.capName] = { wrapperAddonId: input.wrapperAddonId };
3048
+ else perDevice[input.capName] = { wrapperAddonId: null };
3049
+ const nextDeviceBindings = Object.keys(perDevice).length > 0 ? {
3050
+ ...store.deviceBindings,
3051
+ [storeKey]: perDevice
3052
+ } : (() => {
3053
+ const { [storeKey]: _drop, ...rest } = store.deviceBindings;
3054
+ return rest;
3055
+ })();
3056
+ await writeBindingsStore(deps.bindingsDeps, { deviceBindings: nextDeviceBindings });
3057
+ });
2966
3058
  deps.ctx.eventBus.emit({
2967
3059
  id: randomUUID(),
2968
3060
  timestamp: /* @__PURE__ */ new Date(),
@@ -3329,12 +3421,13 @@ async function removeDevice(pctx, input) {
3329
3421
  });
3330
3422
  await pctx.settings.clearDeviceStore(deviceId);
3331
3423
  await pctx.settings.clearDeviceRuntimeState(deviceId);
3332
- const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
3333
3424
  const bindingKey = String(deviceId);
3334
- if (bindingsStore.deviceBindings[bindingKey]) {
3425
+ await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
3426
+ const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
3427
+ if (!bindingsStore.deviceBindings[bindingKey]) return;
3335
3428
  const { [bindingKey]: _removedBindings, ...restBindings } = bindingsStore.deviceBindings;
3336
3429
  await writeBindingsStore(pctx.bindingsDeps, { deviceBindings: restBindings });
3337
- }
3430
+ });
3338
3431
  pctx.host.remoteNativeCaps.delete(deviceId);
3339
3432
  pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(deviceId);
3340
3433
  pctx.metaStore.idToAddonId.delete(deviceId);
@@ -3796,16 +3889,24 @@ async function getRoleDisplayDefaults(pctx, _input) {
3796
3889
  /**
3797
3890
  * Replace the per-role display defaults whole-record (full replace). Override
3798
3891
  * units are normalized (`normalizeUnit`) at write so the render path always
3799
- * looks up canonical spellings. Single-writer top-level key no lock, no RMW,
3800
- * no interaction with the device write lock. Not per-device, so no event
3801
- * is emitted; the UI invalidates its own query on mutate.
3892
+ * looks up canonical spellings. Not per-device, so no event is emitted; the UI
3893
+ * invalidates its own query on mutate.
3894
+ *
3895
+ * This key is not itself read-modify-written, but `writeAddonStore` IS — the
3896
+ * door loads this addon's whole key range, merges the patch and re-inserts the
3897
+ * range (`settings-door-view.ts`). An unordered write of ANY key on that range
3898
+ * can therefore carry a stale copy of every other key, so it takes the same
3899
+ * lock as `deviceBindings` and `locations`. The old comment here claimed "no
3900
+ * lock, no RMW", which described the top-level key and not the write.
3802
3901
  */
3803
3902
  async function setRoleDisplayDefaults(pctx, input) {
3804
3903
  const normalized = Object.fromEntries(Object.entries(input.defaults).map(([role, def]) => [role, def.unit !== void 0 ? {
3805
3904
  ...def,
3806
3905
  unit: normalizeUnit(def.unit) ?? def.unit
3807
3906
  } : def]));
3808
- await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
3907
+ await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
3908
+ await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
3909
+ });
3809
3910
  }
3810
3911
  /**
3811
3912
  * Batched meta pre-seed. Applies every provided field to the
@@ -4003,9 +4104,11 @@ async function listLocations(pctx) {
4003
4104
  async function addLocation(pctx, input) {
4004
4105
  const trimmed = input.name.trim();
4005
4106
  if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
4006
- const current = (await pctx.metaStore.readStore()).locations ?? [];
4007
- if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
4008
- await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
4107
+ await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
4108
+ const current = (await pctx.metaStore.readStore()).locations ?? [];
4109
+ if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
4110
+ await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
4111
+ });
4009
4112
  }
4010
4113
  /**
4011
4114
  * Remove a label from the curated registry. Match is
@@ -4019,9 +4122,12 @@ async function addLocation(pctx, input) {
4019
4122
  async function removeLocation(pctx, input) {
4020
4123
  const trimmed = input.name.trim();
4021
4124
  if (trimmed.length === 0) return;
4022
- const current = (await pctx.metaStore.readStore()).locations ?? [];
4023
- const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
4024
- if (remaining.length !== current.length) await pctx.settings.writeAddonStore({ locations: remaining });
4125
+ await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
4126
+ const current = (await pctx.metaStore.readStore()).locations ?? [];
4127
+ const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
4128
+ if (remaining.length === current.length) return;
4129
+ await pctx.settings.writeAddonStore({ locations: remaining });
4130
+ });
4025
4131
  if (input.cascade !== true) return;
4026
4132
  const cleared = await pctx.metaStore.withMetaWriteLock(async () => {
4027
4133
  const out = [];
@@ -4072,24 +4178,41 @@ function isRoleDisplayDefaults(value) {
4072
4178
  return value !== null && typeof value === "object" && !Array.isArray(value);
4073
4179
  }
4074
4180
  //#endregion
4181
+ //#region src/builtins/device-manager/write-lock.ts
4182
+ function createWriteLock() {
4183
+ let chain = Promise.resolve();
4184
+ return async (fn) => {
4185
+ const previous = chain;
4186
+ let release = () => {};
4187
+ chain = new Promise((resolve) => {
4188
+ release = resolve;
4189
+ });
4190
+ try {
4191
+ await previous.catch(() => {});
4192
+ return await fn();
4193
+ } finally {
4194
+ release();
4195
+ }
4196
+ };
4197
+ }
4198
+ //#endregion
4075
4199
  //#region src/builtins/device-manager/device-meta-store.ts
4076
4200
  var DeviceMetaStore = class {
4077
4201
  settings;
4078
4202
  registry;
4079
4203
  rows;
4204
+ withAddonStoreWriteLock;
4080
4205
  /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
4081
4206
  * The persisted row store is authoritative but reads are async; hub-side
4082
4207
  * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
4083
4208
  * ownership without awaiting. Kept in sync with every register/remove and
4084
4209
  * warmed from persistence on boot. */
4085
4210
  idToAddonId = /* @__PURE__ */ new Map();
4086
- /** Serialises every read-modify-write of a device row through one promise
4087
- * chain (see `withMetaWriteLock`). Per-instance state. */
4088
- metaWriteChain = Promise.resolve();
4089
- constructor(settings, registry, rows) {
4211
+ constructor(settings, registry, rows, withAddonStoreWriteLock = createWriteLock()) {
4090
4212
  this.settings = settings;
4091
4213
  this.registry = registry;
4092
4214
  this.rows = rows;
4215
+ this.withAddonStoreWriteLock = withAddonStoreWriteLock;
4093
4216
  }
4094
4217
  /** The read currently in flight, or null. Never a settled value — see
4095
4218
  * {@link readStore}. */
@@ -4120,20 +4243,7 @@ var DeviceMetaStore = class {
4120
4243
  this.inFlightRead = read;
4121
4244
  return read;
4122
4245
  };
4123
- withMetaWriteLock = async (fn) => {
4124
- const previous = this.metaWriteChain;
4125
- let release = () => {};
4126
- const next = new Promise((resolve) => {
4127
- release = resolve;
4128
- });
4129
- this.metaWriteChain = next;
4130
- try {
4131
- await previous.catch(() => {});
4132
- return await fn();
4133
- } finally {
4134
- release();
4135
- }
4136
- };
4246
+ withMetaWriteLock = createWriteLock();
4137
4247
  /** The whole persisted row for one device, or `null`. */
4138
4248
  getRow = async (deviceId) => this.rows.get(deviceId);
4139
4249
  /**
@@ -4167,11 +4277,24 @@ var DeviceMetaStore = class {
4167
4277
  ids.delete(parentId);
4168
4278
  return [...ids];
4169
4279
  };
4170
- allocateNextDeviceId = async () => {
4280
+ /**
4281
+ * Mint the next numeric device id.
4282
+ *
4283
+ * A read-modify-write of `nextDeviceId`, and it must hold the addon-store
4284
+ * lock for the whole of it. Serialising the WRITES is not enough and never
4285
+ * was: two callers that both read `N` both write `N + 1` and both return
4286
+ * `N`, so two devices receive the same id. (`writeAddonStore` is itself
4287
+ * read-modify-write over the addon's whole key range, so an unlocked bump
4288
+ * can also be reverted wholesale by a concurrent `setWrapperActive`.)
4289
+ *
4290
+ * Callers hold {@link withMetaWriteLock} — this is the one nesting, and it
4291
+ * only ever goes meta ⊃ addon-store.
4292
+ */
4293
+ allocateNextDeviceId = async () => this.withAddonStoreWriteLock(async () => {
4171
4294
  const current = (await this.readStore()).nextDeviceId ?? 1;
4172
4295
  await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
4173
4296
  return current;
4174
- };
4297
+ });
4175
4298
  };
4176
4299
  //#endregion
4177
4300
  //#region src/builtins/device-manager/fleet-read-census.ts
@@ -5554,6 +5677,17 @@ var DeviceManagerAddon = class extends BaseAddon {
5554
5677
  * it through {@link bindingsDeps} or the `ProviderContext`.
5555
5678
  */
5556
5679
  deviceRows = null;
5680
+ /**
5681
+ * The ONE lock ordering every read-modify-write of this addon's
5682
+ * `addon-settings` key range (`deviceBindings`, `locations`, `nextDeviceId`,
5683
+ * `roleDisplayDefaults`).
5684
+ *
5685
+ * It lives on the addon rather than on `DeviceMetaStore` because the writers
5686
+ * do not all go through that class — `setWrapperActive` and `removeDevice`'s
5687
+ * bindings purge reach the store through `BindingsDeps` — and two locks over
5688
+ * one key range is the same defect one level up. Handed to both.
5689
+ */
5690
+ addonStoreWriteLock = createWriteLock();
5557
5691
  /** Build the dependency context the extracted binding resolvers consume. */
5558
5692
  get bindingsDeps() {
5559
5693
  const rows = this.deviceRows;
@@ -5562,7 +5696,8 @@ var DeviceManagerAddon = class extends BaseAddon {
5562
5696
  ctx: this.ctx,
5563
5697
  capabilityRegistry: this.capabilityRegistry,
5564
5698
  remoteNativeCaps: this.remoteNativeCaps,
5565
- rows
5699
+ rows,
5700
+ withAddonStoreWriteLock: this.addonStoreWriteLock
5566
5701
  };
5567
5702
  }
5568
5703
  async getBindings(input) {
@@ -5631,7 +5766,7 @@ var DeviceManagerAddon = class extends BaseAddon {
5631
5766
  } catch (err) {
5632
5767
  this.ctx.logger.warn("retired-row purge failed", { meta: { error: errMsg(err) } });
5633
5768
  }
5634
- const metaStore = new DeviceMetaStore(settings, registry, deviceRows);
5769
+ const metaStore = new DeviceMetaStore(settings, registry, deviceRows, this.addonStoreWriteLock);
5635
5770
  this.stateMirrorImpl = new DeviceStateMirror(this.ctx);
5636
5771
  const stateMirror = this.stateMirrorImpl;
5637
5772
  const resolvePersistedById = metaStore.resolvePersistedById;
@@ -132,9 +132,15 @@ export declare function getRoleDisplayDefaults(pctx: ProviderContext, _input: Pa
132
132
  /**
133
133
  * Replace the per-role display defaults whole-record (full replace). Override
134
134
  * units are normalized (`normalizeUnit`) at write so the render path always
135
- * looks up canonical spellings. Single-writer top-level key no lock, no RMW,
136
- * no interaction with the device write lock. Not per-device, so no event
137
- * is emitted; the UI invalidates its own query on mutate.
135
+ * looks up canonical spellings. Not per-device, so no event is emitted; the UI
136
+ * invalidates its own query on mutate.
137
+ *
138
+ * This key is not itself read-modify-written, but `writeAddonStore` IS — the
139
+ * door loads this addon's whole key range, merges the patch and re-inserts the
140
+ * range (`settings-door-view.ts`). An unordered write of ANY key on that range
141
+ * can therefore carry a stale copy of every other key, so it takes the same
142
+ * lock as `deviceBindings` and `locations`. The old comment here claimed "no
143
+ * lock, no RMW", which described the top-level key and not the write.
138
144
  */
139
145
  export declare function setRoleDisplayDefaults(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['setRoleDisplayDefaults']>[0]): ReturnType<IDeviceManagerProvider['setRoleDisplayDefaults']>;
140
146
  /**
@@ -1,6 +1,7 @@
1
1
  import { AddonContext, IDeviceRegistry } from '@camstack/types';
2
2
  import { AddonStore, PersistedDeviceMeta } from './device-meta-types.js';
3
3
  import { DeviceRow, DeviceRowStore } from './device-row-store.js';
4
+ import { WriteLock } from './write-lock.js';
4
5
  /** The settings surface the meta store uses (`ctx.settings`, narrowed non-null). */
5
6
  export type DeviceManagerSettings = NonNullable<AddonContext['settings']>;
6
7
  export declare class DeviceMetaStore {
@@ -9,20 +10,33 @@ export declare class DeviceMetaStore {
9
10
  readonly registry: IDeviceRegistry | null;
10
11
  /** Row access for `device-manager:devices`. */
11
12
  readonly rows: DeviceRowStore;
13
+ /**
14
+ * The addon's OWN settings row set lock, shared with every other writer of
15
+ * it (`setWrapperActive`, `removeDevice`'s bindings purge, `addLocation`,
16
+ * `removeLocation`, `setRoleDisplayDefaults`) — see
17
+ * {@link BindingsDeps.addonStoreLock}. Owned by the addon, not by this
18
+ * class, because those writers do not all go through it.
19
+ */
20
+ readonly withAddonStoreWriteLock: WriteLock;
12
21
  /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
13
22
  * The persisted row store is authoritative but reads are async; hub-side
14
23
  * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
15
24
  * ownership without awaiting. Kept in sync with every register/remove and
16
25
  * warmed from persistence on boot. */
17
26
  readonly idToAddonId: Map<number, string>;
18
- /** Serialises every read-modify-write of a device row through one promise
19
- * chain (see `withMetaWriteLock`). Per-instance state. */
20
- private metaWriteChain;
21
27
  constructor(settings: DeviceManagerSettings,
22
28
  /** Hub-process device registry (null when this node owns no registry). */
23
29
  registry: IDeviceRegistry | null,
24
30
  /** Row access for `device-manager:devices`. */
25
- rows: DeviceRowStore);
31
+ rows: DeviceRowStore,
32
+ /**
33
+ * The addon's OWN settings row set lock, shared with every other writer of
34
+ * it (`setWrapperActive`, `removeDevice`'s bindings purge, `addLocation`,
35
+ * `removeLocation`, `setRoleDisplayDefaults`) — see
36
+ * {@link BindingsDeps.addonStoreLock}. Owned by the addon, not by this
37
+ * class, because those writers do not all go through it.
38
+ */
39
+ withAddonStoreWriteLock?: WriteLock);
26
40
  /** The read currently in flight, or null. Never a settled value — see
27
41
  * {@link readStore}. */
28
42
  private inFlightRead;
@@ -40,7 +54,7 @@ export declare class DeviceMetaStore {
40
54
  * and the next caller reaches the store again.
41
55
  */
42
56
  readStore: () => Promise<AddonStore>;
43
- withMetaWriteLock: <T>(fn: () => Promise<T>) => Promise<T>;
57
+ withMetaWriteLock: WriteLock;
44
58
  /** The whole persisted row for one device, or `null`. */
45
59
  getRow: (deviceId: number) => Promise<DeviceRow | null>;
46
60
  /**
@@ -62,5 +76,18 @@ export declare class DeviceMetaStore {
62
76
  * and excluding self. Shared by the `remove` cascade and the `resetToSource`
63
77
  * resync purge (#19). */
64
78
  directChildIds: (parentId: number) => Promise<readonly number[]>;
79
+ /**
80
+ * Mint the next numeric device id.
81
+ *
82
+ * A read-modify-write of `nextDeviceId`, and it must hold the addon-store
83
+ * lock for the whole of it. Serialising the WRITES is not enough and never
84
+ * was: two callers that both read `N` both write `N + 1` and both return
85
+ * `N`, so two devices receive the same id. (`writeAddonStore` is itself
86
+ * read-modify-write over the addon's whole key range, so an unlocked bump
87
+ * can also be reverted wholesale by a concurrent `setWrapperActive`.)
88
+ *
89
+ * Callers hold {@link withMetaWriteLock} — this is the one nesting, and it
90
+ * only ever goes meta ⊃ addon-store.
91
+ */
65
92
  allocateNextDeviceId: () => Promise<number>;
66
93
  }
@@ -6,15 +6,19 @@ export declare function listAll(pctx: ProviderContext, input: Parameters<IDevice
6
6
  export declare function getDevice(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['getDevice']>[0]): ReturnType<IDeviceManagerProvider['getDevice']>;
7
7
  export declare function getChildren(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['getChildren']>[0]): ReturnType<IDeviceManagerProvider['getChildren']>;
8
8
  /**
9
- * `getChildren` for a set of parents — TWO store reads for the whole set, not
10
- * two per parent.
9
+ * `getChildren` for a set of parents — THREE store reads for the whole set, not
10
+ * three per parent (and not one per child).
11
11
  *
12
12
  * The single-parent path costs one `resolvePersistedById` (a primary-key read)
13
13
  * plus one `listByParent` (an indexed scan) EACH. Asked once per device at
14
14
  * boot that was 1 024 of each, measured on the live hub 2026-08-27 — see the
15
15
  * cap method's note. Here the ownership lookup for every parent the live
16
16
  * registry does not hold is ONE `getMany`, and the children of every parent
17
- * are ONE `listByParentMany`.
17
+ * are ONE `listByParentMany`. The third read is the settings door, and it is
18
+ * one for the whole batch: `readDeviceConfigs` over every persisted child of
19
+ * every parent. That one was the leftover — the ledger N+1 was removed and a
20
+ * door round trip PER CHILD stayed behind it (896 on the live hub 2026-08-30,
21
+ * all of them forked-owned and therefore all persisted).
18
22
  *
19
23
  * The live half is indexed once too: `registry.getChildren` is
20
24
  * `getAll().filter(…)`, so calling it per parent would trade an RPC N+1 for an
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The promise-chain mutex the device-manager orders its read-modify-writes
3
+ * with.
4
+ *
5
+ * One owner at a time, FIFO, no timers and no timeout: a waiter is parked on
6
+ * the previous holder's completion, so nothing is delayed that was not already
7
+ * waiting for a store round trip. A rejected holder still releases the chain —
8
+ * a failed write must not wedge every writer behind it.
9
+ *
10
+ * Extracted from `DeviceMetaStore.withMetaWriteLock`, which was the only
11
+ * instance until the addon's OWN settings row set needed the same ordering.
12
+ * Two subtly different copies of a mutex is not a thing this file wants to
13
+ * exist.
14
+ */
15
+ /** Run `fn` with exclusive ownership of one lock. */
16
+ export type WriteLock = <T>(fn: () => Promise<T>) => Promise<T>;
17
+ export declare function createWriteLock(): WriteLock;
@@ -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-CDgIzo82.js");
6
+ const require_dist = require("../../dist-CcvXUhHK.js");
7
7
  //#region src/builtins/doorbell/trigger-engine.ts
8
8
  /**
9
9
  * Pure trigger logic for the virtual-doorbell builtin — edge detection over