@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.js CHANGED
@@ -31,8 +31,10 @@ var Event = class {
31
31
  await observer.update(this, args);
32
32
  }
33
33
  }
34
+ /** Snapshot — detaching later does not change an already-returned list,
35
+ * and mutating it does not change the event's registrations. */
34
36
  getObservers() {
35
- return this.observers;
37
+ return [...this.observers];
36
38
  }
37
39
  getMetadata() {
38
40
  return Object.fromEntries(this.metadata);
@@ -184,6 +186,7 @@ var State = class {
184
186
  throw new Error(`State "${this.name}" transitions already set`);
185
187
  }
186
188
  this._transitions = new Set(transitions);
189
+ Object.freeze(this);
187
190
  }
188
191
  getName() {
189
192
  return this.name;
@@ -234,6 +237,7 @@ var Transition = class {
234
237
  this.eventName = eventName;
235
238
  this.condition = condition;
236
239
  this.weight = weight;
240
+ Object.freeze(this);
237
241
  }
238
242
  getTargetState() {
239
243
  return this.targetState;
@@ -368,12 +372,20 @@ var ProcessBuilder = class _ProcessBuilder {
368
372
  { fromState, toState, conditionName }
369
373
  );
370
374
  }
375
+ const weight = options.weight ?? 1;
376
+ if (!Number.isFinite(weight)) {
377
+ throw new GraphValidationError(
378
+ "invalidTransitionWeight",
379
+ `addTransition from "${fromState}" to "${toState}": weight must be a finite number; got ${String(weight)}`,
380
+ { fromState, toState, eventName, weight }
381
+ );
382
+ }
371
383
  this.transitionSpecs.push({
372
384
  fromState,
373
385
  toState,
374
386
  eventName,
375
387
  condition: options.condition ?? null,
376
- weight: options.weight ?? 1
388
+ weight
377
389
  });
378
390
  return this;
379
391
  }
