@camstack/system 1.2.106 → 1.2.108

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.
@@ -1,4 +1,5 @@
1
1
  import { Ct as DeviceFeature, E as buildStreamParamsConfigSchema, F as deviceStatusCapability, Gt as EventCategory, Lt as isDeviceConfigCap, N as deviceManagerCapability, Ot as WELL_KNOWN_TAB_MAP, P as deviceStateCapability, R as enumerateItemArrayFields, Tt as DeviceType, Wt as sleep, a as CAP_NAMES_WITH_STATUS, at as runtimeStatePolicyFor, et as normalizeUnit, nt as parseStreamParamsFormPatch, p as STREAM_PROFILE_META, t as ALL_CAPABILITY_DEFINITIONS, u as DeviceStatusSchema, vt as errMsg, wt as DeviceRole, yt as BaseAddon, z as enumerateSchemaFields } from "../../dist-_oC_QkQA.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-Bsjf7HQ-.mjs";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
4
5
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -289,6 +290,442 @@ var AdoptionJobEngine = class {
289
290
  }
290
291
  };
291
292
  //#endregion
293
+ //#region src/builtins/device-manager/device-aggregation-merge.ts
294
+ /**
295
+ * Pure aggregator-merge + field-tagging helpers for the device-manager
296
+ * device-details aggregator. Extracted verbatim from
297
+ * `device-manager.addon.ts`. These functions are stateless: they take
298
+ * contributions in and return new wire-shape objects, attaching writer
299
+ * provenance to editable fields. No addon-instance dependency.
300
+ *
301
+ * `mergeAggregates` is re-exported from the addon module to preserve the
302
+ * existing public export surface the test-suite imports.
303
+ */
304
+ /**
305
+ * Walk the sections/fields of a contribution and inject `writerCapName` +
306
+ * `writerAddonId` + `source` on each editable field. Readonly fields and
307
+ * structural fields (separator/info/button) pass through untouched. The
308
+ * aggregator is the single place that knows provenance — provider schemas
309
+ * stay clean, UI-bound metadata is attached once at the boundary.
310
+ */
311
+ function tagContribution(contribution, capName, addonId, kind) {
312
+ const source = kind === "settings" ? "settings" : "live";
313
+ return {
314
+ ...contribution.tabs ? { tabs: [...contribution.tabs] } : {},
315
+ sections: contribution.sections.map((section) => ({
316
+ ...section,
317
+ fields: section.fields.map((field) => tagField(field, capName, addonId, source, kind))
318
+ }))
319
+ };
320
+ }
321
+ function isFieldRecord(value) {
322
+ return value !== null && typeof value === "object" && !Array.isArray(value);
323
+ }
324
+ /**
325
+ * Convert a strict `ConfigUISchemaWithValues` (readonly arrays, typed
326
+ * field union) into the cap wire shape `ContributionShape` (mutable
327
+ * arrays, opaque field records). Required because the cap method z.infer
328
+ * uses mutable arrays — readonly arrays are not assignable to mutable
329
+ * even when structurally identical, so a structural copy bridges the gap
330
+ * without disabling the type checker.
331
+ */
332
+ function toWireShape(input) {
333
+ const out = { sections: input.sections.map((s) => ({
334
+ id: s.id,
335
+ title: s.title,
336
+ ...s.description !== void 0 ? { description: s.description } : {},
337
+ ...s.style !== void 0 ? { style: s.style } : {},
338
+ ...s.defaultCollapsed !== void 0 ? { defaultCollapsed: s.defaultCollapsed } : {},
339
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
340
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
341
+ ...s.location !== void 0 ? { location: s.location } : {},
342
+ ...s.order !== void 0 ? { order: s.order } : {},
343
+ fields: [...s.fields]
344
+ })) };
345
+ if (input.tabs) out.tabs = [...input.tabs];
346
+ return out;
347
+ }
348
+ function tagField(field, capName, addonId, source, kind) {
349
+ if (!isFieldRecord(field)) return field;
350
+ const f = field;
351
+ const structuralTypes = new Set([
352
+ "separator",
353
+ "info",
354
+ "button"
355
+ ]);
356
+ if (typeof f.type === "string" && structuralTypes.has(f.type)) return field;
357
+ const tagged = {
358
+ ...f,
359
+ source
360
+ };
361
+ if (kind === "live" || f.readonlyField === true) tagged.readonlyField = true;
362
+ else {
363
+ tagged.writerCapName = capName;
364
+ tagged.writerAddonId = addonId;
365
+ }
366
+ if (f.type === "group") {
367
+ const children = Array.isArray(f.fields) ? f.fields : [];
368
+ if (children.length > 0) tagged.fields = children.map((child) => tagField(child, capName, addonId, source, kind));
369
+ } else if (f.type === "sub-tabs") {
370
+ const rawTabs = Array.isArray(f.tabs) ? f.tabs : [];
371
+ if (rawTabs.length > 0) tagged.tabs = rawTabs.map((tab) => {
372
+ if (!isFieldRecord(tab)) return tab;
373
+ const tabChildren = Array.isArray(tab.fields) ? tab.fields : [];
374
+ return {
375
+ ...tab,
376
+ fields: tabChildren.map((child) => tagField(child, capName, addonId, source, kind))
377
+ };
378
+ });
379
+ }
380
+ return tagged;
381
+ }
382
+ function mergeAggregates(parts) {
383
+ const tabDecls = /* @__PURE__ */ new Map();
384
+ const sections = [];
385
+ const seenSectionIds = /* @__PURE__ */ new Set();
386
+ for (const part of parts) {
387
+ if (part.tabs) {
388
+ for (const t of part.tabs) if (!tabDecls.has(t.id)) tabDecls.set(t.id, t);
389
+ }
390
+ for (const s of part.sections) {
391
+ if (s.id !== void 0) {
392
+ if (seenSectionIds.has(s.id)) continue;
393
+ seenSectionIds.add(s.id);
394
+ }
395
+ sections.push(s);
396
+ }
397
+ }
398
+ for (const s of sections) {
399
+ const tabId = s.tab ?? "general";
400
+ if (tabDecls.has(tabId)) continue;
401
+ const known = WELL_KNOWN_TAB_MAP[tabId];
402
+ if (known) tabDecls.set(tabId, {
403
+ id: known.id,
404
+ label: known.label,
405
+ icon: known.icon,
406
+ order: known.order
407
+ });
408
+ else tabDecls.set(tabId, {
409
+ id: tabId,
410
+ label: tabId,
411
+ icon: "wrench",
412
+ order: 100
413
+ });
414
+ }
415
+ sections.sort((a, b) => {
416
+ const tabA = a.tab ?? "general";
417
+ const tabB = b.tab ?? "general";
418
+ if (tabA !== tabB) {
419
+ const orderA = tabDecls.get(tabA)?.order ?? 100;
420
+ const orderB = tabDecls.get(tabB)?.order ?? 100;
421
+ if (orderA !== orderB) return orderA - orderB;
422
+ return tabA.localeCompare(tabB);
423
+ }
424
+ return (a.order ?? 0) - (b.order ?? 0);
425
+ });
426
+ const sortedTabs = [...tabDecls.values()].toSorted((a, b) => (a.order ?? 100) - (b.order ?? 100));
427
+ const out = { sections };
428
+ if (sortedTabs.length > 0) out.tabs = sortedTabs;
429
+ return out;
430
+ }
431
+ //#endregion
432
+ //#region src/builtins/device-manager/device-bindings-store.ts
433
+ /**
434
+ * Wire the push-fed cross-process native-cap cache (`remoteNativeCaps`).
435
+ * Workers emit `DeviceBindingsChanged` on `ctx.registerNativeCap` / device
436
+ * removal; we mirror those into `remoteNativeCaps` so `getBindings` returns the
437
+ * full cluster view. Events from the local node are ignored: hub-local natives
438
+ * live in `capabilityRegistry` and are folded in directly by getBindings.
439
+ *
440
+ * Push events are accurate in the steady state but can be lost during the
441
+ * Moleculer transport handshake window (hub restart, crash-respawn,
442
+ * restartAddon). The reliable replacement for lost events is the D3 re-handshake
443
+ * (`listClusterNativeCaps()`), which `resolveNativeCapOwnerSync` /
444
+ * `getBindings` step 4 fall through to on a push miss. The `$node.disconnected`
445
+ * handler purges a gone node's entries; the worker re-handshakes (and re-emits
446
+ * `native-registered`) on its next boot.
447
+ */
448
+ function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
449
+ const localNodeId = ctx.kernel.localNodeId ?? "hub";
450
+ ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (event) => {
451
+ const { deviceId, capName, reason, addonId, nodeId } = event.data;
452
+ if (nodeId === localNodeId) return;
453
+ if (reason === "native-registered") {
454
+ let perDevice = remoteNativeCaps.get(deviceId);
455
+ if (!perDevice) {
456
+ perDevice = /* @__PURE__ */ new Map();
457
+ remoteNativeCaps.set(deviceId, perDevice);
458
+ }
459
+ perDevice.set(capName, {
460
+ addonId,
461
+ nodeId
462
+ });
463
+ } else if (reason === "native-unregistered") {
464
+ const perDevice = remoteNativeCaps.get(deviceId);
465
+ if (!perDevice) return;
466
+ perDevice.delete(capName);
467
+ if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
468
+ }
469
+ });
470
+ const cluster = ctx.kernel.cluster;
471
+ if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
472
+ const gone = payload.node.id;
473
+ const emptyDevices = [];
474
+ for (const [deviceId, perDevice] of remoteNativeCaps) {
475
+ const toDelete = [];
476
+ for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
477
+ for (const capName of toDelete) perDevice.delete(capName);
478
+ if (perDevice.size === 0) emptyDevices.push(deviceId);
479
+ }
480
+ for (const deviceId of emptyDevices) remoteNativeCaps.delete(deviceId);
481
+ });
482
+ }
483
+ async function readBindingsStore(deps) {
484
+ return { deviceBindings: (await deps.ctx.settings.readAddonStore()).deviceBindings ?? {} };
485
+ }
486
+ async function writeBindingsStore(deps, next) {
487
+ await deps.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
488
+ }
489
+ function resolveWrapperNodeId(_wrapperAddonId) {
490
+ return "hub";
491
+ }
492
+ /**
493
+ * Reduce a provider node id to the routable form `DeviceProxy` can pin.
494
+ *
495
+ * Every addon runs in its own `addon-runner` with the composite node id
496
+ * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) — e.g.
497
+ * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
498
+ * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
499
+ * only THROUGH its parent (the hub resolves a hub-local-uds child by
500
+ * cap+device; an agent forwards to its own child). `DeviceProxy` pins
501
+ * `entry.providerNodeId` on every cap call, so a binding entry must expose the
502
+ * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
503
+ * to an unknown node → `no-provider`, which surfaces as
504
+ * "this camera doesn't expose …" for client-proxy-driven widget caps
505
+ * (motion-zones, privacy-mask). Wrappers already report the parent via
506
+ * `resolveWrapperNodeId`; this aligns natives with the same contract.
507
+ *
508
+ * A flat node id (a genuine standalone node with no `/`) is returned
509
+ * unchanged.
510
+ */
511
+ function toRoutableProviderNodeId(nodeId) {
512
+ const slash = nodeId.indexOf("/");
513
+ return slash === -1 ? nodeId : nodeId.slice(0, slash);
514
+ }
515
+ /**
516
+ * Resolve a remote native cap entry for a given `(capName, deviceId)` by
517
+ * consulting the handshake-fed `HubNodeRegistry` via
518
+ * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
519
+ * `remoteNativeCaps` cache misses — covers the Moleculer transport
520
+ * handshake window where `DeviceBindingsChanged` events were lost but the
521
+ * D3 re-handshake (post device restore) has already populated the registry.
522
+ *
523
+ * Returns `null` when the entry is genuinely not present in the cluster
524
+ * view (cap not registered on any worker for that device).
525
+ */
526
+ function resolveRemoteNativeCapFromRegistry(deps, capName, deviceId) {
527
+ const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
528
+ if (!clusterCaps) return null;
529
+ for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
530
+ addonId: entry.addonId,
531
+ nodeId: entry.nodeId
532
+ };
533
+ return null;
534
+ }
535
+ /**
536
+ * Resolve the device's declared TYPE (`'camera'`, `'event-emitter'`, …), or
537
+ * `undefined` when it cannot be established.
538
+ *
539
+ * The PERSISTED row is the authority: `ctx.kernel.deviceRegistry` is hub-only
540
+ * and has been observed empty in the very process that answers `getBindings`
541
+ * correctly (see the note on `getAllBindings`). The registry is consulted only
542
+ * as a secondary source, for a device constructed but not yet persisted.
543
+ *
544
+ * `undefined` is a first-class answer and callers must treat it as "no
545
+ * filtering" — an absent row must never be the reason a device loses bindings.
546
+ */
547
+ function resolveDeviceType(deps, row, deviceId) {
548
+ const persisted = row?.meta.type;
549
+ if (typeof persisted === "string" && persisted.length > 0) return persisted;
550
+ const live = deps.ctx.kernel?.deviceRegistry?.getById(deviceId)?.type;
551
+ return typeof live === "string" && live.length > 0 ? live : void 0;
552
+ }
553
+ /**
554
+ * Is this device still part of the fleet?
555
+ *
556
+ * The device-manager's own stores are the authority in-process — no RPC. Two
557
+ * sources, in the same order `resolveDeviceType` uses them: the live registry
558
+ * first (a device constructed but not yet persisted is present), then the
559
+ * PERSISTED meta, which is the index `getBindings` already reads and is present
560
+ * wherever this provider runs.
561
+ *
562
+ * `'absent'` is only ever returned against a NON-EMPTY ledger. An empty one
563
+ * means device restore has not run, not that the fleet was deleted (D49) — the
564
+ * same reason `getAllBindings` warns instead of reporting zero devices. That is
565
+ * the only reason this is async: the `COUNT(*)` runs solely on the miss path,
566
+ * where the alternative is to call a device gone because the store is empty.
567
+ */
568
+ async function resolveDevicePresence(deps, row, deviceId) {
569
+ if (deps.ctx.kernel?.deviceRegistry?.getById(deviceId)) return "present";
570
+ if (row !== null) return "present";
571
+ return await deps.rows.count() > 0 ? "absent" : "unknown";
572
+ }
573
+ /**
574
+ * Does a capability apply to a device of type `deviceType`?
575
+ *
576
+ * The cap's `deviceTypes` is the ONLY declaration consulted (D4: behavioural
577
+ * cap metadata lives in the `*.cap.ts`, never in a manifest). Two deliberate
578
+ * fail-open cases:
579
+ *
580
+ * - a cap that declares no `deviceTypes` (or an empty list) applies to every
581
+ * device — the pre-existing, back-compatible semantics;
582
+ * - an UNKNOWN `deviceType` never filters, so a missing/failed meta lookup
583
+ * changes nothing (D49: a read that fails must not destroy work).
584
+ */
585
+ function capAppliesToDeviceType(def, deviceType) {
586
+ if (deviceType === void 0) return true;
587
+ const declared = def?.deviceTypes;
588
+ if (!declared || declared.length === 0) return true;
589
+ return declared.some((t) => t === deviceType);
590
+ }
591
+ async function getBindings(deps, input) {
592
+ const storeKey = String(input.deviceId);
593
+ const perDevice = (await readBindingsStore(deps)).deviceBindings[storeKey] ?? {};
594
+ const row = await deps.rows.get(input.deviceId);
595
+ const deviceType = resolveDeviceType(deps, row, input.deviceId);
596
+ const presence = await resolveDevicePresence(deps, row, input.deviceId);
597
+ const entries = [];
598
+ const seenCaps = /* @__PURE__ */ new Set();
599
+ const resolveRemote = (capName) => deps.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? resolveRemoteNativeCapFromRegistry(deps, capName, input.deviceId);
600
+ for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
601
+ const hubLocalNative = deps.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
602
+ const remoteNative = resolveRemote(capName);
603
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
604
+ const nativeNodeId = hubLocalNative ? deps.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? deps.ctx.kernel.localNodeId ?? "hub";
605
+ if (wrapperAddonId === null && !nativeAddonId) {
606
+ seenCaps.add(capName);
607
+ continue;
608
+ }
609
+ entries.push({
610
+ capName,
611
+ kind: wrapperAddonId ? "wrapped" : "native",
612
+ providerAddonId: wrapperAddonId ?? nativeAddonId,
613
+ providerNodeId: wrapperAddonId ? resolveWrapperNodeId(wrapperAddonId) : toRoutableProviderNodeId(nativeNodeId),
614
+ nativeAddonId
615
+ });
616
+ seenCaps.add(capName);
617
+ }
618
+ if (presence === "absent") deps.ctx.logger.debug("bindings requested for absent device — returning none", { tags: { deviceId: input.deviceId } });
619
+ else if (deps.capabilityRegistry) {
620
+ const skippedForType = [];
621
+ for (const capName of deps.capabilityRegistry.getCapsWithDefaultWrapper()) {
622
+ if (seenCaps.has(capName)) continue;
623
+ if (!capAppliesToDeviceType(deps.capabilityRegistry.getDefinition(capName), deviceType)) {
624
+ skippedForType.push(capName);
625
+ continue;
626
+ }
627
+ const defaultWrapperAddonId = deps.capabilityRegistry.getDefaultWrapperForCap(capName);
628
+ if (!defaultWrapperAddonId) continue;
629
+ const hubLocalNative = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
630
+ const remoteNative = resolveRemote(capName);
631
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
632
+ entries.push({
633
+ capName,
634
+ kind: "wrapped",
635
+ providerAddonId: defaultWrapperAddonId,
636
+ providerNodeId: resolveWrapperNodeId(defaultWrapperAddonId),
637
+ nativeAddonId
638
+ });
639
+ seenCaps.add(capName);
640
+ }
641
+ if (skippedForType.length > 0) deps.ctx.logger.debug("getBindings: default wrappers skipped — deviceTypes mismatch", {
642
+ tags: { deviceId: input.deviceId },
643
+ meta: {
644
+ deviceType,
645
+ skipped: skippedForType
646
+ }
647
+ });
648
+ }
649
+ if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getNativeCapsForDevice(input.deviceId)) {
650
+ if (seenCaps.has(capName)) continue;
651
+ const nativeAddonId = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? "";
652
+ entries.push({
653
+ capName,
654
+ kind: "native",
655
+ providerAddonId: nativeAddonId,
656
+ providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
657
+ nativeAddonId
658
+ });
659
+ seenCaps.add(capName);
660
+ }
661
+ const pushFed = deps.remoteNativeCaps.get(input.deviceId);
662
+ if (pushFed) for (const [capName, info] of pushFed) {
663
+ if (seenCaps.has(capName)) continue;
664
+ entries.push({
665
+ capName,
666
+ kind: "native",
667
+ providerAddonId: info.addonId,
668
+ providerNodeId: toRoutableProviderNodeId(info.nodeId),
669
+ nativeAddonId: info.addonId
670
+ });
671
+ seenCaps.add(capName);
672
+ }
673
+ const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(input.deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
674
+ if (clusterCaps) for (const entry of clusterCaps) {
675
+ if (entry.deviceId !== input.deviceId) continue;
676
+ if (seenCaps.has(entry.capName)) continue;
677
+ if (!entry.addonId) continue;
678
+ const localNodeId = deps.ctx.kernel.localNodeId ?? "hub";
679
+ if (entry.nodeId === localNodeId) continue;
680
+ entries.push({
681
+ capName: entry.capName,
682
+ kind: "native",
683
+ providerAddonId: entry.addonId,
684
+ providerNodeId: toRoutableProviderNodeId(entry.nodeId),
685
+ nativeAddonId: entry.addonId
686
+ });
687
+ seenCaps.add(entry.capName);
688
+ }
689
+ return {
690
+ deviceId: input.deviceId,
691
+ entries
692
+ };
693
+ }
694
+ /**
695
+ * Whole-fleet binding dump. Iterates every device known to the
696
+ * deviceRegistry and reuses the per-device `getBindings` resolver
697
+ * for each — same routing rules, single round-trip. Used by
698
+ * `SystemManager.init()` for warm-boot.
699
+ *
700
+ * Bindings change rarely (wrapper toggle, device add/remove) so
701
+ * clients invalidate via the existing
702
+ * `capability.binding-changed` event rather than re-fetching this
703
+ * payload periodically.
704
+ */
705
+ async function getAllBindings(deps) {
706
+ const ids = /* @__PURE__ */ new Set();
707
+ for (const row of await deps.rows.listAll()) ids.add(row.meta.id);
708
+ const registered = deps.ctx.kernel?.deviceRegistry?.getAll() ?? [];
709
+ for (const device of registered) ids.add(device.id);
710
+ if (ids.size === 0) {
711
+ deps.ctx.logger.warn("getAllBindings found no devices — warm boot will see nothing", { meta: { hasRegistry: deps.ctx.kernel?.deviceRegistry !== void 0 } });
712
+ return [];
713
+ }
714
+ const out = [];
715
+ for (const deviceId of [...ids].sort((a, b) => a - b)) out.push(await getBindings(deps, { deviceId }));
716
+ return out;
717
+ }
718
+ /**
719
+ * Resolve a numeric deviceId to a stableId via persisted meta.
720
+ * Used only by the device-identity section of the device-details
721
+ * aggregator (see `buildBaseDeviceSection`) to surface the stableId as
722
+ * a readonly display field. All runtime/registry lookups are keyed by
723
+ * numeric deviceId; this helper is display-only.
724
+ */
725
+ async function lookupPersistedStableId(deps, deviceId) {
726
+ return (await deps.rows.get(deviceId))?.meta.stableId;
727
+ }
728
+ //#endregion
292
729
  //#region src/builtins/device-manager/day-night-config-schema.ts
