@camstack/system 1.1.22 → 1.1.23

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,5 +1,5 @@
1
1
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
2
- import { BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateSchemaFields, errMsg, getByPath, isDeviceConfigCap, parseStreamParamsFormPatch, setByPath, sleep } from "@camstack/types";
2
+ import { ALL_CAPABILITY_DEFINITIONS, BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateItemArrayFields, enumerateSchemaFields, errMsg, getByPath, isDeviceConfigCap, parseStreamParamsFormPatch, setByPath, sleep } from "@camstack/types";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { z } from "zod";
5
5
  /**
@@ -504,6 +504,21 @@ async function getBindings(deps, input) {
504
504
  });
505
505
  seenCaps.add(entry.capName);
506
506
  }
507
+ if (deps.devicesWithLinks?.has(input.deviceId)) {
508
+ const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
509
+ for (const link of row?.deviceLinks ?? []) {
510
+ const capName = link.target.cap;
511
+ if (seenCaps.has(capName)) continue;
512
+ entries.push({
513
+ capName,
514
+ kind: "linked",
515
+ providerAddonId: deps.ctx.id,
516
+ providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
517
+ nativeAddonId: ""
518
+ });
519
+ seenCaps.add(capName);
520
+ }
521
+ }
507
522
  return {
508
523
  deviceId: input.deviceId,
509
524
  entries
@@ -1166,11 +1181,47 @@ async function setWrapperActive(deps, input) {
1166
1181
  });
1167
1182
  }
1168
1183
  /**
1184
+ * Build the wireable-cap entry for one capability definition: the status
1185
+ * schema's scalar leaf fields plus — for an item-array cap
1186
+ * (`status.itemArray`) — the per-item fields tagged `item: true` (a link
1187
+ * targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
1188
+ * descriptor so the UI can address items. Returns null when the cap exposes
1189
+ * nothing wireable (no status schema / no leaf fields).
1190
+ */
1191
+ function wireableEntryForDef(capName, def) {
1192
+ const status = def.status;
1193
+ const schema = status?.schema;
1194
+ if (!schema) return null;
1195
+ const itemArray = status.itemArray;
1196
+ const fields = [...enumerateSchemaFields(schema), ...itemArray ? enumerateItemArrayFields(itemArray) : []].map((f) => ({
1197
+ path: f.path,
1198
+ kind: f.kind,
1199
+ ...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
1200
+ ...f.item === true ? { item: true } : {}
1201
+ }));
1202
+ if (fields.length === 0) return null;
1203
+ return {
1204
+ cap: capName,
1205
+ fields,
1206
+ ...itemArray ? { itemArray: {
1207
+ path: itemArray.path,
1208
+ keyField: itemArray.keyField
1209
+ } } : {}
1210
+ };
1211
+ }
1212
+ /**
1169
1213
  * Per-device wireable-field catalog — the domain status fields a device's
1170
1214
  * bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
1171
1215
  * walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
1172
1216
  * internal book-keeping, not wireable domain data), and enumerates each cap's
1173
- * status schema leaf fields. Behavior unchanged from the inline provider method.
1217
+ * status schema leaf fields (per-item fields included for item-array caps).
1218
+ *
1219
+ * `includeSynthesizable: true` (TARGET pickers only) additionally unions in
1220
+ * UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
1221
+ * marker) and whose `deviceTypes` admit this device's persisted type — so the
1222
+ * FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
1223
+ * authored before any binding exists. Default (absent/false) behavior is
1224
+ * byte-identical to the binding-driven catalog.
1174
1225
  */
1175
1226
  async function getWireableFields(deps, input) {
1176
1227
  const { deviceId } = input;
@@ -1184,20 +1235,20 @@ async function getWireableFields(deps, input) {
1184
1235
  seen.add(entry.capName);
1185
1236
  const def = reg.getDefinition(entry.capName);
1186
1237
  if (!def || def.kind === "wrapper") continue;
1187
- const schema = def.status?.schema;
1188
- if (!schema) continue;
1189
- const fields = enumerateSchemaFields(schema).map((f) => f.enumValues !== void 0 ? {
1190
- path: f.path,
1191
- kind: f.kind,
1192
- enumValues: [...f.enumValues]
1193
- } : {
1194
- path: f.path,
1195
- kind: f.kind
1196
- });
1197
- if (fields.length > 0) caps.push({
1198
- cap: entry.capName,
1199
- fields
1200
- });
1238
+ const wireable = wireableEntryForDef(entry.capName, def);
1239
+ if (wireable) caps.push(wireable);
1240
+ }
1241
+ if (input.includeSynthesizable === true) {
1242
+ const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
1243
+ if (deviceType !== void 0) for (const def of ALL_CAPABILITY_DEFINITIONS) {
1244
+ if (seen.has(def.name)) continue;
1245
+ if (def.scope !== "device" || def.kind === "wrapper") continue;
1246
+ if (def.status?.empty === void 0) continue;
1247
+ if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
1248
+ seen.add(def.name);
1249
+ const wireable = wireableEntryForDef(def.name, def);
1250
+ if (wireable) caps.push(wireable);
1251
+ }
1201
1252
  }
1202
1253
  return { caps };
1203
1254
  }
