@almadar/runtime 1.2.3 → 2.0.0

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.
@@ -6,13 +6,28 @@ import { OrbitalSchemaSchema, isEntityReference, parseEntityRef, parseImportedTr
6
6
  var EventBus = class {
7
7
  listeners = /* @__PURE__ */ new Map();
8
8
  debug;
9
+ /** Maximum recursion depth before circuit breaker activates (RCG-05) */
10
+ maxDepth;
11
+ /** Current emission depth for circular loop detection */
12
+ depth = 0;
9
13
  constructor(options = {}) {
10
14
  this.debug = options.debug ?? false;
15
+ this.maxDepth = options.maxDepth ?? 10;
11
16
  }
12
17
  /**
13
- * Emit an event to all registered listeners
18
+ * Emit an event to all registered listeners.
19
+ *
20
+ * Includes circuit breaker (RCG-05): if emit is called recursively
21
+ * beyond `maxDepth`, the event is dropped and an error is logged.
22
+ * This prevents infinite loops from circular emit/listen chains.
14
23
  */
15
24
  emit(type, payload, source) {
25
+ if (this.depth >= this.maxDepth) {
26
+ console.error(
27
+ `[EventBus] Circular event loop detected: "${type}" at depth ${this.depth}. Event dropped to prevent infinite recursion. Increase maxDepth (currently ${this.maxDepth}) if this is intentional.`
28
+ );
29
+ return;
30
+ }
16
31
  const event = {
17
32
  type,
18
33
  payload,
@@ -23,32 +38,37 @@ var EventBus = class {
23
38
  const listenerCount = listeners?.size ?? 0;
24
39
  if (this.debug) {
25
40
  if (listenerCount > 0) {
26
- console.log(`[EventBus] Emit: ${type} \u2192 ${listenerCount} listener(s)`, payload);
41
+ console.log(`[EventBus] Emit: ${type} \u2192 ${listenerCount} listener(s) (depth: ${this.depth})`, payload);
27
42
  } else {
28
43
  console.warn(`[EventBus] Emit: ${type} (NO LISTENERS)`, payload);
29
44
  }
30
45
  }
31
- if (listeners) {
32
- const listenersCopy = Array.from(listeners);
33
- for (const listener of listenersCopy) {
34
- try {
35
- listener(event);
36
- } catch (error) {
37
- console.error(`[EventBus] Error in listener for '${type}':`, error);
38
- }
39
- }
40
- }
41
- if (type !== "*") {
42
- const wildcardListeners = this.listeners.get("*");
43
- if (wildcardListeners) {
44
- for (const listener of Array.from(wildcardListeners)) {
46
+ this.depth++;
47
+ try {
48
+ if (listeners) {
49
+ const listenersCopy = Array.from(listeners);
50
+ for (const listener of listenersCopy) {
45
51
  try {
46
52
  listener(event);
47
53
  } catch (error) {
48
- console.error(`[EventBus] Error in wildcard listener:`, error);
54
+ console.error(`[EventBus] Error in listener for '${type}':`, error);
49
55
  }
50
56
  }
51
57
  }
58
+ if (type !== "*") {
59
+ const wildcardListeners = this.listeners.get("*");
60
+ if (wildcardListeners) {
61
+ for (const listener of Array.from(wildcardListeners)) {
62
+ try {
63
+ listener(event);
64
+ } catch (error) {
65
+ console.error(`[EventBus] Error in wildcard listener:`, error);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ } finally {
71
+ this.depth--;
52
72
  }
53
73
  }
54
74
  /**
@@ -251,12 +271,16 @@ function extractBindings(value) {
251
271
  collect(value);
252
272
  return [...new Set(bindings)];
253
273
  }
254
- function createContextFromBindings(bindings) {
255
- return createMinimalContext(
274
+ function createContextFromBindings(bindings, strictBindings) {
275
+ const ctx = createMinimalContext(
256
276
  bindings.entity || {},
257
277
  bindings.payload || {},
258
278
  bindings.state || "idle"
259
279
  );
280
+ if (strictBindings) {
281
+ ctx.strictBindings = true;
282
+ }
283
+ return ctx;
260
284
  }
261
285
  function findInitialState(trait) {
262
286
  if (!trait.states || trait.states.length === 0) {
@@ -292,7 +316,15 @@ function normalizeEventKey(eventKey) {
292
316
  return eventKey.startsWith("UI:") ? eventKey.slice(3) : eventKey;
293
317
  }
294
318
  function processEvent(options) {
295
- const { traitState, trait, eventKey, payload, entityData } = options;
319
+ const {
320
+ traitState,
321
+ trait,
322
+ eventKey,
323
+ payload,
324
+ entityData,
325
+ guardMode = "permissive",
326
+ strictBindings = false
327
+ } = options;
296
328
  const normalizedEvent = normalizeEventKey(eventKey);
297
329
  const transition = findTransition(trait, traitState.currentState, normalizedEvent);
298
330
  if (!transition) {
@@ -308,7 +340,7 @@ function processEvent(options) {
308
340
  entity: entityData,
309
341
  payload,
310
342
  state: traitState.currentState
311
- });
343
+ }, strictBindings);
312
344
  try {
313
345
  const guardPasses = evaluateGuard(
314
346
  transition.guard,
@@ -329,6 +361,24 @@ function processEvent(options) {
329
361
  };
330
362
  }
331
363
  } catch (error) {
364
+ if (guardMode === "strict") {
365
+ console.error(
366
+ `[StateMachineCore] Guard error blocks transition ${traitState.currentState}\u2192${transition.to} (${normalizedEvent}):`,
367
+ error
368
+ );
369
+ return {
370
+ executed: false,
371
+ newState: traitState.currentState,
372
+ previousState: traitState.currentState,
373
+ effects: [],
374
+ transition: {
375
+ from: traitState.currentState,
376
+ to: transition.to,
377
+ event: normalizedEvent
378
+ },
379
+ guardResult: false
380
+ };
381
+ }
332
382
  console.error("[StateMachineCore] Guard evaluation error:", error);
333
383
  }
334
384
  }
@@ -348,11 +398,23 @@ function processEvent(options) {
348
398
  var StateMachineManager = class {
349
399
  traits = /* @__PURE__ */ new Map();
350
400
  states = /* @__PURE__ */ new Map();
351
- constructor(traits = []) {
401
+ config;
402
+ observer;
403
+ constructor(traits = [], config = {}, observer) {
404
+ this.config = config;
405
+ this.observer = observer;
352
406
  for (const trait of traits) {
353
407
  this.addTrait(trait);
354
408
  }
355
409
  }
410
+ /**
411
+ * Set the transition observer for runtime verification.
412
+ * Wire this to `verificationRegistry.recordTransition()` to enable
413
+ * automatic verification tracking.
414
+ */
415
+ setObserver(observer) {
416
+ this.observer = observer;
417
+ }
356
418
  /**
357
419
  * Add a trait to the manager.
358
420
  */
@@ -403,7 +465,9 @@ var StateMachineManager = class {
403
465
  trait,
404
466
  eventKey,
405
467
  payload,
406
- entityData
468
+ entityData,
469
+ guardMode: this.config.guardMode,
470
+ strictBindings: this.config.strictBindings
407
471
  });
408
472
  if (result.executed) {
409
473
  this.states.set(traitName, {
@@ -414,6 +478,17 @@ var StateMachineManager = class {
414
478
  context: { ...traitState.context, ...payload }
415
479
  });
416
480
  results.push({ traitName, result });
481
+ if (this.observer && result.transition) {
482
+ this.observer.onTransition({
483
+ traitName,
484
+ from: result.transition.from,
485
+ to: result.transition.to,
486
+ event: result.transition.event,
487
+ guardResult: result.guardResult,
488
+ // Effects will be traced when executed — placeholder here
489
+ effects: []
490
+ });
491
+ }
417
492
  }
418
493
  }
419
494
  return results;
@@ -437,6 +512,27 @@ var StateMachineManager = class {
437
512
  }
438
513
  };
439
514
 
515
+ // src/types.ts
516
+ var HANDLER_MANIFEST = {
517
+ client: ["render-ui", "render", "navigate", "notify", "emit", "set", "log"],
518
+ server: ["persist", "fetch", "call-service", "emit", "set", "spawn", "despawn", "log"],
519
+ test: [
520
+ "render-ui",
521
+ "render",
522
+ "navigate",
523
+ "notify",
524
+ "emit",
525
+ "set",
526
+ "persist",
527
+ "fetch",
528
+ "call-service",
529
+ "spawn",
530
+ "despawn",
531
+ "log"
532
+ ],
533
+ ssr: ["render-ui", "render", "fetch", "emit", "set", "log"]
534
+ };
535
+
440
536
  // src/EffectExecutor.ts
441
537
  function parseEffect(effect) {
442
538
  if (!Array.isArray(effect) || effect.length === 0) {
@@ -448,8 +544,8 @@ function parseEffect(effect) {
448
544
  }
449
545
  return { operator, args };
450
546
  }
451
- function resolveArgs(args, bindings) {
452
- const ctx = createContextFromBindings(bindings);
547
+ function resolveArgs(args, bindings, strictBindings) {
548
+ const ctx = createContextFromBindings(bindings, strictBindings);
453
549
  return args.map((arg) => interpolateValue(arg, ctx));
454
550
  }
455
551
  var EffectExecutor = class {
@@ -457,11 +553,77 @@ var EffectExecutor = class {
457
553
  bindings;
458
554
  context;
459
555
  debug;
556
+ strictBindings;
460
557
  constructor(options) {
461
558
  this.handlers = options.handlers;
462
559
  this.bindings = options.bindings;
463
560
  this.context = options.context;
464
561
  this.debug = options.debug ?? false;
562
+ this.strictBindings = options.strictBindings ?? false;
563
+ }
564
+ // ==========================================================================
565
+ // Handler Manifest Validation (RCG-03)
566
+ // ==========================================================================
567
+ /**
568
+ * Validate that all effect types used in a schema have handlers registered.
569
+ * Call this at runtime startup to catch missing handler setup immediately.
570
+ *
571
+ * @param usedEffectTypes - Effect operator names used in the loaded schemas
572
+ * @param environment - Execution environment for context-aware error messages
573
+ * @returns Array of missing handler errors (empty if all handlers are available)
574
+ *
575
+ * @example
576
+ * ```ts
577
+ * const missing = EffectExecutor.validateHandlers(
578
+ * ['persist', 'render-ui', 'fetch'],
579
+ * executor.getRegisteredHandlers(),
580
+ * 'client'
581
+ * );
582
+ * if (missing.length > 0) {
583
+ * console.error('Missing handlers:', missing);
584
+ * }
585
+ * ```
586
+ */
587
+ static validateHandlers(usedEffectTypes, registeredHandlers, environment) {
588
+ const errors = [];
589
+ const expectedHandlers = environment ? HANDLER_MANIFEST[environment] : void 0;
590
+ for (const effectType of usedEffectTypes) {
591
+ if (!registeredHandlers.includes(effectType)) {
592
+ let message = `Effect "${effectType}" is used in schema but no handler is registered.`;
593
+ if (expectedHandlers && !expectedHandlers.includes(effectType)) {
594
+ message += ` Effect "${effectType}" is not expected in "${environment}" environment.`;
595
+ }
596
+ errors.push(message);
597
+ }
598
+ }
599
+ return errors;
600
+ }
601
+ /**
602
+ * Get list of effect operators that have handlers registered.
603
+ */
604
+ getRegisteredHandlers() {
605
+ const registered = [];
606
+ const handlerMap = {
607
+ "emit": this.handlers.emit,
608
+ "persist": this.handlers.persist,
609
+ "set": this.handlers.set,
610
+ "call-service": this.handlers.callService,
611
+ "fetch": this.handlers.fetch,
612
+ "spawn": this.handlers.spawn,
613
+ "despawn": this.handlers.despawn,
614
+ "render-ui": this.handlers.renderUI,
615
+ "render": this.handlers.renderUI,
616
+ "navigate": this.handlers.navigate,
617
+ "notify": this.handlers.notify,
618
+ "log": this.handlers.log
619
+ };
620
+ for (const [name, handler] of Object.entries(handlerMap)) {
621
+ if (handler) {
622
+ registered.push(name);
623
+ }
624
+ }
625
+ registered.push("do", "when");
626
+ return registered;
465
627
  }
466
628
  /**
467
629
  * Execute a single effect.
@@ -476,7 +638,7 @@ var EffectExecutor = class {
476
638
  }
477
639
  const { operator, args } = parsed;
478
640
  const isCompound = operator === "do" || operator === "when";
479
- const resolvedArgs = isCompound ? args : resolveArgs(args, this.bindings);
641
+ const resolvedArgs = isCompound ? args : resolveArgs(args, this.bindings, this.strictBindings);
480
642
  if (this.debug) {
481
643
  console.log("[EffectExecutor] Executing:", operator, resolvedArgs);
482
644
  }
@@ -502,6 +664,54 @@ var EffectExecutor = class {
502
664
  await Promise.all(effects.map((effect) => this.execute(effect)));
503
665
  }
504
666
  // ==========================================================================
667
+ // Effect Execution with Results (RCG-04)
668
+ // ==========================================================================
669
+ /**
670
+ * Execute effects and return detailed results for each.
671
+ * Enables compensating transitions by reporting which effects failed.
672
+ *
673
+ * Unlike `executeAll`, this method does NOT throw on effect errors.
674
+ * Instead, it captures errors in the returned `EffectResult[]` array.
675
+ */
676
+ async executeWithResults(effects) {
677
+ const results = [];
678
+ for (const effect of effects) {
679
+ const parsed = parseEffect(effect);
680
+ if (!parsed) {
681
+ results.push({
682
+ type: "unknown",
683
+ args: [],
684
+ status: "skipped",
685
+ error: "Invalid effect format"
686
+ });
687
+ continue;
688
+ }
689
+ const start = Date.now();
690
+ const { operator, args: rawArgs } = parsed;
691
+ const isCompound = operator === "do" || operator === "when";
692
+ const resolvedArgs = isCompound ? rawArgs : resolveArgs(rawArgs, this.bindings, this.strictBindings);
693
+ try {
694
+ await this.dispatch(operator, resolvedArgs);
695
+ results.push({
696
+ type: operator,
697
+ args: resolvedArgs,
698
+ status: "executed",
699
+ durationMs: Date.now() - start
700
+ });
701
+ } catch (error) {
702
+ const errorMessage = error instanceof Error ? error.message : String(error);
703
+ results.push({
704
+ type: operator,
705
+ args: resolvedArgs,
706
+ status: "failed",
707
+ error: errorMessage,
708
+ durationMs: Date.now() - start
709
+ });
710
+ }
711
+ }
712
+ return results;
713
+ }
714
+ // ==========================================================================
505
715
  // Effect Dispatch
506
716
  // ==========================================================================
507
717
  async dispatch(operator, args) {
@@ -520,9 +730,14 @@ var EffectExecutor = class {
520
730
  }
521
731
  case "persist": {
522
732
  const action = args[0];
523
- const entityType = args[1];
524
- const data = args[2];
525
- await this.handlers.persist(action, entityType, data);
733
+ if (action === "batch") {
734
+ const operations = args[1];
735
+ await this.handlers.persist("batch", "", { operations });
736
+ } else {
737
+ const entityType = args[1];
738
+ const data = args[2];
739
+ await this.handlers.persist(action, entityType, data);
740
+ }
526
741
  break;
527
742
  }
528
743
  case "call-service": {
@@ -1867,6 +2082,6 @@ function parseNamespacedEvent(eventName) {
1867
2082
  return { event: eventName };
1868
2083
  }
1869
2084
 
1870
- export { EffectExecutor, EventBus, StateMachineManager, containsBindings, createContextFromBindings, createInitialTraitState, createTestExecutor, createUnifiedLoader, extractBindings, findInitialState, findTransition, getIsolatedCollectionName, getNamespacedEvent, interpolateProps, interpolateValue, isNamespacedEvent, normalizeEventKey, parseNamespacedEvent, preprocessSchema, processEvent };
1871
- //# sourceMappingURL=chunk-QBQSJJF6.js.map
1872
- //# sourceMappingURL=chunk-QBQSJJF6.js.map
2085
+ export { EffectExecutor, EventBus, HANDLER_MANIFEST, StateMachineManager, containsBindings, createContextFromBindings, createInitialTraitState, createTestExecutor, createUnifiedLoader, extractBindings, findInitialState, findTransition, getIsolatedCollectionName, getNamespacedEvent, interpolateProps, interpolateValue, isNamespacedEvent, normalizeEventKey, parseNamespacedEvent, preprocessSchema, processEvent };
2086
+ //# sourceMappingURL=chunk-EOMQVFFE.js.map
2087
+ //# sourceMappingURL=chunk-EOMQVFFE.js.map