@camcima/finita 3.0.1 → 4.1.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.
package/dist/index.cjs CHANGED
@@ -52,6 +52,8 @@ __export(index_exports, {
52
52
  ProcessBuilder: () => ProcessBuilder,
53
53
  ProcessFinalizedError: () => ProcessFinalizedError,
54
54
  ProcessNotFoundError: () => ProcessNotFoundError,
55
+ QueueLimitExceededError: () => QueueLimitExceededError,
56
+ ReentrancyError: () => ReentrancyError,
55
57
  ScoreTransition: () => ScoreTransition,
56
58
  SingleProcessDetector: () => SingleProcessDetector,
57
59
  State: () => State,
@@ -74,23 +76,22 @@ var Event = class {
74
76
  name;
75
77
  observers = /* @__PURE__ */ new Set();
76
78
  metadata = /* @__PURE__ */ new Map();
77
- invokeArgs = [];
78
79
  constructor(name) {
79
80
  this.name = name;
80
81
  }
81
82
  getName() {
82
83
  return this.name;
83
84
  }
85
+ /**
86
+ * @deprecated Always returns []. Invoke args are now passed directly to
87
+ * Observer.update — reading them from the event was racy when one
88
+ * Process served multiple Statemachines.
89
+ */
84
90
  getInvokeArgs() {
85
- return this.invokeArgs;
91
+ return [];
86
92
  }
87
93
  async invoke(...args) {
88
- this.invokeArgs = args;
89
- try {
90
- await this.notify();
91
- } finally {
92
- this.invokeArgs = [];
93
- }
94
+ await this.notify(args);
94
95
  }
95
96
  attach(observer) {
96
97
  this.observers.add(observer);
@@ -98,9 +99,9 @@ var Event = class {
98
99
  detach(observer) {
99
100
  this.observers.delete(observer);
100
101
  }
101
- async notify() {
102
- for (const observer of this.observers) {
103
- await observer.update(this);
102
+ async notify(args) {
103
+ for (const observer of [...this.observers]) {
104
+ await observer.update(this, args);
104
105
  }
105
106
  }
106
107
  getObservers() {
@@ -256,6 +257,7 @@ var State = class {
256
257
  throw new Error(`State "${this.name}" transitions already set`);
257
258
  }
258
259
  this._transitions = new Set(transitions);
260
+ Object.freeze(this);
259
261
  }
260
262
  getName() {
261
263
  return this.name;
@@ -306,6 +308,7 @@ var Transition = class {
306
308
  this.eventName = eventName;
307
309
  this.condition = condition;
308
310
  this.weight = weight;
311
+ Object.freeze(this);
309
312
  }
310
313
  getTargetState() {
311
314
  return this.targetState;
@@ -382,8 +385,9 @@ var DuplicateTransitionError = class extends FinitaError {
382
385
  const eventLabel = conflict.eventName ?? "<automatic>";
383
386
  const existing = conflict.existingConditionName ?? "<no condition>";
384
387
  const incoming = conflict.newConditionName ?? "<no condition>";
388
+ const weightInfo = conflict.existingWeight !== void 0 && conflict.newWeight !== void 0 && conflict.existingWeight !== conflict.newWeight ? `, existing weight ${conflict.existingWeight} vs new weight ${conflict.newWeight}` : "";
385
389
  super(
386
- `Conflicting transition declarations from "${conflict.fromState}" to "${conflict.toState}" on event "${eventLabel}": existing condition "${existing}" vs new condition "${incoming}"`
390
+ `Conflicting transition declarations from "${conflict.fromState}" to "${conflict.toState}" on event "${eventLabel}": existing condition "${existing}" vs new condition "${incoming}"${weightInfo}`
387
391
  );
388
392
  this.name = "DuplicateTransitionError";
389
393
  this.conflict = Object.freeze({ ...conflict });
@@ -391,7 +395,7 @@ var DuplicateTransitionError = class extends FinitaError {
391
395
  };
392
396
 
393
397
  // src/ProcessBuilder.ts
394
- var ProcessBuilder = class {
398
+ var ProcessBuilder = class _ProcessBuilder {
395
399
  processName;
396
400
  stateSpecs = /* @__PURE__ */ new Map();
397
401
  transitionSpecs = [];
@@ -406,6 +410,9 @@ var ProcessBuilder = class {
406
410
  if (this.stateSpecs.has(name)) {
407
411
  throw new DuplicateStateError(name);
408
412
  }
413
+ this.validateName("invalidStateName", name, `addState("${name}")`, {
414
+ stateName: name
415
+ });
409
416
  this.stateSpecs.set(name, {
410
417
  name,
411
418
  initial: options.initial === true,
@@ -419,32 +426,37 @@ var ProcessBuilder = class {
419
426
  }
420
427
  let eventName = null;
421
428
  if (options.event !== void 0) {
422
- const raw = options.event;
423
- if (raw.trim() === "" || raw !== raw.trim()) {
424
- throw new GraphValidationError(
425
- "invalidEventName",
426
- `addTransition called with an empty or whitespace-padded event name from "${fromState}" to "${toState}"`,
427
- { fromState, toState, eventName: raw }
428
- );
429
- }
430
- eventName = raw;
429
+ this.validateName(
430
+ "invalidEventName",
431
+ options.event,
432
+ `addTransition called with an invalid event name from "${fromState}" to "${toState}"`,
433
+ { fromState, toState, eventName: options.event }
434
+ );
435
+ eventName = options.event;
431
436
  }
432
437
  if (options.condition) {
433
438
  const conditionName = options.condition.getName();
434
- if (conditionName.trim() === "") {
435
- throw new GraphValidationError(
436
- "invalidConditionName",
437
- `addTransition called with an empty/whitespace condition name from "${fromState}" to "${toState}"`,
438
- { fromState, toState, conditionName }
439
- );
440
- }
439
+ this.validateName(
440
+ "invalidConditionName",
441
+ conditionName,
442
+ `addTransition called with an invalid condition name from "${fromState}" to "${toState}"`,
443
+ { fromState, toState, conditionName }
444
+ );
445
+ }
446
+ const weight = options.weight ?? 1;
447
+ if (!Number.isFinite(weight)) {
448
+ throw new GraphValidationError(
449
+ "invalidTransitionWeight",
450
+ `addTransition from "${fromState}" to "${toState}": weight must be a finite number; got ${String(weight)}`,
451
+ { fromState, toState, eventName, weight }
452
+ );
441
453
  }
442
454
  this.transitionSpecs.push({
443
455
  fromState,
444
456
  toState,
445
457
  eventName,
446
458
  condition: options.condition ?? null,
447
- weight: options.weight ?? 1
459
+ weight
448
460
  });
449
461
  return this;
450
462
  }
@@ -452,7 +464,6 @@ var ProcessBuilder = class {
452
464
  if (this.built) {
453
465
  throw new ProcessFinalizedError(this.processName);
454
466
  }
455
- this.built = true;
456
467
  this.validateInitialState();
457
468
  this.validateTransitionEndpoints();
458
469
  this.validateNoConflictingDuplicates();
@@ -463,6 +474,7 @@ var ProcessBuilder = class {
463
474
  this.validateOrphans(finalStates, initialName);
464
475
  }
465
476
  const initialState = finalStates.get(initialName);
477
+ this.built = true;
466
478
  return new Process(
467
479
  INTERNAL_CONSTRUCTION_KEY,
468
480
  this.processName,
@@ -471,6 +483,16 @@ var ProcessBuilder = class {
471
483
  );
472
484
  }
473
485
  // --- private helpers ---
486
+ /** One name rule for every named entity: non-empty, no leading/trailing whitespace. */
487
+ validateName(code, raw, description, details) {
488
+ if (raw.trim() === "" || raw !== raw.trim()) {
489
+ throw new GraphValidationError(
490
+ code,
491
+ `${description}: name ${JSON.stringify(raw)} is empty or whitespace-padded`,
492
+ details
493
+ );
494
+ }
495
+ }
474
496
  validateInitialState() {
475
497
  const initials = Array.from(this.stateSpecs.values()).filter(
476
498
  (s) => s.initial
@@ -522,22 +544,29 @@ var ProcessBuilder = class {
522
544
  }
523
545
  }
524
546
  }
547
+ /** Transition identity: (fromState, eventName, toState). Used by both the
548
+ * conflict check and the build-time dedup — keep them in lockstep. */
549
+ static transitionKey(t) {
550
+ return `${t.fromState}\0${t.eventName ?? ""}\0${t.toState}`;
551
+ }
525
552
  validateNoConflictingDuplicates() {
526
553
  const seen = /* @__PURE__ */ new Map();
527
554
  for (const t of this.transitionSpecs) {
528
- const key = `${t.fromState}\0${t.eventName ?? ""}\0${t.toState}`;
555
+ const key = _ProcessBuilder.transitionKey(t);
529
556
  const existing = seen.get(key);
530
557
  if (!existing) {
531
558
  seen.set(key, t);
532
559
  continue;
533
560
  }
534
- if (existing.condition !== t.condition) {
561
+ if (existing.condition !== t.condition || existing.weight !== t.weight) {
535
562
  throw new DuplicateTransitionError({
536
563
  fromState: t.fromState,
537
564
  toState: t.toState,
538
565
  eventName: t.eventName,
539
566
  existingConditionName: existing.condition ? existing.condition.getName() : null,
540
- newConditionName: t.condition ? t.condition.getName() : null
567
+ newConditionName: t.condition ? t.condition.getName() : null,
568
+ existingWeight: existing.weight,
569
+ newWeight: t.weight
541
570
  });
542
571
  }
543
572
  }
@@ -581,23 +610,12 @@ var ProcessBuilder = class {
581
610
  );
582
611
  }
583
612
  const dedupSeen = /* @__PURE__ */ new Set();
584
- const conditionId = /* @__PURE__ */ new Map();
585
- let nextConditionId = 0;
586
- const idForCondition = (cond) => {
587
- if (cond === null) return "";
588
- let id = conditionId.get(cond);
589
- if (id === void 0) {
590
- id = ++nextConditionId;
591
- conditionId.set(cond, id);
592
- }
593
- return String(id);
594
- };
595
613
  const transitionsByState = /* @__PURE__ */ new Map();
596
614
  for (const spec of this.stateSpecs.values()) {
597
615
  transitionsByState.set(spec.name, []);
598
616
  }
599
617
  for (const tSpec of this.transitionSpecs) {
600
- const dedupKey = `${tSpec.fromState}\0${tSpec.eventName ?? ""}\0${tSpec.toState}\0${idForCondition(tSpec.condition)}`;
618
+ const dedupKey = _ProcessBuilder.transitionKey(tSpec);
601
619
  if (dedupSeen.has(dedupKey)) continue;
602
620
  dedupSeen.add(dedupKey);
603
621
  const targetState = built.get(tSpec.toState);
@@ -689,27 +707,6 @@ var NullMutex = class {
689
707
  }
690
708
  };
691
709
 
692
- // src/internal/Dispatcher.ts
693
- var Dispatcher = class {
694
- commands = [];
695
- ready = false;
696
- dispatch(event, args = []) {
697
- if (this.ready) {
698
- throw new Error("Was already invoked!");
699
- }
700
- this.commands.push({ event, args });
701
- }
702
- async invoke() {
703
- if (this.ready) {
704
- throw new Error("Was already invoked!");
705
- }
706
- for (const { event, args } of this.commands) {
707
- await event.invoke(...args);
708
- }
709
- this.ready = true;
710
- }
711
- };
712
-
713
710
  // src/internal/OperationQueue.ts
714
711
  var OperationQueue = class {
715
712
  items = [];
@@ -722,14 +719,18 @@ var OperationQueue = class {
722
719
  isEmpty() {
723
720
  return this.items.length === 0;
724
721
  }
722
+ size() {
723
+ return this.items.length;
724
+ }
725
725
  };
726
726
 
727
727
  // src/filter/ActiveTransitionFilter.ts
728
728
  var ActiveTransitionFilter = class {
729
- static async filter(transitions, subject, context, event) {
729
+ static async filter(transitions, subject, context, event, wrap) {
730
+ const run = wrap ?? ((fn) => fn());
730
731
  const active = [];
731
732
  for (const transition of transitions) {
732
- if (await transition.isActive(subject, context, event)) {
733
+ if (await run(() => transition.isActive(subject, context, event))) {
733
734
  active.push(transition);
734
735
  }
735
736
  }
@@ -762,16 +763,37 @@ var LockCanNotBeAcquiredError = class extends FinitaError {
762
763
  // src/error/AutomaticTransitionCycleError.ts
763
764
  var AutomaticTransitionCycleError = class extends FinitaError {
764
765
  code = "automaticTransitionCycle";
765
- targetStateName;
766
- visitedStateNames;
767
- constructor(targetStateName, visitedStateNames) {
768
- const visited = Array.from(visitedStateNames);
766
+ stateName;
767
+ hopLimit;
768
+ constructor(stateName, hopLimit) {
769
769
  super(
770
- `Automatic transition cycle detected: state "${targetStateName}" was already visited \u2014 this would cause infinite recursion`
770
+ `Automatic transitions exceeded ${hopLimit} hops without reaching a quiescent state (last target: "${stateName}") \u2014 the graph is likely looping forever. Raise maxAutomaticHops if the loop is legitimate and bounded. Transitions committed before this error are NOT rolled back.`
771
771
  );
772
772
  this.name = "AutomaticTransitionCycleError";
773
- this.targetStateName = targetStateName;
774
- this.visitedStateNames = Object.freeze([...visited]);
773
+ this.stateName = stateName;
774
+ this.hopLimit = hopLimit;
775
+ }
776
+ };
777
+
778
+ // src/error/ReentrancyError.ts
779
+ var ReentrancyError = class extends FinitaError {
780
+ code = "reentrancy";
781
+ constructor(operation) {
782
+ super(
783
+ `${operation} was called from inside an observer or condition of the same Statemachine. Awaiting it would deadlock: the machine runs one operation at a time and the runner is blocked on your callback. Where applicable, use the EnqueueContext passed to after-observers to chain events instead; from other callbacks, defer the call out of the synchronous path, e.g. queueMicrotask(() => sm.triggerEvent(...)).`
784
+ );
785
+ this.name = "ReentrancyError";
786
+ }
787
+ };
788
+
789
+ // src/error/QueueLimitExceededError.ts
790
+ var QueueLimitExceededError = class extends FinitaError {
791
+ code = "queueLimitExceeded";
792
+ constructor(limit, eventName) {
793
+ super(
794
+ `${eventName === null ? "checkTransitions()" : `triggerEvent("${eventName}")`} rejected: the operation queue already holds ${limit} pending operation(s) (maxQueueLength = ${limit}).`
795
+ );
796
+ this.name = "QueueLimitExceededError";
775
797
  }
776
798
  };
777
799
 
@@ -784,17 +806,39 @@ var Statemachine = class {
784
806
  currentState;
785
807
  lastState = null;
786
808
  autoreleaseLock;
809
+ maxAutomaticHops;
810
+ maxQueueLength;
787
811
  queue = new OperationQueue();
788
812
  running = false;
813
+ idleWaiters = [];
814
+ inSyncCallback = false;
789
815
  beforeObservers = [];
790
816
  afterObservers = [];
817
+ onChainedOperationError;
818
+ onReleaseError;
791
819
  constructor(subject, process, options = {}) {
792
820
  this.subject = subject;
793
821
  this.process = process;
794
- this.currentState = options.initialStateName ? process.getState(options.initialStateName) : process.getInitialState();
822
+ this.currentState = options.initialStateName !== void 0 ? process.getState(options.initialStateName) : process.getInitialState();
795
823
  this.transitionSelector = options.transitionSelector ?? new OneOrNoneActiveTransition();
796
824
  this.mutex = options.mutex ?? new NullMutex();
797
825
  this.autoreleaseLock = options.autoreleaseLock ?? true;
826
+ const hops = options.maxAutomaticHops ?? 100;
827
+ if (!Number.isInteger(hops) || hops < 1) {
828
+ throw new RangeError(
829
+ `maxAutomaticHops must be a positive integer; got ${String(options.maxAutomaticHops)}`
830
+ );
831
+ }
832
+ this.maxAutomaticHops = hops;
833
+ const maxQueue = options.maxQueueLength ?? Infinity;
834
+ if (maxQueue !== Infinity && (!Number.isInteger(maxQueue) || maxQueue < 1)) {
835
+ throw new RangeError(
836
+ `maxQueueLength must be a positive integer; got ${String(options.maxQueueLength)}`
837
+ );
838
+ }
839
+ this.maxQueueLength = maxQueue;
840
+ this.onChainedOperationError = options.onChainedOperationError;
841
+ this.onReleaseError = options.onReleaseError;
798
842
  }
799
843
  // --- public getters ---
800
844
  getCurrentState() {
@@ -811,6 +855,7 @@ var Statemachine = class {
811
855
  }
812
856
  // --- public observer attach/detach ---
813
857
  attachBefore(observer) {
858
+ if (this.beforeObservers.includes(observer)) return;
814
859
  this.beforeObservers.push(observer);
815
860
  }
816
861
  detachBefore(observer) {
@@ -821,6 +866,7 @@ var Statemachine = class {
821
866
  return this.beforeObservers;
822
867
  }
823
868
  attachAfter(observer) {
869
+ if (this.afterObservers.includes(observer)) return;
824
870
  this.afterObservers.push(observer);
825
871
  }
826
872
  detachAfter(observer) {
@@ -849,28 +895,64 @@ var Statemachine = class {
849
895
  // --- public top-level operations ---
850
896
  triggerEvent(name, context) {
851
897
  return new Promise((resolve, reject) => {
852
- this.queue.enqueue({
853
- kind: "triggerEvent",
854
- eventName: name,
855
- context: context ?? /* @__PURE__ */ new Map(),
856
- resolve,
857
- reject
858
- });
859
- void this.runIfIdle();
898
+ this.assertNotReentrant(`triggerEvent("${name}")`);
899
+ this.enqueueOperation(name, context, resolve, reject);
860
900
  });
861
901
  }
862
902
  checkTransitions(context) {
863
903
  return new Promise((resolve, reject) => {
864
- this.queue.enqueue({
865
- kind: "checkTransitions",
866
- eventName: null,
867
- context: context ?? /* @__PURE__ */ new Map(),
868
- resolve,
869
- reject
870
- });
871
- void this.runIfIdle();
904
+ this.assertNotReentrant("checkTransitions()");
905
+ this.enqueueOperation(null, context, resolve, reject);
906
+ });
907
+ }
908
+ /**
909
+ * Resolves once the operation queue is empty and the runner is idle —
910
+ * i.e. every operation enqueued so far, including operations chained via
911
+ * EnqueueContext.enqueue(), has completed. Resolves immediately if the
912
+ * machine is already idle. Note this is a quiescence point, not a
913
+ * receipt: work scheduled later (e.g. from a timer) starts a new drain.
914
+ */
915
+ whenIdle() {
916
+ if (!this.running && this.queue.isEmpty()) {
917
+ return Promise.resolve();
918
+ }
919
+ return new Promise((resolve) => {
920
+ this.idleWaiters.push(resolve);
872
921
  });
873
922
  }
923
+ /** Runs fn with the re-entrancy flag set for its SYNCHRONOUS portion only:
924
+ * the flag is cleared as soon as fn returns (before any promise it returned
925
+ * is awaited), so concurrent external callers are never affected. This
926
+ * catches triggerEvent/checkTransitions calls made before a callback's first
927
+ * await; calls made after a prior await are not detectable without
928
+ * AsyncLocalStorage (Node-only) and will still deadlock — a documented gap. */
929
+ guardSync(fn) {
930
+ this.inSyncCallback = true;
931
+ try {
932
+ return fn();
933
+ } finally {
934
+ this.inSyncCallback = false;
935
+ }
936
+ }
937
+ assertNotReentrant(operation) {
938
+ if (this.inSyncCallback) {
939
+ throw new ReentrancyError(operation);
940
+ }
941
+ }
942
+ /** Single entry point to the operation queue — every enqueue kicks the runner. */
943
+ enqueueOperation(eventName, context, resolve, reject, ifStateName) {
944
+ if (this.queue.size() >= this.maxQueueLength) {
945
+ throw new QueueLimitExceededError(this.maxQueueLength, eventName);
946
+ }
947
+ this.queue.enqueue({
948
+ eventName,
949
+ context: context ?? /* @__PURE__ */ new Map(),
950
+ ifStateName,
951
+ resolve,
952
+ reject
953
+ });
954
+ void this.runIfIdle();
955
+ }
874
956
  // --- internal runner ---
875
957
  async runIfIdle() {
876
958
  if (this.running) return;
@@ -882,10 +964,20 @@ var Statemachine = class {
882
964
  }
883
965
  } finally {
884
966
  this.running = false;
967
+ if (this.queue.isEmpty() && this.idleWaiters.length > 0) {
968
+ const waiters = this.idleWaiters;
969
+ this.idleWaiters = [];
970
+ for (const waiter of waiters) waiter();
971
+ }
885
972
  }
886
973
  }
887
974
  async runOperation(op) {
975
+ if (op.ifStateName !== void 0 && this.currentState.getName() !== op.ifStateName) {
976
+ op.resolve();
977
+ return;
978
+ }
888
979
  let acquiredHere = false;
980
+ let failure = null;
889
981
  try {
890
982
  if (!this.mutex.isAcquired()) {
891
983
  if (!await this.mutex.acquireLock()) {
@@ -893,19 +985,28 @@ var Statemachine = class {
893
985
  }
894
986
  acquiredHere = true;
895
987
  }
896
- const event = op.kind === "triggerEvent" ? this.resolveEvent(op.eventName) : null;
988
+ const event = op.eventName !== null ? this.resolveEvent(op.eventName) : null;
897
989
  await this.processOperation(event, op.context);
898
- op.resolve();
899
990
  } catch (err) {
900
- op.reject(err);
991
+ failure = { err };
901
992
  } finally {
902
993
  if (acquiredHere && this.autoreleaseLock) {
903
994
  try {
904
995
  await this.mutex.releaseLock();
905
- } catch {
996
+ } catch (err) {
997
+ try {
998
+ this.onReleaseError?.(err);
999
+ } catch {
1000
+ }
1001
+ if (!failure) failure = { err };
906
1002
  }
907
1003
  }
908
1004
  }
1005
+ if (failure) {
1006
+ op.reject(failure.err);
1007
+ } else {
1008
+ op.resolve();
1009
+ }
909
1010
  }
910
1011
  resolveEvent(name) {
911
1012
  if (!this.currentState.hasEvent(name)) {
@@ -920,11 +1021,13 @@ var Statemachine = class {
920
1021
  */
921
1022
  async processOperation(initialEvent, context) {
922
1023
  let event = initialEvent;
923
- const automaticVisited = /* @__PURE__ */ new Set();
1024
+ let automaticHops = 0;
924
1025
  if (event) {
925
- const dispatcher = new Dispatcher();
926
- dispatcher.dispatch(event, [this.subject, context]);
927
- await dispatcher.invoke();
1026
+ const userEvent = event;
1027
+ const invokeArgs = [this.subject, context];
1028
+ for (const observer of [...userEvent.getObservers()]) {
1029
+ await this.guardSync(() => observer.update(userEvent, invokeArgs));
1030
+ }
928
1031
  }
929
1032
  while (true) {
930
1033
  const transitions = this.currentState.getTransitions();
@@ -932,26 +1035,28 @@ var Statemachine = class {
932
1035
  transitions,
933
1036
  this.subject,
934
1037
  context,
935
- event ?? void 0
1038
+ event ?? void 0,
1039
+ (fn) => this.guardSync(fn)
936
1040
  );
937
- const selected = this.transitionSelector.selectTransition(
938
- active
1041
+ const selected = this.guardSync(
1042
+ () => this.transitionSelector.selectTransition(active)
939
1043
  );
940
1044
  if (!selected) {
941
1045
  return;
942
1046
  }
943
1047
  const target = selected.getTargetState();
944
1048
  if (selected.getEventName() === null) {
945
- automaticVisited.add(this.currentState);
946
- if (automaticVisited.has(target)) {
1049
+ automaticHops += 1;
1050
+ if (automaticHops > this.maxAutomaticHops) {
947
1051
  throw new AutomaticTransitionCycleError(
948
1052
  target.getName(),
949
- Array.from(automaticVisited).map((s) => s.getName())
1053
+ this.maxAutomaticHops
950
1054
  );
951
1055
  }
952
1056
  }
953
1057
  if (this.currentState !== target) {
954
- const proposedFrame = Object.freeze({
1058
+ const frame = Object.freeze({
1059
+ subject: this.subject,
955
1060
  fromState: this.currentState,
956
1061
  toState: target,
957
1062
  transition: selected,
@@ -961,39 +1066,34 @@ var Statemachine = class {
961
1066
  timestamp: Date.now(),
962
1067
  machineName: this.process.getName()
963
1068
  });
964
- for (const observer of this.beforeObservers) {
965
- await observer.notify(proposedFrame);
1069
+ for (const observer of [...this.beforeObservers]) {
1070
+ await this.guardSync(() => observer.notify(frame));
966
1071
  }
967
- const fromState = this.currentState;
968
- this.lastState = fromState;
1072
+ this.lastState = this.currentState;
969
1073
  this.currentState = target;
970
- const committedFrame = Object.freeze({
971
- fromState,
972
- toState: target,
973
- transition: selected,
974
- event,
975
- condition: selected.getCondition(),
976
- context: this.readonlyContext(context),
977
- timestamp: proposedFrame.timestamp,
978
- machineName: this.process.getName()
979
- });
980
1074
  const enqueueCtx = {
981
- enqueue: (chainedEventName, chainedCtx) => {
982
- this.queue.enqueue({
983
- kind: "triggerEvent",
984
- eventName: chainedEventName,
985
- context: chainedCtx ?? /* @__PURE__ */ new Map(),
986
- resolve: () => {
1075
+ enqueue: (chainedEventName, chainedCtx, ifStateName) => {
1076
+ this.enqueueOperation(
1077
+ chainedEventName,
1078
+ chainedCtx,
1079
+ () => {
1080
+ },
1081
+ (err) => {
1082
+ try {
1083
+ this.onChainedOperationError?.(err, {
1084
+ eventName: chainedEventName
1085
+ });
1086
+ } catch {
1087
+ }
987
1088
  },
988
- reject: () => {
989
- }
990
- });
1089
+ ifStateName
1090
+ );
991
1091
  }
992
1092
  };
993
1093
  const errors = [];
994
- for (const observer of this.afterObservers) {
1094
+ for (const observer of [...this.afterObservers]) {
995
1095
  try {
996
- await observer.notify(committedFrame, enqueueCtx);
1096
+ await this.guardSync(() => observer.notify(frame, enqueueCtx));
997
1097
  } catch (err) {
998
1098
  errors.push(err);
999
1099
  }
@@ -1060,6 +1160,23 @@ var CallbackCondition = class {
1060
1160
  }
1061
1161
  };
1062
1162
 
1163
+ // src/error/InvalidSubjectError.ts
1164
+ var InvalidSubjectError = class extends FinitaError {
1165
+ code = "invalidSubject";
1166
+ expectedInterface;
1167
+ missingMembers;
1168
+ constructor(expectedInterface, missingMembers) {
1169
+ const members = Array.from(missingMembers);
1170
+ const memberList = members.map((m) => `"${m}"`).join(", ");
1171
+ super(
1172
+ `Subject does not satisfy ${expectedInterface}; missing member(s): ${memberList || "(unknown)"}`
1173
+ );
1174
+ this.name = "InvalidSubjectError";
1175
+ this.expectedInterface = expectedInterface;
1176
+ this.missingMembers = Object.freeze([...members]);
1177
+ }
1178
+ };
1179
+
1063
1180
  // src/condition/Timeout.ts
1064
1181
  function isLastStateHasChangedDate(obj) {
1065
1182
  return typeof obj === "object" && obj !== null && "getLastStateHasChangedDate" in obj && typeof obj.getLastStateHasChangedDate === "function";
@@ -1078,30 +1195,40 @@ var Timeout = class {
1078
1195
  if (isLastStateHasChangedDate(subject)) {
1079
1196
  return subject.getLastStateHasChangedDate();
1080
1197
  }
1081
- throw new Error("Subject must implement LastStateHasChangedDateInterface");
1198
+ throw new InvalidSubjectError("LastStateHasChangedDateInterface", [
1199
+ "getLastStateHasChangedDate"
1200
+ ]);
1082
1201
  }
1083
1202
  checkCondition(subject, context) {
1084
- const date = new Date(
1085
- this.getLastStateHasChangedDate(subject, context).getTime()
1086
- );
1087
- date.setTime(date.getTime() + this.timeoutMs);
1088
- return date <= /* @__PURE__ */ new Date();
1203
+ return this.getLastStateHasChangedDate(subject, context).getTime() + this.timeoutMs <= Date.now();
1089
1204
  }
1090
1205
  };
1091
1206
 
1092
- // src/condition/AndComposite.ts
1093
- var AndComposite = class {
1207
+ // src/condition/CompositeCondition.ts
1208
+ var CompositeCondition = class {
1094
1209
  conditions = [];
1095
- constructor(condition) {
1210
+ joinWord;
1211
+ constructor(joinWord, condition) {
1212
+ this.joinWord = joinWord;
1096
1213
  this.conditions.push(condition);
1097
1214
  }
1098
- addAnd(condition) {
1215
+ addCondition(condition) {
1099
1216
  this.conditions.push(condition);
1100
1217
  return this;
1101
1218
  }
1102
1219
  getName() {
1103
1220
  const names = this.conditions.map((c) => c.getName());
1104
- return `(${names.join(" and ")})`;
1221
+ return `(${names.join(` ${this.joinWord} `)})`;
1222
+ }
1223
+ };
1224
+
1225
+ // src/condition/AndComposite.ts
1226
+ var AndComposite = class extends CompositeCondition {
1227
+ constructor(condition) {
1228
+ super("and", condition);
1229
+ }
1230
+ addAnd(condition) {
1231
+ return this.addCondition(condition);
1105
1232
  }
1106
1233
  async checkCondition(subject, context) {
1107
1234
  for (const condition of this.conditions) {
@@ -1114,18 +1241,12 @@ var AndComposite = class {
1114
1241
  };
1115
1242
 
1116
1243
  // src/condition/OrComposite.ts
1117
- var OrComposite = class {
1118
- conditions = [];
1244
+ var OrComposite = class extends CompositeCondition {
1119
1245
  constructor(condition) {
1120
- this.conditions.push(condition);
1246
+ super("or", condition);
1121
1247
  }
1122
1248
  addOr(condition) {
1123
- this.conditions.push(condition);
1124
- return this;
1125
- }
1126
- getName() {
1127
- const names = this.conditions.map((c) => c.getName());
1128
- return `(${names.join(" or ")})`;
1249
+ return this.addCondition(condition);
1129
1250
  }
1130
1251
  async checkCondition(subject, context) {
1131
1252
  for (const condition of this.conditions) {
@@ -1157,10 +1278,9 @@ var CallbackObserver = class {
1157
1278
  constructor(callback) {
1158
1279
  this.callback = callback;
1159
1280
  }
1160
- update(subject) {
1161
- const event = subject;
1162
- if (typeof event.getInvokeArgs === "function") {
1163
- return this.callback(...event.getInvokeArgs());
1281
+ update(subject, args) {
1282
+ if (args !== void 0) {
1283
+ return this.callback(...args);
1164
1284
  }
1165
1285
  return this.callback(subject);
1166
1286
  }
@@ -1169,11 +1289,19 @@ var CallbackObserver = class {
1169
1289
  // src/observer/StatefulStatusChanger.ts
1170
1290
  var StatefulStatusChanger = class {
1171
1291
  subject;
1292
+ /**
1293
+ * @param subject Optional explicit subject to write to. When omitted
1294
+ * (recommended), the observer writes to frame.subject — the subject of
1295
+ * whichever machine fired the transition — so a single instance can be
1296
+ * shared safely across every machine a Factory creates.
1297
+ */
1172
1298
  constructor(subject) {
1173
- this.subject = subject;
1299
+ this.subject = subject ?? null;
1174
1300
  }
1175
1301
  notify(frame) {
1176
- this.subject.setCurrentStateName(frame.toState.getName());
1302
+ (this.subject ?? frame.subject).setCurrentStateName(
1303
+ frame.toState.getName()
1304
+ );
1177
1305
  }
1178
1306
  };
1179
1307
 
@@ -1186,19 +1314,25 @@ var OnEnterObserver = class _OnEnterObserver {
1186
1314
  }
1187
1315
  notify(frame, ctx) {
1188
1316
  if (frame.toState.hasEvent(this.eventName)) {
1189
- ctx.enqueue(this.eventName, new Map(frame.context));
1317
+ ctx.enqueue(
1318
+ this.eventName,
1319
+ new Map(frame.context),
1320
+ frame.toState.getName()
1321
+ );
1190
1322
  }
1191
1323
  }
1192
1324
  };
1193
1325
 
1194
- // src/observer/TransitionLogger.ts
1326
+ // src/util/index.ts
1195
1327
  function isNamed(obj) {
1196
1328
  return typeof obj === "object" && obj !== null && "getName" in obj && typeof obj.getName === "function";
1197
1329
  }
1198
- function asString(obj) {
1330
+ function nameOrString(obj) {
1199
1331
  if (isNamed(obj)) return obj.getName();
1200
1332
  return String(obj);
1201
1333
  }
1334
+
1335
+ // src/observer/TransitionLogger.ts
1202
1336
  var TransitionLogger = class {
1203
1337
  logger;
1204
1338
  loggerLevel;
@@ -1208,7 +1342,7 @@ var TransitionLogger = class {
1208
1342
  }
1209
1343
  notify(frame) {
1210
1344
  let message = "Transition";
1211
- message += ` from "${asString(frame.fromState)}" to "${asString(frame.toState)}"`;
1345
+ message += ` from "${nameOrString(frame.fromState)}" to "${nameOrString(frame.toState)}"`;
1212
1346
  const eventName = frame.event ? frame.event.getName() : null;
1213
1347
  const conditionName = frame.condition ? frame.condition.getName() : null;
1214
1348
  if (eventName || conditionName) {
@@ -1315,23 +1449,30 @@ var WeightTransition = class {
1315
1449
  innerSelector;
1316
1450
  epsilon;
1317
1451
  constructor(innerSelector, epsilon = 1e-3) {
1452
+ if (!Number.isFinite(epsilon) || epsilon <= 0) {
1453
+ throw new RangeError(
1454
+ `WeightTransition epsilon must be a finite number greater than 0; got ${String(epsilon)}`
1455
+ );
1456
+ }
1318
1457
  this.innerSelector = innerSelector ?? new OneOrNoneActiveTransition();
1319
1458
  this.epsilon = epsilon;
1320
1459
  }
1321
1460
  selectTransition(transitions) {
1322
- let bestTransitions = [];
1323
- let bestWeight = null;
1324
- for (const transition of transitions) {
1461
+ const all = Array.from(transitions);
1462
+ let maxWeight = Number.NEGATIVE_INFINITY;
1463
+ for (const transition of all) {
1325
1464
  const weight = transition.getWeight();
1326
- const diff = weight - (bestWeight ?? 0);
1327
- if (bestWeight === null || diff >= this.epsilon) {
1328
- bestWeight = weight;
1329
- bestTransitions = [transition];
1330
- } else if (Math.abs(diff) < this.epsilon) {
1331
- bestTransitions.push(transition);
1465
+ if (!Number.isFinite(weight)) {
1466
+ throw new RangeError(
1467
+ `WeightTransition: transition weights must be finite numbers; got ${String(weight)}`
1468
+ );
1332
1469
  }
1470
+ if (weight > maxWeight) maxWeight = weight;
1333
1471
  }
1334
- return this.innerSelector.selectTransition(bestTransitions);
1472
+ const best = all.filter(
1473
+ (transition) => maxWeight - transition.getWeight() < this.epsilon
1474
+ );
1475
+ return this.innerSelector.selectTransition(best);
1335
1476
  }
1336
1477
  };
1337
1478
 
@@ -1474,23 +1615,6 @@ var AbstractNamedProcessDetector = class {
1474
1615
  }
1475
1616
  };
1476
1617
 
1477
- // src/error/InvalidSubjectError.ts
1478
- var InvalidSubjectError = class extends FinitaError {
1479
- code = "invalidSubject";
1480
- expectedInterface;
1481
- missingMembers;
1482
- constructor(expectedInterface, missingMembers) {
1483
- const members = Array.from(missingMembers);
1484
- const memberList = members.map((m) => `"${m}"`).join(", ");
1485
- super(
1486
- `Subject does not satisfy ${expectedInterface}; missing member(s): ${memberList || "(unknown)"}`
1487
- );
1488
- this.name = "InvalidSubjectError";
1489
- this.expectedInterface = expectedInterface;
1490
- this.missingMembers = Object.freeze([...members]);
1491
- }
1492
- };
1493
-
1494
1618
  // src/factory/StatefulStateNameDetector.ts
1495
1619
  function isStateful(obj) {
1496
1620
  return typeof obj === "object" && obj !== null && "getCurrentStateName" in obj && typeof obj.getCurrentStateName === "function";
@@ -1505,11 +1629,8 @@ var StatefulStateNameDetector = class {
1505
1629
  };
1506
1630
 
1507
1631
  // src/graph/GraphBuilder.ts
1508
- function isNamed2(obj) {
1509
- return typeof obj === "object" && obj !== null && "getName" in obj && typeof obj.getName === "function";
1510
- }
1511
- function escapeDoubleQuotes(str) {
1512
- return str.replace(/"/g, '\\"');
1632
+ function escapeDotString(str) {
1633
+ return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1513
1634
  }
1514
1635
  function toMermaidId(name) {
1515
1636
  return "s_" + name.replace(
@@ -1518,13 +1639,15 @@ function toMermaidId(name) {
1518
1639
  );
1519
1640
  }
1520
1641
  function escapeMermaidLabel(str) {
1521
- return str.replace(/"/g, "#quot;");
1642
+ return str.replace(/\\/g, "#92;").replace(/"/g, "#quot;");
1522
1643
  }
1523
- function convertToString(obj) {
1524
- if (isNamed2(obj)) {
1525
- return obj.getName();
1644
+ var VALID_DIRECTIONS = /* @__PURE__ */ new Set(["TB", "BT", "LR", "RL"]);
1645
+ function assertDirection(value, optionName) {
1646
+ if (!VALID_DIRECTIONS.has(value)) {
1647
+ throw new RangeError(
1648
+ `${optionName} must be one of "TB", "BT", "LR", "RL"; got ${JSON.stringify(value)}`
1649
+ );
1526
1650
  }
1527
- return String(obj);
1528
1651
  }
1529
1652
  var GraphBuilder = class {
1530
1653
  nodes = /* @__PURE__ */ new Map();
@@ -1544,13 +1667,15 @@ var GraphBuilder = class {
1544
1667
  const eventName = transition.getEventName();
1545
1668
  if (eventName) {
1546
1669
  parts.push(`E: ${eventName}`);
1547
- const event = state.getEvent(eventName);
1548
- const observerNames = [];
1549
- for (const observer of event.getObservers()) {
1550
- observerNames.push(convertToString(observer));
1551
- }
1552
- if (observerNames.length > 0) {
1553
- parts.push(`C: ${observerNames.join(", ")}`);
1670
+ if (state.hasEvent(eventName)) {
1671
+ const event = state.getEvent(eventName);
1672
+ const observerNames = [];
1673
+ for (const observer of event.getObservers()) {
1674
+ observerNames.push(nameOrString(observer));
1675
+ }
1676
+ if (observerNames.length > 0) {
1677
+ parts.push(`C: ${observerNames.join(", ")}`);
1678
+ }
1554
1679
  }
1555
1680
  }
1556
1681
  const conditionName = transition.getConditionName();
@@ -1599,17 +1724,18 @@ var GraphBuilder = class {
1599
1724
  toDot(options) {
1600
1725
  const graph = this.getGraph();
1601
1726
  const rankdir = options?.rankdir ?? "LR";
1727
+ assertDirection(rankdir, "rankdir");
1602
1728
  const lines = [];
1603
1729
  lines.push("digraph {");
1604
1730
  lines.push(` rankdir=${rankdir};`);
1605
1731
  for (const node of graph.nodes) {
1606
- const label = escapeDoubleQuotes(node.label);
1732
+ const label = escapeDotString(node.label);
1607
1733
  lines.push(` "${label}" [label="${label}"];`);
1608
1734
  }
1609
1735
  for (const edge of graph.edges) {
1610
- const source = escapeDoubleQuotes(edge.source);
1611
- const target = escapeDoubleQuotes(edge.target);
1612
- const label = escapeDoubleQuotes(edge.label);
1736
+ const source = escapeDotString(edge.source);
1737
+ const target = escapeDotString(edge.target);
1738
+ const label = escapeDotString(edge.label);
1613
1739
  lines.push(` "${source}" -> "${target}" [label="${label}"];`);
1614
1740
  }
1615
1741
  lines.push("}");
@@ -1618,6 +1744,7 @@ var GraphBuilder = class {
1618
1744
  toMermaid(options) {
1619
1745
  const graph = this.getGraph();
1620
1746
  const direction = options?.direction ?? "LR";
1747
+ assertDirection(direction, "direction");
1621
1748
  const lines = [];
1622
1749
  lines.push(`stateDiagram-v2`);
1623
1750
  lines.push(` direction ${direction}`);
@@ -1673,6 +1800,8 @@ var GraphBuilder = class {
1673
1800
  ProcessBuilder,
1674
1801
  ProcessFinalizedError,
1675
1802
  ProcessNotFoundError,
1803
+ QueueLimitExceededError,
1804
+ ReentrancyError,
1676
1805
  ScoreTransition,
1677
1806
  SingleProcessDetector,
1678
1807
  State,