293
730
  var MODE_LABELS = {
294
731
  auto: "Auto",
@@ -678,145 +1115,6 @@ function parseDerivedFormSettingsPatch(builderId, patch) {
678
1115
  return reducer.parsePatch(patch);
679
1116
  }
680
1117
  //#endregion
681
- //#region src/builtins/device-manager/device-aggregation-merge.ts
682
- /**
683
- * Pure aggregator-merge + field-tagging helpers for the device-manager
684
- * device-details aggregator. Extracted verbatim from
685
- * `device-manager.addon.ts`. These functions are stateless: they take
686
- * contributions in and return new wire-shape objects, attaching writer
687
- * provenance to editable fields. No addon-instance dependency.
688
- *
689
- * `mergeAggregates` is re-exported from the addon module to preserve the
690
- * existing public export surface the test-suite imports.
691
- */
692
- /**
693
- * Walk the sections/fields of a contribution and inject `writerCapName` +
694
- * `writerAddonId` + `source` on each editable field. Readonly fields and
695
- * structural fields (separator/info/button) pass through untouched. The
696
- * aggregator is the single place that knows provenance — provider schemas
697
- * stay clean, UI-bound metadata is attached once at the boundary.
698
- */
699
- function tagContribution(contribution, capName, addonId, kind) {
700
- const source = kind === "settings" ? "settings" : "live";
701
- return {
702
- ...contribution.tabs ? { tabs: [...contribution.tabs] } : {},
703
- sections: contribution.sections.map((section) => ({
704
- ...section,
705
- fields: section.fields.map((field) => tagField(field, capName, addonId, source, kind))
706
- }))
707
- };
708
- }
709
- function isFieldRecord(value) {
710
- return value !== null && typeof value === "object" && !Array.isArray(value);
711
- }
712
- /**
713
- * Convert a strict `ConfigUISchemaWithValues` (readonly arrays, typed
714
- * field union) into the cap wire shape `ContributionShape` (mutable
715
- * arrays, opaque field records). Required because the cap method z.infer
716
- * uses mutable arrays — readonly arrays are not assignable to mutable
717
- * even when structurally identical, so a structural copy bridges the gap
718
- * without disabling the type checker.
719
- */
720
- function toWireShape(input) {
721
- const out = { sections: input.sections.map((s) => ({
722
- id: s.id,
723
- title: s.title,
724
- ...s.description !== void 0 ? { description: s.description } : {},
725
- ...s.style !== void 0 ? { style: s.style } : {},
726
- ...s.defaultCollapsed !== void 0 ? { defaultCollapsed: s.defaultCollapsed } : {},
727
- ...s.columns !== void 0 ? { columns: s.columns } : {},
728
- ...s.tab !== void 0 ? { tab: s.tab } : {},
729
- ...s.location !== void 0 ? { location: s.location } : {},
730
- ...s.order !== void 0 ? { order: s.order } : {},
731
- fields: [...s.fields]
732
- })) };
733
- if (input.tabs) out.tabs = [...input.tabs];
734
- return out;
735
- }
736
- function tagField(field, capName, addonId, source, kind) {
737
- if (!isFieldRecord(field)) return field;
738
- const f = field;
739
- const structuralTypes = new Set([
740
- "separator",
741
- "info",
742
- "button"
743
- ]);
744
- if (typeof f.type === "string" && structuralTypes.has(f.type)) return field;
745
- const tagged = {
746
- ...f,
747
- source
748
- };
749
- if (kind === "live" || f.readonlyField === true) tagged.readonlyField = true;
750
- else {
751
- tagged.writerCapName = capName;
752
- tagged.writerAddonId = addonId;
753
- }
754
- if (f.type === "group") {
755
- const children = Array.isArray(f.fields) ? f.fields : [];
756
- if (children.length > 0) tagged.fields = children.map((child) => tagField(child, capName, addonId, source, kind));
757
- } else if (f.type === "sub-tabs") {
758
- const rawTabs = Array.isArray(f.tabs) ? f.tabs : [];
759
- if (rawTabs.length > 0) tagged.tabs = rawTabs.map((tab) => {
760
- if (!isFieldRecord(tab)) return tab;
761
- const tabChildren = Array.isArray(tab.fields) ? tab.fields : [];
762
- return {
763
- ...tab,
764
- fields: tabChildren.map((child) => tagField(child, capName, addonId, source, kind))
765
- };
766
- });
767
- }
768
- return tagged;
769
- }
770
- function mergeAggregates(parts) {
771
- const tabDecls = /* @__PURE__ */ new Map();
772
- const sections = [];
773
- const seenSectionIds = /* @__PURE__ */ new Set();
774
- for (const part of parts) {
775
- if (part.tabs) {
776
- for (const t of part.tabs) if (!tabDecls.has(t.id)) tabDecls.set(t.id, t);
777
- }
778
- for (const s of part.sections) {
779
- if (s.id !== void 0) {
780
- if (seenSectionIds.has(s.id)) continue;
781
- seenSectionIds.add(s.id);
782
- }
783
- sections.push(s);
784
- }
785
- }
786
- for (const s of sections) {
787
- const tabId = s.tab ?? "general";
788
- if (tabDecls.has(tabId)) continue;
789
- const known = WELL_KNOWN_TAB_MAP[tabId];
790
- if (known) tabDecls.set(tabId, {
791
- id: known.id,
792
- label: known.label,
793
- icon: known.icon,
794
- order: known.order
795
- });
796
- else tabDecls.set(tabId, {
797
- id: tabId,
798
- label: tabId,
799
- icon: "wrench",
800
- order: 100
801
- });
802
- }
803
- sections.sort((a, b) => {
804
- const tabA = a.tab ?? "general";
805
- const tabB = b.tab ?? "general";
806
- if (tabA !== tabB) {
807
- const orderA = tabDecls.get(tabA)?.order ?? 100;
808
- const orderB = tabDecls.get(tabB)?.order ?? 100;
809
- if (orderA !== orderB) return orderA - orderB;
810
- return tabA.localeCompare(tabB);
811
- }
812
- return (a.order ?? 0) - (b.order ?? 0);
813
- });
814
- const sortedTabs = [...tabDecls.values()].toSorted((a, b) => (a.order ?? 100) - (b.order ?? 100));
815
- const out = { sections };
816
- if (sortedTabs.length > 0) out.tabs = sortedTabs;
817
- return out;
818
- }
819
- //#endregion
820
1118
  //#region src/builtins/device-manager/device-projection.ts
821
1119
  /**
822
1120
  * Return true when `err` is a transient Moleculer error that is worth
@@ -948,22 +1246,15 @@ function resolveDeviceById(registry, deviceId) {
948
1246
  //#region src/builtins/device-manager/device-queries.ts
949
1247
  async function listPersistedByAddon(pctx, input) {
950
1248
  const { addonId } = input;
951
- const [index, meta] = await Promise.all([pctx.metaStore.readIndex(), pctx.metaStore.readMeta()]);
952
- const stableIds = index[addonId] ?? [];
953
- const byStableId = /* @__PURE__ */ new Map();
954
- for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
955
- return stableIds.map((stableId) => {
956
- const m = byStableId.get(stableId);
957
- return {
958
- id: m.id,
959
- stableId,
960
- type: m.type,
961
- name: m.name,
962
- location: m.location ?? null,
963
- disabled: m.disabled ?? false,
964
- parentDeviceId: m.parentDeviceId
965
- };
966
- });
1249
+ return (await pctx.metaStore.rows.listByAddon(addonId)).map(({ meta: m }) => ({
1250
+ id: m.id,
1251
+ stableId: m.stableId,
1252
+ type: m.type,
1253
+ name: m.name,
1254
+ location: m.location ?? null,
1255
+ disabled: m.disabled ?? false,
1256
+ parentDeviceId: m.parentDeviceId
1257
+ }));
967
1258
  }
