@camcima/finita 4.0.0 → 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,7 @@ __export(index_exports, {
52
52
  ProcessBuilder: () => ProcessBuilder,
53
53
  ProcessFinalizedError: () => ProcessFinalizedError,
54
54
  ProcessNotFoundError: () => ProcessNotFoundError,
55
+ QueueLimitExceededError: () => QueueLimitExceededError,
55
56
  ReentrancyError: () => ReentrancyError,
56
57
  ScoreTransition: () => ScoreTransition,
57
58
  SingleProcessDetector: () => SingleProcessDetector,
@@ -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;
@@ -440,12 +443,20 @@ var ProcessBuilder = class _ProcessBuilder {
440
443
  { fromState, toState, conditionName }
441
444
  );
442
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
+ );
453
+ }
443
454
  this.transitionSpecs.push({
444
455
  fromState,
445
456
  toState,
446
457
  eventName,
447
458
  condition: options.condition ?? null,
448
- weight: options.weight ?? 1
459
+ weight
449
460
  });
450
461
  return this;
451
462
  }
@@ -453,7 +464,6 @@ var ProcessBuilder = class _ProcessBuilder {
453
464
  if (this.built) {
454
465
  throw new ProcessFinalizedError(this.processName);
455
466
  }
456
- this.built = true;
457
467
  this.validateInitialState();
458
468
  this.validateTransitionEndpoints();
459
469
  this.validateNoConflictingDuplicates();
@@ -464,6 +474,7 @@ var ProcessBuilder = class _ProcessBuilder {
464
474
  this.validateOrphans(finalStates, initialName);
465
475
  }
466
476
  const initialState = finalStates.get(initialName);
477
+ this.built = true;
467
478
  return new Process(
468
479
  INTERNAL_CONSTRUCTION_KEY,
469
480
  this.processName,
@@ -708,6 +719,9 @@ var OperationQueue = class {
708
719
  isEmpty() {
709
720
  return this.items.length === 0;
710
721
  }
722
+ size() {
723
+ return this.items.length;
724
+ }
711
725
  };
712
726
 
713
727
  // src/filter/ActiveTransitionFilter.ts
@@ -772,6 +786,17 @@ var ReentrancyError = class extends FinitaError {
772
786
  }
773
787
  };
774
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";
797
+ }
798
+ };
799
+
775
800
  // src/Statemachine.ts
776
801
  var Statemachine = class {
777
802
  subject;
@@ -782,11 +807,15 @@ var Statemachine = class {
782
807
  lastState = null;
783
808
  autoreleaseLock;
784
809
  maxAutomaticHops;
810
+ maxQueueLength;
785
811
  queue = new OperationQueue();
786
812
  running = false;
813
+ idleWaiters = [];
787
814
  inSyncCallback = false;
788
815
  beforeObservers = [];
789
816
  afterObservers = [];
817
+ onChainedOperationError;
818
+ onReleaseError;
790
819
  constructor(subject, process, options = {}) {
791
820
  this.subject = subject;
792
821
  this.process = process;
@@ -801,6 +830,15 @@ var Statemachine = class {
801
830
  );
802
831
  }
803
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;
804
842
  }
805
843
  // --- public getters ---
806
844
  getCurrentState() {
@@ -817,6 +855,7 @@ var Statemachine = class {
817
855
  }
818
856
  // --- public observer attach/detach ---
819
857
  attachBefore(observer) {
858
+ if (this.beforeObservers.includes(observer)) return;
820
859
  this.beforeObservers.push(observer);
821
860
  }
822
861
  detachBefore(observer) {
@@ -827,6 +866,7 @@ var Statemachine = class {
827
866
  return this.beforeObservers;
828
867
  }
829
868
  attachAfter(observer) {
869
+ if (this.afterObservers.includes(observer)) return;
830
870
  this.afterObservers.push(observer);
831
871
  }
832
872
  detachAfter(observer) {
@@ -865,6 +905,21 @@ var Statemachine = class {
865
905
  this.enqueueOperation(null, context, resolve, reject);
866
906
  });
867
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);
921
+ });
922
+ }
868
923
  /** Runs fn with the re-entrancy flag set for its SYNCHRONOUS portion only:
869
924
  * the flag is cleared as soon as fn returns (before any promise it returned
870
925
  * is awaited), so concurrent external callers are never affected. This
@@ -886,6 +941,9 @@ var Statemachine = class {
886
941
  }
887
942
  /** Single entry point to the operation queue — every enqueue kicks the runner. */
