@camstack/system 1.1.11 → 1.1.13

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.
@@ -0,0 +1,137 @@
1
+ import { AddonContext, ICapabilityRegistry } from '@camstack/types';
2
+ import { ResolvedTargetLink, DependentLink } from './device-link-overlay.js';
3
+ import { DeviceManagerSettings } from './device-meta-store.js';
4
+ /**
5
+ * Live view of the addon's cross-device link reverse-index + capability
6
+ * registry the mirror reads when overlaying linked values. Exposed via getters
7
+ * because the link maps are reassigned on every `rebuildLinkDependents`.
8
+ */
9
+ export interface LinkOverlayHost {
10
+ readonly linkTargets: Map<string, ResolvedTargetLink[]>;
11
+ readonly linkDependents: Map<string, DependentLink[]>;
12
+ readonly capabilityRegistry: ICapabilityRegistry | undefined;
13
+ }
14
+ export declare class DeviceStateMirror {
15
+ private readonly ctx;
16
+ private readonly linkHost;
17
+ /**
18
+ * Hub-side mirror of every device's cap-keyed runtime state.
19
+ * Key: deviceId. Value: per-cap slice map. Empty by default —
20
+ * slices show up as `setCapSlice` calls trickle in.
21
+ */
22
+ private readonly stateMirror;
23
+ /**
24
+ * Per-device disk-write debouncer for runtime-state. `setCapSlice`
25
+ * updates the in-memory mirror synchronously and emits the change
26
+ * event immediately, but the disk write is coalesced.
27
+ */
28
+ private readonly runtimeStateDebounce;
29
+ private static readonly RUNTIME_STATE_DEBOUNCE_MS;
30
+ /** Loop/churn guard: last overlaid slice emitted per `${deviceId}:${cap}`. */
31
+ private readonly lastEmittedOverlay;
32
+ constructor(ctx: AddonContext, linkHost: LinkOverlayHost);
33
+ /**
34
+ * Single-cap mirror update — diff against the current mirror,
35
+ * persist the new slice in-memory, emit `DeviceStateChanged` for
36
+ * this cap. No-op on identical writes (both same shape and same
37
+ * values). Called by `setCapSlice` provider.
38
+ */
39
+ applySingleCapUpdate(deviceId: number, capName: string, slice: Record<string, unknown>): void;
40
+ /**
41
+ * Debounced disk writer. Coalesces frequent writes (motion phase
42
+ * transitions, battery pushes) into one `writeDeviceRuntimeState`
43
+ * per `RUNTIME_STATE_DEBOUNCE_MS` window. Reads the per-device
44
+ * blob from the live mirror at flush time so the disk picture is
45
+ * always the latest state — no risk of writing a stale snapshot.
46
+ */
47
+ scheduleRuntimeStateDiskWrite(deviceId: number, settings: DeviceManagerSettings): void;
48
+ /**
49
+ * One-shot mirror seed used by `loadRuntimeState` at boot so the
50
+ * hub knows about every persisted slice without waiting for the
51
+ * first `setCapSlice` call. No events emitted — this is
52
+ * initial-state population, not a transition.
53
+ *
54
+ * Callers that must not carry a stale per-session probe across a
55
+ * restart pass the blob through `withResetSessionProbe` first (see
56
+ * `loadRuntimeState`).
57
+ */
58
+ seedMirror(deviceId: number, blob: Record<string, unknown>): void;
59
+ /**
60
+ * The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
61
+ * signal — it means "this worker process completed a probe THIS session".
62
+ * Persisted runtime state carries the PRE-RESTART timestamp, which is stale
63
+ * after a hub or worker restart: the device has not re-probed yet. Seeding it
64
+ * verbatim makes `resolveDeviceProbed` report `probed:true` during the
65
+ * restart→reprobe window, which defeats the export carry-forward gate
66
+ * (`resolveExportFingerprint`, gated on `device.probed`) and posts a spurious
67
+ * partial `AddOrUpdateReport` to Alexa/HAP before the real probe lands.
68
+ *
69
+ * Reset `lastProbedAt` to 0 for the MIRROR seed only — `probed:false` carries
70
+ * the last-advertised fingerprint forward until the worker republishes a
71
+ * fresh probe (its post-probe `setCapSlice` raises `lastProbedAt` again, which
72
+ * also fires `DeviceReady`). The worker's returned `initialRuntimeState` blob
73
+ * is untouched, and every non-probe slice (e.g. `device-status`/online) is
74
+ * preserved.
75
+ */
76
+ withResetSessionProbe(blob: Record<string, unknown>): Record<string, unknown>;
77
+ /**
78
+ * Resolve a device's REAL `online` flag for the persisted/forked-worker
79
+ * list branch. Forked workers own the live `IDevice` in their own process,
80
+ * so the hub registry can't read `device.online` directly. The owning
81
+ * driver instead publishes its liveness through the auto-registered
82
+ * `device-status` runtime-state slice (`markOnline` → `setCapState`), which
83
+ * the canonical `deviceState.setCapSlice` write entrypoint mirrors into the
84
+ * hub-side `stateMirror`. We read that mirrored slice here so the list
85
+ * payload reflects the device's actual reachability instead of a constant.
86
+ *
87
+ * Fallback (`fallbackOnline`) preserves the legacy behaviour when no slice
88
+ * has been published yet: a persisted device with a live registry is
89
+ * assumed online (it was successfully registered by its owning process),
90
+ * and the null-registry "offline view" keeps reporting offline. We never
91
+ * regress a device to offline merely because its mirror is empty.
92
+ */
93
+ resolveDeviceOnline(deviceId: number, fallbackOnline: boolean): boolean;
94
+ /**
95
+ * Derive the `probed` flag for an offline-view (forked-worker, not
96
+ * live in the hub registry) device projection. Reads the mirrored
97
+ * `feature-probe` slice the owning worker publishes. Mirrors the
98
+ * `toDeviceInfo` rule: no mirrored slice → ready (`true`, no probe seen);
99
+ * slice present → ready iff `lastProbedAt` has advanced past 0. The
100
+ * mirror is populated when the worker's first `setCapSlice` RPC arrives
101
+ * (BaseDevice seeds `feature-probe` `lastProbedAt:0` at construction, but
102
+ * cross-process delivery is async); until then the no-entry path returns
103
+ * `true` — a brief transient window, same as `resolveDeviceOnline`.
104
+ */
105
+ resolveDeviceProbed(deviceId: number): boolean;
106
+ snapshotForDevice(deviceId: number): Record<string, Record<string, unknown>>;
107
+ /**
108
+ * Read-time overlay of a cap slice with its cross-device linked values.
109
+ * Returns a cloned raw mirror slice when the (device, cap) pair has no
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.
113
+ */
114
+ overlayedSlice(deviceId: number, cap: string): Record<string, unknown> | null;
115
+ /**
116
+ * Like snapshotForDevice but applies the device-link overlay per cap.
117
+ * 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).
120
+ */
121
+ snapshotForDeviceOverlayed(deviceId: number): Record<string, Record<string, unknown>>;
122
+ /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`. */
123
+ allSnapshotsOverlayed(): Record<string, Record<string, Record<string, unknown>>>;
124
+ private emitStateChanged;
125
+ /** Emit DeviceStateChanged for (deviceId, cap) using the OVERLAID slice,
126
+ * skipping when the overlay is unchanged since the last emit (loop/churn
127
+ * guard). Used for the written pair AND its dependent targets. */
128
+ private emitOverlayed;
129
+ /** Drop a removed device's overlay-emit guard entries (keyed
130
+ * `${deviceId}:${cap}`) so the map doesn't retain rows for a removed device.
131
+ * Called from `removeDevice`. */
132
+ dropDeviceOverlays(deviceId: number): void;
133
+ /** Flush every pending debounced disk write (graceful shutdown). Clears the
134
+ * debounce slots after awaiting in-flight + scheduled writes so shutdown is
135
+ * lossless. */
136
+ flushPendingWrites(settings: DeviceManagerSettings | undefined): Promise<void>;
137
+ }
@@ -0,0 +1,41 @@
1
+ import { ProviderContext } from './device-provider-context.js';
2
+ /**
3
+ * Sync ownership lookup backing persistence fallbacks (e.g. remove()
4
+ * when the owning worker is offline). Ownership is keyed by numeric
5
+ * deviceId → owning addonId as recorded in the persisted meta. NOT
6
+ * a native-cap lookup: an addon can own a device without registering
7
+ * every possible cap natively (e.g. RtspCamera without snapshotUrl
8
+ * doesn't register the snapshot cap). Use `resolveNativeCapOwnerSync`
9
+ * for cap-resolution paths.
10
+ */
11
+ export declare function resolveDeviceOwnerSync(pctx: ProviderContext, deviceId: number): string | null;
12
+ /**
13
+ * Sync lookup for the addon that registered a native provider for
14
+ * `(capName, deviceId)`. Backs `CapabilityRegistry`'s native fallback
15
+ * so the hub only synthesizes a cross-process proxy when the cap is
16
+ * actually published — never on speculative device ownership.
17
+ *
18
+ * Resolution order:
19
+ * 1. Hub-local `capabilityRegistry` (in-process natives — fastest path).
20
+ * 2. Push-fed `remoteNativeCaps` cache (`DeviceBindingsChanged` events
21
+ * from forked workers — accurate in steady state).
22
+ * 3. Handshake-fed `HubNodeRegistry` via `listClusterNativeCaps()`
23
+ * (D3 re-handshake after device restore — covers the Moleculer
24
+ * transport window where push events were lost). This is the
25
+ * reliable replacement for the deleted `syncWorkerNativeCaps` pull.
26
+ */
27
+ export declare function resolveNativeCapOwnerSync(pctx: ProviderContext, capName: string, deviceId: number): {
28
+ addonId: string;
29
+ nodeId: string;
30
+ } | null;
31
+ /**
32
+ * Read-time overlay: merge cross-device linked values onto a target cap's
33
+ * status. Returns null when the device has no links for `cap` (caller uses
34
+ * the base status untouched). Reads each source cap via the hub registry's
35
+ * `getProviderForDevice` (routes cross-process); merge is pure.
36
+ */
37
+ export declare function resolveLinkedStatus(pctx: ProviderContext, input: {
38
+ deviceId: number;
39
+ cap: string;
40
+ baseStatus: unknown;
41
+ }): Promise<Record<string, unknown> | null>;
package/dist/index.js CHANGED
@@ -4896,6 +4896,219 @@ var CapabilityRegistry = class CapabilityRegistry {
4896
4896
  }
4897
4897
  };
4898
4898
  //#endregion
4899
+ //#region src/kernel/cap-router-builder.ts
4900
+ /**
4901
+ * Runtime cap-router builder (BIG PLAN 2).
4902
+ *
4903
+ * Builds the per-capability tRPC routers DYNAMICALLY at hub boot by
4904
+ * iterating `ALL_CAPABILITY_DEFINITIONS`, instead of importing the
4905
+ * statically codegen'd `createCapRouter_<Cap>` value functions baked into
4906
+ * `@camstack/server`. Moving the CONSTRUCTION here means a capability
4907
+ * update ships via the framework plugin (`@camstack/types` +
4908
+ * `@camstack/system`, live-updated together) WITHOUT a `@camstack/server`
4909
+ * image rebuild.
4910
+ *
4911
+ * Type vs value split:
4912
+ * - The runtime VALUE is built here, generically.
4913
+ * - The static AppRouter TYPE stays codegen'd into `@camstack/types`
4914
+ * (`generated/addon-api.ts`) via the `generated-cap-routers.ts`
4915
+ * per-cap router factory TYPES. The server delegation site assigns
4916
+ * this builder's output to that static type at the single documented
4917
+ * boundary, so the SDK keeps full procedure inference.
4918
+ *
4919
+ * Behaviour MUST stay byte-identical to the codegen output
4920
+ * (`scripts/generate-cap-routers.ts`). The per-method procedure shape is
4921
+ * mechanically derived from each `cap.methods.*` (input/output zod schema
4922
+ * + kind) + the auto-injected `getStatus` / 3 settings-contribution
4923
+ * methods (via `expandCapMethods`). Provider resolution is derived from
4924
+ * the declarative `mount` hint (`resolveCapMount`) — the same decision the
4925
+ * codegen `generate-cap-mounts.ts` baked in.
4926
+ *
4927
+ * This module owns NO tRPC primitives of its own: `@camstack/system`
4928
+ * cannot import the server's `TrpcContext`-bound procedures. The server
4929
+ * passes them in via {@link CapRouterPrimitives} (the `t` half of the
4930
+ * `buildCapRouters(t, services)` contract), keeping the kernel free of any
4931
+ * backend coupling (self-sufficiency invariant).
4932
+ */
4933
+ /** Auth level → procedure key. Mirrors codegen's AUTH_PROCEDURE_MAP. */
4934
+ function procedureKeyFor(auth) {
4935
+ if (auth === "public") return "public";
4936
+ if (auth === "admin" || auth === "superAdmin") return "admin";
4937
+ return "protected";
4938
+ }
4939
+ /** Zod schema is `z.void()` — input carries no data. Mirrors codegen's isVoidInput. */
4940
+ function isVoidInput(schema) {
4941
+ const def = schema._def;
4942
+ return schema.constructor?.name === "ZodVoid" || def?.type === "void" || def?.typeName === "ZodVoid";
4943
+ }
4944
+ /** Zod schema is `z.object()` — supports `.loose()` for nodeId passthrough. Mirrors codegen's isObjectInput. */
4945
+ function isObjectInput(schema) {
4946
+ const def = schema._def;
4947
+ return schema.constructor?.name === "ZodObject" || def?.type === "object" || def?.typeName === "ZodObject";
4948
+ }
4949
+ /**
4950
+ * Apply `.loose()` to an object input schema when available (Zod 4) so the
4951
+ * router accepts the out-of-band `nodeId` / `addonId` selector keys
4952
+ * without stripping them — matching the codegen's `.input(schema.loose())`.
4953
+ * Falls back to the schema unchanged when `.loose()` is absent.
4954
+ */
4955
+ function looseSchema(schema) {
4956
+ const loose = schema.loose;
4957
+ return typeof loose === "function" ? loose.call(schema) : schema;
4958
+ }
4959
+ function methodsFor(def) {
4960
+ const effective = (0, _camstack_types.expandCapMethods)(def);
4961
+ return Object.entries(effective).map(([name, schema]) => {
4962
+ const m = schema;
4963
+ return {
4964
+ name,
4965
+ kind: m.kind ?? "query",
4966
+ auth: m.auth ?? "protected",
4967
+ schema: m,
4968
+ isVoid: isVoidInput(m.input),
4969
+ isObject: isObjectInput(m.input)
4970
+ };
4971
+ });
4972
+ }
4973
+ /** Strip a set of selector keys from an input object, returning a NEW object. */
4974
+ function stripKeys(input, keys) {
4975
+ const src = input ?? {};
4976
+ const rest = {};
4977
+ const picked = {};
4978
+ for (const [k, v] of Object.entries(src)) if (keys.includes(k)) picked[k] = v;
4979
+ else rest[k] = v;
4980
+ return {
4981
+ rest,
4982
+ picked
4983
+ };
4984
+ }
4985
+ /**
4986
+ * Resolve the provider object for a NON-void/object method call, applying
4987
+ * the same local-first → remote-proxy routing the codegen's
4988
+ * `resolveProvider` does. `nodeId` undefined / `'hub'` resolves the local
4989
+ * provider; otherwise it crosses to the named node via the proxy.
4990
+ */
4991
+ function resolveProviderForCall(capName, nodeId, getLocal, services) {
4992
+ if (!nodeId || nodeId === "hub") {
4993
+ const local = getLocal();
4994
+ if (local !== null && local !== void 0) return local;
4995
+ const proxy = services.remoteProxy(capName, "hub");
4996
+ if (proxy) return proxy;
4997
+ return services.noProvider(capName);
4998
+ }
4999
+ const proxy = services.remoteProxy(capName, nodeId);
5000
+ if (!proxy) return services.noProvider(capName, nodeId);
5001
+ return proxy;
5002
+ }
5003
+ /** Require a local provider (singleton-style, no node routing) or raise. */
5004
+ function requireLocal(capName, getLocal, services) {
5005
+ const p = getLocal();
5006
+ if (p === null || p === void 0) return services.noProvider(capName);
5007
+ return p;
5008
+ }
5009
+ /**
5010
+ * Build a single capability's router (the runtime equivalent of one
5011
+ * codegen'd `createCapRouter_<Cap>`). Each method becomes a typed
5012
+ * procedure with the SAME routing branch the codegen emits.
5013
+ */
5014
+ function buildOneCapRouter(def, primitives, services) {
5015
+ const capName = def.name;
5016
+ const mount = (0, _camstack_types.resolveCapMount)(def);
5017
+ const isCollection = mount.kind === "collection";
5018
+ const nodeIdData = def.nodeIdMode === "data";
5019
+ const procedures = {};
5020
+ const getLocal = (ctx, addonId) => services.getLocalProvider(capName, mount.kind, ctx, addonId);
5021
+ for (const method of methodsFor(def)) {
5022
+ const base = primitives.procedures[procedureKeyFor(method.auth)];
5023
+ const inputSchema = method.schema.input;
5024
+ const outputSchema = method.schema.output;
5025
+ if (method.kind === "subscription") {
5026
+ procedures[method.name] = base.input(inputSchema).subscription(({ input, ctx }) => {
5027
+ const provider = requireLocal(capName, () => getLocal(ctx), services);
5028
+ return primitives.iterableSubscription((push) => {
5029
+ const fn = provider[method.name];
5030
+ const unsub = fn?.(input, push);
5031
+ return typeof unsub === "function" ? unsub : () => {};
5032
+ });
5033
+ });
5034
+ continue;
5035
+ }
5036
+ const procKind = method.kind === "mutation" ? "mutation" : "query";
5037
+ if (method.isVoid) {
5038
+ const voidSelector = isCollection ? zod.z.object({
5039
+ nodeId: zod.z.string().optional(),
5040
+ addonId: zod.z.string().optional()
5041
+ }).optional() : zod.z.object({ nodeId: zod.z.string().optional() }).optional();
5042
+ procedures[method.name] = base.input(voidSelector).output(outputSchema)[procKind](({ input, ctx }) => {
5043
+ const sel = input ?? {};
5044
+ return (nodeIdData ? requireLocal(capName, () => getLocal(ctx, isCollection ? sel.addonId : void 0), services) : resolveProviderForCall(capName, sel.nodeId, () => getLocal(ctx, isCollection ? sel.addonId : void 0), services))[method.name]?.();
5045
+ });
5046
+ continue;
5047
+ }
5048
+ if (method.isObject) {
5049
+ const schema = nodeIdData ? inputSchema : looseSchema(inputSchema);
5050
+ procedures[method.name] = base.input(schema).output(outputSchema)[procKind](({ input, ctx }) => {
5051
+ if (nodeIdData) return requireLocal(capName, () => getLocal(ctx), services)[method.name]?.(input);
5052
+ if (isCollection) {
5053
+ const { rest, picked } = stripKeys(input, ["nodeId", "addonId"]);
5054
+ const nodeId = typeof picked.nodeId === "string" ? picked.nodeId : void 0;
5055
+ const addonId = typeof picked.addonId === "string" ? picked.addonId : void 0;
5056
+ return resolveProviderForCall(capName, nodeId, () => getLocal(ctx, addonId), services)[method.name]?.(rest);
5057
+ }
5058
+ const { rest, picked } = stripKeys(input, ["nodeId"]);
5059
+ return resolveProviderForCall(capName, typeof picked.nodeId === "string" ? picked.nodeId : void 0, () => getLocal(ctx), services)[method.name]?.(rest);
5060
+ });
5061
+ continue;
5062
+ }
5063
+ procedures[method.name] = base.input(inputSchema).output(outputSchema)[procKind](({ input, ctx }) => {
5064
+ return requireLocal(capName, () => getLocal(ctx), services)[method.name]?.(input);
5065
+ });
5066
+ }
5067
+ return primitives.router(procedures);
5068
+ }
5069
+ /** kebab-case → camelCase, matching the codegen router-map key naming. */
5070
+ function kebabToCamel(s) {
5071
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
5072
+ }
5073
+ /**
5074
+ * Mount kinds the runtime builder constructs. `service-backed` IS built
5075
+ * (the server supplies the provider via `getLocalProvider`); `custom` and
5076
+ * `skip` are NOT (the server's hand-written override fills the slot for
5077
+ * `custom`; `skip` is never mounted). `hub-only` is built but its remote
5078
+ * leg is `null` — handled by the server's `remoteProxy` returning null for
5079
+ * that cap.
5080
+ */
5081
+ function isBuilderMounted(kind) {
5082
+ return kind !== "custom" && kind !== "skip";
5083
+ }
5084
+ /**
5085
+ * Build the runtime cap-router map: `{ <camelCapName>: <router> }` for
5086
+ * every capability the builder owns. Caps with `mount.kind` `custom` /
5087
+ * `skip` are omitted (the server overrides / never mounts them). The
5088
+ * server spreads this BEFORE its hand-written overrides + core routers so
5089
+ * override-by-spread precedence is preserved.
5090
+ */
5091
+ function buildCapRouters(primitives, services) {
5092
+ const out = {};
5093
+ for (const def of _camstack_types.ALL_CAPABILITY_DEFINITIONS) {
5094
+ if (!isBuilderMounted((0, _camstack_types.resolveCapMount)(def).kind)) continue;
5095
+ const effective = (0, _camstack_types.expandCapMethods)(def);
5096
+ if (Object.keys(effective).length === 0) continue;
5097
+ out[kebabToCamel(def.name)] = buildOneCapRouter(def, primitives, services);
5098
+ }
5099
+ return out;
5100
+ }
5101
+ /** The set of cap camelNames the builder owns (for drift checks / tests). */
5102
+ function builderMountedCapNames() {
5103
+ const names = [];
5104
+ for (const def of _camstack_types.ALL_CAPABILITY_DEFINITIONS) {
5105
+ if (!isBuilderMounted((0, _camstack_types.resolveCapMount)(def).kind)) continue;
5106
+ if (Object.keys((0, _camstack_types.expandCapMethods)(def)).length === 0) continue;
5107
+ names.push(kebabToCamel(def.name));
5108
+ }
5109
+ return names;
5110
+ }
5111
+ //#endregion
4899
5112
  //#region src/kernel/provider-kind-drift.ts
4900
5113
  /**
4901
5114
  * Compare a runtime registration hint against the authoritative cap
@@ -93094,11 +93307,13 @@ Object.defineProperty(exports, "buildBinaryPath", {
93094
93307
  return _camstack_types_node.buildBinaryPath;
93095
93308
  }
93096
93309
  });
93310
+ exports.buildCapRouters = buildCapRouters;
93097
93311
  exports.buildLinkChain = require_manifest_python_deps.buildLinkChain;
93098
93312
  exports.buildNativeCapProxy = require_manifest_python_deps.buildNativeCapProxy;
93099
93313
  exports.buildNodeManifest = buildNodeManifest;
93100
93314
  exports.buildStorageLocationRegistry = buildStorageLocationRegistry;
93101
93315
  exports.buildUdsNativeCapProxy = require_manifest_python_deps.buildUdsNativeCapProxy;
93316
+ exports.builderMountedCapNames = builderMountedCapNames;
93102
93317
  exports.callRegisterNodeWithRetry = callRegisterNodeWithRetry;
93103
93318
  exports.callWithServiceDiscovery = require_manifest_python_deps.callWithServiceDiscovery;
93104
93319
  exports.capActionName = require_manifest_python_deps.capActionName;