@@ -381,7 +393,6 @@ var ProcessBuilder = class _ProcessBuilder {
381
393
  if (this.built) {
382
394
  throw new ProcessFinalizedError(this.processName);
383
395
  }
384
- this.built = true;
385
396
  this.validateInitialState();
386
397
  this.validateTransitionEndpoints();
387
398
  this.validateNoConflictingDuplicates();
@@ -392,6 +403,7 @@ var ProcessBuilder = class _ProcessBuilder {
392
403
  this.validateOrphans(finalStates, initialName);
393
404
  }
394
405
  const initialState = finalStates.get(initialName);
406
+ this.built = true;
395
407
  return new Process(
396
408
  INTERNAL_CONSTRUCTION_KEY,
397
409
  this.processName,
@@ -583,12 +595,30 @@ var ProcessBuilder = class _ProcessBuilder {
583
595
  var AmbiguousTransitionError = class extends FinitaError {
584
596
  code = "ambiguousTransition";
585
597
  activeCount;
586
- constructor(activeCount) {
587
- super(`More than one transition is active! (active count: ${activeCount})`);
598
+ /** The competing transitions — what you need to resolve the ambiguity. */
599
+ candidates;
600
+ constructor(activeCount, candidates = []) {
601
+ const list = Array.from(candidates, (c) => Object.freeze({ ...c }));
602
+ const detail = list.length > 0 ? ` Candidates: ${list.map(describeCandidate).join("; ")}.` : "";
603
+ super(
604
+ `More than one transition is active! (active count: ${activeCount})${detail}`
605
+ );
588
606
  this.name = "AmbiguousTransitionError";
589
607
  this.activeCount = activeCount;
608
+ this.candidates = Object.freeze(list);
590
609
  }
591
610
  };
611
+ function describeCandidate(candidate) {
612
+ const parts = [`-> "${candidate.targetStateName}"`];
613
+ parts.push(
614
+ candidate.eventName === null ? "on <automatic>" : `on event "${candidate.eventName}"`
615
+ );
616
+ if (candidate.conditionName !== null) {
617
+ parts.push(`if ${candidate.conditionName}`);
618
+ }
619
+ parts.push(`weight ${candidate.weight}`);
620
+ return parts.join(" ");
621
+ }
592
622
 
593
623
  // src/selector/OneOrNoneActiveTransition.ts
594
624
  var OneOrNoneActiveTransition = class {
@@ -600,7 +630,15 @@ var OneOrNoneActiveTransition = class {
600
630
  case 1:
601
631
  return arr[0];
602
632
  default:
603
- throw new AmbiguousTransitionError(arr.length);
633
+ throw new AmbiguousTransitionError(
634
+ arr.length,
635
+ arr.map((transition) => ({
636
+ targetStateName: transition.getTargetState().getName(),
637
+ eventName: transition.getEventName(),
638
+ conditionName: transition.getConditionName(),
639
+ weight: transition.getWeight()
640
+ }))
641
+ );
604
642
  }
605
643
  }
606
644
  };
@@ -636,6 +674,9 @@ var OperationQueue = class {
636
674
  isEmpty() {
637
675
  return this.items.length === 0;
638
676
  }
677
+ size() {
678
+ return this.items.length;
679
+ }
639
680
  };
640
681
 
641
682
  // src/filter/ActiveTransitionFilter.ts
@@ -674,6 +715,15 @@ var LockCanNotBeAcquiredError = class extends FinitaError {
674
715
  }
675
716
  };
676
717
 
718
+ // src/error/LockCanNotBeReleasedError.ts
719
+ var LockCanNotBeReleasedError = class extends FinitaError {
720
+ code = "lockCanNotBeReleased";
721
+ constructor(message = "Lock can not be released! releaseLock() returned false; the lock may still be held.") {
722
+ super(message);
723
+ this.name = "LockCanNotBeReleasedError";
724
+ }
725
+ };
726
+
677
727
  // src/error/AutomaticTransitionCycleError.ts
678
728
  var AutomaticTransitionCycleError = class extends FinitaError {
679
729
  code = "automaticTransitionCycle";
@@ -700,6 +750,17 @@ var ReentrancyError = class extends FinitaError {
700
750
  }
701
751
  };
702
752
 
753
+ // src/error/QueueLimitExceededError.ts
754
+ var QueueLimitExceededError = class extends FinitaError {
755
+ code = "queueLimitExceeded";
756
+ constructor(limit, eventName) {
757
+ super(
758
+ `${eventName === null ? "checkTransitions()" : `triggerEvent("${eventName}")`} rejected: the operation queue already holds ${limit} pending operation(s) (maxQueueLength = ${limit}).`
759
+ );
760
+ this.name = "QueueLimitExceededError";
761
+ }
762
+ };
763
+
703
764
  // src/Statemachine.ts
704
765
  var Statemachine = class {
705
766
  subject;
@@ -710,11 +771,15 @@ var Statemachine = class {
710
771
  lastState = null;
711
772
  autoreleaseLock;
712
773
  maxAutomaticHops;
774
+ maxQueueLength;
713
775
  queue = new OperationQueue();
714
776
  running = false;
777
+ idleWaiters = [];
715
778
  inSyncCallback = false;
716
779
  beforeObservers = [];
717
780
  afterObservers = [];
781
+ onChainedOperationError;
782
+ onReleaseError;
718
783
  constructor(subject, process, options = {}) {
719
784
  this.subject = subject;
720
785
  this.process = process;
@@ -729,6 +794,15 @@ var Statemachine = class {
729
794
  );
730
795
  }
731
796
  this.maxAutomaticHops = hops;
797
+ const maxQueue = options.maxQueueLength ?? Infinity;
798
+ if (maxQueue !== Infinity && (!Number.isInteger(maxQueue) || maxQueue < 1)) {
799
+ throw new RangeError(
800
+ `maxQueueLength must be a positive integer; got ${String(options.maxQueueLength)}`
801
+ );
802
+ }
803
+ this.maxQueueLength = maxQueue;
804
+ this.onChainedOperationError = options.onChainedOperationError;
805
+ this.onReleaseError = options.onReleaseError;
732
806
  }
733
807
  // --- public getters ---
734
808
  getCurrentState() {
@@ -745,31 +819,43 @@ var Statemachine = class {
745
819
  }
746
820
  // --- public observer attach/detach ---
747
821
  attachBefore(observer) {
822
+ if (this.beforeObservers.includes(observer)) return;
748
823
  this.beforeObservers.push(observer);
749
824
  }
750
825
  detachBefore(observer) {
751
826
  const idx = this.beforeObservers.indexOf(observer);
752
827
  if (idx >= 0) this.beforeObservers.splice(idx, 1);
753
828
  }
829
+ /** Snapshot — detaching later does not change an already-returned list,
830
+ * and mutating it does not change the machine's registrations. */
754
831
  getBeforeObservers() {
755
- return this.beforeObservers;
832
+ return [...this.beforeObservers];
756
833
  }
757
834
  attachAfter(observer) {
835
+ if (this.afterObservers.includes(observer)) return;
758
836
  this.afterObservers.push(observer);
759
837
  }
760
838
  detachAfter(observer) {
761
839
  const idx = this.afterObservers.indexOf(observer);
762
840
  if (idx >= 0) this.afterObservers.splice(idx, 1);
763
841
  }
842
+ /** Snapshot — see getBeforeObservers. */
764
843
  getAfterObservers() {
765
- return this.afterObservers;
844
+ return [...this.afterObservers];
766
845
  }
767
846
  // --- public locking ---
768
847
  async acquireLock() {
769
848
  return this.mutex.acquireLock();
770
849
  }
850
+ /**
851
+ * Releases the mutex. A failed release — whether the mutex throws or
852
+ * returns false — is reported to the onReleaseError hook; it is not thrown,
853
+ * so manual lock management keeps its existing control flow. Inspect
854
+ * isLockAcquired() (or the hook) to learn whether the lock was actually
855
+ * freed.
856
+ */
771
857
  async releaseLock() {
772
- await this.mutex.releaseLock();
858
+ await this.releaseMutex();
773
859
  }
774
860
  isLockAcquired() {
775
861
  return this.mutex.isAcquired();
@@ -793,6 +879,27 @@ var Statemachine = class {
793
879
  this.enqueueOperation(null, context, resolve, reject);
794
880
  });
795
881
  }
882
+ /**
883
+ * Resolves once the operation queue is empty and the runner is idle —
884
+ * i.e. every operation enqueued so far, including operations chained via
885
+ * EnqueueContext.enqueue(), has completed. Resolves immediately if the
886
+ * machine is already idle. Note this is a quiescence point, not a
887
+ * receipt: work scheduled later (e.g. from a timer) starts a new drain.
888
+ *
889
+ * Like triggerEvent/checkTransitions, this may not be called from inside an
890
+ * observer or condition of the same machine: the machine cannot reach idle
891
+ * while the runner is blocked on that very callback, so awaiting it there
892
+ * always deadlocks.
893
+ */
894
+ whenIdle() {
895
+ this.assertNotReentrant("whenIdle()");
896
+ if (!this.running && this.queue.isEmpty()) {
897
+ return Promise.resolve();
898
+ }
899
+ return new Promise((resolve) => {
900
+ this.idleWaiters.push(resolve);
901
+ });
902
+ }
796
903
  /** Runs fn with the re-entrancy flag set for its SYNCHRONOUS portion only:
797
904
  * the flag is cleared as soon as fn returns (before any promise it returned
798
905
  * is awaited), so concurrent external callers are never affected. This
@@ -814,6 +921,9 @@ var Statemachine = class {
814
921
  }
815
922
  /** Single entry point to the operation queue — every enqueue kicks the runner. */
816
923
  enqueueOperation(eventName, context, resolve, reject, ifStateName) {
924
+ if (this.queue.size() >= this.maxQueueLength) {
925
+ throw new QueueLimitExceededError(this.maxQueueLength, eventName);
926
+ }
817
927
  this.queue.enqueue({
818
928
  eventName,
819
929
  context: context ?? /* @__PURE__ */ new Map(),
@@ -834,6 +944,11 @@ var Statemachine = class {
834
944
  }
835
945
  } finally {
836
946
  this.running = false;
947
+ if (this.queue.isEmpty() && this.idleWaiters.length > 0) {
948
+ const waiters = this.idleWaiters;
949
+ this.idleWaiters = [];
950
+ for (const waiter of waiters) waiter();
951
+ }
837
952
  }
838
953
  }
839
954
  async runOperation(op) {
@@ -856,11 +971,8 @@ var Statemachine = class {
856
971
  failure = { err };
857
972
  } finally {
858
973
  if (acquiredHere && this.autoreleaseLock) {
859
- try {
860
- await this.mutex.releaseLock();
861
- } catch (err) {
862
- if (!failure) failure = { err };
863
- }
974
+ const releaseFailure = await this.releaseMutex();
975
+ if (releaseFailure && !failure) failure = releaseFailure;
864
976
  }
865
977
  }
866
978
  if (failure) {
@@ -869,6 +981,36 @@ var Statemachine = class {
869
981
  op.resolve();
870
982
  }
871
983
  }
984
+ /**
985
+ * Releases the mutex, normalizing its two failure modes into one result: a
986
+ * thrown error, and a false return — the failure signal MutexInterface /
987
+ * LockAdapterInterface define (a PostgreSQL advisory unlock that returns
988
+ * false, a Redis DEL that removed nothing). A false return means the lock
989
+ * may still be held, so it must never be mistaken for a successful release.
990
+ *
991
+ * Every failure is surfaced through the diagnostic hook — when the
992
+ * operation also failed, the rejection carries the operation error and this
993
+ * hook is the only place the release error appears.
994
+ *
995
+ * @returns null on success, or the failure wrapped for the caller to raise.
996
+ */
997
+ async releaseMutex() {
998
+ let failure = null;
999
+ try {
1000
+ if (!await this.mutex.releaseLock()) {
1001
+ failure = { err: new LockCanNotBeReleasedError() };
1002
+ }
1003
+ } catch (err) {
1004
+ failure = { err };
1005
+ }
1006
+ if (failure) {
1007
+ try {
1008
+ this.onReleaseError?.(failure.err);
1009
+ } catch {
1010
+ }
1011
+ }
1012
+ return failure;
1013
+ }
872
1014
  resolveEvent(name) {
873
1015
  if (!this.currentState.hasEvent(name)) {
874
1016
  throw new WrongEventForStateError(this.currentState.getName(), name);
@@ -939,7 +1081,13 @@ var Statemachine = class {
939
1081
  chainedCtx,
940
1082
  () => {
941
1083
  },
942
- () => {
1084
+ (err) => {
1085
+ try {
1086
+ this.onChainedOperationError?.(err, {
1087
+ eventName: chainedEventName
1088
+ });
1089
+ } catch {
1090
+ }
943
1091
  },
944
1092
  ifStateName
945
1093
  );
@@ -1304,6 +1452,11 @@ var WeightTransition = class {
1304
1452
  innerSelector;
1305
1453
  epsilon;
1306
1454
  constructor(innerSelector, epsilon = 1e-3) {
1455
+ if (!Number.isFinite(epsilon) || epsilon <= 0) {
1456
+ throw new RangeError(
1457
+ `WeightTransition epsilon must be a finite number greater than 0; got ${String(epsilon)}`
1458
+ );
1459
+ }
1307
1460
  this.innerSelector = innerSelector ?? new OneOrNoneActiveTransition();
1308
1461
  this.epsilon = epsilon;
1309
1462
  }
@@ -1312,6 +1465,11 @@ var WeightTransition = class {
1312
1465
  let maxWeight = Number.NEGATIVE_INFINITY;
1313
1466
  for (const transition of all) {
1314
1467
  const weight = transition.getWeight();
1468
+ if (!Number.isFinite(weight)) {
1469
+ throw new RangeError(
1470
+ `WeightTransition: transition weights must be finite numbers; got ${String(weight)}`
1471
+ );
1472
+ }
1315
1473
  if (weight > maxWeight) maxWeight = weight;
1316
1474
  }
1317
1475
  const best = all.filter(
@@ -1326,15 +1484,31 @@ var LockAdapterMutex = class {
1326
1484
  lockAdapter;
1327
1485
  resourceName;
1328
1486
  acquired = false;
1487
+ pendingAcquire = null;
1329
1488
  constructor(lockAdapter, resourceName) {
1330
1489
  this.lockAdapter = lockAdapter;
1331
1490
  this.resourceName = resourceName;
1332
1491
  }
1492
+ /**
1493
+ * Overlapping calls share one underlying acquire: the `acquired` flag is
1494
+ * only set after the adapter resolves, so without this both callers would
1495
+ * pass the check and acquire twice on a non-idempotent adapter (database
1496
+ * advisory locks, redis SET NX). The pending promise is cleared once it
1497
+ * settles, so a failed acquire can still be retried.
1498
+ */
1333
1499
  async acquireLock() {
1334
- if (!this.acquired) {
1335
- this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1336
- }
1337
- return this.acquired;
1500
+ if (this.acquired) {
1501
+ return true;
1502
+ }
1503
+ this.pendingAcquire ??= (async () => {
1504
+ try {
1505
+ this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1506
+ return this.acquired;
1507
+ } finally {
1508
+ this.pendingAcquire = null;
1509
+ }
1510
+ })();
1511
+ return this.pendingAcquire;
1338
1512
  }
1339
1513
  async releaseLock() {
1340
1514
  if (this.acquired) {
@@ -1378,9 +1552,18 @@ var Factory = class {
1378
1552
  afterObservers = /* @__PURE__ */ new Set();
1379
1553
  transitionSelector = null;
1380
1554
  mutexFactory = null;
1381
- constructor(processDetector, stateNameDetector) {
1555
+ options;
1556
+ /**
1557
+ * @param options Engine options applied to every machine this factory
1558
+ * creates — back-pressure (maxQueueLength), the automatic-hop bound, lock
1559
+ * autorelease, and the onChainedOperationError / onReleaseError diagnostic
1560
+ * sinks. Without them, factory-created machines would silently run on
1561
+ * defaults, which is precisely where those sinks matter most.
1562
+ */
1563
+ constructor(processDetector, stateNameDetector, options = {}) {
1382
1564
  this.processDetector = processDetector;
1383
1565
  this.stateNameDetector = stateNameDetector ?? null;
1566
+ this.options = { ...options };
1384
1567
  }
1385
1568
  setMutexFactory(factory) {
1386
1569
  this.mutexFactory = factory;
@@ -1405,6 +1588,7 @@ var Factory = class {
1405
1588
  const stateName = this.stateNameDetector ? this.stateNameDetector.detectCurrentStateName(subject) : void 0;
1406
1589
  const mutex = this.mutexFactory ? await this.mutexFactory.createMutex(subject) : void 0;
1407
1590
  const sm = new Statemachine(subject, process, {
1591
+ ...this.options,
1408
1592
  initialStateName: stateName ?? void 0,
1409
1593
  transitionSelector: this.transitionSelector ?? void 0,
1410
1594
  mutex: mutex ?? void 0
@@ -1486,6 +1670,14 @@ function toMermaidId(name) {
1486
1670
  function escapeMermaidLabel(str) {
1487
1671
  return str.replace(/\\/g, "#92;").replace(/"/g, "#quot;");
1488
1672
  }
1673
+ var VALID_DIRECTIONS = /* @__PURE__ */ new Set(["TB", "BT", "LR", "RL"]);
1674
+ function assertDirection(value, optionName) {
1675
+ if (!VALID_DIRECTIONS.has(value)) {
1676
+ throw new RangeError(
1677
+ `${optionName} must be one of "TB", "BT", "LR", "RL"; got ${JSON.stringify(value)}`
1678
+ );
1679
+ }
1680
+ }
1489
1681
  var GraphBuilder = class {
1490
1682
  nodes = /* @__PURE__ */ new Map();
1491
1683
  edges = [];
@@ -1561,6 +1753,7 @@ var GraphBuilder = class {
1561
1753
  toDot(options) {
1562
1754
  const graph = this.getGraph();
1563
1755
  const rankdir = options?.rankdir ?? "LR";
1756
+ assertDirection(rankdir, "rankdir");
1564
1757
  const lines = [];
1565
1758
  lines.push("digraph {");
1566
1759
  lines.push(` rankdir=${rankdir};`);
@@ -1580,6 +1773,7 @@ var GraphBuilder = class {
1580
1773
  toMermaid(options) {
1581
1774
  const graph = this.getGraph();
1582
1775
  const direction = options?.direction ?? "LR";
1776
+ assertDirection(direction, "direction");
1583
1777
  const lines = [];
1584
1778
  lines.push(`stateDiagram-v2`);
1585
1779
  lines.push(` direction ${direction}`);
@@ -1624,6 +1818,7 @@ export {
1624
1818
  InvalidSubjectError,
1625
1819
  LockAdapterMutex,
1626
1820
  LockCanNotBeAcquiredError,
1821
+ LockCanNotBeReleasedError,
1627
1822
  MutexFactory,
1628
1823
  Not,
1629
1824
  NullMutex,
@@ -1634,6 +1829,7 @@ export {
1634
1829
  ProcessBuilder,
1635
1830
  ProcessFinalizedError,
1636
1831
  ProcessNotFoundError,
1832
+ QueueLimitExceededError,
1637
1833
  ReentrancyError,
1638
1834
  ScoreTransition,
1639
1835
  SingleProcessDetector,