@@ -1311,6 +1362,99 @@ var DeviceEventPropagator = class {
1311
1362
  }
1312
1363
  };
1313
1364
  //#endregion
1365
+ //#region src/builtins/device-manager/device-link-cycle.ts
1366
+ function nodeKey(deviceId, cap) {
1367
+ return `${deviceId}:${cap}`;
1368
+ }
1369
+ /**
1370
+ * Resolve a link source to its `(deviceId, cap)` node, or null when the
1371
+ * source is a literal (no device) or does not resolve to a known device.
1372
+ * Sibling FIELD sources resolve against the TARGET's container stableId
1373
+ * (its parent's, falling back to its own for a top-level target) — the same
1374
+ * rule `rebuildLinkDependents` / `resolveLinkedStatus` apply.
1375
+ */
1376
+ function resolveSourceNode(link, targetRow, rowById, idByStableId) {
1377
+ const src = link.source;
1378
+ if (src.kind === "literal") return null;
1379
+ const wantedStableId = src.kind === "global" ? src.sourceStableId : `${targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId}-${src.sourceKey}`;
1380
+ const srcId = idByStableId.get(wantedStableId);
1381
+ if (srcId === void 0) return null;
1382
+ return nodeKey(srcId, src.cap);
1383
+ }
1384
+ /**
1385
+ * Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
1386
+ * a dependency cycle. Returns the cycle as an ordered list of node keys
1387
+ * (first node repeated at the end) or null when the set is safe.
1388
+ */
1389
+ function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
1390
+ const rows = Object.values(allMeta);
1391
+ const rowById = /* @__PURE__ */ new Map();
1392
+ const idByStableId = /* @__PURE__ */ new Map();
1393
+ for (const r of rows) {
1394
+ rowById.set(r.id, r);
1395
+ if (!idByStableId.has(r.stableId)) idByStableId.set(r.stableId, r.id);
1396
+ }
1397
+ const dependsOn = /* @__PURE__ */ new Map();
1398
+ const addEdges = (row, links) => {
1399
+ for (const link of links) {
1400
+ const srcNode = resolveSourceNode(link, row, rowById, idByStableId);
1401
+ if (srcNode === null) continue;
1402
+ const tNode = nodeKey(row.id, link.target.cap);
1403
+ const list = dependsOn.get(tNode) ?? [];
1404
+ list.push(srcNode);
1405
+ dependsOn.set(tNode, list);
1406
+ }
1407
+ };
1408
+ for (const r of rows) {
1409
+ const links = r.id === editedDeviceId ? editedLinks : r.deviceLinks ?? [];
1410
+ if (links.length > 0) addEdges(r, links);
1411
+ }
1412
+ const roots = [...new Set(editedLinks.map((l) => nodeKey(editedDeviceId, l.target.cap)))];
1413
+ for (const root of roots) {
1414
+ const cycle = dfsFindCycle(root, dependsOn);
1415
+ if (cycle) return cycle;
1416
+ }
1417
+ return null;
1418
+ }
1419
+ /** Iterative DFS from `root` over `dependsOn`; returns the first back-edge
1420
+ * cycle path (closed — first node repeated last) or null. */
1421
+ function dfsFindCycle(root, dependsOn) {
1422
+ const onPath = /* @__PURE__ */ new Set();
1423
+ const done = /* @__PURE__ */ new Set();
1424
+ const path = [];
1425
+ const stack = [{
1426
+ node: root,
1427
+ nextChild: 0
1428
+ }];
1429
+ onPath.add(root);
1430
+ path.push(root);
1431
+ while (stack.length > 0) {
1432
+ const frame = stack[stack.length - 1];
1433
+ const children = dependsOn.get(frame.node) ?? [];
1434
+ if (frame.nextChild < children.length) {
1435
+ const child = children[frame.nextChild];
1436
+ frame.nextChild += 1;
1437
+ if (onPath.has(child)) {
1438
+ const start = path.indexOf(child);
1439
+ return [...path.slice(start), child];
1440
+ }
1441
+ if (done.has(child)) continue;
1442
+ stack.push({
1443
+ node: child,
1444
+ nextChild: 0
1445
+ });
1446
+ onPath.add(child);
1447
+ path.push(child);
1448
+ continue;
1449
+ }
1450
+ stack.pop();
1451
+ onPath.delete(frame.node);
1452
+ path.pop();
1453
+ done.add(frame.node);
1454
+ }
1455
+ return null;
1456
+ }
1457
+ //#endregion
1314
1458
  //#region src/builtins/device-manager/device-meta-actions.ts
