@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.js CHANGED
@@ -3,23 +3,22 @@ var Event = class {
3
3
  name;
4
4
  observers = /* @__PURE__ */ new Set();
5
5
  metadata = /* @__PURE__ */ new Map();
6
- invokeArgs = [];
7
6
  constructor(name) {
8
7
  this.name = name;
9
8
  }
10
9
  getName() {
11
10
  return this.name;
12
11
  }
12
+ /**
13
+ * @deprecated Always returns []. Invoke args are now passed directly to
14
+ * Observer.update — reading them from the event was racy when one
15
+ * Process served multiple Statemachines.
16
+ */
13
17
  getInvokeArgs() {
14
- return this.invokeArgs;
18
+ return [];
15
19
  }
16
20
  async invoke(...args) {
17
- this.invokeArgs = args;
18
- try {
19
- await this.notify();
20
- } finally {
21
- this.invokeArgs = [];
22
- }
21
+ await this.notify(args);
23
22
  }
24
23
  attach(observer) {
25
24
  this.observers.add(observer);
@@ -27,9 +26,9 @@ var Event = class {
27
26
  detach(observer) {
28
27
  this.observers.delete(observer);
29
28
  }
30
- async notify() {
31
- for (const observer of this.observers) {
32
- await observer.update(this);
29
+ async notify(args) {
30
+ for (const observer of [...this.observers]) {
31
+ await observer.update(this, args);
33
32
  }
34
33
  }
35
34
  getObservers() {
@@ -185,6 +184,7 @@ var State = class {
185
184
  throw new Error(`State "${this.name}" transitions already set`);
186
185
  }
187
186
  this._transitions = new Set(transitions);
187
+ Object.freeze(this);
188
188
  }
189
189
  getName() {
190
190
  return this.name;
@@ -235,6 +235,7 @@ var Transition = class {
235
235
  this.eventName = eventName;
236
236
  this.condition = condition;
237
237
  this.weight = weight;
238
+ Object.freeze(this);
238
239
  }
239
240
  getTargetState() {
240
241
  return this.targetState;
@@ -311,8 +312,9 @@ var DuplicateTransitionError = class extends FinitaError {
311
312
  const eventLabel = conflict.eventName ?? "<automatic>";
312
313
  const existing = conflict.existingConditionName ?? "<no condition>";
313
314
  const incoming = conflict.newConditionName ?? "<no condition>";
315
+ const weightInfo = conflict.existingWeight !== void 0 && conflict.newWeight !== void 0 && conflict.existingWeight !== conflict.newWeight ? `, existing weight ${conflict.existingWeight} vs new weight ${conflict.newWeight}` : "";
314
316
  super(
315
- `Conflicting transition declarations from "${conflict.fromState}" to "${conflict.toState}" on event "${eventLabel}": existing condition "${existing}" vs new condition "${incoming}"`
317
+ `Conflicting transition declarations from "${conflict.fromState}" to "${conflict.toState}" on event "${eventLabel}": existing condition "${existing}" vs new condition "${incoming}"${weightInfo}`
316
318
  );
317
319
  this.name = "DuplicateTransitionError";
318
320
  this.conflict = Object.freeze({ ...conflict });
@@ -320,7 +322,7 @@ var DuplicateTransitionError = class extends FinitaError {
320
322
  };
321
323
 
322
324
  // src/ProcessBuilder.ts
323
- var ProcessBuilder = class {
325
+ var ProcessBuilder = class _ProcessBuilder {
324
326
  processName;
325
327
  stateSpecs = /* @__PURE__ */ new Map();
326
328
  transitionSpecs = [];
@@ -335,6 +337,9 @@ var ProcessBuilder = class {
335
337
  if (this.stateSpecs.has(name)) {
336
338
  throw new DuplicateStateError(name);
337
339
  }
340
+ this.validateName("invalidStateName", name, `addState("${name}")`, {
341
+ stateName: name
342
+ });
338
343
  this.stateSpecs.set(name, {
339
344
  name,
340
345
  initial: options.initial === true,
@@ -348,32 +353,37 @@ var ProcessBuilder = class {
348
353
  }
349
354
  let eventName = null;
350
355
  if (options.event !== void 0) {
351
- const raw = options.event;
352
- if (raw.trim() === "" || raw !== raw.trim()) {
353
- throw new GraphValidationError(
354
- "invalidEventName",
355
- `addTransition called with an empty or whitespace-padded event name from "${fromState}" to "${toState}"`,
356
- { fromState, toState, eventName: raw }
357
- );
358
- }
359
- eventName = raw;
356
+ this.validateName(
357
+ "invalidEventName",
358
+ options.event,
359
+ `addTransition called with an invalid event name from "${fromState}" to "${toState}"`,
360
+ { fromState, toState, eventName: options.event }
361
+ );
362
+ eventName = options.event;
360
363
  }
361
364
  if (options.condition) {
362
365
  const conditionName = options.condition.getName();
363
- if (conditionName.trim() === "") {
364
- throw new GraphValidationError(
365
- "invalidConditionName",
366
- `addTransition called with an empty/whitespace condition name from "${fromState}" to "${toState}"`,
367
- { fromState, toState, conditionName }
368
- );
369
- }
366
+ this.validateName(
367
+ "invalidConditionName",
368
+ conditionName,
369
+ `addTransition called with an invalid condition name from "${fromState}" to "${toState}"`,
370
+ { fromState, toState, conditionName }
371
+ );
372
+ }
373
+ const weight = options.weight ?? 1;
374
+ if (!Number.isFinite(weight)) {
375
+ throw new GraphValidationError(
376
+ "invalidTransitionWeight",
377
+ `addTransition from "${fromState}" to "${toState}": weight must be a finite number; got ${String(weight)}`,
378
+ { fromState, toState, eventName, weight }
379
+ );
370
380
  }
371
381
  this.transitionSpecs.push({
372
382
  fromState,
373
383
  toState,
374
384
  eventName,
375
385
  condition: options.condition ?? null,
376
- weight: options.weight ?? 1
386
+ weight
377
387
  });
378
388
  return this;
379
389
  }
@@ -381,7 +391,6 @@ var ProcessBuilder = class {
381
391
  if (this.built) {
382
392
  throw new ProcessFinalizedError(this.processName);
383
393
  }
384
- this.built = true;
385
394
  this.validateInitialState();
386
395
  this.validateTransitionEndpoints();
387
396
  this.validateNoConflictingDuplicates();
@@ -392,6 +401,7 @@ var ProcessBuilder = class {
392
401
  this.validateOrphans(finalStates, initialName);
393
402
  }
394
403
  const initialState = finalStates.get(initialName);
404
+ this.built = true;
395
405
  return new Process(
396
406
  INTERNAL_CONSTRUCTION_KEY,
397
407
  this.processName,
@@ -400,6 +410,16 @@ var ProcessBuilder = class {
400
410
  );
401
411
  }
402
412
  // --- private helpers ---
413
+ /** One name rule for every named entity: non-empty, no leading/trailing whitespace. */
414
+ validateName(code, raw, description, details) {
415
+ if (raw.trim() === "" || raw !== raw.trim()) {
416
+ throw new GraphValidationError(
417
+ code,
418
+ `${description}: name ${JSON.stringify(raw)} is empty or whitespace-padded`,
419
+ details
420
+ );
421
+ }
422
+ }
403
423
  validateInitialState() {
404
424
  const initials = Array.from(this.stateSpecs.values()).filter(
405
425
  (s) => s.initial
@@ -451,22 +471,29 @@ var ProcessBuilder = class {
451
471
  }
452
472
  }
453
473
  }
474
+ /** Transition identity: (fromState, eventName, toState). Used by both the
475
+ * conflict check and the build-time dedup — keep them in lockstep. */
476
+ static transitionKey(t) {
477
+ return `${t.fromState}\0${t.eventName ?? ""}\0${t.toState}`;
478
+ }
454
479
  validateNoConflictingDuplicates() {
455
480
  const seen = /* @__PURE__ */ new Map();
456
481
  for (const t of this.transitionSpecs) {
457
- const key = `${t.fromState}\0${t.eventName ?? ""}\0${t.toState}`;
482
+ const key = _ProcessBuilder.transitionKey(t);
458
483
  const existing = seen.get(key);
459
484
  if (!existing) {
460
485
  seen.set(key, t);
461
486
  continue;
462
487
  }
463
- if (existing.condition !== t.condition) {
488
+ if (existing.condition !== t.condition || existing.weight !== t.weight) {
464
489
  throw new DuplicateTransitionError({
465
490
  fromState: t.fromState,
466
491
  toState: t.toState,
467
492
  eventName: t.eventName,
468
493
  existingConditionName: existing.condition ? existing.condition.getName() : null,
469
- newConditionName: t.condition ? t.condition.getName() : null
494
+ newConditionName: t.condition ? t.condition.getName() : null,
495
+ existingWeight: existing.weight,
496
+ newWeight: t.weight
470
497
  });
471
498
  }
472
499
  }
@@ -510,23 +537,12 @@ var ProcessBuilder = class {
510
537
  );
511
538
  }
512
539
  const dedupSeen = /* @__PURE__ */ new Set();
513
- const conditionId = /* @__PURE__ */ new Map();
514
- let nextConditionId = 0;
515
- const idForCondition = (cond) => {
516
- if (cond === null) return "";
517
- let id = conditionId.get(cond);
518
- if (id === void 0) {
519
- id = ++nextConditionId;
520
- conditionId.set(cond, id);
521
- }
522
- return String(id);
523
- };
524
540
  const transitionsByState = /* @__PURE__ */ new Map();
525
541
  for (const spec of this.stateSpecs.values()) {
526
542
  transitionsByState.set(spec.name, []);
527
543
  }
528
544
  for (const tSpec of this.transitionSpecs) {
529
- const dedupKey = `${tSpec.fromState}\0${tSpec.eventName ?? ""}\0${tSpec.toState}\0${idForCondition(tSpec.condition)}`;
545
+ const dedupKey = _ProcessBuilder.transitionKey(tSpec);
530
546
  if (dedupSeen.has(dedupKey)) continue;
531
547
  dedupSeen.add(dedupKey);
532
548
  const targetState = built.get(tSpec.toState);
@@ -618,27 +634,6 @@ var NullMutex = class {
618
634
  }
619
635
  };
620
636
 
621
- // src/internal/Dispatcher.ts
622
- var Dispatcher = class {
623
- commands = [];
624
- ready = false;
625
- dispatch(event, args = []) {
626
- if (this.ready) {
627
- throw new Error("Was already invoked!");
628
- }
629
- this.commands.push({ event, args });
630
- }
631
- async invoke() {
632
- if (this.ready) {
633
- throw new Error("Was already invoked!");
634
- }
635
- for (const { event, args } of this.commands) {
636
- await event.invoke(...args);
637
- }
638
- this.ready = true;
639
- }
640
- };
641
-
642
637
  // src/internal/OperationQueue.ts
643
638
  var OperationQueue = class {
644
639
  items = [];
@@ -651,14 +646,18 @@ var OperationQueue = class {
651
646
  isEmpty() {
652
647
  return this.items.length === 0;
653
648
  }
649
+ size() {
650
+ return this.items.length;
651
+ }
654
652
  };
655
653
 
656
654
  // src/filter/ActiveTransitionFilter.ts
657
655
  var ActiveTransitionFilter = class {
658
- static async filter(transitions, subject, context, event) {
656
+ static async filter(transitions, subject, context, event, wrap) {
657
+ const run = wrap ?? ((fn) => fn());
659
658
  const active = [];
660
659
  for (const transition of transitions) {
661
- if (await transition.isActive(subject, context, event)) {
660
+ if (await run(() => transition.isActive(subject, context, event))) {
662
661
  active.push(transition);
663
662
  }
664
663
  }
@@ -691,16 +690,37 @@ var LockCanNotBeAcquiredError = class extends FinitaError {
691
690
  // src/error/AutomaticTransitionCycleError.ts
692
691
  var AutomaticTransitionCycleError = class extends FinitaError {
693
692
  code = "automaticTransitionCycle";
694
- targetStateName;
695
- visitedStateNames;
696
- constructor(targetStateName, visitedStateNames) {
697
- const visited = Array.from(visitedStateNames);
693
+ stateName;
694
+ hopLimit;
695
+ constructor(stateName, hopLimit) {
698
696
  super(
699
- `Automatic transition cycle detected: state "${targetStateName}" was already visited \u2014 this would cause infinite recursion`
697
+ `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.`
700
698
  );
701
699
  this.name = "AutomaticTransitionCycleError";
702
- this.targetStateName = targetStateName;
703
- this.visitedStateNames = Object.freeze([...visited]);
700
+ this.stateName = stateName;
701
+ this.hopLimit = hopLimit;
702
+ }
703
+ };
704
+
705
+ // src/error/ReentrancyError.ts
706
+ var ReentrancyError = class extends FinitaError {
707
+ code = "reentrancy";
708
+ constructor(operation) {
709
+ super(
710
+ `${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(...)).`
711
+ );
712
+ this.name = "ReentrancyError";
713
+ }
714
+ };
715
+
716
+ // src/error/QueueLimitExceededError.ts
717
+ var QueueLimitExceededError = class extends FinitaError {
718
+ code = "queueLimitExceeded";
719
+ constructor(limit, eventName) {
720
+ super(
721
+ `${eventName === null ? "checkTransitions()" : `triggerEvent("${eventName}")`} rejected: the operation queue already holds ${limit} pending operation(s) (maxQueueLength = ${limit}).`
722
+ );
723
+ this.name = "QueueLimitExceededError";
704
724
  }
705
725
  };
706
726
 
@@ -713,17 +733,39 @@ var Statemachine = class {
713
733
  currentState;
714
734
  lastState = null;
715
735
  autoreleaseLock;
736
+ maxAutomaticHops;
737
+ maxQueueLength;
716
738
  queue = new OperationQueue();
717
739
  running = false;
740
+ idleWaiters = [];
741
+ inSyncCallback = false;
718
742
  beforeObservers = [];
719
743
  afterObservers = [];
744
+ onChainedOperationError;
745
+ onReleaseError;
720
746
  constructor(subject, process, options = {}) {
721
747
  this.subject = subject;
722
748
  this.process = process;
723
- this.currentState = options.initialStateName ? process.getState(options.initialStateName) : process.getInitialState();
749
+ this.currentState = options.initialStateName !== void 0 ? process.getState(options.initialStateName) : process.getInitialState();
724
750
  this.transitionSelector = options.transitionSelector ?? new OneOrNoneActiveTransition();
725
751
  this.mutex = options.mutex ?? new NullMutex();
726
752
  this.autoreleaseLock = options.autoreleaseLock ?? true;
753
+ const hops = options.maxAutomaticHops ?? 100;
754
+ if (!Number.isInteger(hops) || hops < 1) {
755
+ throw new RangeError(
756
+ `maxAutomaticHops must be a positive integer; got ${String(options.maxAutomaticHops)}`
757
+ );
758
+ }
759
+ this.maxAutomaticHops = hops;
760
+ const maxQueue = options.maxQueueLength ?? Infinity;
761
+ if (maxQueue !== Infinity && (!Number.isInteger(maxQueue) || maxQueue < 1)) {
762
+ throw new RangeError(
763
+ `maxQueueLength must be a positive integer; got ${String(options.maxQueueLength)}`
764
+ );
765
+ }
766
+ this.maxQueueLength = maxQueue;
767
+ this.onChainedOperationError = options.onChainedOperationError;
768
+ this.onReleaseError = options.onReleaseError;
727
769
  }
728
770
  // --- public getters ---
729
771
  getCurrentState() {
@@ -740,6 +782,7 @@ var Statemachine = class {
740
782
  }
741
783
  // --- public observer attach/detach ---
742
784
  attachBefore(observer) {
785
+ if (this.beforeObservers.includes(observer)) return;
743
786
  this.beforeObservers.push(observer);
744
787
  }
745
788
  detachBefore(observer) {
@@ -750,6 +793,7 @@ var Statemachine = class {
750
793
  return this.beforeObservers;
751
794
  }
752
795
  attachAfter(observer) {
796
+ if (this.afterObservers.includes(observer)) return;
753
797
  this.afterObservers.push(observer);
754
798
  }
755
799
  detachAfter(observer) {
@@ -778,28 +822,64 @@ var Statemachine = class {
778
822
  // --- public top-level operations ---
779
823
  triggerEvent(name, context) {
780
824
  return new Promise((resolve, reject) => {
781
- this.queue.enqueue({
782
- kind: "triggerEvent",
783
- eventName: name,
784
- context: context ?? /* @__PURE__ */ new Map(),
785
- resolve,
786
- reject
787
- });
788
- void this.runIfIdle();
825
+ this.assertNotReentrant(`triggerEvent("${name}")`);
826
+ this.enqueueOperation(name, context, resolve, reject);
789
827
  });
790
828
  }
791
829
  checkTransitions(context) {
792
830
  return new Promise((resolve, reject) => {
793
- this.queue.enqueue({
794
- kind: "checkTransitions",
795
- eventName: null,
796
- context: context ?? /* @__PURE__ */ new Map(),
797
- resolve,
798
- reject
799
- });
800
- void this.runIfIdle();
831
+ this.assertNotReentrant("checkTransitions()");
832
+ this.enqueueOperation(null, context, resolve, reject);
833
+ });
834
+ }
835
+ /**
836
+ * Resolves once the operation queue is empty and the runner is idle —
837
+ * i.e. every operation enqueued so far, including operations chained via
838
+ * EnqueueContext.enqueue(), has completed. Resolves immediately if the
839
+ * machine is already idle. Note this is a quiescence point, not a
840
+ * receipt: work scheduled later (e.g. from a timer) starts a new drain.
841
+ */
842
+ whenIdle() {
843
+ if (!this.running && this.queue.isEmpty()) {
844
+ return Promise.resolve();
845
+ }
846
+ return new Promise((resolve) => {
847
+ this.idleWaiters.push(resolve);
801
848
  });
802
849
  }
850
+ /** Runs fn with the re-entrancy flag set for its SYNCHRONOUS portion only:
851
+ * the flag is cleared as soon as fn returns (before any promise it returned
852
+ * is awaited), so concurrent external callers are never affected. This
853
+ * catches triggerEvent/checkTransitions calls made before a callback's first
854
+ * await; calls made after a prior await are not detectable without
855
+ * AsyncLocalStorage (Node-only) and will still deadlock — a documented gap. */
856
+ guardSync(fn) {
857
+ this.inSyncCallback = true;
858
+ try {
859
+ return fn();
860
+ } finally {
861
+ this.inSyncCallback = false;
862
+ }
863
+ }
864
+ assertNotReentrant(operation) {
865
+ if (this.inSyncCallback) {
866
+ throw new ReentrancyError(operation);
867
+ }
868
+ }
869
+ /** Single entry point to the operation queue — every enqueue kicks the runner. */
870
+ enqueueOperation(eventName, context, resolve, reject, ifStateName) {
871
+ if (this.queue.size() >= this.maxQueueLength) {
872
+ throw new QueueLimitExceededError(this.maxQueueLength, eventName);
873
+ }
874
+ this.queue.enqueue({
875
+ eventName,
876
+ context: context ?? /* @__PURE__ */ new Map(),
877
+ ifStateName,
878
+ resolve,
879
+ reject
880
+ });
881
+ void this.runIfIdle();
882
+ }
803
883
  // --- internal runner ---
804
884
  async runIfIdle() {
805
885
  if (this.running) return;
@@ -811,10 +891,20 @@ var Statemachine = class {
811
891
  }
812
892
  } finally {
813
893
  this.running = false;
894
+ if (this.queue.isEmpty() && this.idleWaiters.length > 0) {
895
+ const waiters = this.idleWaiters;
896
+ this.idleWaiters = [];
897
+ for (const waiter of waiters) waiter();
898
+ }
814
899
  }
815
900
  }
816
901
  async runOperation(op) {
902
+ if (op.ifStateName !== void 0 && this.currentState.getName() !== op.ifStateName) {
903
+ op.resolve();
904
+ return;
905
+ }
817
906
  let acquiredHere = false;
907
+ let failure = null;
818
908
  try {
819
909
  if (!this.mutex.isAcquired()) {
820
910
  if (!await this.mutex.acquireLock()) {
@@ -822,19 +912,28 @@ var Statemachine = class {
822
912
  }
823
913
  acquiredHere = true;
824
914
  }
825
- const event = op.kind === "triggerEvent" ? this.resolveEvent(op.eventName) : null;
915
+ const event = op.eventName !== null ? this.resolveEvent(op.eventName) : null;
826
916
  await this.processOperation(event, op.context);
827
- op.resolve();
828
917
  } catch (err) {
829
- op.reject(err);
918
+ failure = { err };
830
919
  } finally {
831
920
  if (acquiredHere && this.autoreleaseLock) {
832
921
  try {
833
922
  await this.mutex.releaseLock();
834
- } catch {
923
+ } catch (err) {
924
+ try {
925
+ this.onReleaseError?.(err);
926
+ } catch {
927
+ }
928
+ if (!failure) failure = { err };
835
929
  }
836
930
  }
837
931
  }
932
+ if (failure) {
933
+ op.reject(failure.err);
934
+ } else {
935
+ op.resolve();
936
+ }
838
937
  }
839
938
  resolveEvent(name) {
840
939
  if (!this.currentState.hasEvent(name)) {
@@ -849,11 +948,13 @@ var Statemachine = class {
849
948
  */
850
949
  async processOperation(initialEvent, context) {
851
950
  let event = initialEvent;
852
- const automaticVisited = /* @__PURE__ */ new Set();
951
+ let automaticHops = 0;
853
952
  if (event) {
854
- const dispatcher = new Dispatcher();
855
- dispatcher.dispatch(event, [this.subject, context]);
856
- await dispatcher.invoke();
953
+ const userEvent = event;
954
+ const invokeArgs = [this.subject, context];
955
+ for (const observer of [...userEvent.getObservers()]) {
956
+ await this.guardSync(() => observer.update(userEvent, invokeArgs));
957
+ }
857
958
  }
858
959
  while (true) {
859
960
  const transitions = this.currentState.getTransitions();
@@ -861,26 +962,28 @@ var Statemachine = class {
861
962
  transitions,
862
963
  this.subject,
863
964
  context,
864
- event ?? void 0
965
+ event ?? void 0,
966
+ (fn) => this.guardSync(fn)
865
967
  );
866
- const selected = this.transitionSelector.selectTransition(
867
- active
968
+ const selected = this.guardSync(
969
+ () => this.transitionSelector.selectTransition(active)
868
970
  );
869
971
  if (!selected) {
870
972
  return;
871
973
  }
872
974
  const target = selected.getTargetState();
873
975
  if (selected.getEventName() === null) {
874
- automaticVisited.add(this.currentState);
875
- if (automaticVisited.has(target)) {
976
+ automaticHops += 1;
977
+ if (automaticHops > this.maxAutomaticHops) {
876
978
  throw new AutomaticTransitionCycleError(
877
979
  target.getName(),
878
- Array.from(automaticVisited).map((s) => s.getName())
980
+ this.maxAutomaticHops
879
981
  );
880
982
  }
881
983
  }
882
984
  if (this.currentState !== target) {
883
- const proposedFrame = Object.freeze({
985
+ const frame = Object.freeze({
986
+ subject: this.subject,
884
987
  fromState: this.currentState,
885
988
  toState: target,
886
989
  transition: selected,
@@ -890,39 +993,34 @@ var Statemachine = class {
890
993
  timestamp: Date.now(),
891
994
  machineName: this.process.getName()
892
995
  });
893
- for (const observer of this.beforeObservers) {
894
- await observer.notify(proposedFrame);
996
+ for (const observer of [...this.beforeObservers]) {
997
+ await this.guardSync(() => observer.notify(frame));
895
998
  }
896
- const fromState = this.currentState;
897
- this.lastState = fromState;
999
+ this.lastState = this.currentState;
898
1000
  this.currentState = target;
899
- const committedFrame = Object.freeze({
900
- fromState,
901
- toState: target,
902
- transition: selected,
903
- event,
904
- condition: selected.getCondition(),
905
- context: this.readonlyContext(context),
906
- timestamp: proposedFrame.timestamp,
907
- machineName: this.process.getName()
908
- });
909
1001
  const enqueueCtx = {
910
- enqueue: (chainedEventName, chainedCtx) => {
911
- this.queue.enqueue({
912
- kind: "triggerEvent",
913
- eventName: chainedEventName,
914
- context: chainedCtx ?? /* @__PURE__ */ new Map(),
915
- resolve: () => {
1002
+ enqueue: (chainedEventName, chainedCtx, ifStateName) => {
1003
+ this.enqueueOperation(
1004
+ chainedEventName,
1005
+ chainedCtx,
1006
+ () => {
1007
+ },
1008
+ (err) => {
1009
+ try {
1010
+ this.onChainedOperationError?.(err, {
1011
+ eventName: chainedEventName
1012
+ });
1013
+ } catch {
1014
+ }
916
1015
  },
917
- reject: () => {
918
- }
919
- });
1016
+ ifStateName
1017
+ );
920
1018
  }
921
1019
  };
922
1020
  const errors = [];
923
- for (const observer of this.afterObservers) {
1021
+ for (const observer of [...this.afterObservers]) {
924
1022
  try {
925
- await observer.notify(committedFrame, enqueueCtx);
1023
+ await this.guardSync(() => observer.notify(frame, enqueueCtx));
926
1024
  } catch (err) {
927
1025
  errors.push(err);
928
1026
  }
@@ -989,6 +1087,23 @@ var CallbackCondition = class {
989
1087
  }
990
1088
  };
991
1089
 
1090
+ // src/error/InvalidSubjectError.ts
1091
+ var InvalidSubjectError = class extends FinitaError {
1092
+ code = "invalidSubject";
1093
+ expectedInterface;
1094
+ missingMembers;
1095
+ constructor(expectedInterface, missingMembers) {
1096
+ const members = Array.from(missingMembers);
1097
+ const memberList = members.map((m) => `"${m}"`).join(", ");
1098
+ super(
1099
+ `Subject does not satisfy ${expectedInterface}; missing member(s): ${memberList || "(unknown)"}`
1100
+ );
1101
+ this.name = "InvalidSubjectError";
1102
+ this.expectedInterface = expectedInterface;
1103
+ this.missingMembers = Object.freeze([...members]);
1104
+ }
1105
+ };
1106
+
992
1107
  // src/condition/Timeout.ts
993
1108
  function isLastStateHasChangedDate(obj) {
994
1109
  return typeof obj === "object" && obj !== null && "getLastStateHasChangedDate" in obj && typeof obj.getLastStateHasChangedDate === "function";
@@ -1007,30 +1122,40 @@ var Timeout = class {
1007
1122
  if (isLastStateHasChangedDate(subject)) {
1008
1123
  return subject.getLastStateHasChangedDate();
1009
1124
  }
1010
- throw new Error("Subject must implement LastStateHasChangedDateInterface");
1125
+ throw new InvalidSubjectError("LastStateHasChangedDateInterface", [
1126
+ "getLastStateHasChangedDate"
1127
+ ]);
1011
1128
  }
1012
1129
  checkCondition(subject, context) {
1013
- const date = new Date(
1014
- this.getLastStateHasChangedDate(subject, context).getTime()
1015
- );
1016
- date.setTime(date.getTime() + this.timeoutMs);
1017
- return date <= /* @__PURE__ */ new Date();
1130
+ return this.getLastStateHasChangedDate(subject, context).getTime() + this.timeoutMs <= Date.now();
1018
1131
  }
1019
1132
  };
1020
1133
 
1021
- // src/condition/AndComposite.ts
1022
- var AndComposite = class {
1134
+ // src/condition/CompositeCondition.ts
1135
+ var CompositeCondition = class {
1023
1136
  conditions = [];
1024
- constructor(condition) {
1137
+ joinWord;
1138
+ constructor(joinWord, condition) {
1139
+ this.joinWord = joinWord;
1025
1140
  this.conditions.push(condition);
1026
1141
  }
1027
- addAnd(condition) {
1142
+ addCondition(condition) {
1028
1143
  this.conditions.push(condition);
1029
1144
  return this;
1030
1145
  }
1031
1146
  getName() {
1032
1147
  const names = this.conditions.map((c) => c.getName());
1033
- return `(${names.join(" and ")})`;
1148
+ return `(${names.join(` ${this.joinWord} `)})`;
1149
+ }
1150
+ };
1151
+
1152
+ // src/condition/AndComposite.ts
1153
+ var AndComposite = class extends CompositeCondition {
1154
+ constructor(condition) {
1155
+ super("and", condition);
1156
+ }
1157
+ addAnd(condition) {
1158
+ return this.addCondition(condition);
1034
1159
  }
1035
1160
  async checkCondition(subject, context) {
1036
1161
  for (const condition of this.conditions) {
@@ -1043,18 +1168,12 @@ var AndComposite = class {
1043
1168
  };
1044
1169
 
1045
1170
  // src/condition/OrComposite.ts
1046
- var OrComposite = class {
1047
- conditions = [];
1171
+ var OrComposite = class extends CompositeCondition {
1048
1172
  constructor(condition) {
1049
- this.conditions.push(condition);
1173
+ super("or", condition);
1050
1174
  }
1051
1175
  addOr(condition) {
1052
- this.conditions.push(condition);
1053
- return this;
1054
- }
1055
- getName() {
1056
- const names = this.conditions.map((c) => c.getName());
1057
- return `(${names.join(" or ")})`;
1176
+ return this.addCondition(condition);
1058
1177
  }
1059
1178
  async checkCondition(subject, context) {
1060
1179
  for (const condition of this.conditions) {
@@ -1086,10 +1205,9 @@ var CallbackObserver = class {
1086
1205
  constructor(callback) {
1087
1206
  this.callback = callback;
1088
1207
  }
1089
- update(subject) {
1090
- const event = subject;
1091
- if (typeof event.getInvokeArgs === "function") {
1092
- return this.callback(...event.getInvokeArgs());
1208
+ update(subject, args) {
1209
+ if (args !== void 0) {
1210
+ return this.callback(...args);
1093
1211
  }
1094
1212
  return this.callback(subject);
1095
1213
  }
@@ -1098,11 +1216,19 @@ var CallbackObserver = class {
1098
1216
  // src/observer/StatefulStatusChanger.ts
1099
1217
  var StatefulStatusChanger = class {
1100
1218
  subject;
1219
+ /**
1220
+ * @param subject Optional explicit subject to write to. When omitted
1221
+ * (recommended), the observer writes to frame.subject — the subject of
1222
+ * whichever machine fired the transition — so a single instance can be
1223
+ * shared safely across every machine a Factory creates.
1224
+ */
1101
1225
  constructor(subject) {
1102
- this.subject = subject;
1226
+ this.subject = subject ?? null;
1103
1227
  }
1104
1228
  notify(frame) {
1105
- this.subject.setCurrentStateName(frame.toState.getName());
1229
+ (this.subject ?? frame.subject).setCurrentStateName(
1230
+ frame.toState.getName()
1231
+ );
1106
1232
  }
1107
1233
  };
1108
1234
 
@@ -1115,19 +1241,25 @@ var OnEnterObserver = class _OnEnterObserver {
1115
1241
  }
1116
1242
  notify(frame, ctx) {
1117
1243
  if (frame.toState.hasEvent(this.eventName)) {
1118
- ctx.enqueue(this.eventName, new Map(frame.context));
1244
+ ctx.enqueue(
1245
+ this.eventName,
1246
+ new Map(frame.context),
1247
+ frame.toState.getName()
1248
+ );
1119
1249
  }
1120
1250
  }
1121
1251
  };
1122
1252
 
1123
- // src/observer/TransitionLogger.ts
1253
+ // src/util/index.ts
1124
1254
  function isNamed(obj) {
1125
1255
  return typeof obj === "object" && obj !== null && "getName" in obj && typeof obj.getName === "function";
1126
1256
  }
1127
- function asString(obj) {
1257
+ function nameOrString(obj) {
1128
1258
  if (isNamed(obj)) return obj.getName();
1129
1259
  return String(obj);
1130
1260
  }
1261
+
1262
+ // src/observer/TransitionLogger.ts
1131
1263
  var TransitionLogger = class {
1132
1264
  logger;
1133
1265
  loggerLevel;
@@ -1137,7 +1269,7 @@ var TransitionLogger = class {
1137
1269
  }
1138
1270
  notify(frame) {
1139
1271
  let message = "Transition";
1140
- message += ` from "${asString(frame.fromState)}" to "${asString(frame.toState)}"`;
1272
+ message += ` from "${nameOrString(frame.fromState)}" to "${nameOrString(frame.toState)}"`;
1141
1273
  const eventName = frame.event ? frame.event.getName() : null;
1142
1274
  const conditionName = frame.condition ? frame.condition.getName() : null;
1143
1275
  if (eventName || conditionName) {
@@ -1244,23 +1376,30 @@ var WeightTransition = class {
1244
1376
  innerSelector;
1245
1377
  epsilon;
1246
1378
  constructor(innerSelector, epsilon = 1e-3) {
1379
+ if (!Number.isFinite(epsilon) || epsilon <= 0) {
1380
+ throw new RangeError(
1381
+ `WeightTransition epsilon must be a finite number greater than 0; got ${String(epsilon)}`
1382
+ );
1383
+ }
1247
1384
  this.innerSelector = innerSelector ?? new OneOrNoneActiveTransition();
1248
1385
  this.epsilon = epsilon;
1249
1386
  }
1250
1387
  selectTransition(transitions) {
1251
- let bestTransitions = [];
1252
- let bestWeight = null;
1253
- for (const transition of transitions) {
1388
+ const all = Array.from(transitions);
1389
+ let maxWeight = Number.NEGATIVE_INFINITY;
1390
+ for (const transition of all) {
1254
1391
  const weight = transition.getWeight();
1255
- const diff = weight - (bestWeight ?? 0);
1256
- if (bestWeight === null || diff >= this.epsilon) {
1257
- bestWeight = weight;
1258
- bestTransitions = [transition];
1259
- } else if (Math.abs(diff) < this.epsilon) {
1260
- bestTransitions.push(transition);
1392
+ if (!Number.isFinite(weight)) {
1393
+ throw new RangeError(
1394
+ `WeightTransition: transition weights must be finite numbers; got ${String(weight)}`
1395
+ );
1261
1396
  }
1397
+ if (weight > maxWeight) maxWeight = weight;
1262
1398
  }
1263
- return this.innerSelector.selectTransition(bestTransitions);
1399
+ const best = all.filter(
1400
+ (transition) => maxWeight - transition.getWeight() < this.epsilon
1401
+ );
1402
+ return this.innerSelector.selectTransition(best);
1264
1403
  }
1265
1404
  };
1266
1405
 
@@ -1403,23 +1542,6 @@ var AbstractNamedProcessDetector = class {
1403
1542
  }
1404
1543
  };
1405
1544
 
1406
- // src/error/InvalidSubjectError.ts
1407
- var InvalidSubjectError = class extends FinitaError {
1408
- code = "invalidSubject";
1409
- expectedInterface;
1410
- missingMembers;
1411
- constructor(expectedInterface, missingMembers) {
1412
- const members = Array.from(missingMembers);
1413
- const memberList = members.map((m) => `"${m}"`).join(", ");
1414
- super(
1415
- `Subject does not satisfy ${expectedInterface}; missing member(s): ${memberList || "(unknown)"}`
1416
- );
1417
- this.name = "InvalidSubjectError";
1418
- this.expectedInterface = expectedInterface;
1419
- this.missingMembers = Object.freeze([...members]);
1420
- }
1421
- };
1422
-
1423
1545
  // src/factory/StatefulStateNameDetector.ts
1424
1546
  function isStateful(obj) {
1425
1547
  return typeof obj === "object" && obj !== null && "getCurrentStateName" in obj && typeof obj.getCurrentStateName === "function";
@@ -1434,11 +1556,8 @@ var StatefulStateNameDetector = class {
1434
1556
  };
1435
1557
 
1436
1558
  // src/graph/GraphBuilder.ts
1437
- function isNamed2(obj) {
1438
- return typeof obj === "object" && obj !== null && "getName" in obj && typeof obj.getName === "function";
1439
- }
1440
- function escapeDoubleQuotes(str) {
1441
- return str.replace(/"/g, '\\"');
1559
+ function escapeDotString(str) {
1560
+ return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1442
1561
  }
1443
1562
  function toMermaidId(name) {
1444
1563
  return "s_" + name.replace(
@@ -1447,13 +1566,15 @@ function toMermaidId(name) {
1447
1566
  );
1448
1567
  }
1449
1568
  function escapeMermaidLabel(str) {
1450
- return str.replace(/"/g, "#quot;");
1569
+ return str.replace(/\\/g, "#92;").replace(/"/g, "#quot;");
1451
1570
  }
1452
- function convertToString(obj) {
1453
- if (isNamed2(obj)) {
1454
- return obj.getName();
1571
+ var VALID_DIRECTIONS = /* @__PURE__ */ new Set(["TB", "BT", "LR", "RL"]);
1572
+ function assertDirection(value, optionName) {
1573
+ if (!VALID_DIRECTIONS.has(value)) {
1574
+ throw new RangeError(
1575
+ `${optionName} must be one of "TB", "BT", "LR", "RL"; got ${JSON.stringify(value)}`
1576
+ );
1455
1577
  }
1456
- return String(obj);
1457
1578
  }
1458
1579
  var GraphBuilder = class {
1459
1580
  nodes = /* @__PURE__ */ new Map();
@@ -1473,13 +1594,15 @@ var GraphBuilder = class {
1473
1594
  const eventName = transition.getEventName();
1474
1595
  if (eventName) {
1475
1596
  parts.push(`E: ${eventName}`);
1476
- const event = state.getEvent(eventName);
1477
- const observerNames = [];
1478
- for (const observer of event.getObservers()) {
1479
- observerNames.push(convertToString(observer));
1480
- }
1481
- if (observerNames.length > 0) {
1482
- parts.push(`C: ${observerNames.join(", ")}`);
1597
+ if (state.hasEvent(eventName)) {
1598
+ const event = state.getEvent(eventName);
1599
+ const observerNames = [];
1600
+ for (const observer of event.getObservers()) {
1601
+ observerNames.push(nameOrString(observer));
1602
+ }
1603
+ if (observerNames.length > 0) {
1604
+ parts.push(`C: ${observerNames.join(", ")}`);
1605
+ }
1483
1606
  }
1484
1607
  }
1485
1608
  const conditionName = transition.getConditionName();
@@ -1528,17 +1651,18 @@ var GraphBuilder = class {
1528
1651
  toDot(options) {
1529
1652
  const graph = this.getGraph();
1530
1653
  const rankdir = options?.rankdir ?? "LR";
1654
+ assertDirection(rankdir, "rankdir");
1531
1655
  const lines = [];
1532
1656
  lines.push("digraph {");
1533
1657
  lines.push(` rankdir=${rankdir};`);
1534
1658
  for (const node of graph.nodes) {
1535
- const label = escapeDoubleQuotes(node.label);
1659
+ const label = escapeDotString(node.label);
1536
1660
  lines.push(` "${label}" [label="${label}"];`);
1537
1661
  }
1538
1662
  for (const edge of graph.edges) {
1539
- const source = escapeDoubleQuotes(edge.source);
1540
- const target = escapeDoubleQuotes(edge.target);
1541
- const label = escapeDoubleQuotes(edge.label);
1663
+ const source = escapeDotString(edge.source);
1664
+ const target = escapeDotString(edge.target);
1665
+ const label = escapeDotString(edge.label);
1542
1666
  lines.push(` "${source}" -> "${target}" [label="${label}"];`);
1543
1667
  }
1544
1668
  lines.push("}");
@@ -1547,6 +1671,7 @@ var GraphBuilder = class {
1547
1671
  toMermaid(options) {
1548
1672
  const graph = this.getGraph();
1549
1673
  const direction = options?.direction ?? "LR";
1674
+ assertDirection(direction, "direction");
1550
1675
  const lines = [];
1551
1676
  lines.push(`stateDiagram-v2`);
1552
1677
  lines.push(` direction ${direction}`);
@@ -1601,6 +1726,8 @@ export {
1601
1726
  ProcessBuilder,
1602
1727
  ProcessFinalizedError,
1603
1728
  ProcessNotFoundError,
1729
+ QueueLimitExceededError,
1730
+ ReentrancyError,
1604
1731
  ScoreTransition,
1605
1732
  SingleProcessDetector,
1606
1733
  State,