@camcima/finita 4.0.0 → 4.2.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
@@ -42,6 +42,7 @@ __export(index_exports, {
42
42
  InvalidSubjectError: () => InvalidSubjectError,
43
43
  LockAdapterMutex: () => LockAdapterMutex,
44
44
  LockCanNotBeAcquiredError: () => LockCanNotBeAcquiredError,
45
+ LockCanNotBeReleasedError: () => LockCanNotBeReleasedError,
45
46
  MutexFactory: () => MutexFactory,
46
47
  Not: () => Not,
47
48
  NullMutex: () => NullMutex,
@@ -52,6 +53,7 @@ __export(index_exports, {
52
53
  ProcessBuilder: () => ProcessBuilder,
53
54
  ProcessFinalizedError: () => ProcessFinalizedError,
54
55
  ProcessNotFoundError: () => ProcessNotFoundError,
56
+ QueueLimitExceededError: () => QueueLimitExceededError,
55
57
  ReentrancyError: () => ReentrancyError,
56
58
  ScoreTransition: () => ScoreTransition,
57
59
  SingleProcessDetector: () => SingleProcessDetector,
@@ -103,8 +105,10 @@ var Event = class {
103
105
  await observer.update(this, args);
104
106
  }
105
107
  }
108
+ /** Snapshot — detaching later does not change an already-returned list,
109
+ * and mutating it does not change the event's registrations. */
106
110
  getObservers() {
107
- return this.observers;
111
+ return [...this.observers];
108
112
  }
109
113
  getMetadata() {
110
114
  return Object.fromEntries(this.metadata);
@@ -256,6 +260,7 @@ var State = class {
256
260
  throw new Error(`State "${this.name}" transitions already set`);
257
261
  }
258
262
  this._transitions = new Set(transitions);
263
+ Object.freeze(this);
259
264
  }
260
265
  getName() {
261
266
  return this.name;
@@ -306,6 +311,7 @@ var Transition = class {
306
311
  this.eventName = eventName;
307
312
  this.condition = condition;
308
313
  this.weight = weight;
314
+ Object.freeze(this);
309
315
  }
310
316
  getTargetState() {
311
317
  return this.targetState;
@@ -440,12 +446,20 @@ var ProcessBuilder = class _ProcessBuilder {
440
446
  { fromState, toState, conditionName }
441
447
  );
442
448
  }
449
+ const weight = options.weight ?? 1;
450
+ if (!Number.isFinite(weight)) {
451
+ throw new GraphValidationError(
452
+ "invalidTransitionWeight",
453
+ `addTransition from "${fromState}" to "${toState}": weight must be a finite number; got ${String(weight)}`,
454
+ { fromState, toState, eventName, weight }
455
+ );
456
+ }
443
457
  this.transitionSpecs.push({
444
458
  fromState,
445
459
  toState,
446
460
  eventName,
447
461
  condition: options.condition ?? null,
448
- weight: options.weight ?? 1
462
+ weight
449
463
  });
450
464
  return this;
451
465
  }
@@ -453,7 +467,6 @@ var ProcessBuilder = class _ProcessBuilder {
453
467
  if (this.built) {
454
468
  throw new ProcessFinalizedError(this.processName);
455
469
  }
456
- this.built = true;
457
470
  this.validateInitialState();
458
471
  this.validateTransitionEndpoints();
459
472
  this.validateNoConflictingDuplicates();
@@ -464,6 +477,7 @@ var ProcessBuilder = class _ProcessBuilder {
464
477
  this.validateOrphans(finalStates, initialName);
465
478
  }
466
479
  const initialState = finalStates.get(initialName);
480
+ this.built = true;
467
481
  return new Process(
468
482
  INTERNAL_CONSTRUCTION_KEY,
469
483
  this.processName,
@@ -655,12 +669,30 @@ var ProcessBuilder = class _ProcessBuilder {
655
669
  var AmbiguousTransitionError = class extends FinitaError {
656
670
  code = "ambiguousTransition";
657
671
  activeCount;
658
- constructor(activeCount) {
659
- super(`More than one transition is active! (active count: ${activeCount})`);
672
+ /** The competing transitions — what you need to resolve the ambiguity. */
673
+ candidates;
674
+ constructor(activeCount, candidates = []) {
675
+ const list = Array.from(candidates, (c) => Object.freeze({ ...c }));
676
+ const detail = list.length > 0 ? ` Candidates: ${list.map(describeCandidate).join("; ")}.` : "";
677
+ super(
678
+ `More than one transition is active! (active count: ${activeCount})${detail}`
679
+ );
660
680
  this.name = "AmbiguousTransitionError";
661
681
  this.activeCount = activeCount;
682
+ this.candidates = Object.freeze(list);
662
683
  }
663
684
  };
685
+ function describeCandidate(candidate) {
686
+ const parts = [`-> "${candidate.targetStateName}"`];
687
+ parts.push(
688
+ candidate.eventName === null ? "on <automatic>" : `on event "${candidate.eventName}"`
689
+ );
690
+ if (candidate.conditionName !== null) {
691
+ parts.push(`if ${candidate.conditionName}`);
692
+ }
693
+ parts.push(`weight ${candidate.weight}`);
694
+ return parts.join(" ");
695
+ }
664
696
 
665
697
  // src/selector/OneOrNoneActiveTransition.ts
666
698
  var OneOrNoneActiveTransition = class {
@@ -672,7 +704,15 @@ var OneOrNoneActiveTransition = class {
672
704
  case 1:
673
705
  return arr[0];
674
706
  default:
675
- throw new AmbiguousTransitionError(arr.length);
707
+ throw new AmbiguousTransitionError(
708
+ arr.length,
709
+ arr.map((transition) => ({
710
+ targetStateName: transition.getTargetState().getName(),
711
+ eventName: transition.getEventName(),
712
+ conditionName: transition.getConditionName(),
713
+ weight: transition.getWeight()
714
+ }))
715
+ );
676
716
  }
677
717
  }
678
718
  };
@@ -708,6 +748,9 @@ var OperationQueue = class {
708
748
  isEmpty() {
709
749
  return this.items.length === 0;
710
750
  }
751
+ size() {
752
+ return this.items.length;
753
+ }
711
754
  };
712
755
 
713
756
  // src/filter/ActiveTransitionFilter.ts
@@ -746,6 +789,15 @@ var LockCanNotBeAcquiredError = class extends FinitaError {
746
789
  }
747
790
  };
748
791
 
792
+ // src/error/LockCanNotBeReleasedError.ts
793
+ var LockCanNotBeReleasedError = class extends FinitaError {
794
+ code = "lockCanNotBeReleased";
795
+ constructor(message = "Lock can not be released! releaseLock() returned false; the lock may still be held.") {
796
+ super(message);
797
+ this.name = "LockCanNotBeReleasedError";
798
+ }
799
+ };
800
+
749
801
  // src/error/AutomaticTransitionCycleError.ts
750
802
  var AutomaticTransitionCycleError = class extends FinitaError {
751
803
  code = "automaticTransitionCycle";
@@ -772,6 +824,17 @@ var ReentrancyError = class extends FinitaError {
772
824
  }
773
825
  };
774
826
 
827
+ // src/error/QueueLimitExceededError.ts
828
+ var QueueLimitExceededError = class extends FinitaError {
829
+ code = "queueLimitExceeded";
830
+ constructor(limit, eventName) {
831
+ super(
832
+ `${eventName === null ? "checkTransitions()" : `triggerEvent("${eventName}")`} rejected: the operation queue already holds ${limit} pending operation(s) (maxQueueLength = ${limit}).`
833
+ );
834
+ this.name = "QueueLimitExceededError";
835
+ }
836
+ };
837
+
775
838
  // src/Statemachine.ts
776
839
  var Statemachine = class {
777
840
  subject;
@@ -782,11 +845,15 @@ var Statemachine = class {
782
845
  lastState = null;
783
846
  autoreleaseLock;
784
847
  maxAutomaticHops;
848
+ maxQueueLength;
785
849
  queue = new OperationQueue();
786
850
  running = false;
851
+ idleWaiters = [];
787
852
  inSyncCallback = false;
788
853
  beforeObservers = [];
789
854
  afterObservers = [];
855
+ onChainedOperationError;
856
+ onReleaseError;
790
857
  constructor(subject, process, options = {}) {
791
858
  this.subject = subject;
792
859
  this.process = process;
@@ -801,6 +868,15 @@ var Statemachine = class {
801
868
  );
802
869
  }
803
870
  this.maxAutomaticHops = hops;
871
+ const maxQueue = options.maxQueueLength ?? Infinity;
872
+ if (maxQueue !== Infinity && (!Number.isInteger(maxQueue) || maxQueue < 1)) {
873
+ throw new RangeError(
874
+ `maxQueueLength must be a positive integer; got ${String(options.maxQueueLength)}`
875
+ );
876
+ }
877
+ this.maxQueueLength = maxQueue;
878
+ this.onChainedOperationError = options.onChainedOperationError;
879
+ this.onReleaseError = options.onReleaseError;
804
880
  }
805
881
  // --- public getters ---
806
882
  getCurrentState() {
@@ -817,31 +893,43 @@ var Statemachine = class {
817
893
  }
818
894
  // --- public observer attach/detach ---
819
895
  attachBefore(observer) {
896
+ if (this.beforeObservers.includes(observer)) return;
820
897
  this.beforeObservers.push(observer);
821
898
  }
822
899
  detachBefore(observer) {
823
900
  const idx = this.beforeObservers.indexOf(observer);
824
901
  if (idx >= 0) this.beforeObservers.splice(idx, 1);
825
902
  }
903
+ /** Snapshot — detaching later does not change an already-returned list,
904
+ * and mutating it does not change the machine's registrations. */
826
905
  getBeforeObservers() {
827
- return this.beforeObservers;
906
+ return [...this.beforeObservers];
828
907
  }
829
908
  attachAfter(observer) {
909
+ if (this.afterObservers.includes(observer)) return;
830
910
  this.afterObservers.push(observer);
831
911
  }
832
912
  detachAfter(observer) {
833
913
  const idx = this.afterObservers.indexOf(observer);
834
914
  if (idx >= 0) this.afterObservers.splice(idx, 1);
835
915
  }
916
+ /** Snapshot — see getBeforeObservers. */
836
917
  getAfterObservers() {
837
- return this.afterObservers;
918
+ return [...this.afterObservers];
838
919
  }
839
920
  // --- public locking ---
840
921
  async acquireLock() {
841
922
  return this.mutex.acquireLock();
842
923
  }
924
+ /**
925
+ * Releases the mutex. A failed release — whether the mutex throws or
926
+ * returns false — is reported to the onReleaseError hook; it is not thrown,
927
+ * so manual lock management keeps its existing control flow. Inspect
928
+ * isLockAcquired() (or the hook) to learn whether the lock was actually
929
+ * freed.
930
+ */
843
931
  async releaseLock() {
844
- await this.mutex.releaseLock();
932
+ await this.releaseMutex();
845
933
  }
846
934
  isLockAcquired() {
847
935
  return this.mutex.isAcquired();
@@ -865,6 +953,27 @@ var Statemachine = class {
865
953
  this.enqueueOperation(null, context, resolve, reject);
866
954
  });
867
955
  }
956
+ /**
957
+ * Resolves once the operation queue is empty and the runner is idle —
958
+ * i.e. every operation enqueued so far, including operations chained via
959
+ * EnqueueContext.enqueue(), has completed. Resolves immediately if the
960
+ * machine is already idle. Note this is a quiescence point, not a
961
+ * receipt: work scheduled later (e.g. from a timer) starts a new drain.
962
+ *
963
+ * Like triggerEvent/checkTransitions, this may not be called from inside an
964
+ * observer or condition of the same machine: the machine cannot reach idle
965
+ * while the runner is blocked on that very callback, so awaiting it there
966
+ * always deadlocks.
967
+ */
968
+ whenIdle() {
969
+ this.assertNotReentrant("whenIdle()");
970
+ if (!this.running && this.queue.isEmpty()) {
971
+ return Promise.resolve();
972
+ }
973
+ return new Promise((resolve) => {
974
+ this.idleWaiters.push(resolve);
975
+ });
976
+ }
868
977
  /** Runs fn with the re-entrancy flag set for its SYNCHRONOUS portion only:
869
978
  * the flag is cleared as soon as fn returns (before any promise it returned
870
979
  * is awaited), so concurrent external callers are never affected. This
@@ -886,6 +995,9 @@ var Statemachine = class {
886
995
  }
887
996
  /** Single entry point to the operation queue — every enqueue kicks the runner. */
888
997
  enqueueOperation(eventName, context, resolve, reject, ifStateName) {
998
+ if (this.queue.size() >= this.maxQueueLength) {
999
+ throw new QueueLimitExceededError(this.maxQueueLength, eventName);
1000
+ }
889
1001
  this.queue.enqueue({
890
1002
  eventName,
891
1003
  context: context ?? /* @__PURE__ */ new Map(),
@@ -906,6 +1018,11 @@ var Statemachine = class {
906
1018
  }
907
1019
  } finally {
908
1020
  this.running = false;
1021
+ if (this.queue.isEmpty() && this.idleWaiters.length > 0) {
1022
+ const waiters = this.idleWaiters;
1023
+ this.idleWaiters = [];
1024
+ for (const waiter of waiters) waiter();
1025
+ }
909
1026
  }
910
1027
  }
911
1028
  async runOperation(op) {
@@ -928,11 +1045,8 @@ var Statemachine = class {
928
1045
  failure = { err };
929
1046
  } finally {
930
1047
  if (acquiredHere && this.autoreleaseLock) {
931
- try {
932
- await this.mutex.releaseLock();
933
- } catch (err) {
934
- if (!failure) failure = { err };
935
- }
1048
+ const releaseFailure = await this.releaseMutex();
1049
+ if (releaseFailure && !failure) failure = releaseFailure;
936
1050
  }
937
1051
  }
938
1052
  if (failure) {
@@ -941,6 +1055,36 @@ var Statemachine = class {
941
1055
  op.resolve();
942
1056
  }
943
1057
  }
1058
+ /**
1059
+ * Releases the mutex, normalizing its two failure modes into one result: a
1060
+ * thrown error, and a false return — the failure signal MutexInterface /
1061
+ * LockAdapterInterface define (a PostgreSQL advisory unlock that returns
1062
+ * false, a Redis DEL that removed nothing). A false return means the lock
1063
+ * may still be held, so it must never be mistaken for a successful release.
1064
+ *
1065
+ * Every failure is surfaced through the diagnostic hook — when the
1066
+ * operation also failed, the rejection carries the operation error and this
1067
+ * hook is the only place the release error appears.
1068
+ *
1069
+ * @returns null on success, or the failure wrapped for the caller to raise.
1070
+ */
1071
+ async releaseMutex() {
1072
+ let failure = null;
1073
+ try {
1074
+ if (!await this.mutex.releaseLock()) {
1075
+ failure = { err: new LockCanNotBeReleasedError() };
1076
+ }
1077
+ } catch (err) {
1078
+ failure = { err };
1079
+ }
1080
+ if (failure) {
1081
+ try {
1082
+ this.onReleaseError?.(failure.err);
1083
+ } catch {
1084
+ }
1085
+ }
1086
+ return failure;
1087
+ }
944
1088
  resolveEvent(name) {
945
1089
  if (!this.currentState.hasEvent(name)) {
946
1090
  throw new WrongEventForStateError(this.currentState.getName(), name);
@@ -1011,7 +1155,13 @@ var Statemachine = class {
1011
1155
  chainedCtx,
1012
1156
  () => {
1013
1157
  },
1014
- () => {
1158
+ (err) => {
1159
+ try {
1160
+ this.onChainedOperationError?.(err, {
1161
+ eventName: chainedEventName
1162
+ });
1163
+ } catch {
1164
+ }
1015
1165
  },
1016
1166
  ifStateName
1017
1167
  );
@@ -1376,6 +1526,11 @@ var WeightTransition = class {
1376
1526
  innerSelector;
1377
1527
  epsilon;
1378
1528
  constructor(innerSelector, epsilon = 1e-3) {
1529
+ if (!Number.isFinite(epsilon) || epsilon <= 0) {
1530
+ throw new RangeError(
1531
+ `WeightTransition epsilon must be a finite number greater than 0; got ${String(epsilon)}`
1532
+ );
1533
+ }
1379
1534
  this.innerSelector = innerSelector ?? new OneOrNoneActiveTransition();
1380
1535
  this.epsilon = epsilon;
1381
1536
  }
@@ -1384,6 +1539,11 @@ var WeightTransition = class {
1384
1539
  let maxWeight = Number.NEGATIVE_INFINITY;
1385
1540
  for (const transition of all) {
1386
1541
  const weight = transition.getWeight();
1542
+ if (!Number.isFinite(weight)) {
1543
+ throw new RangeError(
1544
+ `WeightTransition: transition weights must be finite numbers; got ${String(weight)}`
1545
+ );
1546
+ }
1387
1547
  if (weight > maxWeight) maxWeight = weight;
1388
1548
  }
1389
1549
  const best = all.filter(
@@ -1398,15 +1558,31 @@ var LockAdapterMutex = class {
1398
1558
  lockAdapter;
1399
1559
  resourceName;
1400
1560
  acquired = false;
1561
+ pendingAcquire = null;
1401
1562
  constructor(lockAdapter, resourceName) {
1402
1563
  this.lockAdapter = lockAdapter;
1403
1564
  this.resourceName = resourceName;
1404
1565
  }
1566
+ /**
1567
+ * Overlapping calls share one underlying acquire: the `acquired` flag is
1568
+ * only set after the adapter resolves, so without this both callers would
1569
+ * pass the check and acquire twice on a non-idempotent adapter (database
1570
+ * advisory locks, redis SET NX). The pending promise is cleared once it
1571
+ * settles, so a failed acquire can still be retried.
1572
+ */
1405
1573
  async acquireLock() {
1406
- if (!this.acquired) {
1407
- this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1408
- }
1409
- return this.acquired;
1574
+ if (this.acquired) {
1575
+ return true;
1576
+ }
1577
+ this.pendingAcquire ??= (async () => {
1578
+ try {
1579
+ this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1580
+ return this.acquired;
1581
+ } finally {
1582
+ this.pendingAcquire = null;
1583
+ }
1584
+ })();
1585
+ return this.pendingAcquire;
1410
1586
  }
1411
1587
  async releaseLock() {
1412
1588
  if (this.acquired) {
@@ -1450,9 +1626,18 @@ var Factory = class {
1450
1626
  afterObservers = /* @__PURE__ */ new Set();
1451
1627
  transitionSelector = null;
1452
1628
  mutexFactory = null;
1453
- constructor(processDetector, stateNameDetector) {
1629
+ options;
1630
+ /**
1631
+ * @param options Engine options applied to every machine this factory
1632
+ * creates — back-pressure (maxQueueLength), the automatic-hop bound, lock
1633
+ * autorelease, and the onChainedOperationError / onReleaseError diagnostic
1634
+ * sinks. Without them, factory-created machines would silently run on
1635
+ * defaults, which is precisely where those sinks matter most.
1636
+ */
1637
+ constructor(processDetector, stateNameDetector, options = {}) {
1454
1638
  this.processDetector = processDetector;
1455
1639
  this.stateNameDetector = stateNameDetector ?? null;
1640
+ this.options = { ...options };
1456
1641
  }
1457
1642
  setMutexFactory(factory) {
1458
1643
  this.mutexFactory = factory;
@@ -1477,6 +1662,7 @@ var Factory = class {
1477
1662
  const stateName = this.stateNameDetector ? this.stateNameDetector.detectCurrentStateName(subject) : void 0;
1478
1663
  const mutex = this.mutexFactory ? await this.mutexFactory.createMutex(subject) : void 0;
1479
1664
  const sm = new Statemachine(subject, process, {
1665
+ ...this.options,
1480
1666
  initialStateName: stateName ?? void 0,
1481
1667
  transitionSelector: this.transitionSelector ?? void 0,
1482
1668
  mutex: mutex ?? void 0
@@ -1558,6 +1744,14 @@ function toMermaidId(name) {
1558
1744
  function escapeMermaidLabel(str) {
1559
1745
  return str.replace(/\\/g, "#92;").replace(/"/g, "#quot;");
1560
1746
  }
1747
+ var VALID_DIRECTIONS = /* @__PURE__ */ new Set(["TB", "BT", "LR", "RL"]);
1748
+ function assertDirection(value, optionName) {
1749
+ if (!VALID_DIRECTIONS.has(value)) {
1750
+ throw new RangeError(
1751
+ `${optionName} must be one of "TB", "BT", "LR", "RL"; got ${JSON.stringify(value)}`
1752
+ );
1753
+ }
1754
+ }
1561
1755
  var GraphBuilder = class {
1562
1756
  nodes = /* @__PURE__ */ new Map();
1563
1757
  edges = [];
@@ -1633,6 +1827,7 @@ var GraphBuilder = class {
1633
1827
  toDot(options) {
1634
1828
  const graph = this.getGraph();
1635
1829
  const rankdir = options?.rankdir ?? "LR";
1830
+ assertDirection(rankdir, "rankdir");
1636
1831
  const lines = [];
1637
1832
  lines.push("digraph {");
1638
1833
  lines.push(` rankdir=${rankdir};`);
@@ -1652,6 +1847,7 @@ var GraphBuilder = class {
1652
1847
  toMermaid(options) {
1653
1848
  const graph = this.getGraph();
1654
1849
  const direction = options?.direction ?? "LR";
1850
+ assertDirection(direction, "direction");
1655
1851
  const lines = [];
1656
1852
  lines.push(`stateDiagram-v2`);
1657
1853
  lines.push(` direction ${direction}`);
@@ -1697,6 +1893,7 @@ var GraphBuilder = class {
1697
1893
  InvalidSubjectError,
1698
1894
  LockAdapterMutex,
1699
1895
  LockCanNotBeAcquiredError,
1896
+ LockCanNotBeReleasedError,
1700
1897
  MutexFactory,
1701
1898
  Not,
1702
1899
  NullMutex,
@@ -1707,6 +1904,7 @@ var GraphBuilder = class {
1707
1904
  ProcessBuilder,
1708
1905
  ProcessFinalizedError,
1709
1906
  ProcessNotFoundError,
1907
+ QueueLimitExceededError,
1710
1908
  ReentrancyError,
1711
1909
  ScoreTransition,
1712
1910
  SingleProcessDetector,