@camcima/finita 4.2.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
@@ -60,8 +60,8 @@ var INTERNAL_CONSTRUCTION_KEY = /* @__PURE__ */ Symbol(
60
60
 
61
61
  // src/error/FinitaError.ts
62
62
  var FinitaError = class _FinitaError extends Error {
63
- constructor(message) {
64
- super(message);
63
+ constructor(message, options) {
64
+ super(message, options);
65
65
  if (new.target === _FinitaError) {
66
66
  throw new TypeError(
67
67
  "FinitaError is abstract and cannot be instantiated directly"
@@ -191,11 +191,13 @@ var State = class {
191
191
  getName() {
192
192
  return this.name;
193
193
  }
194
+ /** Snapshot — the graph is shared by every machine built from the
195
+ * process, so callers must never receive the collection itself. */
194
196
  getTransitions() {
195
197
  if (this._transitions === null) {
196
198
  return [];
197
199
  }
198
- return this._transitions;
200
+ return Array.from(this._transitions);
199
201
  }
200
202
  getEventNames() {
201
203
  return Array.from(this.events.keys());
@@ -474,9 +476,11 @@ var ProcessBuilder = class _ProcessBuilder {
474
476
  }
475
477
  }
476
478
  /** Transition identity: (fromState, eventName, toState). Used by both the
477
- * 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. */
478
482
  static transitionKey(t) {
479
- return `${t.fromState}\0${t.eventName ?? ""}\0${t.toState}`;
483
+ return JSON.stringify([t.fromState, t.eventName, t.toState]);
480
484
  }
481
485
  validateNoConflictingDuplicates() {
482
486
  const seen = /* @__PURE__ */ new Map();
@@ -663,19 +667,34 @@ var NullMutex = class {
663
667
  };
664
668
 
665
669
  // src/internal/OperationQueue.ts
666
- var OperationQueue = class {
670
+ var OperationQueue = class _OperationQueue {
671
+ static COMPACT_THRESHOLD = 1024;
667
672
  items = [];
673
+ head = 0;
668
674
  enqueue(op) {
669
675
  this.items.push(op);
670
676
  }
671
677
  dequeue() {
672
- 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;
673
692
  }
674
693
  isEmpty() {
675
- return this.items.length === 0;
694
+ return this.head === this.items.length;
676
695
  }
677
696
  size() {
678
- return this.items.length;
697
+ return this.items.length - this.head;
679
698
  }
680
699
  };
681
700
 
@@ -724,6 +743,18 @@ var LockCanNotBeReleasedError = class extends FinitaError {
724
743
  }
725
744
  };
726
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
+
727
758
  // src/error/AutomaticTransitionCycleError.ts
728
759
  var AutomaticTransitionCycleError = class extends FinitaError {
729
760
  code = "automaticTransitionCycle";
@@ -761,6 +792,18 @@ var QueueLimitExceededError = class extends FinitaError {
761
792
  }
762
793
  };
763
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
+
764
807
  // src/Statemachine.ts
765
808
  var Statemachine = class {
766
809
  subject;
@@ -776,6 +819,8 @@ var Statemachine = class {
776
819
  running = false;
777
820
  idleWaiters = [];
778
821
  inSyncCallback = false;
822
+ /** Set when releasing a held lock fails; see LockOwnershipUncertainError. */
823
+ ownershipUncertainty = null;
779
824
  beforeObservers = [];
780
825
  afterObservers = [];
781
826
  onChainedOperationError;
@@ -853,6 +898,9 @@ var Statemachine = class {
853
898
  * so manual lock management keeps its existing control flow. Inspect
854
899
  * isLockAcquired() (or the hook) to learn whether the lock was actually
855
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.
856
904
  */
857
905
  async releaseLock() {
858
906
  await this.releaseMutex();
@@ -952,6 +1000,10 @@ var Statemachine = class {
952
1000
  }
953
1001
  }
954
1002
  async runOperation(op) {
1003
+ if (this.ownershipUncertainty) {
1004
+ op.reject(new LockOwnershipUncertainError(this.ownershipUncertainty.err));
1005
+ return;
1006
+ }
955
1007
  if (op.ifStateName !== void 0 && this.currentState.getName() !== op.ifStateName) {
956
1008
  op.resolve();
957
1009
  return;
@@ -992,9 +1044,15 @@ var Statemachine = class {
992
1044
  * operation also failed, the rejection carries the operation error and this
993
1045
  * hook is the only place the release error appears.
994
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
+ *
995
1052
  * @returns null on success, or the failure wrapped for the caller to raise.
996
1053
  */
997
1054
  async releaseMutex() {
1055
+ const held = this.mutex.isAcquired();
998
1056
  let failure = null;
999
1057
  try {
1000
1058
  if (!await this.mutex.releaseLock()) {
@@ -1004,13 +1062,31 @@ var Statemachine = class {
1004
1062
  failure = { err };
1005
1063
  }
1006
1064
  if (failure) {
1007
- try {
1008
- this.onReleaseError?.(failure.err);
1009
- } catch {
1010
- }
1065
+ if (held) this.ownershipUncertainty = failure;
1066
+ const err = failure.err;
1067
+ this.callDiagnosticHook(() => this.onReleaseError?.(err));
1068
+ } else {
1069
+ this.ownershipUncertainty = null;
1011
1070
  }
1012
1071
  return failure;
1013
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
+ }
1014
1090
  resolveEvent(name) {
1015
1091
  if (!this.currentState.hasEvent(name)) {
1016
1092
  throw new WrongEventForStateError(this.currentState.getName(), name);
@@ -1082,12 +1158,11 @@ var Statemachine = class {
1082
1158
  () => {
1083
1159
  },
1084
1160
  (err) => {
1085
- try {
1086
- this.onChainedOperationError?.(err, {
1161
+ this.callDiagnosticHook(
1162
+ () => this.onChainedOperationError?.(err, {
1087
1163
  eventName: chainedEventName
1088
- });
1089
- } catch {
1090
- }
1164
+ })
1165
+ );
1091
1166
  },
1092
1167
  ifStateName
1093
1168
  );
@@ -1223,6 +1298,30 @@ var CompositeCondition = class {
1223
1298
  const names = this.conditions.map((c) => c.getName());
1224
1299
  return `(${names.join(` ${this.joinWord} `)})`;
1225
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
+ }
1226
1325
  };
1227
1326
 
1228
1327
  // src/condition/AndComposite.ts
@@ -1233,13 +1332,8 @@ var AndComposite = class extends CompositeCondition {
1233
1332
  addAnd(condition) {
1234
1333
  return this.addCondition(condition);
1235
1334
  }
1236
- async checkCondition(subject, context) {
1237
- for (const condition of this.conditions) {
1238
- if (!await condition.checkCondition(subject, context)) {
1239
- return false;
1240
- }
1241
- }
1242
- return true;
1335
+ checkCondition(subject, context) {
1336
+ return this.evaluate(subject, context, false);
1243
1337
  }
1244
1338
  };
1245
1339
 
@@ -1251,13 +1345,8 @@ var OrComposite = class extends CompositeCondition {
1251
1345
  addOr(condition) {
1252
1346
  return this.addCondition(condition);
1253
1347
  }
1254
- async checkCondition(subject, context) {
1255
- for (const condition of this.conditions) {
1256
- if (await condition.checkCondition(subject, context)) {
1257
- return true;
1258
- }
1259
- }
1260
- return false;
1348
+ checkCondition(subject, context) {
1349
+ return this.evaluate(subject, context, true);
1261
1350
  }
1262
1351
  };
1263
1352
 
@@ -1270,8 +1359,13 @@ var Not = class {
1270
1359
  getName() {
1271
1360
  return `not ( ${this.condition.getName()} )`;
1272
1361
  }
1273
- async checkCondition(subject, context) {
1274
- 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;
1275
1369
  }
1276
1370
  };
1277
1371
 
@@ -1326,15 +1420,6 @@ var OnEnterObserver = class _OnEnterObserver {
1326
1420
  }
1327
1421
  };
1328
1422
 
1329
- // src/util/index.ts
1330
- function isNamed(obj) {
1331
- return typeof obj === "object" && obj !== null && "getName" in obj && typeof obj.getName === "function";
1332
- }
1333
- function nameOrString(obj) {
1334
- if (isNamed(obj)) return obj.getName();
1335
- return String(obj);
1336
- }
1337
-
1338
1423
  // src/observer/TransitionLogger.ts
1339
1424
  var TransitionLogger = class {
1340
1425
  logger;
@@ -1495,19 +1580,27 @@ var LockAdapterMutex = class {
1495
1580
  * pass the check and acquire twice on a non-idempotent adapter (database
1496
1581
  * advisory locks, redis SET NX). The pending promise is cleared once it
1497
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.
1498
1588
  */
1499
1589
  async acquireLock() {
1500
1590
  if (this.acquired) {
1501
1591
  return true;
1502
1592
  }
1503
- this.pendingAcquire ??= (async () => {
1504
- try {
1593
+ if (!this.pendingAcquire) {
1594
+ const attempt = (async () => {
1505
1595
  this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
1506
1596
  return this.acquired;
1507
- } finally {
1508
- this.pendingAcquire = null;
1509
- }
1510
- })();
1597
+ })();
1598
+ this.pendingAcquire = attempt;
1599
+ const clear = () => {
1600
+ if (this.pendingAcquire === attempt) this.pendingAcquire = null;
1601
+ };
1602
+ attempt.then(clear, clear);
1603
+ }
1511
1604
  return this.pendingAcquire;
1512
1605
  }
1513
1606
  async releaseLock() {
@@ -1819,6 +1912,7 @@ export {
1819
1912
  LockAdapterMutex,
1820
1913
  LockCanNotBeAcquiredError,
1821
1914
  LockCanNotBeReleasedError,
1915
+ LockOwnershipUncertainError,
1822
1916
  MutexFactory,
1823
1917
  Not,
1824
1918
  NullMutex,