968
1259
  async function listAll(pctx, input) {
969
1260
  const ownerFilter = Reflect.get(input, "addonId");
@@ -973,8 +1264,9 @@ async function listAll(pctx, input) {
973
1264
  const camerasOnly = input.isCamera === true;
974
1265
  const results = [];
975
1266
  const seen = /* @__PURE__ */ new Set();
976
- const meta = await pctx.metaStore.readMeta();
977
- const metadataMap = await pctx.metaStore.readMetadataMap();
1267
+ const fleet = addonId ? await pctx.metaStore.rows.listByAddon(addonId) : await pctx.metaStore.rows.listAll();
1268
+ const rowById = /* @__PURE__ */ new Map();
1269
+ for (const row of fleet) rowById.set(row.meta.id, row);
978
1270
  if (pctx.registry) {
979
1271
  const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
980
1272
  addonId,
@@ -982,7 +1274,8 @@ async function listAll(pctx, input) {
982
1274
  })) : pctx.registry.getAllWithAddonId();
983
1275
  for (const { addonId: aid, device } of liveEntries) {
984
1276
  const key = String(device.id);
985
- const info = toDeviceInfo(aid, device, metadataMap[key] ?? null, meta[key] ?? null);
1277
+ const row = rowById.get(device.id);
1278
+ const info = toDeviceInfo(aid, device, row?.metadata ?? null, row?.meta ?? null);
986
1279
  seen.add(key);
987
1280
  if (camerasOnly && !info.isCamera) continue;
988
1281
  results.push(slim ? {
@@ -992,39 +1285,36 @@ async function listAll(pctx, input) {
992
1285
  } : info);
993
1286
  }
994
1287
  }
995
- const index = await pctx.metaStore.readIndex();
996
- const metaByAddonStable = /* @__PURE__ */ new Map();
997
- for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
998
- const targetAddons = addonId ? [addonId] : Object.keys(index);
999
- for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
1000
- const m = metaByAddonStable.get(`${aid}${stableId}`);
1001
- const key = String(m.id);
1002
- if (seen.has(key)) continue;
1288
+ for (const row of fleet) {
1289
+ const m = row.meta;
1290
+ const aid = m.addonId;
1291
+ const stableId = m.stableId;
1292
+ if (seen.has(String(m.id))) continue;
1003
1293
  const persistedType = m.type;
1004
1294
  if (camerasOnly && persistedType !== DeviceType.Camera) continue;
1005
1295
  const persistedConfig = slim ? {} : await pctx.settings.readDeviceStore(m.id);
1006
- const metadata = slim ? null : metadataMap[key] ?? null;
1296
+ const metadata = slim ? null : row.metadata;
1007
1297
  results.push({
1008
1298
  id: m.id,
1009
1299
  stableId,
1010
1300
  addonId: aid,
1011
1301
  type: persistedType,
1012
- name: m?.name ?? stableId,
1013
- location: m?.location ?? null,
1014
- disabled: m?.disabled ?? false,
1015
- parentDeviceId: m?.parentDeviceId ?? null,
1016
- role: toDeviceRole(m?.role),
1302
+ name: m.name,
1303
+ location: m.location ?? null,
1304
+ disabled: m.disabled ?? false,
1305
+ parentDeviceId: m.parentDeviceId ?? null,
1306
+ role: toDeviceRole(m.role),
1017
1307
  online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
1018
1308
  probed: pctx.host.resolveDeviceProbed(m.id),
1019
- features: persistedFeatures(m?.features),
1309
+ features: persistedFeatures(m.features),
1020
1310
  isCamera: persistedType === DeviceType.Camera,
1021
1311
  config: persistedConfig ?? {},
1022
1312
  metadata,
1023
- ...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1024
- ...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1025
- ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1026
- ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1027
- ...m?.display !== void 0 ? { display: m.display } : {},
1313
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1314
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1315
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1316
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1317
+ ...m.display !== void 0 ? { display: m.display } : {},
1028
1318
  ...(() => {
1029
1319
  if (slim) return {};
1030
1320
  const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
@@ -1039,19 +1329,15 @@ async function getDevice(pctx, input) {
1039
1329
  if (pctx.registry) {
1040
1330
  const found = resolveDeviceById(pctx.registry, deviceId);
1041
1331
  if (found) {
1042
- const key = String(found.device.id);
1043
- const [map, metaMap] = await Promise.all([pctx.metaStore.readMetadataMap(), pctx.metaStore.readMeta()]);
1044
- const metadata = map[key] ?? null;
1045
- const metaRow = metaMap[key] ?? null;
1046
- return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
1332
+ const row = await pctx.metaStore.getRow(found.device.id);
1333
+ return toDeviceInfo(found.addonId, found.device, row?.metadata ?? null, row?.meta ?? null);
1047
1334
  }
1048
1335
  }
1049
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1050
- if (!persisted) return null;
1051
- const { addonId: aid, stableId, meta: m } = persisted;
1336
+ const row = await pctx.metaStore.getRow(deviceId);
1337
+ if (row === null) return null;
1338
+ const { meta: m, metadata } = row;
1339
+ const { addonId: aid, stableId } = m;
1052
1340
  const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1053
- const key = String(deviceId);
1054
- const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
1055
1341
  const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
1056
1342
  return {
1057
1343
  id: deviceId,
@@ -1090,30 +1376,25 @@ async function getChildren(pctx, input) {
1090
1376
  }
1091
1377
  const results = [];
1092
1378
  const seen = /* @__PURE__ */ new Set();
1093
- const [index, meta, metadataMap] = await Promise.all([
1094
- pctx.metaStore.readIndex(),
1095
- pctx.metaStore.readMeta(),
1096
- pctx.metaStore.readMetadataMap()
1097
- ]);
1379
+ const childRows = await pctx.metaStore.rows.listByParent(parentDeviceId);
1380
+ const rowById = /* @__PURE__ */ new Map();
1381
+ for (const row of childRows) rowById.set(row.meta.id, row);
1098
1382
  if (pctx.registry) {
1099
1383
  const liveChildren = pctx.registry.getChildren(parentDeviceId);
1100
1384
  for (const device of liveChildren) {
1101
1385
  const key = String(device.id);
1102
- const metadata = metadataMap[key] ?? null;
1103
- const metaRow = meta[key] ?? null;
1104
- results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
1386
+ const row = rowById.get(device.id);
1387
+ results.push(toDeviceInfo(ownerAddonId, device, row?.metadata ?? null, row?.meta ?? null));
1105
1388
  seen.add(key);
1106
1389
  }
1107
1390
  }
1108
- const ownerMetaByStableId = /* @__PURE__ */ new Map();
1109
- for (const m of Object.values(meta)) if (m.addonId === ownerAddonId) ownerMetaByStableId.set(m.stableId, m);
1110
- const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => ownerMetaByStableId.get(sid)?.parentDeviceId === parentDeviceId);
1111
- for (const childStableId of persistedChildren) {
1112
- const m = ownerMetaByStableId.get(childStableId);
1391
+ for (const row of childRows) {
1392
+ const m = row.meta;
1393
+ const childStableId = m.stableId;
1113
1394
  const key = String(m.id);
1114
1395
  if (seen.has(key)) continue;
1115
1396
  const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1116
- const metadata = metadataMap[key] ?? null;
1397
+ const metadata = row.metadata;
1117
1398
  const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
1118
1399
  results.push({
1119
1400
  id: m.id,
@@ -1265,16 +1546,10 @@ async function remove(pctx, input) {
1265
1546
  */
1266
1547
  async function removeByIntegration(pctx, input) {
1267
1548
  const { integrationId } = input;
1268
- const meta = await pctx.metaStore.readMeta();
1269
- const parentKeys = Object.keys(meta).filter((key) => {
1270
- const m = meta[key];
1271
- return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
1272
- });
1549
+ const parents = (await pctx.metaStore.rows.listByIntegration(integrationId)).filter((row) => row.meta.parentDeviceId === null);
1273
1550
  let removed = 0;
1274
- for (const _key of parentKeys) {
1275
- const m = meta[_key];
1276
- if (!m) continue;
1277
- await pctx.provider.remove({ deviceId: m.id });
1551
+ for (const parent of parents) {
1552
+ await pctx.provider.remove({ deviceId: parent.meta.id });
1278
1553
  removed++;
1279
1554
  }
1280
1555
  return { removed };
@@ -1697,317 +1972,17 @@ function splitDeviceStoreKeys(patch) {
1697
1972
  claimed.add(key);
1698
1973
  }
1699
1974
  if (Object.keys(slice).length > 0) storeGroups.push({
1700
- section,
1701
- patch: slice
1702
- });
1703
- }
1704
- const driverPatch = {};
1705
- for (const [key, value] of Object.entries(patch)) if (!claimed.has(key)) driverPatch[key] = value;
1706
- return {
1707
- storeGroups,
1708
- driverPatch
1709
- };
1710
- }
1711
- //#endregion
1712
- //#region src/builtins/device-manager/device-bindings-store.ts
1713
- /**
1714
- * Wire the push-fed cross-process native-cap cache (`remoteNativeCaps`).
1715
- * Workers emit `DeviceBindingsChanged` on `ctx.registerNativeCap` / device
1716
- * removal; we mirror those into `remoteNativeCaps` so `getBindings` returns the
1717
- * full cluster view. Events from the local node are ignored: hub-local natives
1718
- * live in `capabilityRegistry` and are folded in directly by getBindings.
1719
- *
1720
- * Push events are accurate in the steady state but can be lost during the
1721
- * Moleculer transport handshake window (hub restart, crash-respawn,
1722
- * restartAddon). The reliable replacement for lost events is the D3 re-handshake
1723
- * (`listClusterNativeCaps()`), which `resolveNativeCapOwnerSync` /
1724
- * `getBindings` step 4 fall through to on a push miss. The `$node.disconnected`
1725
- * handler purges a gone node's entries; the worker re-handshakes (and re-emits
1726
- * `native-registered`) on its next boot.
1727
- */
1728
- function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
1729
- const localNodeId = ctx.kernel.localNodeId ?? "hub";
1730
- ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (event) => {
1731
- const { deviceId, capName, reason, addonId, nodeId } = event.data;
1732
- if (nodeId === localNodeId) return;
1733
- if (reason === "native-registered") {
1734
- let perDevice = remoteNativeCaps.get(deviceId);
1735
- if (!perDevice) {
1736
- perDevice = /* @__PURE__ */ new Map();
1737
- remoteNativeCaps.set(deviceId, perDevice);
1738
- }
1739
- perDevice.set(capName, {
1740
- addonId,
1741
- nodeId
1742
- });
1743
- } else if (reason === "native-unregistered") {
1744
- const perDevice = remoteNativeCaps.get(deviceId);
1745
- if (!perDevice) return;
1746
- perDevice.delete(capName);
1747
- if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
1748
- }
1749
- });
1750
- const cluster = ctx.kernel.cluster;
1751
- if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
1752
- const gone = payload.node.id;
1753
- const emptyDevices = [];
1754
- for (const [deviceId, perDevice] of remoteNativeCaps) {
1755
- const toDelete = [];
1756
- for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
1757
- for (const capName of toDelete) perDevice.delete(capName);
1758
- if (perDevice.size === 0) emptyDevices.push(deviceId);
1759
- }
1760
- for (const deviceId of emptyDevices) remoteNativeCaps.delete(deviceId);
1761
- });
1762
- }
1763
- async function readBindingsStore(deps) {
1764
- return { deviceBindings: (await deps.ctx.settings.readAddonStore()).deviceBindings ?? {} };
1765
- }
1766
- async function writeBindingsStore(deps, next) {
1767
- await deps.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
1768
- }
1769
- function resolveWrapperNodeId(_wrapperAddonId) {
1770
- return "hub";
1771
- }
1772
- /**
1773
- * Reduce a provider node id to the routable form `DeviceProxy` can pin.
1774
- *
1775
- * Every addon runs in its own `addon-runner` with the composite node id
1776
- * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) — e.g.
1777
- * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
1778
- * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
1779
- * only THROUGH its parent (the hub resolves a hub-local-uds child by
1780
- * cap+device; an agent forwards to its own child). `DeviceProxy` pins
1781
- * `entry.providerNodeId` on every cap call, so a binding entry must expose the
1782
- * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
1783
- * to an unknown node → `no-provider`, which surfaces as
1784
- * "this camera doesn't expose …" for client-proxy-driven widget caps
1785
- * (motion-zones, privacy-mask). Wrappers already report the parent via
1786
- * `resolveWrapperNodeId`; this aligns natives with the same contract.
1787
- *
1788
- * A flat node id (a genuine standalone node with no `/`) is returned
1789
- * unchanged.
1790
- */
1791
- function toRoutableProviderNodeId(nodeId) {
1792
- const slash = nodeId.indexOf("/");
1793
- return slash === -1 ? nodeId : nodeId.slice(0, slash);
1794
- }
1795
- /**
1796
- * Resolve a remote native cap entry for a given `(capName, deviceId)` by
1797
- * consulting the handshake-fed `HubNodeRegistry` via
1798
- * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
1799
- * `remoteNativeCaps` cache misses — covers the Moleculer transport
1800
- * handshake window where `DeviceBindingsChanged` events were lost but the
1801
- * D3 re-handshake (post device restore) has already populated the registry.
1802
- *
1803
- * Returns `null` when the entry is genuinely not present in the cluster
1804
- * view (cap not registered on any worker for that device).
1805
- */
1806
- function resolveRemoteNativeCapFromRegistry(deps, capName, deviceId) {
1807
- const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
1808
- if (!clusterCaps) return null;
1809
- for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
1810
- addonId: entry.addonId,
1811
- nodeId: entry.nodeId
1812
- };
1813
- return null;
1814
- }
1815
- /**
1816
- * Resolve the device's declared TYPE (`'camera'`, `'event-emitter'`, …), or
1817
- * `undefined` when it cannot be established.
1818
- *
1819
- * The PERSISTED meta is the authority: `ctx.kernel.deviceRegistry` is hub-only
1820
- * and has been observed empty in the very process that answers `getBindings`
1821
- * correctly (see the note on `getAllBindings`). The registry is consulted only
1822
- * as a secondary source, for a device constructed but not yet persisted.
1823
- *
1824
- * `undefined` is a first-class answer and callers must treat it as "no
1825
- * filtering" — an absent row must never be the reason a device loses bindings.
1826
- */
1827
- function resolveDeviceType(deps, rawStore, deviceId) {
1828
- const persisted = rawStore.deviceMeta?.[String(deviceId)]?.type;
1829
- if (typeof persisted === "string" && persisted.length > 0) return persisted;
1830
- const live = deps.ctx.kernel?.deviceRegistry?.getById(deviceId)?.type;
1831
- return typeof live === "string" && live.length > 0 ? live : void 0;
1832
- }
1833
- /**
1834
- * Is this device still part of the fleet?
1835
- *
1836
- * The device-manager's own stores are the authority in-process — no RPC. Two
1837
- * sources, in the same order `resolveDeviceType` uses them: the live registry
1838
- * first (a device constructed but not yet persisted is present), then the
1839
- * PERSISTED meta, which is the index `getBindings` already reads and is present
1840
- * wherever this provider runs.
1841
- *
1842
- * `'absent'` is only ever returned against a NON-EMPTY meta store. An empty one
1843
- * means device restore has not run, not that the fleet was deleted (D49) — the
1844
- * same reason `getAllBindings` warns instead of reporting zero devices.
1845
- */
1846
- function resolveDevicePresence(deps, rawStore, deviceId) {
1847
- if (deps.ctx.kernel?.deviceRegistry?.getById(deviceId)) return "present";
1848
- const meta = rawStore.deviceMeta;
1849
- if (meta && Object.keys(meta).length > 0) return meta[String(deviceId)] === void 0 ? "absent" : "present";
1850
- return "unknown";
1851
- }
1852
- /**
1853
- * Does a capability apply to a device of type `deviceType`?
1854
- *
1855
- * The cap's `deviceTypes` is the ONLY declaration consulted (D4: behavioural
1856
- * cap metadata lives in the `*.cap.ts`, never in a manifest). Two deliberate
1857
- * fail-open cases:
1858
- *
1859
- * - a cap that declares no `deviceTypes` (or an empty list) applies to every
1860
- * device — the pre-existing, back-compatible semantics;
1861
- * - an UNKNOWN `deviceType` never filters, so a missing/failed meta lookup
1862
- * changes nothing (D49: a read that fails must not destroy work).
1863
- */
1864
- function capAppliesToDeviceType(def, deviceType) {
1865
- if (deviceType === void 0) return true;
1866
- const declared = def?.deviceTypes;
1867
- if (!declared || declared.length === 0) return true;
1868
- return declared.some((t) => t === deviceType);
1869
- }
1870
- async function getBindings(deps, input) {
1871
- const storeKey = String(input.deviceId);
1872
- const rawStore = await deps.ctx.settings.readAddonStore();
1873
- const perDevice = (rawStore.deviceBindings ?? {})[storeKey] ?? {};
1874
- const deviceType = resolveDeviceType(deps, rawStore, input.deviceId);
1875
- const presence = resolveDevicePresence(deps, rawStore, input.deviceId);
1876
- const entries = [];
1877
- const seenCaps = /* @__PURE__ */ new Set();
1878
- const resolveRemote = (capName) => deps.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? resolveRemoteNativeCapFromRegistry(deps, capName, input.deviceId);
1879
- for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
1880
- const hubLocalNative = deps.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
1881
- const remoteNative = resolveRemote(capName);
1882
- const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
1883
- const nativeNodeId = hubLocalNative ? deps.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? deps.ctx.kernel.localNodeId ?? "hub";
1884
- if (wrapperAddonId === null && !nativeAddonId) {
1885
- seenCaps.add(capName);
1886
- continue;
1887
- }
1888
- entries.push({
1889
- capName,
1890
- kind: wrapperAddonId ? "wrapped" : "native",
1891
- providerAddonId: wrapperAddonId ?? nativeAddonId,
1892
- providerNodeId: wrapperAddonId ? resolveWrapperNodeId(wrapperAddonId) : toRoutableProviderNodeId(nativeNodeId),
1893
- nativeAddonId
1894
- });
1895
- seenCaps.add(capName);
1896
- }
1897
- if (presence === "absent") deps.ctx.logger.debug("bindings requested for absent device — returning none", { tags: { deviceId: input.deviceId } });
1898
- else if (deps.capabilityRegistry) {
1899
- const skippedForType = [];
1900
- for (const capName of deps.capabilityRegistry.getCapsWithDefaultWrapper()) {
1901
- if (seenCaps.has(capName)) continue;
1902
- if (!capAppliesToDeviceType(deps.capabilityRegistry.getDefinition(capName), deviceType)) {
1903
- skippedForType.push(capName);
1904
- continue;
1905
- }
1906
- const defaultWrapperAddonId = deps.capabilityRegistry.getDefaultWrapperForCap(capName);
1907
- if (!defaultWrapperAddonId) continue;
1908
- const hubLocalNative = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
1909
- const remoteNative = resolveRemote(capName);
1910
- const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
1911
- entries.push({
1912
- capName,
1913
- kind: "wrapped",
1914
- providerAddonId: defaultWrapperAddonId,
1915
- providerNodeId: resolveWrapperNodeId(defaultWrapperAddonId),
1916
- nativeAddonId
1917
- });
1918
- seenCaps.add(capName);
1919
- }
1920
- if (skippedForType.length > 0) deps.ctx.logger.debug("getBindings: default wrappers skipped — deviceTypes mismatch", {
1921
- tags: { deviceId: input.deviceId },
1922
- meta: {
1923
- deviceType,
1924
- skipped: skippedForType
1925
- }
1926
- });
1927
- }
1928
- if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getNativeCapsForDevice(input.deviceId)) {
1929
- if (seenCaps.has(capName)) continue;
1930
- const nativeAddonId = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? "";
1931
- entries.push({
1932
- capName,
1933
- kind: "native",
1934
- providerAddonId: nativeAddonId,
1935
- providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
1936
- nativeAddonId
1937
- });
1938
- seenCaps.add(capName);
1939
- }
1940
- const pushFed = deps.remoteNativeCaps.get(input.deviceId);
1941
- if (pushFed) for (const [capName, info] of pushFed) {
1942
- if (seenCaps.has(capName)) continue;
1943
- entries.push({
1944
- capName,
1945
- kind: "native",
1946
- providerAddonId: info.addonId,
1947
- providerNodeId: toRoutableProviderNodeId(info.nodeId),
1948
- nativeAddonId: info.addonId
1949
- });
1950
- seenCaps.add(capName);
1951
- }
1952
- const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(input.deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
1953
- if (clusterCaps) for (const entry of clusterCaps) {
1954
- if (entry.deviceId !== input.deviceId) continue;
1955
- if (seenCaps.has(entry.capName)) continue;
1956
- if (!entry.addonId) continue;
1957
- const localNodeId = deps.ctx.kernel.localNodeId ?? "hub";
1958
- if (entry.nodeId === localNodeId) continue;
1959
- entries.push({
1960
- capName: entry.capName,
1961
- kind: "native",
1962
- providerAddonId: entry.addonId,
1963
- providerNodeId: toRoutableProviderNodeId(entry.nodeId),
1964
- nativeAddonId: entry.addonId
1975
+ section,
1976
+ patch: slice
1965
1977
  });
1966
- seenCaps.add(entry.capName);
1967
1978
  }
1979
+ const driverPatch = {};
1980
+ for (const [key, value] of Object.entries(patch)) if (!claimed.has(key)) driverPatch[key] = value;
1968
1981
  return {
1969
- deviceId: input.deviceId,
1970
- entries
1982
+ storeGroups,
1983
+ driverPatch
1971
1984
  };
1972
1985
  }
1973
- /**
1974
- * Whole-fleet binding dump. Iterates every device known to the
1975
- * deviceRegistry and reuses the per-device `getBindings` resolver
1976
- * for each — same routing rules, single round-trip. Used by
1977
- * `SystemManager.init()` for warm-boot.
1978
- *
1979
- * Bindings change rarely (wrapper toggle, device add/remove) so
1980
- * clients invalidate via the existing
1981
- * `capability.binding-changed` event rather than re-fetching this
1982
- * payload periodically.
1983
- */
1984
- async function getAllBindings(deps) {
1985
- const store = await deps.ctx.settings.readAddonStore();
1986
- const ids = /* @__PURE__ */ new Set();
1987
- for (const key of Object.keys(store.deviceMeta ?? {})) {
1988
- const id = Number(key);
1989
- if (Number.isInteger(id)) ids.add(id);
1990
- }
1991
- const registered = deps.ctx.kernel?.deviceRegistry?.getAll() ?? [];
1992
- for (const device of registered) ids.add(device.id);
1993
- if (ids.size === 0) {
1994
- deps.ctx.logger.warn("getAllBindings found no devices — warm boot will see nothing", { meta: { hasRegistry: deps.ctx.kernel?.deviceRegistry !== void 0 } });
1995
- return [];
1996
- }
1997
- const out = [];
1998
- for (const deviceId of [...ids].sort((a, b) => a - b)) out.push(await getBindings(deps, { deviceId }));
1999
- return out;
2000
- }
2001
- /**
2002
- * Resolve a numeric deviceId to a stableId via persisted meta.
2003
- * Used only by the device-identity section of the device-details
2004
- * aggregator (see `buildBaseDeviceSection`) to surface the stableId as
2005
- * a readonly display field. All runtime/registry lookups are keyed by
2006
- * numeric deviceId; this helper is display-only.
2007
- */
2008
- async function lookupPersistedStableId(deps, deviceId) {
2009
- return ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.stableId;
2010
- }
2011
1986
  //#endregion
2012
1987
  //#region src/builtins/device-manager/device-aggregation.ts
2013
1988
  /**
@@ -2796,7 +2771,7 @@ async function getWireableFields(deps, input) {
2796
2771
  if (wireable) caps.push(wireable);
2797
2772
  }
2798
2773
  if (input.includeSynthesizable === true) {
2799
- const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
2774
+ const deviceType = (await deps.bindingsDeps.rows.get(deviceId))?.meta.type;
2800
2775
  if (deviceType !== void 0) for (const def of ALL_CAPABILITY_DEFINITIONS) {
2801
2776
  if (seen.has(def.name)) continue;
2802
2777
  if (def.scope !== "device" || def.kind === "wrapper") continue;
@@ -2950,20 +2925,10 @@ var DeviceEventPropagator = class {
2950
2925
  * because `ProviderContext.stampIntegrationId` delegates HERE — passing the
2951
2926
  * context would be a capture cycle.
2952
2927
  */
2953
- async function stampIntegrationId(metaStore, settings, ctx, deviceId, integrationId) {
2928
+ async function stampIntegrationId(metaStore, ctx, deviceId, integrationId) {
2954
2929
  await metaStore.withMetaWriteLock(async () => {
2955
- const persisted = await metaStore.resolvePersistedById(deviceId);
2956
- if (!persisted) throw new Error(`[device-manager] stampIntegrationId: unknown device id=${deviceId}`);
2957
- const { meta: m } = persisted;
2958
- const key = String(deviceId);
2959
- const allMeta = await metaStore.readMeta();
2960
- await settings.writeAddonStore({ deviceMeta: {
2961
- ...allMeta,
2962
- [key]: {
2963
- ...m,
2964
- integrationId
2965
- }
2966
- } });
2930
+ if (!await metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] stampIntegrationId: unknown device id=${deviceId}`);
2931
+ await metaStore.rows.patch(deviceId, { integrationId });
2967
2932
  });
2968
2933
  ctx.eventBus.emit({
2969
2934
  id: randomUUID(),
@@ -2983,70 +2948,54 @@ async function stampIntegrationId(metaStore, settings, ctx, deviceId, integratio
2983
2948
  async function allocateDeviceId(pctx, input) {
2984
2949
  const { addonId, stableId } = input;
2985
2950
  return await pctx.metaStore.withMetaWriteLock(async () => {
2986
- const meta = await pctx.metaStore.readMeta();
2987
- const existing = Object.values(meta).find((m) => m.addonId === addonId && m.stableId === stableId);
2988
- if (existing) return { id: existing.id };
2951
+ const existing = await pctx.metaStore.rows.findByStableId(addonId, stableId);
2952
+ if (existing) return { id: existing.meta.id };
2989
2953
  const id = await pctx.metaStore.allocateNextDeviceId();
2990
- await pctx.settings.writeAddonStore({ deviceMeta: {
2991
- ...meta,
2992
- [String(id)]: {
2993
- addonId,
2994
- stableId,
2995
- type: "generic",
2996
- name: stableId,
2997
- location: null,
2998
- disabled: false,
2999
- parentDeviceId: null,
3000
- id
3001
- }
3002
- } });
2954
+ await pctx.metaStore.rows.upsert({
2955
+ addonId,
2956
+ stableId,
2957
+ type: "generic",
2958
+ name: stableId,
2959
+ location: null,
2960
+ disabled: false,
2961
+ parentDeviceId: null,
2962
+ id
2963
+ }, {
2964
+ registered: false,
2965
+ metadata: null
2966
+ });
3003
2967
  return { id };
3004
2968
  });
3005
2969
  }
3006
2970
  async function registerDevice(pctx, input) {
3007
2971
  const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
3008
- const key = String(id);
3009
2972
  const featuresArr = Array.isArray(features) ? [...features] : [];
3010
2973
  const { isFirstRegistration, exportFingerprint, fingerprintChanged } = await pctx.metaStore.withMetaWriteLock(async () => {
3011
- const index = await pctx.metaStore.readIndex();
3012
- const existing = index[addonId] ?? [];
3013
- const wasInIndex = existing.includes(stableId);
3014
- if (!wasInIndex) await pctx.settings.writeAddonStore({ deviceIndex: {
3015
- ...index,
3016
- [addonId]: [...existing, stableId]
3017
- } });
3018
- const meta = await pctx.metaStore.readMeta();
3019
- const existingMeta = meta[key];
2974
+ const existingRow = await pctx.metaStore.rows.get(id);
2975
+ const existingMeta = existingRow?.meta;
2976
+ const wasRegistered = existingRow?.registered ?? false;
3020
2977
  const wasUserNamed = existingMeta?.userNamed ?? (existingMeta !== void 0 && existingMeta.name !== stableId);
3021
2978
  const resolvedName = wasUserNamed && existingMeta !== void 0 ? existingMeta.name : name;
3022
- const isFirst = !existingMeta || !wasInIndex;
2979
+ const isFirst = !existingMeta || !wasRegistered;
3023
2980
  const fingerprint = canonicalDeviceFingerprint({
3024
2981
  deviceId: id,
3025
2982
  deviceType: type,
3026
2983
  features: featuresArr
3027
2984
  });
3028
- await pctx.settings.writeAddonStore({ deviceMeta: {
3029
- ...meta,
3030
- [key]: {
3031
- addonId,
3032
- stableId,
3033
- type,
3034
- name: resolvedName,
3035
- userNamed: wasUserNamed,
3036
- location: existingMeta?.location ?? null,
3037
- disabled: existingMeta?.disabled ?? false,
3038
- ...existingMeta?.integrationId !== void 0 ? { integrationId: existingMeta.integrationId } : {},
3039
- ...existingMeta?.linkDeviceId !== void 0 ? { linkDeviceId: existingMeta.linkDeviceId } : {},
3040
- ...existingMeta?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: existingMeta.primaryChildEntityId } : {},
3041
- ...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
3042
- ...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
3043
- ...existingMeta?.display !== void 0 ? { display: existingMeta.display } : {},
3044
- parentDeviceId,
3045
- id,
3046
- features: featuresArr,
3047
- exportFingerprint: fingerprint
3048
- }
3049
- } });
2985
+ await pctx.metaStore.rows.upsertRegistration({
2986
+ deviceId: id,
2987
+ addonId,
2988
+ stableId,
2989
+ type,
2990
+ name: resolvedName,
2991
+ userNamed: wasUserNamed,
2992
+ location: existingMeta?.location ?? null,
2993
+ disabled: existingMeta?.disabled ?? false,
2994
+ parentDeviceId: parentDeviceId ?? null,
2995
+ registered: true,
2996
+ features: featuresArr,
2997
+ exportFingerprint: fingerprint
2998
+ });
3050
2999
  return {
3051
3000
  isFirstRegistration: isFirst,
3052
3001
  exportFingerprint: fingerprint,
@@ -3106,26 +3055,9 @@ async function removeDevice(pctx, input) {
3106
3055
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3107
3056
  if (!persisted) return;
3108
3057
  const { addonId, stableId, meta: persistedMeta } = persisted;
3109
- const key = String(deviceId);
3110
3058
  const deviceName = persistedMeta.name;
3111
3059
  await pctx.metaStore.withMetaWriteLock(async () => {
3112
- const index = await pctx.metaStore.readIndex();
3113
- const remaining = (index[addonId] ?? []).filter((sid) => sid !== stableId);
3114
- const updatedIndex = remaining.length > 0 ? {
3115
- ...index,
3116
- [addonId]: remaining
3117
- } : (() => {
3118
- const { [addonId]: _removed, ...rest } = index;
3119
- return rest;
3120
- })();
3121
- await pctx.settings.writeAddonStore({ deviceIndex: updatedIndex });
3122
- const { [key]: _removedMeta, ...restMeta } = await pctx.metaStore.readMeta();
3123
- await pctx.settings.writeAddonStore({ deviceMeta: restMeta });
3124
- const map = await pctx.metaStore.readMetadataMap();
3125
- if (key in map) {
3126
- const { [key]: _removedMetadata, ...restMap } = map;
3127
- await pctx.settings.writeAddonStore({ deviceMetadata: restMap });
3128
- }
3060
+ await pctx.metaStore.rows.remove(deviceId);
3129
3061
  });
3130
3062
  await pctx.settings.clearDeviceStore(deviceId);
3131
3063
  await pctx.settings.clearDeviceRuntimeState(deviceId);
@@ -3181,11 +3113,10 @@ async function loadConfig(pctx, input) {
3181
3113
  */
3182
3114
  async function loadMeta(pctx, input) {
3183
3115
  const { deviceId } = input;
3184
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3185
- if (!persisted) return null;
3186
- const { addonId, stableId, meta: m } = persisted;
3187
- const key = String(deviceId);
3188
- const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
3116
+ const row = await pctx.metaStore.getRow(deviceId);
3117
+ if (row === null) return null;
3118
+ const { meta: m, metadata } = row;
3119
+ const { addonId, stableId } = m;
3189
3120
  return {
3190
3121
  id: m.id,
3191
3122
  stableId,
@@ -3215,32 +3146,26 @@ async function setName(pctx, input) {
3215
3146
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3216
3147
  if (!persisted) throw new Error(`[device-manager] setName: unknown device id=${deviceId}`);
3217
3148
  const { meta: m } = persisted;
3218
- const key = String(deviceId);
3219
3149
  const oldName = m.name;
3220
- const allMeta = await pctx.metaStore.readMeta();
3221
- const nextMeta = {
3222
- ...allMeta,
3223
- [key]: {
3224
- ...m,
3225
- name,
3226
- userNamed: true
3150
+ await pctx.metaStore.rows.patch(deviceId, {
3151
+ name,
3152
+ userNamed: true
3153
+ });
3154
+ if (oldName.length > 0 && oldName !== name) {
3155
+ const candidates = /* @__PURE__ */ new Map();
3156
+ for (const row of await pctx.metaStore.rows.listByParent(deviceId)) candidates.set(row.meta.id, row.meta);
3157
+ for (const row of await pctx.metaStore.rows.listAll()) if (row.meta.linkDeviceId === deviceId) candidates.set(row.meta.id, row.meta);
3158
+ candidates.delete(deviceId);
3159
+ for (const childMeta of candidates.values()) {
3160
+ if (childMeta.name !== oldName && !childMeta.name.startsWith(`${oldName} `)) continue;
3161
+ const childName = childMeta.name === oldName ? name : `${name}${childMeta.name.slice(oldName.length)}`;
3162
+ await pctx.metaStore.rows.patch(childMeta.id, { name: childName });
3163
+ cascaded.push({
3164
+ id: childMeta.id,
3165
+ name: childName
3166
+ });
3227
3167
  }
3228
- };
3229
- if (oldName.length > 0 && oldName !== name) for (const [childKey, childMeta] of Object.entries(allMeta)) {
3230
- if (childKey === key) continue;
3231
- if (!(childMeta.parentDeviceId === deviceId || childMeta.linkDeviceId === deviceId)) continue;
3232
- if (childMeta.name !== oldName && !childMeta.name.startsWith(`${oldName} `)) continue;
3233
- const childName = childMeta.name === oldName ? name : `${name}${childMeta.name.slice(oldName.length)}`;
3234
- nextMeta[childKey] = {
3235
- ...childMeta,
3236
- name: childName
3237
- };
3238
- cascaded.push({
3239
- id: Number(childKey),
3240
- name: childName
3241
- });
3242
3168
  }
3243
- await pctx.settings.writeAddonStore({ deviceMeta: nextMeta });
3244
3169
  });
3245
3170
  pctx.host.ctx.eventBus.emit({
3246
3171
  id: randomUUID(),
@@ -3280,18 +3205,8 @@ async function setName(pctx, input) {
3280
3205
  async function setLocation(pctx, input) {
3281
3206
  const { deviceId, location } = input;
3282
3207
  await pctx.metaStore.withMetaWriteLock(async () => {
3283
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3284
- if (!persisted) throw new Error(`[device-manager] setLocation: unknown device id=${deviceId}`);
3285
- const { meta: m } = persisted;
3286
- const key = String(deviceId);
3287
- const allMeta = await pctx.metaStore.readMeta();
3288
- await pctx.settings.writeAddonStore({ deviceMeta: {
3289
- ...allMeta,
3290
- [key]: {
3291
- ...m,
3292
- location
3293
- }
3294
- } });
3208
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setLocation: unknown device id=${deviceId}`);
3209
+ await pctx.metaStore.rows.patch(deviceId, { location });
3295
3210
  });
3296
3211
  pctx.host.ctx.eventBus.emit({
3297
3212
  id: randomUUID(),
@@ -3318,18 +3233,8 @@ async function setLocation(pctx, input) {
3318
3233
  async function setType(pctx, input) {
3319
3234
  const { deviceId, type } = input;
3320
3235
  await pctx.metaStore.withMetaWriteLock(async () => {
3321
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3322
- if (!persisted) throw new Error(`[device-manager] setType: unknown device id=${deviceId}`);
3323
- const { meta: m } = persisted;
3324
- const key = String(deviceId);
3325
- const allMeta = await pctx.metaStore.readMeta();
3326
- await pctx.settings.writeAddonStore({ deviceMeta: {
3327
- ...allMeta,
3328
- [key]: {
3329
- ...m,
3330
- type
3331
- }
3332
- } });
3236
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setType: unknown device id=${deviceId}`);
3237
+ await pctx.metaStore.rows.patch(deviceId, { type });
3333
3238
  });
3334
3239
  pctx.host.ctx.eventBus.emit({
3335
3240
  id: randomUUID(),
@@ -3364,18 +3269,8 @@ async function setIntegrationId(pctx, input) {
3364
3269
  async function setLinkDeviceId(pctx, input) {
3365
3270
  const { deviceId, linkDeviceId } = input;
3366
3271
  await pctx.metaStore.withMetaWriteLock(async () => {
3367
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3368
- if (!persisted) throw new Error(`[device-manager] setLinkDeviceId: unknown device id=${deviceId}`);
3369
- const { meta: m } = persisted;
3370
- const key = String(deviceId);
3371
- const allMeta = await pctx.metaStore.readMeta();
3372
- await pctx.settings.writeAddonStore({ deviceMeta: {
3373
- ...allMeta,
3374
- [key]: {
3375
- ...m,
3376
- linkDeviceId
3377
- }
3378
- } });
3272
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setLinkDeviceId: unknown device id=${deviceId}`);
3273
+ await pctx.metaStore.rows.patch(deviceId, { linkDeviceId });
3379
3274
  });
3380
3275
  pctx.host.ctx.eventBus.emit({
3381
3276
  id: randomUUID(),
@@ -3402,18 +3297,8 @@ async function setLinkDeviceId(pctx, input) {
3402
3297
  async function setPrimaryChildEntityId(pctx, input) {
3403
3298
  const { deviceId, primaryChildEntityId } = input;
3404
3299
  await pctx.metaStore.withMetaWriteLock(async () => {
3405
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3406
- if (!persisted) throw new Error(`[device-manager] setPrimaryChildEntityId: unknown device id=${deviceId}`);
3407
- const { meta: m } = persisted;
3408
- const key = String(deviceId);
3409
- const allMeta = await pctx.metaStore.readMeta();
3410
- await pctx.settings.writeAddonStore({ deviceMeta: {
3411
- ...allMeta,
3412
- [key]: {
3413
- ...m,
3414
- primaryChildEntityId
3415
- }
3416
- } });
3300
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setPrimaryChildEntityId: unknown device id=${deviceId}`);
3301
+ await pctx.metaStore.rows.patch(deviceId, { primaryChildEntityId });
3417
3302
  });
3418
3303
  pctx.host.ctx.eventBus.emit({
3419
3304
  id: randomUUID(),
@@ -3439,18 +3324,8 @@ async function setPrimaryChildEntityId(pctx, input) {
3439
3324
  async function setChildLayout(pctx, input) {
3440
3325
  const { deviceId, childLayout } = input;
3441
3326
  await pctx.metaStore.withMetaWriteLock(async () => {
3442
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3443
- if (!persisted) throw new Error(`[device-manager] setChildLayout: unknown device id=${deviceId}`);
3444
- const { meta: m } = persisted;
3445
- const key = String(deviceId);
3446
- const allMeta = await pctx.metaStore.readMeta();
3447
- await pctx.settings.writeAddonStore({ deviceMeta: {
3448
- ...allMeta,
3449
- [key]: {
3450
- ...m,
3451
- childLayout
3452
- }
3453
- } });
3327
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setChildLayout: unknown device id=${deviceId}`);
3328
+ await pctx.metaStore.rows.patch(deviceId, { childLayout });
3454
3329
  });
3455
3330
  pctx.host.ctx.eventBus.emit({
3456
3331
  id: randomUUID(),
@@ -3476,18 +3351,8 @@ async function setChildLayout(pctx, input) {
3476
3351
  async function setRole(pctx, input) {
3477
3352
  const { deviceId, role } = input;
3478
3353
  await pctx.metaStore.withMetaWriteLock(async () => {
3479
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3480
- if (!persisted) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
3481
- const { meta: m } = persisted;
3482
- const key = String(deviceId);
3483
- const allMeta = await pctx.metaStore.readMeta();
3484
- await pctx.settings.writeAddonStore({ deviceMeta: {
3485
- ...allMeta,
3486
- [key]: {
3487
- ...m,
3488
- role
3489
- }
3490
- } });
3354
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
3355
+ await pctx.metaStore.rows.patch(deviceId, { role });
3491
3356
  });
3492
3357
  pctx.host.ctx.eventBus.emit({
3493
3358
  id: randomUUID(),
@@ -3532,19 +3397,8 @@ async function setDisplay(pctx, input) {
3532
3397
  const { deviceId, display } = input;
3533
3398
  const normalized = display === null ? null : normalizeDisplayOverride(display);
3534
3399
  await pctx.metaStore.withMetaWriteLock(async () => {
3535
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3536
- if (!persisted) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
3537
- const { meta: m } = persisted;
3538
- const key = String(deviceId);
3539
- const allMeta = await pctx.metaStore.readMeta();
3540
- const nextRow = normalized === null ? (({ display: _drop, ...rest }) => rest)(m) : {
3541
- ...m,
3542
- display: normalized
3543
- };
3544
- await pctx.settings.writeAddonStore({ deviceMeta: {
3545
- ...allMeta,
3546
- [key]: nextRow
3547
- } });
3400
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
3401
+ await pctx.metaStore.rows.patch(deviceId, { display: normalized });
3548
3402
  });
3549
3403
  pctx.host.ctx.eventBus.emit({
3550
3404
  id: randomUUID(),
@@ -3572,7 +3426,7 @@ async function getRoleDisplayDefaults(pctx, _input) {
3572
3426
  * Replace the per-role display defaults whole-record (full replace). Override
3573
3427
  * units are normalized (`normalizeUnit`) at write so the render path always
3574
3428
  * looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
3575
- * no interaction with the `deviceMeta` write lock. Not per-device, so no event
3429
+ * no interaction with the device write lock. Not per-device, so no event
3576
3430
  * is emitted; the UI invalidates its own query on mutate.
3577
3431
  */
3578
3432
  async function setRoleDisplayDefaults(pctx, input) {
@@ -3585,7 +3439,7 @@ async function setRoleDisplayDefaults(pctx, input) {
3585
3439
  /**
3586
3440
  * Batched meta pre-seed. Applies every provided field to the
3587
3441
  * device's meta row in ONE read-modify-write under a single
3588
- * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
3442
+ * `withMetaWriteLock` acquisition (one row write),
3589
3443
  * then emits one `DeviceMetaChanged` event per field that was
3590
3444
  * supplied — preserving the exact semantics of the individual
3591
3445
  * setters (`setName` / `setLocation` / `setType` /
@@ -3601,24 +3455,15 @@ async function setRoleDisplayDefaults(pctx, input) {
3601
3455
  async function applyInitialMeta(pctx, input) {
3602
3456
  const { deviceId, name, location, type, integrationId, linkDeviceId, role } = input;
3603
3457
  await pctx.metaStore.withMetaWriteLock(async () => {
3604
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3605
- if (!persisted) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
3606
- const { meta: m } = persisted;
3607
- const key = String(deviceId);
3608
- const allMeta = await pctx.metaStore.readMeta();
3609
- const merged = {
3610
- ...m,
3458
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
3459
+ await pctx.metaStore.rows.patch(deviceId, {
3611
3460
  ...name !== void 0 ? { name } : {},
3612
3461
  ...location !== void 0 ? { location } : {},
3613
3462
  ...type !== void 0 ? { type } : {},
3614
3463
  ...integrationId !== void 0 ? { integrationId } : {},
3615
3464
  ...linkDeviceId !== void 0 ? { linkDeviceId } : {},
3616
3465
  ...role !== void 0 ? { role } : {}
3617
- };
3618
- await pctx.settings.writeAddonStore({ deviceMeta: {
3619
- ...allMeta,
3620
- [key]: merged
3621
- } });
3466
+ });
3622
3467
  });
3623
3468
  const emitMetaChanged = (field, value) => {
3624
3469
  pctx.host.ctx.eventBus.emit({
@@ -3654,10 +3499,9 @@ async function applyInitialMeta(pctx, input) {
3654
3499
  async function setMetadata(pctx, input) {
3655
3500
  const { deviceId, patch } = input;
3656
3501
  const result = await pctx.metaStore.withMetaWriteLock(async () => {
3657
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
3658
- const key = String(deviceId);
3659
- const map = await pctx.metaStore.readMetadataMap();
3660
- const next = { ...map[key] ?? {} };
3502
+ const row = await pctx.metaStore.rows.get(deviceId);
3503
+ if (row === null) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
3504
+ const next = { ...row.metadata ?? {} };
3661
3505
  let changed = false;
3662
3506
  for (const [k, v] of Object.entries(patch)) if (v === null) {
3663
3507
  if (k in next) {
@@ -3670,10 +3514,7 @@ async function setMetadata(pctx, input) {
3670
3514
  }
3671
3515
  if (!changed) return { changed: false };
3672
3516
  const hasFields = Object.keys(next).length > 0;
3673
- const updatedMap = { ...map };
3674
- if (hasFields) updatedMap[key] = next;
3675
- else delete updatedMap[key];
3676
- await pctx.settings.writeAddonStore({ deviceMetadata: updatedMap });
3517
+ await pctx.metaStore.rows.patch(deviceId, { metadata: hasFields ? next : null });
3677
3518
  return {
3678
3519
  changed: true,
3679
3520
  finalMeta: hasFields ? next : null
@@ -3719,15 +3560,7 @@ async function setDisabled(pctx, input) {
3719
3560
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3720
3561
  if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
3721
3562
  const { meta: m } = persisted;
3722
- const key = String(deviceId);
3723
- const allMeta = await pctx.metaStore.readMeta();
3724
- await pctx.settings.writeAddonStore({ deviceMeta: {
3725
- ...allMeta,
3726
- [key]: {
3727
- ...m,
3728
- disabled
3729
- }
3730
- } });
3563
+ await pctx.metaStore.rows.patch(deviceId, { disabled });
3731
3564
  return {
3732
3565
  changed: (m.disabled ?? false) !== disabled,
3733
3566
  integrationId: m.integrationId ?? ""
@@ -3777,9 +3610,7 @@ async function loadRuntimeState(pctx, input) {
3777
3610
  * location autocomplete.
3778
3611
  */
3779
3612
  async function listLocations(pctx) {
3780
- const store = await pctx.settings.readAddonStore();
3781
- const meta = store.deviceMeta ?? {};
3782
- const locations = store.locations ?? [];
3613
+ const locations = (await pctx.metaStore.readStore()).locations ?? [];
3783
3614
  const seen = /* @__PURE__ */ new Map();
3784
3615
  const consider = (raw) => {
3785
3616
  if (typeof raw !== "string") return;
@@ -3789,7 +3620,7 @@ async function listLocations(pctx) {
3789
3620
  if (!seen.has(key)) seen.set(key, trimmed);
3790
3621
  };
3791
3622
  for (const label of locations) consider(label);
3792
- for (const m of Object.values(meta)) consider(m.location);
3623
+ for (const row of await pctx.metaStore.rows.listAll()) consider(row.meta.location);
3793
3624
  return [...seen.values()].toSorted((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
3794
3625
  }
3795
3626
  /**
@@ -3801,7 +3632,7 @@ async function listLocations(pctx) {
3801
3632
  async function addLocation(pctx, input) {
3802
3633
  const trimmed = input.name.trim();
3803
3634
  if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
3804
- const current = (await pctx.settings.readAddonStore()).locations ?? [];
3635
+ const current = (await pctx.metaStore.readStore()).locations ?? [];
3805
3636
  if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
3806
3637
  await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
3807
3638
  }
@@ -3817,25 +3648,21 @@ async function addLocation(pctx, input) {
3817
3648
  async function removeLocation(pctx, input) {
3818
3649
  const trimmed = input.name.trim();
3819
3650
  if (trimmed.length === 0) return;
3820
- const store = await pctx.settings.readAddonStore();
3821
- const current = store.locations ?? [];
3651
+ const current = (await pctx.metaStore.readStore()).locations ?? [];
3822
3652
  const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
3823
3653
  if (remaining.length !== current.length) await pctx.settings.writeAddonStore({ locations: remaining });
3824
3654
  if (input.cascade !== true) return;
3825
- const meta = store.deviceMeta ?? {};
3826
- const updates = { ...meta };
3827
- const cleared = [];
3828
- for (const [key, m] of Object.entries(meta)) {
3829
- if (typeof m.location !== "string") continue;
3830
- if (m.location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
3831
- updates[key] = {
3832
- ...m,
3833
- location: null
3834
- };
3835
- cleared.push(m.id);
3836
- }
3837
- if (cleared.length === 0) return;
3838
- await pctx.settings.writeAddonStore({ deviceMeta: updates });
3655
+ const cleared = await pctx.metaStore.withMetaWriteLock(async () => {
3656
+ const out = [];
3657
+ for (const row of await pctx.metaStore.rows.listAll()) {
3658
+ const location = row.meta.location;
3659
+ if (typeof location !== "string") continue;
3660
+ if (location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
3661
+ await pctx.metaStore.rows.patch(row.meta.id, { location: null });
3662
+ out.push(row.meta.id);
3663
+ }
3664
+ return out;
3665
+ });
3839
3666
  for (const deviceId of cleared) pctx.host.ctx.eventBus.emit({
3840
3667
  id: randomUUID(),
3841
3668
  timestamp: /* @__PURE__ */ new Date(),
@@ -3852,41 +3679,75 @@ async function removeLocation(pctx, input) {
3852
3679
  });
3853
3680
  }
3854
3681
  //#endregion
3682
+ //#region src/builtins/device-manager/device-meta-types.ts
3683
+ /**
3684
+ * Decode the raw `ctx.settings.readAddonStore()` record into {@link AddonStore}.
3685
+ *
3686
+ * A field of the wrong shape reads as ABSENT rather than being trusted through
3687
+ * a cast: the store is JSON on disk and a hand-edit or a partial restore must
3688
+ * cost the caller its default, never a downstream `TypeError`.
3689
+ */
3690
+ function decodeAddonStore(raw) {
3691
+ const nextDeviceId = raw["nextDeviceId"];
3692
+ const locations = raw["locations"];
3693
+ const roleDisplayDefaults = raw["roleDisplayDefaults"];
3694
+ return {
3695
+ ...typeof nextDeviceId === "number" && Number.isFinite(nextDeviceId) ? { nextDeviceId } : {},
3696
+ ...Array.isArray(locations) ? { locations: locations.filter((l) => typeof l === "string") } : {},
3697
+ ...isRoleDisplayDefaults(roleDisplayDefaults) ? { roleDisplayDefaults } : {}
3698
+ };
3699
+ }
3700
+ function isRoleDisplayDefaults(value) {
3701
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3702
+ }
3703
+ //#endregion
3855
3704
  //#region src/builtins/device-manager/device-meta-store.ts
3856
3705
  var DeviceMetaStore = class {
3857
3706
  settings;
3858
3707
  registry;
3708
+ rows;
3859
3709
  /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
3860
- * The persisted meta store is authoritative but reads are async; hub-side
3710
+ * The persisted row store is authoritative but reads are async; hub-side
3861
3711
  * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
3862
3712
  * ownership without awaiting. Kept in sync with every register/remove and
3863
3713
  * warmed from persistence on boot. */
3864
3714
  idToAddonId = /* @__PURE__ */ new Map();
3865
- /** Serialises every read-modify-write of the deviceMeta / deviceIndex blob
3866
- * through one promise chain (see `withMetaWriteLock`). Per-instance state
3867
- * identical to the former `onInitialize` closure variable. */
3715
+ /** Serialises every read-modify-write of a device row through one promise
3716
+ * chain (see `withMetaWriteLock`). Per-instance state. */
3868
3717
  metaWriteChain = Promise.resolve();
3869
- constructor(settings, registry) {
3718
+ constructor(settings, registry, rows) {
3870
3719
  this.settings = settings;
3871
3720
  this.registry = registry;
3721
+ this.rows = rows;
3872
3722
  }
3723
+ /** The read currently in flight, or null. Never a settled value — see
3724
+ * {@link readStore}. */
3725
+ inFlightRead = null;
3726
+ /**
3727
+ * The addon's own settings row set — `nextDeviceId`, `roleDisplayDefaults`,
3728
+ * `locations`. NOT the fleet: no device has lived here since the flatten.
3729
+ *
3730
+ * **Concurrent callers join the read already in flight.** This is not a
3731
+ * cache and nothing survives settlement: a caller that awaited the running
3732
+ * promise could not have observed anything older than its result, so the
3733
+ * only thing that changes is cost.
3734
+ *
3735
+ * A rejection is NOT latched: the slot is cleared before the promise
3736
+ * settles either way, so a failed read costs the joiners that one failure
3737
+ * and the next caller reaches the store again.
3738
+ */
3873
3739
  readStore = async () => {
3874
- return await this.settings.readAddonStore();
3875
- };
3876
- readIndex = async () => {
3877
- return (await this.readStore()).deviceIndex ?? {};
3878
- };
3879
- readMeta = async () => {
3880
- return (await this.readStore()).deviceMeta ?? {};
3881
- };
3882
- /** Hardware-identity metadata map. Lives in a sibling key on the
3883
- * device-manager addon store so its writers (`setMetadata`) never
3884
- * collide with the lifecycle writers on `deviceMeta`
3885
- * (`registerDevice` / `setName` / `setLocation` / `setDisabled`).
3886
- * Single-writer per row eliminates the "writer X clobbers writer
3887
- * Y's field" bug class — `setMetadata` is the only producer. */
3888
- readMetadataMap = async () => {
3889
- return (await this.readStore()).deviceMetadata ?? {};
3740
+ const existing = this.inFlightRead;
3741
+ if (existing !== null) return existing;
3742
+ const read = (async () => {
3743
+ try {
3744
+ return decodeAddonStore(await this.settings.readAddonStore());
3745
+ } finally {
3746
+ this.inFlightRead = null;
3747
+ }
3748
+ })();
3749
+ this.inFlightRead = read;
3750
+ return read;
3890
3751
  };
3891
3752
  withMetaWriteLock = async (fn) => {
3892
3753
  const previous = this.metaWriteChain;
@@ -3902,31 +3763,36 @@ var DeviceMetaStore = class {
3902
3763
  release();
3903
3764
  }
3904
3765
  };
3766
+ /** The whole persisted row for one device, or `null`. */
3767
+ getRow = async (deviceId) => this.rows.get(deviceId);
3905
3768
  /**
3906
3769
  * Resolve a numeric deviceId to the owning `(addonId, stableId)` pair.
3907
- * Scans persisted meta — live IDevice lookup (hub registry) is handled
3770
+ * Reads the device's own row — live IDevice lookup (hub registry) is handled
3908
3771
  * separately per call site so callers can decide whether to route to
3909
3772
  * an in-process driver or to the cross-process `device-ops` bridge.
3910
3773
  * Returns null when no device with that id is known to the hub.
3911
3774
  */
3912
3775
  resolvePersistedById = async (deviceId) => {
3913
- const m = (await this.readMeta())[String(deviceId)];
3914
- if (!m) return null;
3776
+ const row = await this.rows.get(deviceId);
3777
+ if (row === null) return null;
3915
3778
  return {
3916
- addonId: m.addonId,
3917
- stableId: m.stableId,
3918
- meta: m
3779
+ addonId: row.meta.addonId,
3780
+ stableId: row.meta.stableId,
3781
+ meta: row.meta
3919
3782
  };
3920
3783
  };
3784
+ /** The device's hardware-identity metadata blob, or `null`. */
3785
+ readMetadata = async (deviceId) => {
3786
+ return (await this.rows.get(deviceId))?.metadata ?? null;
3787
+ };
3921
3788
  /** Direct children of a device: the union of the live registry's children
3922
- * and the persisted-meta scan (`parentDeviceId === parentId`), deduplicated
3789
+ * and the persisted rows whose `parentDeviceId` is `parentId`, deduplicated
3923
3790
  * and excluding self. Shared by the `remove` cascade and the `resetToSource`
3924
3791
  * resync purge (#19). */
3925
3792
  directChildIds = async (parentId) => {
3926
3793
  const ids = /* @__PURE__ */ new Set();
3927
3794
  if (this.registry) for (const c of this.registry.getChildren(parentId)) ids.add(c.id);
3928
- const meta = await this.readMeta();
3929
- for (const m of Object.values(meta)) if (m.parentDeviceId === parentId) ids.add(m.id);
3795
+ for (const row of await this.rows.listByParent(parentId)) ids.add(row.meta.id);
3930
3796
  ids.delete(parentId);
3931
3797
  return [...ids];
3932
3798
  };
@@ -3937,6 +3803,525 @@ var DeviceMetaStore = class {
3937
3803
  };
3938
3804
  };
3939
3805
  //#endregion
3806
+ //#region src/builtins/device-manager/device-row-store.ts
3807
+ /**
3808
+ * @durable class=registry owner=device-manager
3809
+ * write="one row per device, written by `allocateDeviceId` (identity placeholder),
3810
+ * `registerDevice` (full reconcile) and every meta setter (single-column patch);
3811
+ * `setMetadata` patches the `metadata` column of the same row"
3812
+ * retention="none — a row goes only when the operator removes the device
3813
+ * (`removeDevice`), or when an integration is deleted and cascades. Bounded by the
3814
+ * fleet an operator configures (974 rows on the reference hub)."
3815
+ */
3816
+ var DEVICE_ROWS_COLLECTION = "device-manager:devices";
3817
+ /**
3818
+ * Fleet reads pass this explicitly.
3819
+ *
3820
+ * `settings-store.query` applies a DEFAULT row cap of 2 000 when the caller
3821
+ * names no `limit` (`query-bounds.ts`), and truncation there is silent to the
3822
+ * caller — it warns in the engine's log and returns a short list that looks
3823
+ * complete. The reference hub is already at 974 devices; half the default cap
3824
+ * is not a margin worth betting the fleet listing on. 20 000 is the engine's
3825
+ * hard ceiling, so this asks for everything the engine will ever serve in one
3826
+ * call and any future need to page is a loud failure rather than a quiet one.
3827
+ */
3828
+ var DEVICE_ROWS_FLEET_LIMIT = 2e4;
3829
+ var DEVICE_ROWS_COLUMNS = [
3830
+ {
3831
+ name: "id",
3832
+ type: "TEXT",
3833
+ primaryKey: true,
3834
+ notNull: true
3835
+ },
3836
+ {
3837
+ name: "deviceId",
3838
+ type: "INTEGER",
3839
+ notNull: true
3840
+ },
3841
+ {
3842
+ name: "addonId",
3843
+ type: "TEXT",
3844
+ notNull: true
3845
+ },
3846
+ {
3847
+ name: "stableId",
3848
+ type: "TEXT",
3849
+ notNull: true
3850
+ },
3851
+ {
3852
+ name: "type",
3853
+ type: "TEXT",
3854
+ notNull: true
3855
+ },
3856
+ {
3857
+ name: "name",
3858
+ type: "TEXT",
3859
+ notNull: true
3860
+ },
3861
+ {
3862
+ name: "userNamed",
3863
+ type: "BOOLEAN"
3864
+ },
3865
+ {
3866
+ name: "location",
3867
+ type: "TEXT"
3868
+ },
3869
+ {
3870
+ name: "disabled",
3871
+ type: "BOOLEAN",
3872
+ notNull: true,
3873
+ defaultValue: false
3874
+ },
3875
+ {
3876
+ name: "parentDeviceId",
3877
+ type: "INTEGER"
3878
+ },
3879
+ {
3880
+ name: "registered",
3881
+ type: "BOOLEAN",
3882
+ notNull: true,
3883
+ defaultValue: false
3884
+ },
3885
+ {
3886
+ name: "features",
3887
+ type: "JSON"
3888
+ },
3889
+ {
3890
+ name: "exportFingerprint",
3891
+ type: "TEXT"
3892
+ },
3893
+ {
3894
+ name: "integrationId",
3895
+ type: "TEXT"
3896
+ },
3897
+ {
3898
+ name: "linkDeviceId",
3899
+ type: "INTEGER"
3900
+ },
3901
+ {
3902
+ name: "primaryChildEntityId",
3903
+ type: "TEXT"
3904
+ },
3905
+ {
3906
+ name: "childLayout",
3907
+ type: "JSON"
3908
+ },
3909
+ {
3910
+ name: "role",
3911
+ type: "TEXT"
3912
+ },
3913
+ {
3914
+ name: "display",
3915
+ type: "JSON"
3916
+ },
3917
+ {
3918
+ name: "metadata",
3919
+ type: "JSON"
3920
+ }
3921
+ ];
3922
+ var DEVICE_ROWS_INDEXES = [
3923
+ {
3924
+ name: "idx_dm_devices_addon_stable",
3925
+ columns: ["addonId", "stableId"]
3926
+ },
3927
+ {
3928
+ name: "idx_dm_devices_parent",
3929
+ columns: ["parentDeviceId"]
3930
+ },
3931
+ {
3932
+ name: "idx_dm_devices_integration",
3933
+ columns: ["integrationId"]
3934
+ }
3935
+ ];
3936
+ /** Adapt `ctx.api.settingsStore` (tRPC namespace) to {@link DeviceRowBackend}. */
3937
+ function deviceRowBackendOf(client) {
3938
+ return {
3939
+ declareCollection: async (input) => {
3940
+ await client.declareCollection.mutate(input);
3941
+ },
3942
+ get: (input) => client.get.query(input),
3943
+ set: async (input) => {
3944
+ await client.set.mutate(input);
3945
+ },
3946
+ query: (input) => client.query.query(input),
3947
+ updateWhere: (input) => client.updateWhere.mutate(input),
3948
+ delete: async (input) => {
3949
+ await client.delete.mutate(input);
3950
+ },
3951
+ count: (input) => client.count.query(input)
3952
+ };
3953
+ }
3954
+ function isPlainObject(value) {
3955
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3956
+ }
3957
+ function readString(value) {
3958
+ return typeof value === "string" ? value : void 0;
3959
+ }
3960
+ function readNumber(value) {
3961
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
3962
+ }
3963
+ function readBoolean(value) {
3964
+ if (typeof value === "boolean") return value;
3965
+ if (value === 1) return true;
3966
+ if (value === 0) return false;
3967
+ }
3968
+ function readStringArray(value) {
3969
+ if (!Array.isArray(value)) return void 0;
3970
+ const out = [];
3971
+ for (const entry of value) if (typeof entry === "string") out.push(entry);
3972
+ return out;
3973
+ }
3974
+ /**
3975
+ * Decode one stored row.
3976
+ *
3977
+ * Returns `null` for a row missing an identity field the rest of the system
3978
+ * treats as an invariant (`deviceId` / `addonId` / `stableId` / `type` /
3979
+ * `name`). A row like that cannot be projected into a `DeviceInfo` and taking
3980
+ * the whole fleet read down for it would be worse — the caller logs the skip.
3981
+ */
3982
+ function decodeDeviceRow(data) {
3983
+ const deviceId = readNumber(data["deviceId"]);
3984
+ const addonId = readString(data["addonId"]);
3985
+ const stableId = readString(data["stableId"]);
3986
+ const type = readString(data["type"]);
3987
+ const name = readString(data["name"]);
3988
+ if (deviceId === void 0 || addonId === void 0 || stableId === void 0 || type === void 0 || name === void 0) return null;
3989
+ const location = readString(data["location"]);
3990
+ const parentDeviceId = readNumber(data["parentDeviceId"]);
3991
+ const userNamed = readBoolean(data["userNamed"]);
3992
+ const features = readStringArray(data["features"]);
3993
+ const exportFingerprint = readString(data["exportFingerprint"]);
3994
+ const integrationId = readString(data["integrationId"]);
3995
+ const linkDeviceId = readNumber(data["linkDeviceId"]);
3996
+ const primaryChildEntityId = readString(data["primaryChildEntityId"]);
3997
+ const role = readString(data["role"]);
3998
+ const rawChildLayout = data["childLayout"];
3999
+ const rawDisplay = data["display"];
4000
+ const rawMetadata = data["metadata"];
4001
+ return {
4002
+ meta: {
4003
+ id: deviceId,
4004
+ addonId,
4005
+ stableId,
4006
+ type,
4007
+ name,
4008
+ location: location ?? null,
4009
+ disabled: readBoolean(data["disabled"]) ?? false,
4010
+ parentDeviceId: parentDeviceId ?? null,
4011
+ ...userNamed !== void 0 ? { userNamed } : {},
4012
+ ...features !== void 0 ? { features } : {},
4013
+ ...exportFingerprint !== void 0 ? { exportFingerprint } : {},
4014
+ ...integrationId !== void 0 ? { integrationId } : {},
4015
+ ...linkDeviceId !== void 0 ? { linkDeviceId } : {},
4016
+ ...primaryChildEntityId !== void 0 ? { primaryChildEntityId } : {},
4017
+ ...isChildLayout(rawChildLayout) ? { childLayout: rawChildLayout } : {},
4018
+ ...role !== void 0 ? { role } : {},
4019
+ ...isPlainObject(rawDisplay) ? { display: toDisplayOverride(rawDisplay) } : {}
4020
+ },
4021
+ metadata: isPlainObject(rawMetadata) ? rawMetadata : null,
4022
+ registered: readBoolean(data["registered"]) ?? false
4023
+ };
4024
+ }
4025
+ /**
4026
+ * `ChildLayout` is a JSON column: what comes back is whatever was written.
4027
+ * The projection hands it to the UI untouched, so the only thing worth
4028
+ * checking is that it is still the array shape the writer put in.
4029
+ */
4030
+ function isChildLayout(value) {
4031
+ return Array.isArray(value);
4032
+ }
4033
+ /**
4034
+ * `DeviceDisplayOverride` is likewise stored verbatim in a JSON column. It is
4035
+ * an all-optional record, so any object round-trips; the write path
4036
+ * (`normalizeDisplayOverride`) is what gives it shape.
4037
+ */
4038
+ function toDisplayOverride(value) {
4039
+ return value;
4040
+ }
4041
+ /** Full-row value for an upsert: identity + every field of the meta record. */
4042
+ function encodeDeviceRow(meta, extra = {}) {
4043
+ const row = {
4044
+ id: String(meta.id),
4045
+ deviceId: meta.id,
4046
+ addonId: meta.addonId,
4047
+ stableId: meta.stableId,
4048
+ type: meta.type,
4049
+ name: meta.name,
4050
+ userNamed: meta.userNamed ?? null,
4051
+ location: meta.location,
4052
+ disabled: meta.disabled,
4053
+ parentDeviceId: meta.parentDeviceId,
4054
+ registered: extra.registered ?? false,
4055
+ features: meta.features === void 0 ? null : [...meta.features],
4056
+ exportFingerprint: meta.exportFingerprint ?? null,
4057
+ integrationId: meta.integrationId ?? null,
4058
+ linkDeviceId: meta.linkDeviceId ?? null,
4059
+ primaryChildEntityId: meta.primaryChildEntityId ?? null,
4060
+ childLayout: meta.childLayout ?? null,
4061
+ role: meta.role ?? null,
4062
+ display: meta.display ?? null
4063
+ };
4064
+ if (extra.metadata !== void 0) row["metadata"] = extra.metadata;
4065
+ return row;
4066
+ }
4067
+ /** Column map for a partial write — only the keys the caller actually named. */
4068
+ function encodeDeviceRowPatch(patch) {
4069
+ const row = {};
4070
+ for (const [key, value] of Object.entries(patch)) {
4071
+ if (value === void 0) continue;
4072
+ row[key] = value;
4073
+ }
4074
+ return row;
4075
+ }
4076
+ /**
4077
+ * Row access for the device fleet. Every method is a single statement against
4078
+ * `device-manager:devices` — there is no in-memory copy of the fleet here and
4079
+ * no cache: a per-device question is a primary-key lookup, a fleet question is
4080
+ * one indexed scan.
4081
+ */
4082
+ var DeviceRowStore = class {
4083
+ backend;
4084
+ logger;
4085
+ declared = null;
4086
+ constructor(backend, logger) {
4087
+ this.backend = backend;
4088
+ this.logger = logger;
4089
+ }
4090
+ /**
4091
+ * Lazy, idempotent `declareCollection`, memoised on the PROMISE so N
4092
+ * concurrent first-callers issue one declaration rather than N. A rejection
4093
+ * is not latched — the slot is cleared so the next caller retries instead of
4094
+ * inheriting a dead collection forever.
4095
+ */
4096
+ async declare() {
4097
+ const existing = this.declared;
4098
+ if (existing !== null) return existing;
4099
+ const run = (async () => {
4100
+ try {
4101
+ await this.backend.declareCollection({
4102
+ collection: DEVICE_ROWS_COLLECTION,
4103
+ columns: DEVICE_ROWS_COLUMNS,
4104
+ indexes: DEVICE_ROWS_INDEXES
4105
+ });
4106
+ } catch (err) {
4107
+ this.declared = null;
4108
+ throw err;
4109
+ }
4110
+ })();
4111
+ this.declared = run;
4112
+ return run;
4113
+ }
4114
+ /** One device, by numeric id. `null` when the fleet does not know it. */
4115
+ async get(deviceId) {
4116
+ await this.declare();
4117
+ const raw = await this.backend.get({
4118
+ collection: DEVICE_ROWS_COLLECTION,
4119
+ key: String(deviceId)
4120
+ });
4121
+ if (!isPlainObject(raw)) return null;
4122
+ const decoded = decodeDeviceRow(raw);
4123
+ if (decoded === null) {
4124
+ this.logger.warn("device row skipped — identity fields missing", { tags: { deviceId } });
4125
+ return null;
4126
+ }
4127
+ return decoded;
4128
+ }
4129
+ /** Every device, ordered by numeric id. */
4130
+ async listAll() {
4131
+ return this.list({
4132
+ orderBy: {
4133
+ field: "deviceId",
4134
+ direction: "asc"
4135
+ },
4136
+ limit: DEVICE_ROWS_FLEET_LIMIT
4137
+ });
4138
+ }
4139
+ /**
4140
+ * The device an addon knows as `stableId`, or `null`.
4141
+ *
4142
+ * `(addonId, stableId)` is the addon-facing identity — unique by
4143
+ * construction, since `allocateDeviceId` is the only thing that mints a row
4144
+ * and it returns the existing id for a pair it already knows. A second row
4145
+ * for the pair would be a corruption, so this takes the LOWEST id and says
4146
+ * so rather than picking arbitrarily.
4147
+ */
4148
+ async findByStableId(addonId, stableId) {
4149
+ const rows = await this.list({
4150
+ where: {
4151
+ addonId,
4152
+ stableId
4153
+ },
4154
+ orderBy: {
4155
+ field: "deviceId",
4156
+ direction: "asc"
4157
+ },
4158
+ limit: 2
4159
+ });
4160
+ const first = rows[0];
4161
+ if (first === void 0) return null;
4162
+ if (rows.length > 1) this.logger.warn("duplicate device rows for one (addonId, stableId) — using the lowest id", {
4163
+ tags: { deviceId: first.meta.id },
4164
+ meta: {
4165
+ addonId,
4166
+ stableId,
4167
+ ids: rows.map((r) => r.meta.id)
4168
+ }
4169
+ });
4170
+ return first;
4171
+ }
4172
+ /** Every device owned by one addon, ordered by numeric id. */
4173
+ async listByAddon(addonId) {
4174
+ return this.list({
4175
+ where: { addonId },
4176
+ orderBy: {
4177
+ field: "deviceId",
4178
+ direction: "asc"
4179
+ },
4180
+ limit: DEVICE_ROWS_FLEET_LIMIT
4181
+ });
4182
+ }
4183
+ /** Every device an integration owns, ordered by numeric id. */
4184
+ async listByIntegration(integrationId) {
4185
+ return this.list({
4186
+ where: { integrationId },
4187
+ orderBy: {
4188
+ field: "deviceId",
4189
+ direction: "asc"
4190
+ },
4191
+ limit: DEVICE_ROWS_FLEET_LIMIT
4192
+ });
4193
+ }
4194
+ /** Direct children of one device, ordered by numeric id. */
4195
+ async listByParent(parentDeviceId) {
4196
+ return this.list({
4197
+ where: { parentDeviceId },
4198
+ orderBy: {
4199
+ field: "deviceId",
4200
+ direction: "asc"
4201
+ },
4202
+ limit: DEVICE_ROWS_FLEET_LIMIT
4203
+ });
4204
+ }
4205
+ /** How many devices the fleet holds. Used to tell "empty store" from "gone device". */
4206
+ async count() {
4207
+ await this.declare();
4208
+ return this.backend.count({ collection: DEVICE_ROWS_COLLECTION });
4209
+ }
4210
+ async list(filter) {
4211
+ await this.declare();
4212
+ const records = await this.backend.query({
4213
+ collection: DEVICE_ROWS_COLLECTION,
4214
+ filter
4215
+ });
4216
+ const out = [];
4217
+ for (const record of records) {
4218
+ const decoded = decodeDeviceRow(record.data);
4219
+ if (decoded === null) {
4220
+ this.logger.warn("device row skipped — identity fields missing", { meta: { rowId: record.id } });
4221
+ continue;
4222
+ }
4223
+ out.push(decoded);
4224
+ }
4225
+ return out;
4226
+ }
4227
+ /** Insert or replace the whole identity row. */
4228
+ async upsert(meta, extra = {}) {
4229
+ await this.declare();
4230
+ await this.backend.set({
4231
+ collection: DEVICE_ROWS_COLLECTION,
4232
+ key: String(meta.id),
4233
+ value: encodeDeviceRow(meta, extra)
4234
+ });
4235
+ }
4236
+ /**
4237
+ * Write the named columns of an EXISTING row and nothing else.
4238
+ *
4239
+ * An UPDATE, deliberately not an upsert: a partial upsert would try to INSERT
4240
+ * a row carrying only the patched columns, and SQLite rejects that on the
4241
+ * `NOT NULL` identity columns before the primary-key conflict can turn it
4242
+ * into an update. Every caller resolves the row under the write lock and
4243
+ * throws when it is gone, so matching zero rows means a device was removed
4244
+ * between the resolve and the write — the patch is lost, and a lost write
4245
+ * that says nothing reads as a write that happened.
4246
+ */
4247
+ async patch(deviceId, patch) {
4248
+ const columns = encodeDeviceRowPatch(patch);
4249
+ if (Object.keys(columns).length === 0) return;
4250
+ await this.declare();
4251
+ const { updated } = await this.backend.updateWhere({
4252
+ collection: DEVICE_ROWS_COLLECTION,
4253
+ filter: { where: { id: String(deviceId) } },
4254
+ data: columns
4255
+ });
4256
+ if (updated === 0) this.logger.warn("device row patch matched no row — the device was removed under it", {
4257
+ tags: { deviceId },
4258
+ meta: { columns: Object.keys(columns) }
4259
+ });
4260
+ }
4261
+ /**
4262
+ * Insert-or-update the registration columns. See {@link DeviceRegistrationRow}
4263
+ * for why this is a separate, fully-specified statement rather than a patch.
4264
+ */
4265
+ async upsertRegistration(row) {
4266
+ await this.declare();
4267
+ await this.backend.set({
4268
+ collection: DEVICE_ROWS_COLLECTION,
4269
+ key: String(row.deviceId),
4270
+ value: {
4271
+ deviceId: row.deviceId,
4272
+ addonId: row.addonId,
4273
+ stableId: row.stableId,
4274
+ type: row.type,
4275
+ name: row.name,
4276
+ userNamed: row.userNamed,
4277
+ location: row.location,
4278
+ disabled: row.disabled,
4279
+ parentDeviceId: row.parentDeviceId,
4280
+ registered: row.registered,
4281
+ features: [...row.features],
4282
+ exportFingerprint: row.exportFingerprint
4283
+ }
4284
+ });
4285
+ }
4286
+ /** Drop the device's row. Idempotent. */
4287
+ async remove(deviceId) {
4288
+ await this.declare();
4289
+ await this.backend.delete({
4290
+ collection: DEVICE_ROWS_COLLECTION,
4291
+ key: String(deviceId)
4292
+ });
4293
+ }
4294
+ /**
4295
+ * The retirement door for the three blobs this collection replaced.
4296
+ *
4297
+ * The purge is gated on THIS collection being non-empty, and the count has to
4298
+ * be taken after `declare()` — which is why the owning addon runs it and the
4299
+ * settings engine cannot: at the engine's own boot no addon has declared
4300
+ * anything, so every successor would read as unreadable, forever.
4301
+ */
4302
+ retiredRowStore() {
4303
+ return {
4304
+ hasRow: async (spec) => {
4305
+ const raw = await this.backend.get({
4306
+ collection: spec.collection,
4307
+ key: spec.row
4308
+ });
4309
+ return raw !== void 0 && raw !== null;
4310
+ },
4311
+ deleteRow: async (spec) => {
4312
+ await this.backend.delete({
4313
+ collection: spec.collection,
4314
+ key: spec.row
4315
+ });
4316
+ },
4317
+ countRows: async (collection) => {
4318
+ await this.declare();
4319
+ return this.backend.count({ collection });
4320
+ }
4321
+ };
4322
+ }
4323
+ };
4324
+ //#endregion
3940
4325
  //#region src/builtins/device-manager/runtime-state-persist-gate.ts
3941
4326
  /**
3942
4327
  * The subset of `blob` that is allowed on disk: slices whose capability
@@ -4501,12 +4886,21 @@ var DeviceManagerAddon = class extends BaseAddon {
4501
4886
  }
4502
4887
  }))).filter((id) => id !== null);
4503
4888
  }
4889
+ /**
4890
+ * Row access for `device-manager:devices`. Built once in `onInitialize`
4891
+ * (which is also where the collection is declared) — every consumer reaches
4892
+ * it through {@link bindingsDeps} or the `ProviderContext`.
4893
+ */
4894
+ deviceRows = null;
4504
4895
  /** Build the dependency context the extracted binding resolvers consume. */
4505
4896
  get bindingsDeps() {
4897
+ const rows = this.deviceRows;
4898
+ if (rows === null) throw new Error("[device-manager] device row store not initialized");
4506
4899
  return {
4507
4900
  ctx: this.ctx,
4508
4901
  capabilityRegistry: this.capabilityRegistry,
4509
- remoteNativeCaps: this.remoteNativeCaps
4902
+ remoteNativeCaps: this.remoteNativeCaps,
4903
+ rows
4510
4904
  };
4511
4905
  }
4512
4906
  async getBindings(input) {
@@ -4562,17 +4956,23 @@ var DeviceManagerAddon = class extends BaseAddon {
4562
4956
  if (!ops) throw new Error(`[device-manager] device-ops native provider not found for '${deviceId}'`);
4563
4957
  return ops;
4564
4958
  };
4565
- const metaStore = new DeviceMetaStore(settings, registry);
4959
+ const settingsStoreApi = this.ctx.api?.settingsStore;
4960
+ if (!settingsStoreApi) throw new Error("[device-manager] settings-store API not available — refusing to serve a fleet it cannot persist");
4961
+ const deviceRows = new DeviceRowStore(deviceRowBackendOf(settingsStoreApi), this.ctx.logger.child("rows"));
4962
+ await deviceRows.declare();
4963
+ this.deviceRows = deviceRows;
4964
+ try {
4965
+ await purgeRetiredSettingsRows(deviceRows.retiredRowStore(), this.ctx.logger.child("RetiredRows"));
4966
+ } catch (err) {
4967
+ this.ctx.logger.warn("retired-row purge failed", { meta: { error: errMsg(err) } });
4968
+ }
4969
+ const metaStore = new DeviceMetaStore(settings, registry, deviceRows);
4566
4970
  this.stateMirrorImpl = new DeviceStateMirror(this.ctx);
4567
4971
  const stateMirror = this.stateMirrorImpl;
4568
- const readMeta = metaStore.readMeta;
4569
4972
  const resolvePersistedById = metaStore.resolvePersistedById;
4570
4973
  const idToAddonId = metaStore.idToAddonId;
4571
- {
4572
- const meta = await readMeta();
4573
- for (const m of Object.values(meta)) idToAddonId.set(m.id, m.addonId);
4574
- }
4575
- const stampIntegrationId$1 = (deviceId, integrationId) => stampIntegrationId(metaStore, settings, this.ctx, deviceId, integrationId);
4974
+ for (const row of await deviceRows.listAll()) idToAddonId.set(row.meta.id, row.meta.addonId);
4975
+ const stampIntegrationId$1 = (deviceId, integrationId) => stampIntegrationId(metaStore, this.ctx, deviceId, integrationId);
4576
4976
  const pctx = {
4577
4977
  host: this.providerHost,
4578
4978
  metaStore,