@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/CHANGELOG.md +141 -0
- package/dist/index.cjs +144 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +69 -10
- package/dist/index.d.ts +69 -10
- package/dist/index.js +143 -49
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1086
|
-
this.onChainedOperationError?.(err, {
|
|
1161
|
+
this.callDiagnosticHook(
|
|
1162
|
+
() => this.onChainedOperationError?.(err, {
|
|
1087
1163
|
eventName: chainedEventName
|
|
1088
|
-
})
|
|
1089
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
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
|
|
1504
|
-
|
|
1593
|
+
if (!this.pendingAcquire) {
|
|
1594
|
+
const attempt = (async () => {
|
|
1505
1595
|
this.acquired = await this.lockAdapter.acquireLock(this.resourceName);
|
|
1506
1596
|
return this.acquired;
|
|
1507
|
-
}
|
|
1508
|
-
|
|
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,
|