@skenora/flow 0.1.2

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,711 @@
1
+ import { EventHub, } from "@skenora/events";
2
+ import { FLOW_MATERIAL_PROPERTIES, } from "../model/types.js";
3
+ import { createBuiltInFlowNodeRegistry, } from "../model/node-registry.js";
4
+ import { FlowValidationError, validateFlowGraph } from "../model/validation.js";
5
+ export class FlowRuntime {
6
+ host;
7
+ events;
8
+ nodeRegistry;
9
+ #ownsEvents;
10
+ #maxSteps;
11
+ #executors = new Map();
12
+ #graphs = new Map();
13
+ #subscriptions = new Map();
14
+ #runs = new Map();
15
+ #disposed = false;
16
+ constructor(host, options = {}) {
17
+ this.host = host;
18
+ this.events = options.events ?? new EventHub();
19
+ this.#ownsEvents = options.events === undefined;
20
+ this.#maxSteps = options.maxSteps ?? 1_000;
21
+ if (!Number.isInteger(this.#maxSteps) || this.#maxSteps < 1) {
22
+ throw new Error("FlowRuntime maxSteps must be a positive integer");
23
+ }
24
+ this.nodeRegistry = createBuiltInFlowNodeRegistry();
25
+ for (const [type, executor] of Object.entries(createBuiltInExecutors())) {
26
+ if (!this.nodeRegistry.has(type)) {
27
+ throw new Error(`Built-in Flow node definition is missing: ${type}`);
28
+ }
29
+ this.#executors.set(type, executor);
30
+ }
31
+ for (const registration of options.nodes ?? []) {
32
+ this.registerNode(registration.definition, registration.executor);
33
+ }
34
+ }
35
+ registerNode(definition, executor) {
36
+ this.#assertActive();
37
+ const { type } = definition;
38
+ const normalizedType = type.toLowerCase();
39
+ if (["network", "http", "https", "api", "websocket", "remote", "dom"].some((prefix) => normalizedType === prefix ||
40
+ normalizedType.startsWith(`${prefix}.`) ||
41
+ normalizedType.startsWith(`${prefix}:`))) {
42
+ throw new Error(`Side-effect category is not allowed in Skenora Flow: ${type}`);
43
+ }
44
+ if (typeof executor !== "function") {
45
+ throw new Error(`Flow node executor must be a function: ${type}`);
46
+ }
47
+ const unregisterDefinition = this.nodeRegistry.register(definition);
48
+ this.#executors.set(type, executor);
49
+ return () => {
50
+ if (this.#executors.get(type) === executor)
51
+ this.#executors.delete(type);
52
+ unregisterDefinition();
53
+ };
54
+ }
55
+ mount(graph) {
56
+ this.#assertActive();
57
+ const ownedGraph = ownFlowGraph(graph);
58
+ const validation = validateFlowGraph(ownedGraph, this.nodeRegistry);
59
+ if (!validation.valid) {
60
+ throw new FlowValidationError(validation.issues);
61
+ }
62
+ this.unmount(ownedGraph.id);
63
+ this.#graphs.set(ownedGraph.id, ownedGraph);
64
+ if (!ownedGraph.enabled)
65
+ return;
66
+ const controller = new AbortController();
67
+ this.#subscriptions.set(ownedGraph.id, controller);
68
+ for (const trigger of ownedGraph.nodes.filter((node) => node.enabled !== false &&
69
+ this.nodeRegistry.get(node.type)?.category === "trigger")) {
70
+ if (trigger.type === "trigger.event") {
71
+ const eventType = stringConfig(trigger, "event");
72
+ this.host.events.source.subscribe(eventType, (payload, event) => {
73
+ if (matchesTrigger(trigger, payload)) {
74
+ this.#triggerFromSubscription(ownedGraph.id, trigger.id, payload, event);
75
+ }
76
+ }, { signal: controller.signal });
77
+ }
78
+ if (trigger.type === "trigger.pointer") {
79
+ const pointerEvent = stringConfig(trigger, "event", "pick");
80
+ this.host.events.source.subscribe(`runtime.pointer.${pointerEvent}`, (payload, event) => {
81
+ if (matchesTrigger(trigger, payload)) {
82
+ this.#triggerFromSubscription(ownedGraph.id, trigger.id, payload, event);
83
+ }
84
+ }, { signal: controller.signal });
85
+ }
86
+ if (trigger.type === "trigger.animation") {
87
+ const animationEvent = stringConfig(trigger, "event", "completed");
88
+ this.host.events.source.subscribe(`runtime.animation.${animationEvent}`, (payload, event) => {
89
+ if (matchesTrigger(trigger, payload)) {
90
+ this.#triggerFromSubscription(ownedGraph.id, trigger.id, payload, event);
91
+ }
92
+ }, { signal: controller.signal });
93
+ }
94
+ if (trigger.type === "trigger.variable") {
95
+ this.host.events.source.subscribe("variable.changed", (payload, event) => {
96
+ if (matchesTrigger(trigger, payload)) {
97
+ this.#triggerFromSubscription(ownedGraph.id, trigger.id, payload, event);
98
+ }
99
+ }, { signal: controller.signal });
100
+ }
101
+ if (trigger.type === "trigger.timer") {
102
+ this.#mountTimer(ownedGraph, trigger, controller.signal);
103
+ }
104
+ }
105
+ }
106
+ unmount(graphId) {
107
+ this.#assertActive();
108
+ this.#subscriptions.get(graphId)?.abort();
109
+ this.#subscriptions.delete(graphId);
110
+ this.#graphs.delete(graphId);
111
+ for (const [runId, run] of this.#runs) {
112
+ if (run.graphId !== graphId)
113
+ continue;
114
+ run.controller.abort();
115
+ this.#runs.delete(runId);
116
+ }
117
+ }
118
+ start(graphId, payload) {
119
+ this.#assertActive();
120
+ const graph = this.#getGraph(graphId);
121
+ if (!graph.enabled)
122
+ return Promise.resolve([]);
123
+ const triggers = graph.nodes.filter((node) => node.type === "trigger.scene.ready" && node.enabled !== false);
124
+ return Promise.all(triggers.map((trigger) => this.trigger(graphId, trigger.id, payload)));
125
+ }
126
+ async trigger(graphId, triggerNodeId, payload, triggerEvent) {
127
+ this.#assertActive();
128
+ const graph = this.#getGraph(graphId);
129
+ if (!graph.enabled) {
130
+ throw new Error(`Flow graph is disabled: ${graphId}`);
131
+ }
132
+ const trigger = graph.nodes.find((node) => node.id === triggerNodeId);
133
+ if (!trigger ||
134
+ trigger.enabled === false ||
135
+ this.nodeRegistry.get(trigger.type)?.category !== "trigger") {
136
+ throw new Error(`Flow trigger not found: ${triggerNodeId}`);
137
+ }
138
+ const active = [...this.#runs.entries()].filter(([, run]) => run.graphId === graphId);
139
+ if (graph.concurrency === "drop" && active[0])
140
+ return active[0][0];
141
+ if (graph.concurrency === "restart") {
142
+ for (const [, run] of active)
143
+ run.controller.abort();
144
+ }
145
+ const runId = createRunId();
146
+ const controller = new AbortController();
147
+ this.#runs.set(runId, { controller, graphId });
148
+ const startedEvent = this.events.publish("flow.run.started", { runId, graphId, triggerNodeId }, {
149
+ source: "flow",
150
+ ...(triggerEvent
151
+ ? {
152
+ correlationId: triggerEvent.correlationId,
153
+ causationId: triggerEvent.id,
154
+ }
155
+ : {}),
156
+ });
157
+ const context = {
158
+ runId,
159
+ graphId,
160
+ triggerNodeId,
161
+ payload,
162
+ correlationId: startedEvent.correlationId,
163
+ causationId: startedEvent.id,
164
+ signal: controller.signal,
165
+ };
166
+ try {
167
+ const steps = await this.#executeRun(graph, trigger, context);
168
+ if (controller.signal.aborted) {
169
+ if (!this.#disposed) {
170
+ this.events.publish("flow.run.cancelled", { runId, graphId }, flowEventMetadata(context));
171
+ }
172
+ }
173
+ else {
174
+ this.events.publish("flow.run.completed", { runId, graphId, steps }, flowEventMetadata(context));
175
+ }
176
+ }
177
+ catch (error) {
178
+ if (controller.signal.aborted) {
179
+ if (!this.#disposed) {
180
+ this.events.publish("flow.run.cancelled", { runId, graphId }, flowEventMetadata(context));
181
+ }
182
+ return runId;
183
+ }
184
+ this.events.publish("flow.run.failed", { runId, graphId, error }, flowEventMetadata(context));
185
+ throw error;
186
+ }
187
+ finally {
188
+ this.#runs.delete(runId);
189
+ }
190
+ return runId;
191
+ }
192
+ cancel(runId) {
193
+ this.#runs.get(runId)?.controller.abort();
194
+ }
195
+ cancelAll(graphId) {
196
+ this.#assertActive();
197
+ let cancelled = 0;
198
+ for (const run of this.#runs.values()) {
199
+ if (graphId !== undefined && run.graphId !== graphId)
200
+ continue;
201
+ if (run.controller.signal.aborted)
202
+ continue;
203
+ run.controller.abort();
204
+ cancelled += 1;
205
+ }
206
+ return cancelled;
207
+ }
208
+ dispose() {
209
+ if (this.#disposed)
210
+ return;
211
+ this.#disposed = true;
212
+ for (const controller of this.#subscriptions.values())
213
+ controller.abort();
214
+ for (const run of this.#runs.values())
215
+ run.controller.abort();
216
+ this.#subscriptions.clear();
217
+ this.#runs.clear();
218
+ this.#graphs.clear();
219
+ this.#executors.clear();
220
+ if (this.#ownsEvents)
221
+ this.events.dispose();
222
+ }
223
+ async #executeRun(graph, trigger, context) {
224
+ let steps = 0;
225
+ const queue = controlTargets(graph, trigger.id, "next");
226
+ const valueCache = new Map();
227
+ valueCache.set(trigger.id, { payload: context.payload });
228
+ while (queue.length > 0) {
229
+ if (context.signal.aborted)
230
+ break;
231
+ steps += 1;
232
+ if (steps > this.#maxSteps) {
233
+ throw new Error(`Flow exceeded ${this.#maxSteps} execution steps`);
234
+ }
235
+ const next = queue.shift();
236
+ if (!next)
237
+ continue;
238
+ const node = graph.nodes.find((candidate) => candidate.id === next.targetNodeId);
239
+ if (!node || node.enabled === false)
240
+ continue;
241
+ let result;
242
+ try {
243
+ const values = await this.#resolveInputs(graph, node, context, valueCache, new Set());
244
+ result = await this.#executeNode(node, values, context);
245
+ }
246
+ catch (error) {
247
+ if (context.signal.aborted)
248
+ break;
249
+ this.events.publish("flow.node.failed", { runId: context.runId, graphId: graph.id, nodeId: node.id, error }, flowEventMetadata(context));
250
+ if (graph.errorPolicy !== "continue")
251
+ throw error;
252
+ continue;
253
+ }
254
+ valueCache.set(node.id, result.values ?? {});
255
+ for (const port of result.control ?? ["next"]) {
256
+ queue.push(...controlTargets(graph, node.id, port));
257
+ }
258
+ }
259
+ return steps;
260
+ }
261
+ async #resolveInputs(graph, node, context, cache, resolving) {
262
+ const values = {};
263
+ const edges = graph.edges.filter((edge) => edge.kind === "value" && edge.targetNodeId === node.id);
264
+ for (const edge of edges) {
265
+ throwIfFlowAborted(context.signal);
266
+ if (resolving.has(edge.sourceNodeId)) {
267
+ throw new Error(`Value dependency cycle at node ${edge.sourceNodeId}`);
268
+ }
269
+ let sourceValues = cache.get(edge.sourceNodeId);
270
+ if (!sourceValues) {
271
+ const source = graph.nodes.find((candidate) => candidate.id === edge.sourceNodeId);
272
+ if (!source || source.enabled === false)
273
+ continue;
274
+ resolving.add(source.id);
275
+ const sourceInputs = await this.#resolveInputs(graph, source, context, cache, resolving);
276
+ const result = await this.#executeNode(source, sourceInputs, context);
277
+ sourceValues = result.values ?? {};
278
+ cache.set(source.id, sourceValues);
279
+ resolving.delete(source.id);
280
+ }
281
+ const definition = this.nodeRegistry.get(node.type);
282
+ const port = definition?.inputs.find((candidate) => candidate.kind === "value" && candidate.name === edge.targetPort);
283
+ const nextValue = sourceValues[edge.sourcePort];
284
+ if (port?.multiple) {
285
+ const previous = values[edge.targetPort];
286
+ values[edge.targetPort] = [
287
+ ...(Array.isArray(previous) ? previous : []),
288
+ nextValue,
289
+ ];
290
+ }
291
+ else {
292
+ values[edge.targetPort] = nextValue;
293
+ }
294
+ }
295
+ return values;
296
+ }
297
+ async #executeNode(node, values, context) {
298
+ throwIfFlowAborted(context.signal);
299
+ const executor = this.#executors.get(node.type);
300
+ if (!executor)
301
+ throw new Error(`No Flow executor registered for ${node.type}`);
302
+ this.events.publish("flow.node.started", { runId: context.runId, graphId: context.graphId, nodeId: node.id }, flowEventMetadata(context));
303
+ const input = {
304
+ node,
305
+ values,
306
+ context,
307
+ host: this.host,
308
+ };
309
+ const result = await executor(input);
310
+ throwIfFlowAborted(context.signal);
311
+ this.events.publish("flow.node.completed", { runId: context.runId, graphId: context.graphId, nodeId: node.id }, flowEventMetadata(context));
312
+ return result;
313
+ }
314
+ #mountTimer(graph, node, signal) {
315
+ const interval = Math.max(numberConfig(node, "intervalMs", 1_000), 16);
316
+ const repeat = Boolean(node.config.repeat ?? true);
317
+ const tick = () => {
318
+ if (signal.aborted)
319
+ return;
320
+ this.#triggerFromSubscription(graph.id, node.id, {
321
+ occurredAt: Date.now(),
322
+ });
323
+ if (repeat)
324
+ timer = globalThis.setTimeout(tick, interval);
325
+ };
326
+ let timer = globalThis.setTimeout(tick, interval);
327
+ signal.addEventListener("abort", () => globalThis.clearTimeout(timer), {
328
+ once: true,
329
+ });
330
+ }
331
+ #getGraph(graphId) {
332
+ const graph = this.#graphs.get(graphId);
333
+ if (!graph)
334
+ throw new Error(`Flow graph is not mounted: ${graphId}`);
335
+ return graph;
336
+ }
337
+ #triggerFromSubscription(graphId, triggerNodeId, payload, event) {
338
+ void this.trigger(graphId, triggerNodeId, payload, event).catch(() => {
339
+ // trigger() already publishes a structured flow.run.failed event.
340
+ });
341
+ }
342
+ #assertActive() {
343
+ if (this.#disposed)
344
+ throw new Error("FlowRuntime has been disposed");
345
+ }
346
+ }
347
+ function createBuiltInExecutors() {
348
+ return {
349
+ "value.constant": ({ node }) => ({ values: { value: node.config.value } }),
350
+ "value.variable": ({ node, host }) => ({
351
+ values: {
352
+ value: host.variables.getVariable(stringConfig(node, "name")),
353
+ },
354
+ }),
355
+ "value.entity.state": ({ node, host }) => ({
356
+ values: {
357
+ value: host.entities.getEntityState(stringConfig(node, "entityId")),
358
+ },
359
+ }),
360
+ "value.object.get": ({ node, values, context }) => ({
361
+ values: {
362
+ value: getValueAtPath(values.object ?? node.config.object ?? context.payload, stringConfig(node, "path")),
363
+ },
364
+ }),
365
+ "value.math": ({ node, values }) => ({
366
+ values: {
367
+ value: calculate(values.left ?? node.config.left, values.right ?? node.config.right, stringConfig(node, "operator", "add"), node.id),
368
+ },
369
+ }),
370
+ "value.logic": ({ node, values }) => ({
371
+ values: {
372
+ value: logic(values.left ?? node.config.left, values.right ?? node.config.right, stringConfig(node, "operator", "and")),
373
+ },
374
+ }),
375
+ "value.compare": ({ node, values }) => ({
376
+ values: {
377
+ value: compare(values.left ?? node.config.left, values.right ?? node.config.right, stringConfig(node, "operator", "equals")),
378
+ },
379
+ }),
380
+ "control.branch": ({ values, node }) => ({
381
+ control: [
382
+ Boolean(values.condition ?? node.config.condition) ? "true" : "false",
383
+ ],
384
+ }),
385
+ "control.delay": async ({ values, node, context }) => {
386
+ await abortableDelay(Number(values.durationMs ?? node.config.durationMs ?? 0), context.signal);
387
+ return { control: ["next"] };
388
+ },
389
+ "action.variable.set": async ({ values, node, host }) => {
390
+ await host.variables.setVariable(stringConfig(node, "name"), values.value ?? node.config.value);
391
+ return {};
392
+ },
393
+ "action.entity.setEnabled": async ({ values, node, host }) => {
394
+ await host.entities.setEntityEnabled(stringConfig(node, "entityId"), Boolean(values.enabled ?? node.config.enabled));
395
+ return {};
396
+ },
397
+ "action.entity.setVisible": async ({ values, node, host }) => {
398
+ await host.entities.setEntityVisible(stringConfig(node, "entityId"), Boolean(values.visible ?? node.config.visible));
399
+ return {};
400
+ },
401
+ "action.entity.setTransform": async ({ values, node, host }) => {
402
+ await host.entities.setEntityTransform(stringConfig(node, "entityId"), transformUpdate(values.transform ?? node.config.transform, node.id));
403
+ return {};
404
+ },
405
+ "action.entity.setTransparent": async ({ values, node, host }) => {
406
+ const alpha = Number(values.alpha ?? node.config.alpha ?? 0.5);
407
+ if (!Number.isFinite(alpha)) {
408
+ throw new Error(`Flow node ${node.id} requires a finite alpha`);
409
+ }
410
+ await host.entities.setEntityTransparent(stringConfig(node, "entityId"), Math.max(0, Math.min(1, alpha)));
411
+ return {};
412
+ },
413
+ "action.entity.clearTransparent": async ({ node, host }) => {
414
+ await host.entities.setEntityTransparent(stringConfig(node, "entityId"), null);
415
+ return {};
416
+ },
417
+ "action.entity.setOutline": async ({ values, node, host }) => {
418
+ const color = values.color ?? node.config.color ?? "#3399ff";
419
+ if (typeof color !== "string") {
420
+ throw new Error(`Flow node ${node.id} requires a color string`);
421
+ }
422
+ await host.entities.setEntityOutline(stringConfig(node, "entityId"), color);
423
+ return {};
424
+ },
425
+ "action.entity.clearOutline": async ({ node, host }) => {
426
+ await host.entities.setEntityOutline(stringConfig(node, "entityId"), null);
427
+ return {};
428
+ },
429
+ "action.material.set": async ({ values, node, host }) => {
430
+ await host.materials.setMaterialProperty(stringConfig(node, "materialId"), materialProperty(node), values.value ?? node.config.value);
431
+ return {};
432
+ },
433
+ "action.materialAnimation.play": async ({ node, host, context }) => {
434
+ if (!host.materialAnimations)
435
+ throw new Error("Material animation capability is unavailable");
436
+ await host.materialAnimations.playMaterialAnimation(stringConfig(node, "materialId"), stringConfig(node, "animationId"), { signal: context.signal });
437
+ return {};
438
+ },
439
+ "action.materialAnimation.pause": async ({ node, host }) => {
440
+ if (!host.materialAnimations)
441
+ throw new Error("Material animation capability is unavailable");
442
+ await host.materialAnimations.pauseMaterialAnimation(stringConfig(node, "materialId"), stringConfig(node, "animationId"));
443
+ return {};
444
+ },
445
+ "action.materialAnimation.resume": async ({ node, host }) => {
446
+ if (!host.materialAnimations)
447
+ throw new Error("Material animation capability is unavailable");
448
+ await host.materialAnimations.resumeMaterialAnimation(stringConfig(node, "materialId"), stringConfig(node, "animationId"));
449
+ return {};
450
+ },
451
+ "action.materialAnimation.stop": async ({ node, host }) => {
452
+ if (!host.materialAnimations)
453
+ throw new Error("Material animation capability is unavailable");
454
+ await host.materialAnimations.stopMaterialAnimation(stringConfig(node, "materialId"), stringConfig(node, "animationId"));
455
+ return {};
456
+ },
457
+ "action.materialAnimation.reset": async ({ node, host }) => {
458
+ if (!host.materialAnimations)
459
+ throw new Error("Material animation capability is unavailable");
460
+ await host.materialAnimations.resetMaterialAnimation(stringConfig(node, "materialId"), stringConfig(node, "animationId"));
461
+ return {};
462
+ },
463
+ "action.camera.frame": async ({ values, node, host }) => {
464
+ const id = values.entityId ?? node.config.entityId;
465
+ await host.camera.frameEntity(typeof id === "string" ? id : null);
466
+ return {};
467
+ },
468
+ "action.camera.activateEntity": async ({ values, node, host }) => {
469
+ const entityId = values.entityId ?? node.config.entityId;
470
+ if (typeof entityId !== "string") {
471
+ throw new Error(`Flow node ${node.id} requires a camera entityId`);
472
+ }
473
+ await host.camera.activateCameraEntity(entityId, Boolean(values.controls ?? node.config.controls ?? false));
474
+ return {};
475
+ },
476
+ "action.camera.restoreViewport": async ({ host }) => {
477
+ await host.camera.restoreViewportCamera();
478
+ return {};
479
+ },
480
+ "action.camera.playPath": async ({ values, node, host }) => {
481
+ const pathId = values.pathId ?? node.config.pathId;
482
+ if (typeof pathId !== "string") {
483
+ throw new Error(`Flow node ${node.id} requires a camera pathId`);
484
+ }
485
+ await host.camera.playCameraPath(pathId);
486
+ return {};
487
+ },
488
+ "action.camera.pausePath": async ({ host }) => {
489
+ await host.camera.pauseCameraPath();
490
+ return {};
491
+ },
492
+ "action.camera.resumePath": async ({ host }) => {
493
+ await host.camera.resumeCameraPath();
494
+ return {};
495
+ },
496
+ "action.camera.stopPath": async ({ host }) => {
497
+ await host.camera.stopCameraPath();
498
+ return {};
499
+ },
500
+ "action.animation.play": async ({ values, node, host }) => {
501
+ const animation = values.animation ?? node.config.animation;
502
+ await host.animations.playAnimation(stringConfig(node, "entityId"), typeof animation === "string" ? animation : null);
503
+ return {};
504
+ },
505
+ "action.animation.stop": async ({ values, node, host }) => {
506
+ const animation = values.animation ?? node.config.animation;
507
+ await host.animations.stopAnimation(stringConfig(node, "entityId"), typeof animation === "string" ? animation : null);
508
+ return {};
509
+ },
510
+ "action.particle.start": async ({ node, host }) => {
511
+ await host.particles.setParticleRunning(stringConfig(node, "entityId"), true);
512
+ return {};
513
+ },
514
+ "action.particle.stop": async ({ node, host }) => {
515
+ await host.particles.setParticleRunning(stringConfig(node, "entityId"), false);
516
+ return {};
517
+ },
518
+ "action.textureAnimation.play": async ({ node, host, context }) => {
519
+ await host.textureAnimations.playTextureAnimation(stringConfig(node, "animationId"), { signal: context.signal });
520
+ return {};
521
+ },
522
+ "action.textureAnimation.stop": async ({ node, host }) => {
523
+ await host.textureAnimations.stopTextureAnimation(stringConfig(node, "animationId"));
524
+ return {};
525
+ },
526
+ "action.event.emit": async ({ values, node, host, context }) => {
527
+ await host.events.emit(stringConfig(node, "event"), values.payload ?? node.config.payload, flowEventMetadata(context));
528
+ return {};
529
+ },
530
+ };
531
+ }
532
+ function controlTargets(graph, sourceNodeId, sourcePort) {
533
+ return graph.edges.filter((edge) => edge.kind === "control" &&
534
+ edge.sourceNodeId === sourceNodeId &&
535
+ edge.sourcePort === sourcePort);
536
+ }
537
+ function stringConfig(node, key, fallback) {
538
+ const value = node.config[key] ?? fallback;
539
+ if (typeof value !== "string" || !value) {
540
+ throw new Error(`Flow node ${node.id} requires string config: ${key}`);
541
+ }
542
+ return value;
543
+ }
544
+ function numberConfig(node, key, fallback) {
545
+ const value = node.config[key];
546
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
547
+ }
548
+ function materialProperty(node) {
549
+ const property = stringConfig(node, "property");
550
+ if (!FLOW_MATERIAL_PROPERTIES.includes(property)) {
551
+ throw new Error(`Flow node ${node.id} has unsupported material property`);
552
+ }
553
+ return property;
554
+ }
555
+ function flowEventMetadata(context) {
556
+ return {
557
+ source: "flow",
558
+ correlationId: context.correlationId,
559
+ ...(context.causationId ? { causationId: context.causationId } : {}),
560
+ };
561
+ }
562
+ function transformUpdate(value, nodeId) {
563
+ if (!isRecord(value)) {
564
+ throw new Error(`Flow node ${nodeId} requires a transform object`);
565
+ }
566
+ for (const key of ["position", "rotation", "scaling"]) {
567
+ const component = value[key];
568
+ if (component !== undefined &&
569
+ !isNumericRecord(component, ["x", "y", "z"])) {
570
+ throw new Error(`Flow node ${nodeId} has an invalid transform.${key}`);
571
+ }
572
+ }
573
+ const quaternion = value.rotationQuaternion;
574
+ if (quaternion !== undefined &&
575
+ quaternion !== null &&
576
+ !isNumericRecord(quaternion, ["x", "y", "z", "w"])) {
577
+ throw new Error(`Flow node ${nodeId} has an invalid transform.rotationQuaternion`);
578
+ }
579
+ return value;
580
+ }
581
+ function isNumericRecord(value, keys) {
582
+ if (!isRecord(value))
583
+ return false;
584
+ return keys.every((key) => value[key] === undefined ||
585
+ (typeof value[key] === "number" && Number.isFinite(value[key])));
586
+ }
587
+ function isRecord(value) {
588
+ return value !== null && typeof value === "object" && !Array.isArray(value);
589
+ }
590
+ function compare(left, right, operator) {
591
+ if (operator === "equals")
592
+ return Object.is(left, right);
593
+ if (operator === "notEquals")
594
+ return !Object.is(left, right);
595
+ if (operator === "contains")
596
+ return String(left).includes(String(right));
597
+ if (operator === "greater")
598
+ return Number(left) > Number(right);
599
+ if (operator === "greaterOrEqual")
600
+ return Number(left) >= Number(right);
601
+ if (operator === "less")
602
+ return Number(left) < Number(right);
603
+ if (operator === "lessOrEqual")
604
+ return Number(left) <= Number(right);
605
+ if (operator === "isEmpty")
606
+ return left === null || left === undefined || left === "";
607
+ throw new Error(`Unsupported comparison operator: ${operator}`);
608
+ }
609
+ function calculate(left, right, operator, nodeId) {
610
+ const a = Number(left);
611
+ const b = Number(right);
612
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
613
+ throw new Error(`Flow node ${nodeId} requires finite numeric inputs`);
614
+ }
615
+ if (operator === "add")
616
+ return a + b;
617
+ if (operator === "subtract")
618
+ return a - b;
619
+ if (operator === "multiply")
620
+ return a * b;
621
+ if (operator === "divide") {
622
+ if (b === 0)
623
+ throw new Error(`Flow node ${nodeId} cannot divide by zero`);
624
+ return a / b;
625
+ }
626
+ if (operator === "min")
627
+ return Math.min(a, b);
628
+ if (operator === "max")
629
+ return Math.max(a, b);
630
+ if (operator === "modulo") {
631
+ if (b === 0)
632
+ throw new Error(`Flow node ${nodeId} cannot modulo by zero`);
633
+ return a % b;
634
+ }
635
+ throw new Error(`Unsupported math operator: ${operator}`);
636
+ }
637
+ function logic(left, right, operator) {
638
+ if (operator === "not")
639
+ return !Boolean(left);
640
+ if (operator === "and")
641
+ return Boolean(left) && Boolean(right);
642
+ if (operator === "or")
643
+ return Boolean(left) || Boolean(right);
644
+ if (operator === "xor")
645
+ return Boolean(left) !== Boolean(right);
646
+ throw new Error(`Unsupported logic operator: ${operator}`);
647
+ }
648
+ function getValueAtPath(value, path) {
649
+ const segments = path.split(".").filter(Boolean);
650
+ let current = value;
651
+ for (const segment of segments) {
652
+ if (["__proto__", "prototype", "constructor"].includes(segment)) {
653
+ throw new Error(`Unsafe object path segment: ${segment}`);
654
+ }
655
+ if (!isRecord(current) && !Array.isArray(current))
656
+ return undefined;
657
+ current = current[segment];
658
+ }
659
+ return current;
660
+ }
661
+ function matchesTrigger(node, payload) {
662
+ if (!payload || typeof payload !== "object")
663
+ return true;
664
+ const record = payload;
665
+ const entityId = node.config.entityId;
666
+ if (typeof entityId === "string" && record.entityId !== entityId)
667
+ return false;
668
+ const animation = node.config.animation;
669
+ if (typeof animation === "string" && record.animation !== animation)
670
+ return false;
671
+ const name = node.config.name;
672
+ if (typeof name === "string" && record.name !== name)
673
+ return false;
674
+ return true;
675
+ }
676
+ function abortableDelay(durationMs, signal) {
677
+ if (signal.aborted)
678
+ return Promise.resolve();
679
+ return new Promise((resolve) => {
680
+ const timer = globalThis.setTimeout(resolve, Math.max(durationMs, 0));
681
+ signal.addEventListener("abort", () => {
682
+ globalThis.clearTimeout(timer);
683
+ resolve();
684
+ }, { once: true });
685
+ });
686
+ }
687
+ function throwIfFlowAborted(signal) {
688
+ if (!signal.aborted)
689
+ return;
690
+ const error = new Error("Flow execution was aborted");
691
+ error.name = "AbortError";
692
+ throw error;
693
+ }
694
+ let runSequence = 0;
695
+ function createRunId() {
696
+ runSequence += 1;
697
+ return `flow-run-${Date.now()}-${runSequence}`;
698
+ }
699
+ function ownFlowGraph(graph) {
700
+ return freezeGraph(structuredClone(graph));
701
+ }
702
+ function freezeGraph(value) {
703
+ if (value === null || typeof value !== "object" || Object.isFrozen(value)) {
704
+ return value;
705
+ }
706
+ for (const child of Object.values(value))
707
+ freezeGraph(child);
708
+ Object.freeze(value);
709
+ return value;
710
+ }
711
+ //# sourceMappingURL=flow-runtime.js.map