@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.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);
@@ -58,8 +60,8 @@ var INTERNAL_CONSTRUCTION_KEY = /* @__PURE__ */ Symbol(
58
60
 
59
61
  // src/error/FinitaError.ts
60
62
  var FinitaError = class _FinitaError extends Error {
61
- constructor(message) {
62
- super(message);
63
+ constructor(message, options) {
64
+ super(message, options);
63
65
  if (new.target === _FinitaError) {
64
66
  throw new TypeError(
65
67
  "FinitaError is abstract and cannot be instantiated directly"
@@ -189,11 +191,13 @@ var State = class {
189
191
  getName() {
190
192
  return this.name;
191
193
  }
194
+ /** Snapshot — the graph is shared by every machine built from the
195
+ * process, so callers must never receive the collection itself. */
192
196
  getTransitions() {
193
197
  if (this._transitions === null) {
194
198
  return [];
195
199
  }
196
- return this._transitions;
200
+ return Array.from(this._transitions);
197
201
  }
198
202
  getEventNames() {
199
203
  return Array.from(this.events.keys());
@@ -472,9 +476,11 @@ var ProcessBuilder = class _ProcessBuilder {
472
476
  }
473
477
  }
474
478
  /** Transition identity: (fromState, eventName, toState). Used by both the
475
- * conflict check and the build-time dedup — keep them in lockstep. */
479
+ * conflict check and the build-time dedup — keep them in lockstep.
480
+ * Encoded as a JSON tuple, not a delimiter join: names may contain any
481
+ * character, so no delimiter can keep distinct tuples distinct. */
476
482
  static transitionKey(t) {
477
- return `${t.fromState}\0${t.eventName ?? ""}\0${t.toState}`;
483
+ return JSON.stringify([t.fromState, t.eventName, t.toState]);
478
484
  }
479
485
  validateNoConflictingDuplicates() {
480
486
  const seen = /* @__PURE__ */ new Map();
@@ -593,12 +599,30 @@ var ProcessBuilder = class _ProcessBuilder {
593
599
  var AmbiguousTransitionError = class extends FinitaError {
594
600
  code = "ambiguousTransition";
595
601
  activeCount;
596
- constructor(activeCount) {
597
- super(`More than one transition is active! (active count: ${activeCount})`);
602
+ /** The competing transitions — what you need to resolve the ambiguity. */
603
+ candidates;
604
+ constructor(activeCount, candidates = []) {
605
+ const list = Array.from(candidates, (c) => Object.freeze({ ...c }));
606
+ const detail = list.length > 0 ? ` Candidates: ${list.map(describeCandidate).join("; ")}.` : "";
607
+ super(
608
+ `More than one transition is active! (active count: ${activeCount})${detail}`
609
+ );
598
610
  this.name = "AmbiguousTransitionError";
599
611
  this.activeCount = activeCount;
612
+ this.candidates = Object.freeze(list);
600
613
  }
601
614
  };
615
+ function describeCandidate(candidate) {
616
+ const parts = [`-> "${candidate.targetStateName}"`];
617
+ parts.push(
618
+ candidate.eventName === null ? "on <automatic>" : `on event "${candidate.eventName}"`
619
+ );
620
+ if (candidate.conditionName !== null) {
621
+ parts.push(`if ${candidate.conditionName}`);
622
+ }
623
+ parts.push(`weight ${candidate.weight}`);
624
+ return parts.join(" ");
625
+ }
602
626
 
603
627
  // src/selector/OneOrNoneActiveTransition.ts
604
628
  var OneOrNoneActiveTransition = class {
@@ -610,7 +634,15 @@ var OneOrNoneActiveTransition = class {
610
634
  case 1:
611
635
  return arr[0];
612
636
  default:
613
- throw new AmbiguousTransitionError(arr.length);
637
+ throw new AmbiguousTransitionError(
638
+ arr.length,
639
+ arr.map((transition) => ({
640
+ targetStateName: transition.getTargetState().getName(),
641
+ eventName: transition.getEventName(),
642
+ conditionName: transition.getConditionName(),
643
+ weight: transition.getWeight()
644
+ }))
645
+ );
614
646
  }
615
647
  }
616
648
  };
@@ -635,19 +667,34 @@ var NullMutex = class {
635
667
  };
636
668
 
637
669
  // src/internal/OperationQueue.ts
638
- var OperationQueue = class {
670
+ var OperationQueue = class _OperationQueue {
671
+ static COMPACT_THRESHOLD = 1024;
639
672
  items = [];
673
+ head = 0;
640
674
  enqueue(op) {
641
675
  this.items.push(op);
642
676
  }
643
677
  dequeue() {
644
- return this.items.shift();
678
+ if (this.head >= this.items.length) {
679
+ return void 0;
680
+ }
681
+ const op = this.items[this.head];
682
+ this.items[this.head] = void 0;
683
+ this.head++;
684
+ if (this.head === this.items.length) {
685
+ this.items = [];
686
+ this.head = 0;
687
+ } else if (this.head >= _OperationQueue.COMPACT_THRESHOLD && this.head * 2 >= this.items.length) {
688
+ this.items = this.items.slice(this.head);
689
+ this.head = 0;
690
+ }
691
+ return op;
645
692
  }
646
693
  isEmpty() {
647
- return this.items.length === 0;
694
+ return this.head === this.items.length;
648
695
  }
649
696
  size() {
650
- return this.items.length;
697
+ return this.items.length - this.head;
651
698
  }
652
699
  };
653
700
 
@@ -687,6 +734,27 @@ var LockCanNotBeAcquiredError = class extends FinitaError {
687
734
  }
688
735
  };
689
736
 
737
+ // src/error/LockCanNotBeReleasedError.ts
738
+ var LockCanNotBeReleasedError = class extends FinitaError {
739
+ code = "lockCanNotBeReleased";
740
+ constructor(message = "Lock can not be released! releaseLock() returned false; the lock may still be held.") {
741
+ super(message);
742
+ this.name = "LockCanNotBeReleasedError";
743
+ }
744
+ };
745
+
746
+ // src/error/LockOwnershipUncertainError.ts
747
+ var LockOwnershipUncertainError = class extends FinitaError {
748
+ code = "lockOwnershipUncertain";
749
+ constructor(cause) {
750
+ super(
751
+ "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.",
752
+ { cause }
753
+ );
754
+ this.name = "LockOwnershipUncertainError";
755
+ }
756
+ };
757
+
690
758
  // src/error/AutomaticTransitionCycleError.ts
691
759
  var AutomaticTransitionCycleError = class extends FinitaError {
692
760
  code = "automaticTransitionCycle";
@@ -724,6 +792,18 @@ var QueueLimitExceededError = class extends FinitaError {
724
792
  }
725
793
  };
726
794
 
795
+ // src/util/index.ts
796
+ function isNamed(obj) {
797
+ return typeof obj === "object" && obj !== null && "getName" in obj && typeof obj.getName === "function";
798
+ }
799
+ function nameOrString(obj) {
800
+ if (isNamed(obj)) return obj.getName();
801
+ return String(obj);
802
+ }
803
+ function isPromiseLike(value) {
804
+ return (typeof value === "object" || typeof value === "function") && value !== null && typeof value.then === "function";
805
+ }
806
+
727
807
  // src/Statemachine.ts
728
808
  var Statemachine = class {
729
809
  subject;
@@ -739,6 +819,8 @@ var Statemachine = class {
739
819
  running = false;
740
820
  idleWaiters = [];
741
821
  inSyncCallback = false;
822
+ /** Set when releasing a held lock fails; see LockOwnershipUncertainError. */
823
+ ownershipUncertainty = null;
742
824
  beforeObservers = [];
743
825
  afterObservers = [];
744
826
  onChainedOperationError;
@@ -789,8 +871,10 @@ var Statemachine = class {
789
871
  const idx = this.beforeObservers.indexOf(observer);
790
872
  if (idx >= 0) this.beforeObservers.splice(idx, 1);
791
873
  }
874
+ /** Snapshot — detaching later does not change an already-returned list,
875
+ * and mutating it does not change the machine's registrations. */
792
876
  getBeforeObservers() {
793
- return this.beforeObservers;
877
+ return [...this.beforeObservers];
794
878
  }
795
879
  attachAfter(observer) {
796
880
  if (this.afterObservers.includes(observer)) return;
@@ -800,15 +884,26 @@ var Statemachine = class {
800
884
  const idx = this.afterObservers.indexOf(observer);
801
885
  if (idx >= 0) this.afterObservers.splice(idx, 1);
802
886
  }
887
+ /** Snapshot — see getBeforeObservers. */
803
888
  getAfterObservers() {
804
- return this.afterObservers;
889
+ return [...this.afterObservers];
805
890
  }
806
891
  // --- public locking ---
807
892
  async acquireLock() {
808
893
  return this.mutex.acquireLock();
809
894
  }
895
+ /**
896
+ * Releases the mutex. A failed release — whether the mutex throws or
897
+ * returns false — is reported to the onReleaseError hook; it is not thrown,
898
+ * so manual lock management keeps its existing control flow. Inspect
899
+ * isLockAcquired() (or the hook) to learn whether the lock was actually
900
+ * freed.
901
+ *
902
+ * A failed release of a held lock makes every later operation reject with
903
+ * LockOwnershipUncertainError; a successful call here is how to recover.
904
+ */
810
905
  async releaseLock() {
811
- await this.mutex.releaseLock();
906
+ await this.releaseMutex();
812
907
  }
813
908
  isLockAcquired() {
814
909
  return this.mutex.isAcquired();
@@ -838,8 +933,14 @@ var Statemachine = class {
838
933
  * EnqueueContext.enqueue(), has completed. Resolves immediately if the
839
934
  * machine is already idle. Note this is a quiescence point, not a
840
935
  * receipt: work scheduled later (e.g. from a timer) starts a new drain.
936
+ *
937
+ * Like triggerEvent/checkTransitions, this may not be called from inside an
938
+ * observer or condition of the same machine: the machine cannot reach idle
939
+ * while the runner is blocked on that very callback, so awaiting it there
940
+ * always deadlocks.
841
941
  */
842
942
  whenIdle() {
943
+ this.assertNotReentrant("whenIdle()");
843
944
  if (!this.running && this.queue.isEmpty()) {
844
945
  return Promise.resolve();
845
946
  }
@@ -899,6 +1000,10 @@ var Statemachine = class {
899
1000
  }
900
1001
  }
901
1002
  async runOperation(op) {
1003
+ if (this.ownershipUncertainty) {
1004
+ op.reject(new LockOwnershipUncertainError(this.ownershipUncertainty.err));
1005
+ return;
1006
+ }
902
1007
  if (op.ifStateName !== void 0 && this.currentState.getName() !== op.ifStateName) {
903
1008
  op.resolve();
904
1009
  return;
@@ -918,15 +1023,8 @@ var Statemachine = class {
918
1023
  failure = { err };
919
1024
  } finally {
920
1025
  if (acquiredHere && this.autoreleaseLock) {
921
- try {
922
- await this.mutex.releaseLock();
923
- } catch (err) {
924
- try {
925
- this.onReleaseError?.(err);
926
- } catch {
927
- }
928
- if (!failure) failure = { err };
929
- }
1026
+ const releaseFailure = await this.releaseMutex();
1027
+ if (releaseFailure && !failure) failure = releaseFailure;
930
1028
  }
931
1029
  }
932
1030
  if (failure) {
@@ -935,6 +1033,60 @@ var Statemachine = class {
935
1033
  op.resolve();
936
1034
  }
937
1035
  }
1036
+ /**
1037
+ * Releases the mutex, normalizing its two failure modes into one result: a
1038
+ * thrown error, and a false return — the failure signal MutexInterface /
1039
+ * LockAdapterInterface define (a PostgreSQL advisory unlock that returns
1040
+ * false, a Redis DEL that removed nothing). A false return means the lock
1041
+ * may still be held, so it must never be mistaken for a successful release.
1042
+ *
1043
+ * Every failure is surfaced through the diagnostic hook — when the
1044
+ * operation also failed, the rejection carries the operation error and this
1045
+ * hook is the only place the release error appears.
1046
+ *
1047
+ * A failure while the mutex claimed to hold the lock leaves ownership
1048
+ * uncertain and blocks later operations; a success clears that state. A
1049
+ * failed release of a lock the mutex did not claim (a defensive manual
1050
+ * release) is still reported but changes nothing.
1051
+ *
1052
+ * @returns null on success, or the failure wrapped for the caller to raise.
1053
+ */
1054
+ async releaseMutex() {
1055
+ const held = this.mutex.isAcquired();
1056
+ let failure = null;
1057
+ try {
1058
+ if (!await this.mutex.releaseLock()) {
1059
+ failure = { err: new LockCanNotBeReleasedError() };
1060
+ }
1061
+ } catch (err) {
1062
+ failure = { err };
1063
+ }
1064
+ if (failure) {
1065
+ if (held) this.ownershipUncertainty = failure;
1066
+ const err = failure.err;
1067
+ this.callDiagnosticHook(() => this.onReleaseError?.(err));
1068
+ } else {
1069
+ this.ownershipUncertainty = null;
1070
+ }
1071
+ return failure;
1072
+ }
1073
+ /**
1074
+ * Runs a user diagnostic hook in isolation. Neither a synchronous throw nor
1075
+ * a rejection of a returned promise may reach the drain loop or the host:
1076
+ * an unavailable telemetry backend must not fail an operation or, via an
1077
+ * unhandled rejection, terminate the process. A returned promise is
1078
+ * deliberately not awaited — a slow reporter must not stall the runner.
1079
+ */
1080
+ callDiagnosticHook(hook) {
1081
+ try {
1082
+ const result = hook();
1083
+ if (isPromiseLike(result)) {
1084
+ result.then(void 0, () => {
1085
+ });
1086
+ }
1087
+ } catch {
1088
+ }
1089
+ }
938
1090
  resolveEvent(name) {
939
1091
  if (!this.currentState.hasEvent(name)) {
940
1092
  throw new WrongEventForStateError(this.currentState.getName(), name);
@@ -1006,12 +1158,11 @@ var Statemachine = class {
1006
1158
  () => {
1007
1159
  },
1008
1160
  (err) => {
1009
- try {
1010
- this.onChainedOperationError?.(err, {
1161
+ this.callDiagnosticHook(
1162
+ () => this.onChainedOperationError?.(err, {
1011
1163
  eventName: chainedEventName
1012
- });
1013
- } catch {
1014
- }
1164
+ })
1165
+ );
1015
1166
  },
1016
1167
  ifStateName
1017
1168
  );
@@ -1147,6 +1298,30 @@ var CompositeCondition = class {
1147
1298
  const names = this.conditions.map((c) => c.getName());
1148
1299
  return `(${names.join(` ${this.joinWord} `)})`;
1149
1300
  }
1301
+ /**
1302
+ * Evaluates children in order, stopping at the first whose result equals
1303
+ * `shortCircuitOn`. A child that returns a plain boolean is consumed
1304
+ * synchronously; only a returned promise is awaited. Awaiting plain values
1305
+ * would yield between children and end the machine's synchronous
1306
+ * re-entrancy guard, so a re-entrant later child would deadlock instead of
1307
+ * throwing ReentrancyError. For the same reason the composite itself
1308
+ * returns a plain boolean when every child it evaluated did.
1309
+ */
1310
+ evaluate(subject, context, shortCircuitOn) {
1311
+ const from = (start) => {
1312
+ for (let i = start; i < this.conditions.length; i++) {
1313
+ const result = this.conditions[i].checkCondition(subject, context);
1314
+ if (isPromiseLike(result)) {
1315
+ return Promise.resolve(result).then(
1316
+ (value) => Boolean(value) === shortCircuitOn ? shortCircuitOn : from(i + 1)
1317
+ );
1318
+ }
1319
+ if (Boolean(result) === shortCircuitOn) return shortCircuitOn;
1320
+ }
1321
+ return !shortCircuitOn;
1322
+ };
1323
+ return from(0);
1324
+ }
1150
1325
  };
1151
1326
 
1152
1327
  // src/condition/AndComposite.ts
@@ -1157,13 +1332,8 @@ var AndComposite = class extends CompositeCondition {
1157
1332
  addAnd(condition) {
1158
1333
  return this.addCondition(condition);
1159
1334
  }
1160
- async checkCondition(subject, context) {
1161
- for (const condition of this.conditions) {
1162
- if (!await condition.checkCondition(subject, context)) {
1163
- return false;
1164
- }
1165
- }
1166
- return true;
1335
+ checkCondition(subject, context) {
1336
+ return this.evaluate(subject, context, false);
1167
1337
  }
1168
1338
  };
1169
1339
 
@@ -1175,13 +1345,8 @@ var OrComposite = class extends CompositeCondition {
1175
1345
  addOr(condition) {
1176
1346
  return this.addCondition(condition);
1177
1347
  }
1178
- async checkCondition(subject, context) {
1179
- for (const condition of this.conditions) {
1180
- if (await condition.checkCondition(subject, context)) {
1181
- return true;
1182
- }
1183
- }
1184
- return false;
1348
+ checkCondition(subject, context) {
1349
+ return this.evaluate(subject, context, true);
1185
1350
  }
1186
1351
  };
1187
1352
 
@@ -1194,8 +1359,13 @@ var Not = class {
1194
1359
  getName() {
1195
1360
  return `not ( ${this.condition.getName()} )`;
1196
1361
  }
1197
- async checkCondition(subject, context) {
1198
- return !await this.condition.checkCondition(subject, context);
1362
+ /** Stays synchronous for a synchronous child — see CompositeCondition. */
1363
+ checkCondition(subject, context) {
1364
+ const result = this.condition.checkCondition(subject, context);
1365
+ if (isPromiseLike(result)) {
1366
+ return Promise.resolve(result).then((value) => !value);
1367
+ }
1368
+ return !result;
1199
1369
  }
1200
1370
  };
1201
1371
 
@@ -1250,15 +1420,6 @@ var OnEnterObserver = class _OnEnterObserver {
1250
1420
  }
1251
1421
  };
1252
1422
 
1253
- // src/util/index.ts
1254
- function isNamed(obj) {
1255
- return typeof obj === "object" && obj !== null && "getName" in obj && typeof obj.getName === "function";
1256
- }
1257
- function nameOrString(obj) {
1258
- if (isNamed(obj)) return obj.getName();
1259
- return String(obj);
1260
- }
1261
-
1262
1423
  // src/observer/TransitionLogger.ts
1263
1424
  var TransitionLogger = class {
1264
1425
  logger;
@@ -1408,15 +1569,39 @@ var LockAdapterMutex = class {
1408
1569
  lockAdapter;
1409
1570
  resourceName;
1410
1571
  acquired = false;
1572
+ pendingAcquire = null;
1411
1573
  constructor(lockAdapter, resourceName) {
1412
1574
  this.lockAdapter = lockAdapter;
1413
1575
  this.resourceName = resourceName;
1414
1576
  }
1577
+ /**
1578
+ * Overlapping calls share one underlying acquire: the `acquired` flag is
1579
+ * only set after the adapter resolves, so without this both callers would
1580
+ * pass the check and acquire twice on a non-idempotent adapter (database
1581
+ * advisory locks, redis SET NX). The pending promise is cleared once it
1582
+ * settles, so a failed acquire can still be retried.
1583
+ *
1584
+ * The clearing is attached to the attempt only after it is stored: an
1585
+ * adapter that throws synchronously settles the attempt before the
1586
+ * assignment would otherwise run, and clearing inside the attempt itself
1587
+ * would then leave the rejected promise cached forever.
1588
+ */
1415
1589
  async acquireLock() {
1416
- if (!this.acquired) {
1417
- this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1590
+ if (this.acquired) {
1591
+ return true;
1418
1592
  }
1419
- return this.acquired;
1593
+ if (!this.pendingAcquire) {
1594
+ const attempt = (async () => {
1595
+ this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1596
+ return this.acquired;
1597
+ })();
1598
+ this.pendingAcquire = attempt;
1599
+ const clear = () => {
1600
+ if (this.pendingAcquire === attempt) this.pendingAcquire = null;
1601
+ };
1602
+ attempt.then(clear, clear);
1603
+ }
1604
+ return this.pendingAcquire;
1420
1605
  }
1421
1606
  async releaseLock() {
1422
1607
  if (this.acquired) {
@@ -1460,9 +1645,18 @@ var Factory = class {
1460
1645
  afterObservers = /* @__PURE__ */ new Set();
1461
1646
  transitionSelector = null;
1462
1647
  mutexFactory = null;
1463
- constructor(processDetector, stateNameDetector) {
1648
+ options;
1649
+ /**
1650
+ * @param options Engine options applied to every machine this factory
1651
+ * creates — back-pressure (maxQueueLength), the automatic-hop bound, lock
1652
+ * autorelease, and the onChainedOperationError / onReleaseError diagnostic
1653
+ * sinks. Without them, factory-created machines would silently run on
1654
+ * defaults, which is precisely where those sinks matter most.
1655
+ */
1656
+ constructor(processDetector, stateNameDetector, options = {}) {
1464
1657
  this.processDetector = processDetector;
1465
1658
  this.stateNameDetector = stateNameDetector ?? null;
1659
+ this.options = { ...options };
1466
1660
  }
1467
1661
  setMutexFactory(factory) {
1468
1662
  this.mutexFactory = factory;
@@ -1487,6 +1681,7 @@ var Factory = class {
1487
1681
  const stateName = this.stateNameDetector ? this.stateNameDetector.detectCurrentStateName(subject) : void 0;
1488
1682
  const mutex = this.mutexFactory ? await this.mutexFactory.createMutex(subject) : void 0;
1489
1683
  const sm = new Statemachine(subject, process, {
1684
+ ...this.options,
1490
1685
  initialStateName: stateName ?? void 0,
1491
1686
  transitionSelector: this.transitionSelector ?? void 0,
1492
1687
  mutex: mutex ?? void 0
@@ -1716,6 +1911,8 @@ export {
1716
1911
  InvalidSubjectError,
1717
1912
  LockAdapterMutex,
1718
1913
  LockCanNotBeAcquiredError,
1914
+ LockCanNotBeReleasedError,
1915
+ LockOwnershipUncertainError,
1719
1916
  MutexFactory,
1720
1917
  Not,
1721
1918
  NullMutex,