888
943
  enqueueOperation(eventName, context, resolve, reject, ifStateName) {
944
+ if (this.queue.size() >= this.maxQueueLength) {
945
+ throw new QueueLimitExceededError(this.maxQueueLength, eventName);
946
+ }
889
947
  this.queue.enqueue({
890
948
  eventName,
891
949
  context: context ?? /* @__PURE__ */ new Map(),
@@ -906,6 +964,11 @@ var Statemachine = class {
906
964
  }
907
965
  } finally {
908
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
+ }
909
972
  }
910
973
  }
911
974
  async runOperation(op) {
@@ -931,6 +994,10 @@ var Statemachine = class {
931
994
  try {
932
995
  await this.mutex.releaseLock();
933
996
  } catch (err) {
997
+ try {
998
+ this.onReleaseError?.(err);
999
+ } catch {
1000
+ }
934
1001
  if (!failure) failure = { err };
935
1002
  }
936
1003
  }
@@ -1011,7 +1078,13 @@ var Statemachine = class {
1011
1078
  chainedCtx,
1012
1079
  () => {
1013
1080
  },
1014
- () => {
1081
+ (err) => {
1082
+ try {
1083
+ this.onChainedOperationError?.(err, {
1084
+ eventName: chainedEventName
1085
+ });
1086
+ } catch {
1087
+ }
1015
1088
  },
1016
1089
  ifStateName
1017
1090
  );
@@ -1376,6 +1449,11 @@ var WeightTransition = class {
1376
1449
  innerSelector;
1377
1450
  epsilon;
1378
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
+ }
1379
1457
  this.innerSelector = innerSelector ?? new OneOrNoneActiveTransition();
1380
1458
  this.epsilon = epsilon;
1381
1459
  }
@@ -1384,6 +1462,11 @@ var WeightTransition = class {
1384
1462
  let maxWeight = Number.NEGATIVE_INFINITY;
1385
1463
  for (const transition of all) {
1386
1464
  const weight = transition.getWeight();
1465
+ if (!Number.isFinite(weight)) {
1466
+ throw new RangeError(
1467
+ `WeightTransition: transition weights must be finite numbers; got ${String(weight)}`
1468
+ );
1469
+ }
1387
1470
  if (weight > maxWeight) maxWeight = weight;
1388
1471
  }
1389
1472
  const best = all.filter(
@@ -1558,6 +1641,14 @@ function toMermaidId(name) {
1558
1641
  function escapeMermaidLabel(str) {
1559
1642
  return str.replace(/\\/g, "#92;").replace(/"/g, "#quot;");
1560
1643
  }
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
+ );
1650
+ }
1651
+ }
1561
1652
  var GraphBuilder = class {
1562
1653
  nodes = /* @__PURE__ */ new Map();
1563
1654
  edges = [];
@@ -1633,6 +1724,7 @@ var GraphBuilder = class {
1633
1724
  toDot(options) {
1634
1725
  const graph = this.getGraph();
1635
1726
  const rankdir = options?.rankdir ?? "LR";
1727
+ assertDirection(rankdir, "rankdir");
1636
1728
  const lines = [];
1637
1729
  lines.push("digraph {");
1638
1730
  lines.push(` rankdir=${rankdir};`);
@@ -1652,6 +1744,7 @@ var GraphBuilder = class {
1652
1744
  toMermaid(options) {
1653
1745
  const graph = this.getGraph();
1654
1746
  const direction = options?.direction ?? "LR";
1747
+ assertDirection(direction, "direction");
1655
1748
  const lines = [];
1656
1749
  lines.push(`stateDiagram-v2`);
1657
1750
  lines.push(` direction ${direction}`);
@@ -1707,6 +1800,7 @@ var GraphBuilder = class {
1707
1800
  ProcessBuilder,
1708
1801
  ProcessFinalizedError,
1709
1802
  ProcessNotFoundError,
1803
+ QueueLimitExceededError,
1710
1804
  ReentrancyError,
1711
1805
  ScoreTransition,
1712
1806
  SingleProcessDetector,