1315
1459
  /**
1316
1460
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -1872,6 +2016,8 @@ async function setChildLayout(pctx, input) {
1872
2016
  */
1873
2017
  async function setDeviceLinks(pctx, input) {
1874
2018
  const { deviceId, deviceLinks } = input;
2019
+ const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
2020
+ if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
1875
2021
  await pctx.metaStore.withMetaWriteLock(async () => {
1876
2022
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1877
2023
  if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
@@ -2194,13 +2340,15 @@ function buildLinkIndexes(entries) {
2194
2340
  sourceDeviceId
2195
2341
  });
2196
2342
  targets.set(tKey, tList);
2197
- const sKey = `${sourceDeviceId}:${link.source.cap}`;
2198
- const sList = dependents.get(sKey) ?? [];
2199
- sList.push({
2200
- targetDeviceId,
2201
- targetCap: link.target.cap
2202
- });
2203
- dependents.set(sKey, sList);
2343
+ if (link.source.kind !== "literal") {
2344
+ const sKey = `${sourceDeviceId}:${link.source.cap}`;
2345
+ const sList = dependents.get(sKey) ?? [];
2346
+ sList.push({
2347
+ targetDeviceId,
2348
+ targetCap: link.target.cap
2349
+ });
2350
+ dependents.set(sKey, sList);
2351
+ }
2204
2352
  }
2205
2353
  return {
2206
2354
  targets,
@@ -2315,6 +2463,15 @@ var DeviceMetaStore = class {
2315
2463
  for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
2316
2464
  return null;
2317
2465
  };
2466
+ /** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
2467
+ * FULL stableId equals `sourceStableId`, regardless of parent container.
2468
+ * First match wins (stableIds are effectively unique cluster-wide — see the
2469
+ * `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
2470
+ * pre-read meta map so a multi-link resolve reads the store once. */
2471
+ resolveGlobalSourceDeviceId = (sourceStableId, meta) => {
2472
+ for (const m of Object.values(meta)) if (m.stableId === sourceStableId) return m.id;
2473
+ return null;
2474
+ };
2318
2475
  allocateNextDeviceId = async () => {
2319
2476
  const current = (await this.readStore()).nextDeviceId ?? 1;
2320
2477
  await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
@@ -2345,8 +2502,18 @@ var DeviceMetaStore = class {
2345
2502
  const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
2346
2503
  if (container === void 0) continue;
2347
2504
  for (const link of targetMeta.deviceLinks ?? []) {
2348
- expectedSources.add(`${container}-${link.source.sourceKey}`);
2349
- const srcId = idByStableId.get(`${container}-${link.source.sourceKey}`);
2505
+ const src = link.source;
2506
+ if (src.kind === "literal") {
2507
+ entries.push({
2508
+ targetDeviceId: targetId,
2509
+ link,
2510
+ sourceDeviceId: -1
2511
+ });
2512
+ continue;
2513
+ }
2514
+ const wantedStableId = src.kind === "global" ? src.sourceStableId : `${container}-${src.sourceKey}`;
2515
+ expectedSources.add(wantedStableId);
2516
+ const srcId = idByStableId.get(wantedStableId);
2350
2517
  if (srcId === void 0) continue;
2351
2518
  entries.push({
2352
2519
  targetDeviceId: targetId,
@@ -2892,6 +3059,18 @@ async function testField(pctx, input) {
2892
3059
  function isRecord(x) {
2893
3060
  return x !== null && typeof x === "object" && !Array.isArray(x);
2894
3061
  }
3062
+ /**
3063
+ * Resolve one link's transformed value. Field sources read via `readField`
3064
+ * (the caller supplies the sibling lookup — sync, e.g. the in-hub state
3065
+ * mirror); literal sources use their per-device constant and never consult
3066
+ * the reader. Returns `undefined` to skip the overlay (unreadable field).
3067
+ * Pure. Async source reads (`resolveLinkedStatus`'s provider `getStatus`
3068
+ * path) cannot use this helper for field sources — they branch on
3069
+ * `source.kind` directly.
3070
+ */
3071
+ function resolveLinkValue(link, readField) {
3072
+ return applyTransform(link.source.kind === "literal" ? link.source.value : readField(link.source.cap, link.source.fieldPath), link.transform);
3073
+ }
2895
3074
  /** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
2896
3075
  * concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
2897
3076
  * is a true `instanceof` guard rather than a cast. */
@@ -2923,19 +3102,88 @@ function fillNullableDefaults(schema, value) {
2923
3102
  return out;
2924
3103
  }
2925
3104
  /**
3105
+ * Group item-targeted links (`target.itemKey` set) by itemKey and upsert each
3106
+ * group as ONE item into the status' item array (see
3107
+ * `CapabilityStatusItemArray`). A NEW item is seeded from `emptyItem` with
3108
+ * `keyField` (and `labelField`, when declared) set to the itemKey; an
3109
+ * EXISTING item (matched by `keyField`) is cloned and overlaid in place, so
3110
+ * native fields a link doesn't touch are preserved. Each upserted item is
3111
+ * validated against `itemSchema` — an invalid item is skipped WITHOUT
3112
+ * discarding its valid siblings. Returns the new draft and whether anything
3113
+ * was applied. Pure + immutable (base array/items are never mutated).
3114
+ */
3115
+ function upsertItemArrayLinks(draft, grouped, itemArray) {
3116
+ const arrRaw = getByPath(draft, itemArray.path);
3117
+ const arr = Array.isArray(arrRaw) ? [...arrRaw] : [];
3118
+ let touched = false;
3119
+ for (const [itemKey, group] of grouped) {
3120
+ const idx = arr.findIndex((el) => isRecord(el) && el[itemArray.keyField] === itemKey);
3121
+ const existing = idx >= 0 ? arr[idx] : null;
3122
+ let item = isRecord(existing) ? { ...existing } : {
3123
+ ...itemArray.emptyItem,
3124
+ [itemArray.keyField]: itemKey,
3125
+ ...itemArray.labelField !== void 0 ? { [itemArray.labelField]: itemKey } : {}
3126
+ };
3127
+ let itemTouched = false;
3128
+ for (const { link, sourceValue } of group) {
3129
+ if (sourceValue === void 0) continue;
3130
+ item = setByPath(item, link.target.fieldPath, sourceValue);
3131
+ itemTouched = true;
3132
+ }
3133
+ if (!itemTouched) continue;
3134
+ const repaired = fillNullableDefaults(itemArray.itemSchema, item);
3135
+ const parsed = itemArray.itemSchema.safeParse(repaired);
3136
+ if (!parsed.success) continue;
3137
+ if (isRecord(parsed.data)) item = parsed.data;
3138
+ if (idx >= 0) arr[idx] = item;
3139
+ else arr.push(item);
3140
+ touched = true;
3141
+ }
3142
+ if (!touched) return {
3143
+ draft,
3144
+ touched: false
3145
+ };
3146
+ return {
3147
+ draft: setByPath(draft, itemArray.path, arr),
3148
+ touched: true
3149
+ };
3150
+ }
3151
+ /**
2926
3152
  * Overlay transformed source values onto `base` by dot-path, then validate the
2927
3153
  * result against the target cap's `statusSchema`. On validation failure the
2928
3154
  * overlay is discarded and `base` is returned unchanged (a misconfigured link
2929
3155
  * must never corrupt a cap response). Pure — all I/O happens in the caller.
3156
+ *
3157
+ * Item-array grouping (P2b): when the cap declares `status.itemArray`, links
3158
+ * carrying a `target.itemKey` are grouped per key and upserted as items into
3159
+ * the array (see `upsertItemArrayLinks`); their `fieldPath` is relative to
3160
+ * ONE item ('level', 'status', 'label'). Links WITHOUT an itemKey keep the
3161
+ * scalar dot-path behavior unchanged. A link with an itemKey on a cap that
3162
+ * declares NO `itemArray` is dropped (its item-relative path must never be
3163
+ * scalar-applied to the status root).
2930
3164
  */
2931
- function mergeLinkedStatus(base, resolved, statusSchema) {
3165
+ function mergeLinkedStatus(base, resolved, statusSchema, itemArray) {
2932
3166
  let draft = base;
2933
3167
  let touched = false;
2934
- for (const { link, sourceValue } of resolved) {
2935
- if (sourceValue === void 0) continue;
2936
- draft = setByPath(draft, link.target.fieldPath, sourceValue);
3168
+ const grouped = /* @__PURE__ */ new Map();
3169
+ for (const entry of resolved) {
3170
+ const itemKey = entry.link.target.itemKey;
3171
+ if (itemKey !== void 0) {
3172
+ if (!itemArray) continue;
3173
+ const group = grouped.get(itemKey);
3174
+ if (group) group.push(entry);
3175
+ else grouped.set(itemKey, [entry]);
3176
+ continue;
3177
+ }
3178
+ if (entry.sourceValue === void 0) continue;
3179
+ draft = setByPath(draft, entry.link.target.fieldPath, entry.sourceValue);
2937
3180
  touched = true;
2938
3181
  }
3182
+ if (itemArray && grouped.size > 0) {
3183
+ const upserted = upsertItemArrayLinks(draft, grouped, itemArray);
3184
+ draft = upserted.draft;
3185
+ touched = touched || upserted.touched;
3186
+ }
2939
3187
  if (!touched) return base;
2940
3188
  if (!statusSchema) return draft;
2941
3189
  const repaired = fillNullableDefaults(statusSchema, draft);
@@ -3132,8 +3380,11 @@ var DeviceStateMirror = class DeviceStateMirror {
3132
3380
  * Read-time overlay of a cap slice with its cross-device linked values.
3133
3381
  * Returns a cloned raw mirror slice when the (device, cap) pair has no
3134
3382
  * links. Sources are read from the same in-hub stateMirror — sync, no
3135
- * cross-process call. The disk writer must NOT use this method; it must
3136
- * persist raw provider truth via snapshotForDevice.
3383
+ * cross-process call. When the raw slice is ABSENT but the cap is a link
3384
+ * target, the slice is SYNTHESIZED: the merge base is seeded from the
3385
+ * cap's declared `status.empty` default; a failed/empty synthesize
3386
+ * returns null (no phantom slices). The disk writer must NOT use this
3387
+ * method; it must persist raw provider truth via snapshotForDevice.
3137
3388
  */
3138
3389
  overlayedSlice(deviceId, cap) {
3139
3390
  const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
@@ -3141,34 +3392,50 @@ var DeviceStateMirror = class DeviceStateMirror {
3141
3392
  if (!links || links.length === 0) return raw ? { ...raw } : null;
3142
3393
  const resolved = links.map((rl) => ({
3143
3394
  link: rl.link,
3144
- sourceValue: applyTransform(getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(rl.link.source.cap), rl.link.source.fieldPath), rl.link.transform)
3395
+ sourceValue: resolveLinkValue(rl.link, (srcCap, fieldPath) => getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
3145
3396
  }));
3146
- const schema = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status?.schema;
3147
- return mergeLinkedStatus(raw ? { ...raw } : {}, resolved, schema);
3397
+ const capStatus = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status;
3398
+ const schema = capStatus?.schema;
3399
+ const empty = capStatus?.empty;
3400
+ const base = raw ? { ...raw } : empty !== null && typeof empty === "object" && !Array.isArray(empty) ? { ...empty } : {};
3401
+ const merged = mergeLinkedStatus(base, resolved, schema, capStatus?.itemArray);
3402
+ if (!raw && merged === base) return null;
3403
+ return merged;
3148
3404
  }
3149
3405
  /**
3150
3406
  * Like snapshotForDevice but applies the device-link overlay per cap.
3151
3407
  * Used exclusively by the device-state READ methods (getSnapshot,
3152
- * getAllSnapshots) so callers see overlayed values. The debounced disk
3153
- * writer must continue to call snapshotForDevice (raw truth).
3408
+ * getAllSnapshots) so callers see overlayed values. SYNTHESIZE-only
3409
+ * link-target caps (no base mirror slice) are unioned into the iterated
3410
+ * key set so they appear at warm-load — `overlayedSlice` builds them from
3411
+ * the cap's `status.empty` (and drops them when nothing resolves). The
3412
+ * debounced disk writer must continue to call snapshotForDevice (raw truth).
3154
3413
  */
3155
3414
  snapshotForDeviceOverlayed(deviceId) {
3156
3415
  const perCap = this.stateMirror.get(deviceId);
3157
- if (!perCap) return {};
3416
+ const capNames = new Set(perCap ? perCap.keys() : []);
3417
+ const prefix = `${deviceId}:`;
3418
+ for (const key of this.linkHost.linkTargets.keys()) if (key.startsWith(prefix)) capNames.add(key.slice(prefix.length));
3158
3419
  const out = {};
3159
- for (const capName of perCap.keys()) {
3420
+ for (const capName of capNames) {
3160
3421
  const s = this.overlayedSlice(deviceId, capName);
3161
3422
  if (s) out[capName] = s;
3162
3423
  }
3163
3424
  return out;
3164
3425
  }
3165
- /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`. */
3426
+ /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
3427
+ * Devices that only exist as link TARGETS (no mirror slice yet) are
3428
+ * included via the link reverse-index so their synthesized caps appear. */
3166
3429
  allSnapshotsOverlayed() {
3430
+ const deviceIds = new Set(this.stateMirror.keys());
3431
+ for (const key of this.linkHost.linkTargets.keys()) {
3432
+ const id = Number(key.slice(0, key.indexOf(":")));
3433
+ if (Number.isFinite(id)) deviceIds.add(id);
3434
+ }
3167
3435
  const out = {};
3168
- for (const [deviceId, perCap] of this.stateMirror) {
3169
- const dev = {};
3170
- for (const [capName, slice] of perCap) dev[capName] = this.overlayedSlice(deviceId, capName) ?? { ...slice };
3171
- out[String(deviceId)] = dev;
3436
+ for (const deviceId of deviceIds) {
3437
+ const dev = this.snapshotForDeviceOverlayed(deviceId);
3438
+ if (this.stateMirror.has(deviceId) || Object.keys(dev).length > 0) out[String(deviceId)] = dev;
3172
3439
  }
3173
3440
  return out;
3174
3441
  }
@@ -3287,14 +3554,27 @@ function resolveNativeCapOwnerSync(pctx, capName, deviceId) {
3287
3554
  * `getProviderForDevice` (routes cross-process); merge is pure.
3288
3555
  */
3289
3556
  async function resolveLinkedStatus(pctx, input) {
3290
- const { deviceId, cap, baseStatus } = input;
3557
+ const { deviceId, cap } = input;
3291
3558
  if (!pctx.host.devicesWithLinks.has(deviceId)) return null;
3559
+ const inFlightKey = `${deviceId}:${cap}`;
3560
+ if (pctx.host.linkResolveInFlight.has(inFlightKey)) return null;
3561
+ pctx.host.linkResolveInFlight.add(inFlightKey);
3562
+ try {
3563
+ return await resolveLinkedStatusInner(pctx, input);
3564
+ } finally {
3565
+ pctx.host.linkResolveInFlight.delete(inFlightKey);
3566
+ }
3567
+ }
3568
+ /** Body of `resolveLinkedStatus` — see the wrapper for the re-entrancy guard. */
3569
+ async function resolveLinkedStatusInner(pctx, input) {
3570
+ const { deviceId, cap, baseStatus } = input;
3292
3571
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3293
3572
  if (!persisted) return null;
3294
3573
  const links = (persisted.meta.deviceLinks ?? []).filter((l) => l.target.cap === cap);
3295
3574
  if (links.length === 0) return null;
3296
3575
  const capRegistry = pctx.host.capabilityRegistry;
3297
- const schema = capRegistry?.getDefinition(cap)?.status?.schema;
3576
+ const capStatus = capRegistry?.getDefinition(cap)?.status;
3577
+ const schema = capStatus?.schema;
3298
3578
  if (!schema) return null;
3299
3579
  const allMeta = await pctx.metaStore.readMeta();
3300
3580
  let containerStableId = persisted.stableId;
@@ -3305,11 +3585,19 @@ async function resolveLinkedStatus(pctx, input) {
3305
3585
  }
3306
3586
  const resolved = [];
3307
3587
  for (const link of links) {
3308
- const srcId = pctx.metaStore.resolveSourceDeviceId(containerStableId, link.source.sourceKey, allMeta);
3588
+ const src = link.source;
3589
+ if (src.kind === "literal") {
3590
+ resolved.push({
3591
+ link,
3592
+ sourceValue: applyTransform(src.value, link.transform)
3593
+ });
3594
+ continue;
3595
+ }
3596
+ const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
3309
3597
  if (srcId === null) continue;
3310
3598
  try {
3311
- const srcProvider = capRegistry?.getProviderForDevice(link.source.cap, srcId);
3312
- const raw = getByPath(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, link.source.fieldPath);
3599
+ const srcProvider = capRegistry?.getProviderForDevice(src.cap, srcId);
3600
+ const raw = getByPath(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, src.fieldPath);
3313
3601
  resolved.push({
3314
3602
  link,
3315
3603
  sourceValue: applyTransform(raw, link.transform)
@@ -3321,14 +3609,18 @@ async function resolveLinkedStatus(pctx, input) {
3321
3609
  capName: cap
3322
3610
  },
3323
3611
  meta: {
3324
- sourceKey: link.source.sourceKey,
3612
+ sourceKey: src.kind === "global" ? src.sourceStableId : src.sourceKey,
3325
3613
  error: err instanceof Error ? err.message : String(err)
3326
3614
  }
3327
3615
  });
3328
3616
  }
3329
3617
  }
3330
3618
  if (resolved.length === 0) return null;
3331
- return mergeLinkedStatus(isRecord$1(baseStatus) ? baseStatus : {}, resolved, schema);
3619
+ const synthesizing = !isRecord$1(baseStatus);
3620
+ const base = isRecord$1(baseStatus) ? baseStatus : isRecord$1(capStatus.empty) ? { ...capStatus.empty } : {};
3621
+ const merged = mergeLinkedStatus(base, resolved, schema, capStatus.itemArray);
3622
+ if (synthesizing && merged === base) return null;
3623
+ return merged;
3332
3624
  }
3333
3625
  //#endregion
3334
3626
  //#region src/builtins/device-manager/device-manager.addon.ts
@@ -3394,10 +3686,15 @@ var DeviceManagerAddon = class extends BaseAddon {
3394
3686
  linkTargets = /* @__PURE__ */ new Map();
3395
3687
  /** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
3396
3688
  linkDependents = /* @__PURE__ */ new Map();
3397
- /** Expected source `stableId`s (`${container}-${sourceKey}`) across all links,
3689
+ /** Expected source `stableId`s (`${container}-${sourceKey}` for sibling
3690
+ * sources, the full `sourceStableId` for global sources) across all links,
3398
3691
  * resolved or not — gates the `registerDevice` rebuild so only a registering
3399
3692
  * device that IS a link source triggers a reindex (not every boot restore). */
3400
3693
  expectedSourceStableIds = /* @__PURE__ */ new Set();
3694
+ /** In-flight `${deviceId}:${cap}` pairs of `resolveLinkedStatus` — the P2e
3695
+ * defensive re-entrancy guard bounding link cycles at resolve time. Mutated
3696
+ * in place (never reassigned), so the host exposes a direct reference. */
3697
+ linkResolveInFlight = /* @__PURE__ */ new Set();
3401
3698
  /** Test/diagnostic accessors. */
3402
3699
  linkTargetKeys() {
3403
3700
  return [...this.linkTargets.keys()];
@@ -3469,6 +3766,7 @@ var DeviceManagerAddon = class extends BaseAddon {
3469
3766
  get expectedSourceStableIds() {
3470
3767
  return expectedSourceStableIds();
3471
3768
  },
3769
+ linkResolveInFlight: this.linkResolveInFlight,
3472
3770
  dropDeviceOverlays: (deviceId) => this.stateMirror.dropDeviceOverlays(deviceId),
3473
3771
  remoteNativeCaps: this.remoteNativeCaps,
3474
3772
  seedMirror: (deviceId, blob) => this.stateMirror.seedMirror(deviceId, blob),
@@ -3517,12 +3815,15 @@ var DeviceManagerAddon = class extends BaseAddon {
3517
3815
  }
3518
3816
  }))).filter((id) => id !== null);
3519
3817
  }
3520
- /** Build the dependency context the extracted binding resolvers consume. */
3818
+ /** Build the dependency context the extracted binding resolvers consume.
3819
+ * `devicesWithLinks` is mutated in place (never reassigned) so the direct
3820
+ * reference stays live — it gates the virtual `linked` binding step. */
3521
3821
  get bindingsDeps() {
3522
3822
  return {
3523
3823
  ctx: this.ctx,
3524
3824
  capabilityRegistry: this.capabilityRegistry,
3525
- remoteNativeCaps: this.remoteNativeCaps
3825
+ remoteNativeCaps: this.remoteNativeCaps,
3826
+ devicesWithLinks: this.devicesWithLinks
3526
3827
  };
3527
3828
  }
3528
3829
  async getBindings(input) {
@@ -62,6 +62,12 @@ export declare class DeviceMetaStore {
62
62
  * whose stableId is `${parentStableId}-${sourceKey}`. Null when absent.
63
63
  * Pure over a pre-read meta map so a multi-link resolve reads the store once. */
64
64
  resolveSourceDeviceId: (parentStableId: string, sourceKey: string, meta: Record<string, PersistedDeviceMeta>) => number | null;
65
+ /** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
66
+ * FULL stableId equals `sourceStableId`, regardless of parent container.
67
+ * First match wins (stableIds are effectively unique cluster-wide — see the
68
+ * `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
69
+ * pre-read meta map so a multi-link resolve reads the store once. */
70
+ resolveGlobalSourceDeviceId: (sourceStableId: string, meta: Record<string, PersistedDeviceMeta>) => number | null;
65
71
  allocateNextDeviceId: () => Promise<number>;
66
72
  /** Rebuild the `linkTargets` / `linkDependents` reverse-index maps from the
67
73
  * current persisted meta. Called at boot (once the `devicesWithLinks` seed
@@ -14,6 +14,10 @@ export interface ProviderHost {
14
14
  readonly devicesWithLinks: Set<number>;
15
15
  /** Expected link-source stableIds, gating the `registerDevice` rebuild. */
16
16
  readonly expectedSourceStableIds: Set<string>;
17
+ /** In-flight `${deviceId}:${cap}` pairs of `resolveLinkedStatus` — the
18
+ * defensive re-entrancy guard that bounds link cycles at resolve time
19
+ * (P2e). A nested resolve of a pair already in flight returns null. */
20
+ readonly linkResolveInFlight: Set<string>;
17
21
  /** Drop a removed device's overlay-emit guard entries (state-mirror keyed
18
22
  * `${deviceId}:${cap}`). Called from `removeDevice`. */
19
23
  dropDeviceOverlays(deviceId: number): void;
@@ -108,18 +108,26 @@ export declare class DeviceStateMirror {
108
108
  * Read-time overlay of a cap slice with its cross-device linked values.
109
109
  * Returns a cloned raw mirror slice when the (device, cap) pair has no
110
110
  * links. Sources are read from the same in-hub stateMirror — sync, no
111
- * cross-process call. The disk writer must NOT use this method; it must
112
- * persist raw provider truth via snapshotForDevice.
111
+ * cross-process call. When the raw slice is ABSENT but the cap is a link
112
+ * target, the slice is SYNTHESIZED: the merge base is seeded from the
113
+ * cap's declared `status.empty` default; a failed/empty synthesize
114
+ * returns null (no phantom slices). The disk writer must NOT use this
115
+ * method; it must persist raw provider truth via snapshotForDevice.
113
116
  */
114
117
  overlayedSlice(deviceId: number, cap: string): Record<string, unknown> | null;
115
118
  /**
116
119
  * Like snapshotForDevice but applies the device-link overlay per cap.
117
120
  * Used exclusively by the device-state READ methods (getSnapshot,
118
- * getAllSnapshots) so callers see overlayed values. The debounced disk
119
- * writer must continue to call snapshotForDevice (raw truth).
121
+ * getAllSnapshots) so callers see overlayed values. SYNTHESIZE-only
122
+ * link-target caps (no base mirror slice) are unioned into the iterated
123
+ * key set so they appear at warm-load — `overlayedSlice` builds them from
124
+ * the cap's `status.empty` (and drops them when nothing resolves). The
125
+ * debounced disk writer must continue to call snapshotForDevice (raw truth).
120
126
  */
121
127
  snapshotForDeviceOverlayed(deviceId: number): Record<string, Record<string, unknown>>;
122
- /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`. */
128
+ /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
129
+ * Devices that only exist as link TARGETS (no mirror slice yet) are
130
+ * included via the link reverse-index so their synthesized caps appear. */
123
131
  allSnapshotsOverlayed(): Record<string, Record<string, Record<string, unknown>>>;
124
132
  private emitStateChanged;
125
133
  /** Emit DeviceStateChanged for (deviceId, cap) using the OVERLAID slice,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.1.22",
3
+ "version": "1.1.23",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",