@camcima/finita 4.1.0 → 4.3.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,8 @@ __export(index_exports, {
42
42
  InvalidSubjectError: () => InvalidSubjectError,
43
43
  LockAdapterMutex: () => LockAdapterMutex,
44
44
  LockCanNotBeAcquiredError: () => LockCanNotBeAcquiredError,
45
+ LockCanNotBeReleasedError: () => LockCanNotBeReleasedError,
46
+ LockOwnershipUncertainError: () => LockOwnershipUncertainError,
45
47
  MutexFactory: () => MutexFactory,
46
48
  Not: () => Not,
47
49
  NullMutex: () => NullMutex,
@@ -104,8 +106,10 @@ var Event = class {
104
106
  await observer.update(this, args);
105
107
  }
106
108
  }
109
+ /** Snapshot — detaching later does not change an already-returned list,
110
+ * and mutating it does not change the event's registrations. */
107
111
  getObservers() {
108
- return this.observers;
112
+ return [...this.observers];
109
113
  }
110
114
  getMetadata() {
111
115
  return Object.fromEntries(this.metadata);
@@ -131,8 +135,8 @@ var INTERNAL_CONSTRUCTION_KEY = /* @__PURE__ */ Symbol(
131
135
 
132
136
  // src/error/FinitaError.ts
133
137
  var FinitaError = class _FinitaError extends Error {
134
- constructor(message) {
135
- super(message);
138
+ constructor(message, options) {
139
+ super(message, options);
136
140
  if (new.target === _FinitaError) {
137
141
  throw new TypeError(
138
142
  "FinitaError is abstract and cannot be instantiated directly"
@@ -262,11 +266,13 @@ var State = class {
262
266
  getName() {
263
267
  return this.name;
264
268
  }
269
+ /** Snapshot — the graph is shared by every machine built from the
270
+ * process, so callers must never receive the collection itself. */
265
271
  getTransitions() {
266
272
  if (this._transitions === null) {
267
273
  return [];
268
274
  }
269
- return this._transitions;
275
+ return Array.from(this._transitions);
270
276
  }
271
277
  getEventNames() {
272
278
  return Array.from(this.events.keys());
@@ -545,9 +551,11 @@ var ProcessBuilder = class _ProcessBuilder {
545
551
  }
546
552
  }
547
553
  /** Transition identity: (fromState, eventName, toState). Used by both the
548
- * conflict check and the build-time dedup — keep them in lockstep. */
554
+ * conflict check and the build-time dedup — keep them in lockstep.
555
+ * Encoded as a JSON tuple, not a delimiter join: names may contain any
556
+ * character, so no delimiter can keep distinct tuples distinct. */
549
557
  static transitionKey(t) {
550
- return `${t.fromState}\0${t.eventName ?? ""}\0${t.toState}`;
558
+ return JSON.stringify([t.fromState, t.eventName, t.toState]);
551
559
  }
552
560
  validateNoConflictingDuplicates() {
553
561
  const seen = /* @__PURE__ */ new Map();
@@ -666,12 +674,30 @@ var ProcessBuilder = class _ProcessBuilder {
666
674
  var AmbiguousTransitionError = class extends FinitaError {
667
675
  code = "ambiguousTransition";
668
676
  activeCount;
669
- constructor(activeCount) {
670
- super(`More than one transition is active! (active count: ${activeCount})`);
677
+ /** The competing transitions — what you need to resolve the ambiguity. */
678
+ candidates;
679
+ constructor(activeCount, candidates = []) {
680
+ const list = Array.from(candidates, (c) => Object.freeze({ ...c }));
681
+ const detail = list.length > 0 ? ` Candidates: ${list.map(describeCandidate).join("; ")}.` : "";
682
+ super(
683
+ `More than one transition is active! (active count: ${activeCount})${detail}`
684
+ );
671
685
  this.name = "AmbiguousTransitionError";
672
686
  this.activeCount = activeCount;
687
+ this.candidates = Object.freeze(list);
673
688
  }
674
689
  };
690
+ function describeCandidate(candidate) {
691
+ const parts = [`-> "${candidate.targetStateName}"`];
692
+ parts.push(
693
+ candidate.eventName === null ? "on <automatic>" : `on event "${candidate.eventName}"`
694
+ );
695
+ if (candidate.conditionName !== null) {
696
+ parts.push(`if ${candidate.conditionName}`);
697
+ }
698
+ parts.push(`weight ${candidate.weight}`);
699
+ return parts.join(" ");
700
+ }
675
701
 
676
702
  // src/selector/OneOrNoneActiveTransition.ts
677
703
  var OneOrNoneActiveTransition = class {
@@ -683,7 +709,15 @@ var OneOrNoneActiveTransition = class {
683
709
  case 1:
684
710
  return arr[0];
685
711
  default:
686
- throw new AmbiguousTransitionError(arr.length);
712
+ throw new AmbiguousTransitionError(
713
+ arr.length,
714
+ arr.map((transition) => ({
715
+ targetStateName: transition.getTargetState().getName(),
716
+ eventName: transition.getEventName(),
717
+ conditionName: transition.getConditionName(),
718
+ weight: transition.getWeight()
719
+ }))
720
+ );
687
721
  }
688
722
  }
689
723
  };
@@ -708,19 +742,34 @@ var NullMutex = class {
708
742
  };
709
743
 
710
744
  // src/internal/OperationQueue.ts
711
- var OperationQueue = class {
745
+ var OperationQueue = class _OperationQueue {
746
+ static COMPACT_THRESHOLD = 1024;
712
747
  items = [];
748
+ head = 0;
713
749
  enqueue(op) {
714
750
  this.items.push(op);
715
751
  }
716
752
  dequeue() {
717
- return this.items.shift();
753
+ if (this.head >= this.items.length) {
754
+ return void 0;
755
+ }
756
+ const op = this.items[this.head];
757
+ this.items[this.head] = void 0;
758
+ this.head++;
759
+ if (this.head === this.items.length) {
760
+ this.items = [];
761
+ this.head = 0;
762
+ } else if (this.head >= _OperationQueue.COMPACT_THRESHOLD && this.head * 2 >= this.items.length) {
763
+ this.items = this.items.slice(this.head);
764
+ this.head = 0;
765
+ }
766
+ return op;
718
767
  }
719
768
  isEmpty() {
720
- return this.items.length === 0;
769
+ return this.head === this.items.length;
721
770
  }
722
771
  size() {
723
- return this.items.length;
772
+ return this.items.length - this.head;
724
773
  }
725
774
  };
726
775
 
@@ -760,6 +809,27 @@ var LockCanNotBeAcquiredError = class extends FinitaError {
760
809
  }
761
810
  };
762
811
 
812
+ // src/error/LockCanNotBeReleasedError.ts
813
+ var LockCanNotBeReleasedError = class extends FinitaError {
814
+ code = "lockCanNotBeReleased";
815
+ constructor(message = "Lock can not be released! releaseLock() returned false; the lock may still be held.") {
816
+ super(message);
817
+ this.name = "LockCanNotBeReleasedError";
818
+ }
819
+ };
820
+
821
+ // src/error/LockOwnershipUncertainError.ts
822
+ var LockOwnershipUncertainError = class extends FinitaError {
823
+ code = "lockOwnershipUncertain";
824
+ constructor(cause) {
825
+ super(
826
+ "Operation rejected: a previous lock release failed, so this machine cannot tell whether it still holds the lock. Call releaseLock() and confirm it succeeds, or discard the machine and rebuild it from persisted state.",
827
+ { cause }
828
+ );
829
+ this.name = "LockOwnershipUncertainError";
830
+ }
831
+ };
832
+
763
833
  // src/error/AutomaticTransitionCycleError.ts
764
834
  var AutomaticTransitionCycleError = class extends FinitaError {
765
835
  code = "automaticTransitionCycle";
@@ -797,6 +867,18 @@ var QueueLimitExceededError = class extends FinitaError {
797
867
  }
798
868
  };
799
869
 
870
+ // src/util/index.ts
871
+ function isNamed(obj) {
872
+ return typeof obj === "object" && obj !== null && "getName" in obj && typeof obj.getName === "function";
873
+ }
874
+ function nameOrString(obj) {
875
+ if (isNamed(obj)) return obj.getName();
876
+ return String(obj);
877
+ }
878
+ function isPromiseLike(value) {
879
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
880
+ }
881
+
800
882
  // src/Statemachine.ts
801
883
  var Statemachine = class {
802
884
  subject;
@@ -812,6 +894,8 @@ var Statemachine = class {
812
894
  running = false;
813
895
  idleWaiters = [];
814
896
  inSyncCallback = false;
897
+ /** Set when releasing a held lock fails; see LockOwnershipUncertainError. */
898
+ ownershipUncertainty = null;
815
899
  beforeObservers = [];
816
900
  afterObservers = [];
817
901
  onChainedOperationError;
@@ -862,8 +946,10 @@ var Statemachine = class {
862
946
  const idx = this.beforeObservers.indexOf(observer);
863
947
  if (idx >= 0) this.beforeObservers.splice(idx, 1);
864
948
  }
949
+ /** Snapshot — detaching later does not change an already-returned list,
950
+ * and mutating it does not change the machine's registrations. */
865
951
  getBeforeObservers() {
866
- return this.beforeObservers;
952
+ return [...this.beforeObservers];
867
953
  }
868
954
  attachAfter(observer) {
869
955
  if (this.afterObservers.includes(observer)) return;
@@ -873,15 +959,26 @@ var Statemachine = class {
873
959
  const idx = this.afterObservers.indexOf(observer);
874
960
  if (idx >= 0) this.afterObservers.splice(idx, 1);
875
961
  }
962
+ /** Snapshot — see getBeforeObservers. */
876
963
  getAfterObservers() {
877
- return this.afterObservers;
964
+ return [...this.afterObservers];
878
965
  }
879
966
  // --- public locking ---
880
967
  async acquireLock() {
881
968
  return this.mutex.acquireLock();
882
969
  }
970
+ /**
971
+ * Releases the mutex. A failed release — whether the mutex throws or
972
+ * returns false — is reported to the onReleaseError hook; it is not thrown,
973
+ * so manual lock management keeps its existing control flow. Inspect
974
+ * isLockAcquired() (or the hook) to learn whether the lock was actually
975
+ * freed.
976
+ *
977
+ * A failed release of a held lock makes every later operation reject with
978
+ * LockOwnershipUncertainError; a successful call here is how to recover.
979
+ */
883
980
  async releaseLock() {
884
- await this.mutex.releaseLock();
981
+ await this.releaseMutex();
885
982
  }
886
983
  isLockAcquired() {
887
984
  return this.mutex.isAcquired();
@@ -911,8 +1008,14 @@ var Statemachine = class {
911
1008
  * EnqueueContext.enqueue(), has completed. Resolves immediately if the
912
1009
  * machine is already idle. Note this is a quiescence point, not a
913
1010
  * receipt: work scheduled later (e.g. from a timer) starts a new drain.
1011
+ *
1012
+ * Like triggerEvent/checkTransitions, this may not be called from inside an
1013
+ * observer or condition of the same machine: the machine cannot reach idle
1014
+ * while the runner is blocked on that very callback, so awaiting it there
1015
+ * always deadlocks.
914
1016
  */
915
1017
  whenIdle() {
1018
+ this.assertNotReentrant("whenIdle()");
916
1019
  if (!this.running && this.queue.isEmpty()) {
917
1020
  return Promise.resolve();
918
1021
  }
@@ -972,6 +1075,10 @@ var Statemachine = class {
972
1075
  }
973
1076
  }
974
1077
  async runOperation(op) {
1078
+ if (this.ownershipUncertainty) {
1079
+ op.reject(new LockOwnershipUncertainError(this.ownershipUncertainty.err));
1080
+ return;
1081
+ }
975
1082
  if (op.ifStateName !== void 0 && this.currentState.getName() !== op.ifStateName) {
976
1083
  op.resolve();
977
1084
  return;
@@ -991,15 +1098,8 @@ var Statemachine = class {
991
1098
  failure = { err };
992
1099
  } finally {
993
1100
  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
- }
1101
+ const releaseFailure = await this.releaseMutex();
1102
+ if (releaseFailure && !failure) failure = releaseFailure;
1003
1103
  }
1004
1104
  }
1005
1105
  if (failure) {
@@ -1008,6 +1108,60 @@ var Statemachine = class {
1008
1108
  op.resolve();
1009
1109
  }
1010
1110
  }
1111
+ /**
1112
+ * Releases the mutex, normalizing its two failure modes into one result: a
1113
+ * thrown error, and a false return — the failure signal MutexInterface /
1114
+ * LockAdapterInterface define (a PostgreSQL advisory unlock that returns
1115
+ * false, a Redis DEL that removed nothing). A false return means the lock
1116
+ * may still be held, so it must never be mistaken for a successful release.
1117
+ *
1118
+ * Every failure is surfaced through the diagnostic hook — when the
1119
+ * operation also failed, the rejection carries the operation error and this
1120
+ * hook is the only place the release error appears.
1121
+ *
1122
+ * A failure while the mutex claimed to hold the lock leaves ownership
1123
+ * uncertain and blocks later operations; a success clears that state. A
1124
+ * failed release of a lock the mutex did not claim (a defensive manual
1125
+ * release) is still reported but changes nothing.
1126
+ *
1127
+ * @returns null on success, or the failure wrapped for the caller to raise.
1128
+ */
1129
+ async releaseMutex() {
1130
+ const held = this.mutex.isAcquired();
1131
+ let failure = null;
1132
+ try {
1133
+ if (!await this.mutex.releaseLock()) {
1134
+ failure = { err: new LockCanNotBeReleasedError() };
1135
+ }
1136
+ } catch (err) {
1137
+ failure = { err };
1138
+ }
1139
+ if (failure) {
1140
+ if (held) this.ownershipUncertainty = failure;
1141
+ const err = failure.err;
1142
+ this.callDiagnosticHook(() => this.onReleaseError?.(err));
1143
+ } else {
1144
+ this.ownershipUncertainty = null;
1145
+ }
1146
+ return failure;
1147
+ }
1148
+ /**
1149
+ * Runs a user diagnostic hook in isolation. Neither a synchronous throw nor
1150
+ * a rejection of a returned promise may reach the drain loop or the host:
1151
+ * an unavailable telemetry backend must not fail an operation or, via an
1152
+ * unhandled rejection, terminate the process. A returned promise is
1153
+ * deliberately not awaited — a slow reporter must not stall the runner.
1154
+ */
1155
+ callDiagnosticHook(hook) {
1156
+ try {
1157
+ const result = hook();
1158
+ if (isPromiseLike(result)) {
1159
+ result.then(void 0, () => {
1160
+ });
1161
+ }
1162
+ } catch {
1163
+ }
1164
+ }
1011
1165
  resolveEvent(name) {
1012
1166
  if (!this.currentState.hasEvent(name)) {
1013
1167
  throw new WrongEventForStateError(this.currentState.getName(), name);
@@ -1079,12 +1233,11 @@ var Statemachine = class {
1079
1233
  () => {
1080
1234
  },
1081
1235
  (err) => {
1082
- try {
1083
- this.onChainedOperationError?.(err, {
1236
+ this.callDiagnosticHook(
1237
+ () => this.onChainedOperationError?.(err, {
1084
1238
  eventName: chainedEventName
1085
- });
1086
- } catch {
1087
- }
1239
+ })
1240
+ );
1088
1241
  },
1089
1242
  ifStateName
1090
1243
  );
@@ -1220,6 +1373,30 @@ var CompositeCondition = class {
1220
1373
  const names = this.conditions.map((c) => c.getName());
1221
1374
  return `(${names.join(` ${this.joinWord} `)})`;
1222
1375
  }
1376
+ /**
1377
+ * Evaluates children in order, stopping at the first whose result equals
1378
+ * `shortCircuitOn`. A child that returns a plain boolean is consumed
1379
+ * synchronously; only a returned promise is awaited. Awaiting plain values
1380
+ * would yield between children and end the machine's synchronous
1381
+ * re-entrancy guard, so a re-entrant later child would deadlock instead of
1382
+ * throwing ReentrancyError. For the same reason the composite itself
1383
+ * returns a plain boolean when every child it evaluated did.
1384
+ */
1385
+ evaluate(subject, context, shortCircuitOn) {
1386
+ const from = (start) => {
1387
+ for (let i = start; i < this.conditions.length; i++) {
1388
+ const result = this.conditions[i].checkCondition(subject, context);
1389
+ if (isPromiseLike(result)) {
1390
+ return Promise.resolve(result).then(
1391
+ (value) => Boolean(value) === shortCircuitOn ? shortCircuitOn : from(i + 1)
1392
+ );
1393
+ }
1394
+ if (Boolean(result) === shortCircuitOn) return shortCircuitOn;
1395
+ }
1396
+ return !shortCircuitOn;
1397
+ };
1398
+ return from(0);
1399
+ }
1223
1400
  };
1224
1401
 
1225
1402
  // src/condition/AndComposite.ts
@@ -1230,13 +1407,8 @@ var AndComposite = class extends CompositeCondition {
1230
1407
  addAnd(condition) {
1231
1408
  return this.addCondition(condition);
1232
1409
  }
1233
- async checkCondition(subject, context) {
1234
- for (const condition of this.conditions) {
1235
- if (!await condition.checkCondition(subject, context)) {
1236
- return false;
1237
- }
1238
- }
1239
- return true;
1410
+ checkCondition(subject, context) {
1411
+ return this.evaluate(subject, context, false);
1240
1412
  }
1241
1413
  };
1242
1414
 
@@ -1248,13 +1420,8 @@ var OrComposite = class extends CompositeCondition {
1248
1420
  addOr(condition) {
1249
1421
  return this.addCondition(condition);
1250
1422
  }
1251
- async checkCondition(subject, context) {
1252
- for (const condition of this.conditions) {
1253
- if (await condition.checkCondition(subject, context)) {
1254
- return true;
1255
- }
1256
- }
1257
- return false;
1423
+ checkCondition(subject, context) {
1424
+ return this.evaluate(subject, context, true);
1258
1425
  }
1259
1426
  };
1260
1427
 
@@ -1267,8 +1434,13 @@ var Not = class {
1267
1434
  getName() {
1268
1435
  return `not ( ${this.condition.getName()} )`;
1269
1436
  }
1270
- async checkCondition(subject, context) {
1271
- return !await this.condition.checkCondition(subject, context);
1437
+ /** Stays synchronous for a synchronous child — see CompositeCondition. */
1438
+ checkCondition(subject, context) {
1439
+ const result = this.condition.checkCondition(subject, context);
1440
+ if (isPromiseLike(result)) {
1441
+ return Promise.resolve(result).then((value) => !value);
1442
+ }
1443
+ return !result;
1272
1444
  }
1273
1445
  };
1274
1446
 
@@ -1323,15 +1495,6 @@ var OnEnterObserver = class _OnEnterObserver {
1323
1495
  }
1324
1496
  };
1325
1497
 
1326
- // src/util/index.ts
1327
- function isNamed(obj) {
1328
- return typeof obj === "object" && obj !== null && "getName" in obj && typeof obj.getName === "function";
1329
- }
1330
- function nameOrString(obj) {
1331
- if (isNamed(obj)) return obj.getName();
1332
- return String(obj);
1333
- }
1334
-
1335
1498
  // src/observer/TransitionLogger.ts
1336
1499
  var TransitionLogger = class {
1337
1500
  logger;
@@ -1481,15 +1644,39 @@ var LockAdapterMutex = class {
1481
1644
  lockAdapter;
1482
1645
  resourceName;
1483
1646
  acquired = false;
1647
+ pendingAcquire = null;
1484
1648
  constructor(lockAdapter, resourceName) {
1485
1649
  this.lockAdapter = lockAdapter;
1486
1650
  this.resourceName = resourceName;
1487
1651
  }
1652
+ /**
1653
+ * Overlapping calls share one underlying acquire: the `acquired` flag is
1654
+ * only set after the adapter resolves, so without this both callers would
1655
+ * pass the check and acquire twice on a non-idempotent adapter (database
1656
+ * advisory locks, redis SET NX). The pending promise is cleared once it
1657
+ * settles, so a failed acquire can still be retried.
1658
+ *
1659
+ * The clearing is attached to the attempt only after it is stored: an
1660
+ * adapter that throws synchronously settles the attempt before the
1661
+ * assignment would otherwise run, and clearing inside the attempt itself
1662
+ * would then leave the rejected promise cached forever.
1663
+ */
1488
1664
  async acquireLock() {
1489
- if (!this.acquired) {
1490
- this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1665
+ if (this.acquired) {
1666
+ return true;
1491
1667
  }
1492
- return this.acquired;
1668
+ if (!this.pendingAcquire) {
1669
+ const attempt = (async () => {
1670
+ this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1671
+ return this.acquired;
1672
+ })();
1673
+ this.pendingAcquire = attempt;
1674
+ const clear = () => {
1675
+ if (this.pendingAcquire === attempt) this.pendingAcquire = null;
1676
+ };
1677
+ attempt.then(clear, clear);
1678
+ }
1679
+ return this.pendingAcquire;
1493
1680
  }
1494
1681
  async releaseLock() {
1495
1682
  if (this.acquired) {
@@ -1533,9 +1720,18 @@ var Factory = class {
1533
1720
  afterObservers = /* @__PURE__ */ new Set();
1534
1721
  transitionSelector = null;
1535
1722
  mutexFactory = null;
1536
- constructor(processDetector, stateNameDetector) {
1723
+ options;
1724
+ /**
1725
+ * @param options Engine options applied to every machine this factory
1726
+ * creates — back-pressure (maxQueueLength), the automatic-hop bound, lock
1727
+ * autorelease, and the onChainedOperationError / onReleaseError diagnostic
1728
+ * sinks. Without them, factory-created machines would silently run on
1729
+ * defaults, which is precisely where those sinks matter most.
1730
+ */
1731
+ constructor(processDetector, stateNameDetector, options = {}) {
1537
1732
  this.processDetector = processDetector;
1538
1733
  this.stateNameDetector = stateNameDetector ?? null;
1734
+ this.options = { ...options };
1539
1735
  }
1540
1736
  setMutexFactory(factory) {
1541
1737
  this.mutexFactory = factory;
@@ -1560,6 +1756,7 @@ var Factory = class {
1560
1756
  const stateName = this.stateNameDetector ? this.stateNameDetector.detectCurrentStateName(subject) : void 0;
1561
1757
  const mutex = this.mutexFactory ? await this.mutexFactory.createMutex(subject) : void 0;
1562
1758
  const sm = new Statemachine(subject, process, {
1759
+ ...this.options,
1563
1760
  initialStateName: stateName ?? void 0,
1564
1761
  transitionSelector: this.transitionSelector ?? void 0,
1565
1762
  mutex: mutex ?? void 0
@@ -1790,6 +1987,8 @@ var GraphBuilder = class {
1790
1987
  InvalidSubjectError,
1791
1988
  LockAdapterMutex,
1792
1989
  LockCanNotBeAcquiredError,
1990
+ LockCanNotBeReleasedError,
1991
+ LockOwnershipUncertainError,
1793
1992
  MutexFactory,
1794
1993
  Not,
1795
1994
  NullMutex,