@camcima/finita 4.1.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,
@@ -104,8 +105,10 @@ var Event = class {
104
105
  await observer.update(this, args);
105
106
  }
106
107
  }
108
+ /** Snapshot — detaching later does not change an already-returned list,
109
+ * and mutating it does not change the event's registrations. */
107
110
  getObservers() {
108
- return this.observers;
111
+ return [...this.observers];
109
112
  }
110
113
  getMetadata() {
111
114
  return Object.fromEntries(this.metadata);
@@ -666,12 +669,30 @@ var ProcessBuilder = class _ProcessBuilder {
666
669
  var AmbiguousTransitionError = class extends FinitaError {
667
670
  code = "ambiguousTransition";
668
671
  activeCount;
669
- constructor(activeCount) {
670
- 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
+ );
671
680
  this.name = "AmbiguousTransitionError";
672
681
  this.activeCount = activeCount;
682
+ this.candidates = Object.freeze(list);
673
683
  }
674
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
+ }
675
696
 
676
697
  // src/selector/OneOrNoneActiveTransition.ts
677
698
  var OneOrNoneActiveTransition = class {
@@ -683,7 +704,15 @@ var OneOrNoneActiveTransition = class {
683
704
  case 1:
684
705
  return arr[0];
685
706
  default:
686
- 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
+ );
687
716
  }
688
717
  }
689
718
  };
@@ -760,6 +789,15 @@ var LockCanNotBeAcquiredError = class extends FinitaError {
760
789
  }
761
790
  };
762
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
+
763
801
  // src/error/AutomaticTransitionCycleError.ts
764
802
  var AutomaticTransitionCycleError = class extends FinitaError {
765
803
  code = "automaticTransitionCycle";
@@ -862,8 +900,10 @@ var Statemachine = class {
862
900
  const idx = this.beforeObservers.indexOf(observer);
863
901
  if (idx >= 0) this.beforeObservers.splice(idx, 1);
864
902
  }
903
+ /** Snapshot — detaching later does not change an already-returned list,
904
+ * and mutating it does not change the machine's registrations. */
865
905
  getBeforeObservers() {
866
- return this.beforeObservers;
906
+ return [...this.beforeObservers];
867
907
  }
868
908
  attachAfter(observer) {
869
909
  if (this.afterObservers.includes(observer)) return;
@@ -873,15 +913,23 @@ var Statemachine = class {
873
913
  const idx = this.afterObservers.indexOf(observer);
874
914
  if (idx >= 0) this.afterObservers.splice(idx, 1);
875
915
  }
916
+ /** Snapshot — see getBeforeObservers. */
876
917
  getAfterObservers() {
877
- return this.afterObservers;
918
+ return [...this.afterObservers];
878
919
  }
879
920
  // --- public locking ---
880
921
  async acquireLock() {
881
922
  return this.mutex.acquireLock();
882
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
+ */
883
931
  async releaseLock() {
884
- await this.mutex.releaseLock();
932
+ await this.releaseMutex();
885
933
  }
886
934
  isLockAcquired() {
887
935
  return this.mutex.isAcquired();
@@ -911,8 +959,14 @@ var Statemachine = class {
911
959
  * EnqueueContext.enqueue(), has completed. Resolves immediately if the
912
960
  * machine is already idle. Note this is a quiescence point, not a
913
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.
914
967
  */
915
968
  whenIdle() {
969
+ this.assertNotReentrant("whenIdle()");
916
970
  if (!this.running && this.queue.isEmpty()) {
917
971
  return Promise.resolve();
918
972
  }
@@ -991,15 +1045,8 @@ var Statemachine = class {
991
1045
  failure = { err };
992
1046
  } finally {
993
1047
  if (acquiredHere && this.autoreleaseLock) {
994
- try {
995
- await this.mutex.releaseLock();
996
- } catch (err) {
997
- try {
998
- this.onReleaseError?.(err);
999
- } catch {
1000
- }
1001
- if (!failure) failure = { err };
1002
- }
1048
+ const releaseFailure = await this.releaseMutex();
1049
+ if (releaseFailure && !failure) failure = releaseFailure;
1003
1050
  }
1004
1051
  }
1005
1052
  if (failure) {
@@ -1008,6 +1055,36 @@ var Statemachine = class {
1008
1055
  op.resolve();
1009
1056
  }
1010
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
+ }
1011
1088
  resolveEvent(name) {
1012
1089
  if (!this.currentState.hasEvent(name)) {
1013
1090
  throw new WrongEventForStateError(this.currentState.getName(), name);
@@ -1481,15 +1558,31 @@ var LockAdapterMutex = class {
1481
1558
  lockAdapter;
1482
1559
  resourceName;
1483
1560
  acquired = false;
1561
+ pendingAcquire = null;
1484
1562
  constructor(lockAdapter, resourceName) {
1485
1563
  this.lockAdapter = lockAdapter;
1486
1564
  this.resourceName = resourceName;
1487
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
+ */
1488
1573
  async acquireLock() {
1489
- if (!this.acquired) {
1490
- this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1491
- }
1492
- 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;
1493
1586
  }
1494
1587
  async releaseLock() {
1495
1588
  if (this.acquired) {
@@ -1533,9 +1626,18 @@ var Factory = class {
1533
1626
  afterObservers = /* @__PURE__ */ new Set();
1534
1627
  transitionSelector = null;
1535
1628
  mutexFactory = null;
1536
- 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 = {}) {
1537
1638
  this.processDetector = processDetector;
1538
1639
  this.stateNameDetector = stateNameDetector ?? null;
1640
+ this.options = { ...options };
1539
1641
  }
1540
1642
  setMutexFactory(factory) {
1541
1643
  this.mutexFactory = factory;
@@ -1560,6 +1662,7 @@ var Factory = class {
1560
1662
  const stateName = this.stateNameDetector ? this.stateNameDetector.detectCurrentStateName(subject) : void 0;
1561
1663
  const mutex = this.mutexFactory ? await this.mutexFactory.createMutex(subject) : void 0;
1562
1664
  const sm = new Statemachine(subject, process, {
1665
+ ...this.options,
1563
1666
  initialStateName: stateName ?? void 0,
1564
1667
  transitionSelector: this.transitionSelector ?? void 0,
1565
1668
  mutex: mutex ?? void 0
@@ -1790,6 +1893,7 @@ var GraphBuilder = class {
1790
1893
  InvalidSubjectError,
1791
1894
  LockAdapterMutex,
1792
1895
  LockCanNotBeAcquiredError,
1896
+ LockCanNotBeReleasedError,
1793
1897
  MutexFactory,
1794
1898
  Not,
1795
1899
  NullMutex,