@vedmalex/statemachine 1.0.0-beta.1 → 1.0.0-beta.2
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/README.md +36 -1
- package/dist/index.cjs +434 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +434 -18
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
- package/types/config_validator.d.ts +32 -0
- package/types/index.d.ts +10 -0
- package/types/state_machine.d.ts +101 -0
- package/types/types.d.ts +11 -2
package/README.md
CHANGED
|
@@ -28,13 +28,48 @@ const sm = createMachine({
|
|
|
28
28
|
})
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
## Hierarchical regions, parallel states & join
|
|
32
|
+
|
|
33
|
+
A state may declare `regions` to run several orthogonal sub-machines at once. Entry/exit follow SCXML/UML ordering, and a region may complete via a `final` state that raises a `done.state.<id>` join event.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const sm = createMachine({
|
|
37
|
+
name: 'proc',
|
|
38
|
+
initialState: 'proc',
|
|
39
|
+
states: {
|
|
40
|
+
proc: {
|
|
41
|
+
initial: 'a.run|b.run', // both regions active in parallel
|
|
42
|
+
onEnter: () => {/* parent runs BEFORE region children (ancestor-first) */},
|
|
43
|
+
regions: {
|
|
44
|
+
a: { run: {}, done: { final: true } },
|
|
45
|
+
b: { run: {}, done: { final: true } },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
complete: {},
|
|
49
|
+
},
|
|
50
|
+
events: {
|
|
51
|
+
finishA: { transitions: [{ from: 'proc.a.run', to: 'proc.a.done' }] },
|
|
52
|
+
finishB: { transitions: [{ from: 'proc.b.run', to: 'proc.b.done' }] },
|
|
53
|
+
// Join: raised automatically once EVERY region reached a `final` state.
|
|
54
|
+
'done.state.proc': { transitions: [{ from: 'proc', to: 'complete' }] },
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- **Expansion** — entering `proc` (as initial state *or* via a transition) expands to the parallel configuration `proc.a.run|proc.b.run`.
|
|
60
|
+
- **Ordering** — entry is ancestor-first (`proc` then region children); exit is descendant-first (region children then `proc`).
|
|
61
|
+
- **Parallel-exit** — a plain transition `from: 'proc'` on a user event preempts and exits all active regions immediately (LCCA).
|
|
62
|
+
- **Join** — when all regions are `final`, the engine raises `done.state.proc`; `sm.isDone('proc')` reflects the all-final configuration. (`done.state.*` is never matched by a `from: '*'` wildcard.)
|
|
63
|
+
|
|
64
|
+
See [`docs/regions-and-parallel.md`](./docs/regions-and-parallel.md) for the full model, ordering rules, and validation.
|
|
65
|
+
|
|
31
66
|
## Documentation
|
|
32
67
|
|
|
33
68
|
Full API documentation: [https://vedmalex.github.io/statemachine/](https://vedmalex.github.io/statemachine/)
|
|
34
69
|
|
|
35
70
|
## Extension Points
|
|
36
71
|
|
|
37
|
-
This package exposes 7 extension points (`IMonitor`, `ITimerScheduler`, `IErrorHandler`, `Adapter<T>`, `ILogger`, `StatePersistenceAdapter`, `validateConfig`) for host integration. See [`docs/extension-points.md`](./docs/extension-points.md) for the full catalog.
|
|
72
|
+
This package exposes 7 extension points (`IMonitor`, `ITimerScheduler`, `IErrorHandler`, `Adapter<T>`, `ILogger`, `StatePersistenceAdapter`, `validateConfig`) for host integration. Callbacks resolved from config or `setContext()` receive the underlying owner object directly, so host code does not need to unwrap `Adapter<T>` inside each callback. See [`docs/extension-points.md`](./docs/extension-points.md) for the full catalog.
|
|
38
73
|
|
|
39
74
|
## Stability policy
|
|
40
75
|
|
package/dist/index.cjs
CHANGED
|
@@ -1850,6 +1850,9 @@ var StateMachine = class _StateMachine {
|
|
|
1850
1850
|
setContext(context) {
|
|
1851
1851
|
this.context = context;
|
|
1852
1852
|
}
|
|
1853
|
+
resolveCallbackOwner(value) {
|
|
1854
|
+
return isAdapter(value) ? value.adaptee : value;
|
|
1855
|
+
}
|
|
1853
1856
|
enqueueEvent(eventName, obj, args, type) {
|
|
1854
1857
|
if (this.externalQueue.length + this.internalQueue.length >= this.maxQueueDepth) {
|
|
1855
1858
|
const _s0 = this.getCurrentState(obj);
|
|
@@ -1958,7 +1961,8 @@ var StateMachine = class _StateMachine {
|
|
|
1958
1961
|
let transitions = event ? event.transitions.filter(
|
|
1959
1962
|
(t) => this.isTransitionPossible(t, currentState)
|
|
1960
1963
|
) : [];
|
|
1961
|
-
|
|
1964
|
+
const isEngineDoneEvent = String(eventName).startsWith("done.state.");
|
|
1965
|
+
if (!transitions.length && !isEngineDoneEvent) {
|
|
1962
1966
|
const wildcardEvent = this.events.get("*");
|
|
1963
1967
|
if (wildcardEvent) {
|
|
1964
1968
|
const wildcardTransitions = wildcardEvent.transitions.filter(
|
|
@@ -2163,6 +2167,10 @@ var StateMachine = class _StateMachine {
|
|
|
2163
2167
|
if (!currentState) return expectedState === "";
|
|
2164
2168
|
const currentParts = currentState.split("|").sort();
|
|
2165
2169
|
const expectedParts = expectedState.split("|").sort();
|
|
2170
|
+
const ancestorMatch = expectedParts.every(
|
|
2171
|
+
(expectedPart) => currentParts.some((leaf) => this.isParentState(expectedPart, leaf))
|
|
2172
|
+
);
|
|
2173
|
+
if (ancestorMatch) return true;
|
|
2166
2174
|
if (currentParts.length !== expectedParts.length) return false;
|
|
2167
2175
|
return currentParts.every((part, index) => part === expectedParts[index]);
|
|
2168
2176
|
}
|
|
@@ -2541,7 +2549,7 @@ var StateMachine = class _StateMachine {
|
|
|
2541
2549
|
const currentState = this.getCurrentState(adaptee) || "";
|
|
2542
2550
|
const currentStateMap = this.parseCompositeState(currentState);
|
|
2543
2551
|
const newStateParts = state.split("|");
|
|
2544
|
-
const isRootState = newStateParts.length === 1 && !state.includes(".");
|
|
2552
|
+
const isRootState = newStateParts.length === 1 && !state.includes(".") && !this.states.get(state)?.regions;
|
|
2545
2553
|
if (isRootState) {
|
|
2546
2554
|
currentStateMap.clear();
|
|
2547
2555
|
currentStateMap.set(state, state);
|
|
@@ -2617,12 +2625,13 @@ var StateMachine = class _StateMachine {
|
|
|
2617
2625
|
initialStates = initialState;
|
|
2618
2626
|
}
|
|
2619
2627
|
this.setCurrentState(initialStates, targetAdaptee);
|
|
2620
|
-
const
|
|
2628
|
+
const { enterStates } = this.computeEnterExitSets("", initialStates);
|
|
2629
|
+
const enterFireOrder = enterStates.length > 0 ? enterStates : [initialStates];
|
|
2621
2630
|
const context = {
|
|
2622
2631
|
state: initialStates,
|
|
2623
2632
|
phase: "enter"
|
|
2624
2633
|
};
|
|
2625
|
-
for (const statePart of
|
|
2634
|
+
for (const statePart of enterFireOrder) {
|
|
2626
2635
|
this.executeEnterActions(
|
|
2627
2636
|
targetAdaptee,
|
|
2628
2637
|
statePart,
|
|
@@ -2636,6 +2645,11 @@ var StateMachine = class _StateMachine {
|
|
|
2636
2645
|
);
|
|
2637
2646
|
});
|
|
2638
2647
|
}
|
|
2648
|
+
this.checkCompletion(
|
|
2649
|
+
targetAdaptee,
|
|
2650
|
+
"",
|
|
2651
|
+
initialStates
|
|
2652
|
+
);
|
|
2639
2653
|
}
|
|
2640
2654
|
getInitialCompositeState(initialState) {
|
|
2641
2655
|
const stateConfig = this.states.get(initialState);
|
|
@@ -2675,6 +2689,211 @@ var StateMachine = class _StateMachine {
|
|
|
2675
2689
|
}
|
|
2676
2690
|
return regionStates.join("|");
|
|
2677
2691
|
}
|
|
2692
|
+
/**
|
|
2693
|
+
* Whether `leaf` is a UML/SCXML `<final>` atomic state of its region.
|
|
2694
|
+
*
|
|
2695
|
+
* Reads the `final` marker straight from the flattened state map populated by
|
|
2696
|
+
* processStates, so it works for any registered atomic leaf regardless of
|
|
2697
|
+
* nesting depth. Returns `false` for unregistered names and for composites
|
|
2698
|
+
* (the all-regions-final join derives doneness from atomic leaves, not from a
|
|
2699
|
+
* `final` flag on a composite parent).
|
|
2700
|
+
*/
|
|
2701
|
+
isStateFinal(leaf) {
|
|
2702
|
+
return Boolean(this.states.get(leaf)?.final);
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Whether composite `compositeId` has reached its UML/SCXML "done"
|
|
2706
|
+
* configuration: every one of its regions has its active atomic leaf in a
|
|
2707
|
+
* `final` state (recursively, for nested composites).
|
|
2708
|
+
*
|
|
2709
|
+
* D10 (mustFix): doneness is derived by scanning the active atomic `|`-leaves
|
|
2710
|
+
* against the STATIC regions tree (`this.states.get(C)?.regions`), NEVER via a
|
|
2711
|
+
* region-key Map lookup (`configMap.get`) — that map keys leaves by their
|
|
2712
|
+
* deepest region container and so cannot answer "which leaf is active in
|
|
2713
|
+
* region X of composite C". For each region we locate the active leaf via the
|
|
2714
|
+
* `C.region.` dotted prefix; the region is final iff that leaf is `final`, or
|
|
2715
|
+
* the leaf lives under a nested composite that is itself `isCompositeDone`.
|
|
2716
|
+
*
|
|
2717
|
+
* Returns `false` for a non-composite id, for a composite with no active leaf
|
|
2718
|
+
* in some region, or when no substate under it is ever `final` (cheap miss).
|
|
2719
|
+
*/
|
|
2720
|
+
isCompositeDone(compositeId, atomicLeaves) {
|
|
2721
|
+
const regions = this.states.get(compositeId)?.regions;
|
|
2722
|
+
if (!regions) return false;
|
|
2723
|
+
for (const regionName of Object.keys(regions)) {
|
|
2724
|
+
const regionPrefix = `${compositeId}.${regionName}.`;
|
|
2725
|
+
const activeLeaf = atomicLeaves.find(
|
|
2726
|
+
(leaf) => leaf.startsWith(regionPrefix)
|
|
2727
|
+
);
|
|
2728
|
+
if (!activeLeaf) return false;
|
|
2729
|
+
const nestedComposite = this.regionComposite(activeLeaf, regionPrefix);
|
|
2730
|
+
if (nestedComposite) {
|
|
2731
|
+
if (this.isCompositeDone(nestedComposite, atomicLeaves)) continue;
|
|
2732
|
+
return false;
|
|
2733
|
+
}
|
|
2734
|
+
if (this.isStateFinal(activeLeaf)) continue;
|
|
2735
|
+
return false;
|
|
2736
|
+
}
|
|
2737
|
+
return true;
|
|
2738
|
+
}
|
|
2739
|
+
/**
|
|
2740
|
+
* The OUTERMOST registered composite (regions-bearing) ancestor of `leaf`
|
|
2741
|
+
* that lives strictly under `regionPrefix`, or `undefined` if the region
|
|
2742
|
+
* holds a simple atomic state directly. Used by {@link isCompositeDone} to
|
|
2743
|
+
* delegate a region's completeness to its nested composite (recursing over
|
|
2744
|
+
* every parallel branch), independent of whether any single branch leaf
|
|
2745
|
+
* happens to carry the `final` flag.
|
|
2746
|
+
*
|
|
2747
|
+
* ancestorChain is root-to-leaf, so the FIRST matching ancestor is the
|
|
2748
|
+
* region's direct composite child (e.g. `C.p.D` for leaf `C.p.D.s.s2` under
|
|
2749
|
+
* region prefix `C.p.`); isCompositeDone then recurses into its regions.
|
|
2750
|
+
*/
|
|
2751
|
+
regionComposite(leaf, regionPrefix) {
|
|
2752
|
+
for (const ancestor of this.ancestorChain(leaf)) {
|
|
2753
|
+
if (ancestor !== leaf && ancestor.startsWith(regionPrefix) && this.states.get(ancestor)?.regions) {
|
|
2754
|
+
return ancestor;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
return void 0;
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2760
|
+
* Whether composite `compositeId` has reached its all-regions-final ("done")
|
|
2761
|
+
* configuration in the CURRENT active state. Public guard surface (`@stable`)
|
|
2762
|
+
* for authoring a join as `guard: () => sm.isDone('C')` instead of (or
|
|
2763
|
+
* alongside) listening on the engine `done.state.<C>` event.
|
|
2764
|
+
*
|
|
2765
|
+
* @param compositeId - The dotted id of the composite/parallel state.
|
|
2766
|
+
* @returns `true` iff every region's active atomic leaf is `final`.
|
|
2767
|
+
*/
|
|
2768
|
+
isDone(compositeId, adaptee) {
|
|
2769
|
+
const currentState = this.getCurrentState(adaptee);
|
|
2770
|
+
if (!currentState) return false;
|
|
2771
|
+
const atomicLeaves = currentState.split("|").filter(Boolean);
|
|
2772
|
+
return this.isCompositeDone(compositeId, atomicLeaves);
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* SCXML completion hook (D10/D11/D12): after a new configuration is written,
|
|
2776
|
+
* raise `done.state.<C>` for each composite that became all-regions-final.
|
|
2777
|
+
*
|
|
2778
|
+
* - Scans only composites that GAINED an active leaf (the ancestor chain of
|
|
2779
|
+
* each `|`-leaf of `newState`), so unaffected composites are not re-checked.
|
|
2780
|
+
* - Emits INNERMOST-first (a deeper composite's `done.state` precedes its
|
|
2781
|
+
* parent's), matching SCXML's inner-before-outer completion ordering, via a
|
|
2782
|
+
* per-config emitted-id Set so each id is raised at most once per call.
|
|
2783
|
+
* - Gates each emission on `this.events.has('done.state.'+C)` (D11 mustFix):
|
|
2784
|
+
* raising an undeclared event would hit the `Invalid event` throw
|
|
2785
|
+
* (executeQueuedTransition) as an unhandled microtask rejection. No declared
|
|
2786
|
+
* `done.state.<C>` event => no emission, no crash, no observable effect.
|
|
2787
|
+
* - Uses the internal queue (`raiseEvent`) + `scheduleProcessing` so the
|
|
2788
|
+
* completion event is processed before subsequent external events.
|
|
2789
|
+
*/
|
|
2790
|
+
checkCompletion(obj, oldState, newState) {
|
|
2791
|
+
const atomicLeaves = newState.split("|").filter(Boolean);
|
|
2792
|
+
if (atomicLeaves.length === 0) return;
|
|
2793
|
+
const oldLeaves = oldState.split("|").filter(Boolean);
|
|
2794
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2795
|
+
for (const leaf of atomicLeaves) {
|
|
2796
|
+
for (const ancestor of this.ancestorChain(leaf)) {
|
|
2797
|
+
if (ancestor === leaf) continue;
|
|
2798
|
+
if (seen.has(ancestor)) continue;
|
|
2799
|
+
if (!this.states.get(ancestor)?.regions) continue;
|
|
2800
|
+
seen.add(ancestor);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
const candidates = Array.from(seen).sort(
|
|
2804
|
+
(a, b) => b.split(".").length - a.split(".").length
|
|
2805
|
+
);
|
|
2806
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
2807
|
+
for (const compositeId of candidates) {
|
|
2808
|
+
if (emitted.has(compositeId)) continue;
|
|
2809
|
+
if (!this.isCompositeDone(compositeId, atomicLeaves)) continue;
|
|
2810
|
+
if (this.isCompositeDone(compositeId, oldLeaves)) continue;
|
|
2811
|
+
emitted.add(compositeId);
|
|
2812
|
+
const doneEvent = `done.state.${compositeId}`;
|
|
2813
|
+
if (!this.events.has(doneEvent)) continue;
|
|
2814
|
+
this.raiseEvent(doneEvent, obj);
|
|
2815
|
+
this.scheduleProcessing();
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* Build the registered ancestor chain for an atomic leaf, ordered root-to-leaf.
|
|
2820
|
+
*
|
|
2821
|
+
* Walks every dot-prefix of `leaf` and keeps only the ones that are real
|
|
2822
|
+
* registered states (`this.states.has`). Region containers are never
|
|
2823
|
+
* registered (only composite parents and atomic leaves are — see
|
|
2824
|
+
* processStates/processRegions), so they are filtered out automatically. The
|
|
2825
|
+
* result is exactly `[parent..leaf]` for a leaf inside a composite, and
|
|
2826
|
+
* `[leaf]` for a flat state.
|
|
2827
|
+
*
|
|
2828
|
+
* Example: `ancestorChain('a.r1.c1')` -> `['a', 'a.r1.c1']` (the `a.r1`
|
|
2829
|
+
* region container is excluded). Nested:
|
|
2830
|
+
* `ancestorChain('a.r1.c1.r3.x')` -> `['a', 'a.r1.c1', 'a.r1.c1.r3.x']`.
|
|
2831
|
+
*/
|
|
2832
|
+
ancestorChain(leaf) {
|
|
2833
|
+
const chain = [];
|
|
2834
|
+
let dotIndex = leaf.indexOf(".");
|
|
2835
|
+
while (dotIndex !== -1) {
|
|
2836
|
+
const prefix = leaf.substring(0, dotIndex);
|
|
2837
|
+
if (this.states.has(prefix)) {
|
|
2838
|
+
chain.push(prefix);
|
|
2839
|
+
}
|
|
2840
|
+
dotIndex = leaf.indexOf(".", dotIndex + 1);
|
|
2841
|
+
}
|
|
2842
|
+
if (this.states.has(leaf)) {
|
|
2843
|
+
chain.push(leaf);
|
|
2844
|
+
}
|
|
2845
|
+
return chain;
|
|
2846
|
+
}
|
|
2847
|
+
/**
|
|
2848
|
+
* Compute the ordered enter/exit sets between two composite configurations
|
|
2849
|
+
* (SCXML ancestor-first entry / descendant-first exit).
|
|
2850
|
+
*
|
|
2851
|
+
* For each `|`-separated atomic leaf in `oldComposite` and `newComposite` the
|
|
2852
|
+
* union of its {@link ancestorChain} forms the old/new active ancestry. A
|
|
2853
|
+
* state shared by both ancestries lands in NEITHER diff, so a surviving
|
|
2854
|
+
* ancestor is never re-entered nor exited (no onEnter/onExit re-fire, no
|
|
2855
|
+
* timer re-arm/leak).
|
|
2856
|
+
*
|
|
2857
|
+
* - `enterStates` = new ancestry MINUS old, sorted ascending by depth then
|
|
2858
|
+
* document (insertion) order -> root-to-leaf entry.
|
|
2859
|
+
* - `exitStates` = old ancestry MINUS new, sorted descending by depth ->
|
|
2860
|
+
* leaf-to-root exit.
|
|
2861
|
+
*
|
|
2862
|
+
* Consumed by BOTH R1 (applyTransition / setInitialState / reset) and R2.
|
|
2863
|
+
*/
|
|
2864
|
+
computeEnterExitSets(oldComposite, newComposite) {
|
|
2865
|
+
const collectAncestry = (composite) => {
|
|
2866
|
+
const ancestry = /* @__PURE__ */ new Set();
|
|
2867
|
+
if (!composite) return ancestry;
|
|
2868
|
+
for (const leaf of composite.split("|")) {
|
|
2869
|
+
if (!leaf) continue;
|
|
2870
|
+
for (const ancestor of this.ancestorChain(leaf)) {
|
|
2871
|
+
ancestry.add(ancestor);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
return ancestry;
|
|
2875
|
+
};
|
|
2876
|
+
const oldAncestry = collectAncestry(oldComposite);
|
|
2877
|
+
const newAncestry = collectAncestry(newComposite);
|
|
2878
|
+
const depthOf = (state) => {
|
|
2879
|
+
let depth = 0;
|
|
2880
|
+
for (let i = 0; i < state.length; i++) {
|
|
2881
|
+
if (state[i] === ".") depth++;
|
|
2882
|
+
}
|
|
2883
|
+
return depth;
|
|
2884
|
+
};
|
|
2885
|
+
const enterRaw = [];
|
|
2886
|
+
for (const state of newAncestry) {
|
|
2887
|
+
if (!oldAncestry.has(state)) enterRaw.push(state);
|
|
2888
|
+
}
|
|
2889
|
+
const exitRaw = [];
|
|
2890
|
+
for (const state of oldAncestry) {
|
|
2891
|
+
if (!newAncestry.has(state)) exitRaw.push(state);
|
|
2892
|
+
}
|
|
2893
|
+
const enterStates = enterRaw.map((state, index) => ({ state, index, depth: depthOf(state) })).sort((a, b) => a.depth - b.depth || a.index - b.index).map((entry) => entry.state);
|
|
2894
|
+
const exitStates = exitRaw.map((state, index) => ({ state, index, depth: depthOf(state) })).sort((a, b) => b.depth - a.depth || a.index - b.index).map((entry) => entry.state);
|
|
2895
|
+
return { enterStates, exitStates };
|
|
2896
|
+
}
|
|
2678
2897
|
validateCompositeState(compositeState) {
|
|
2679
2898
|
const stateParts = compositeState.split("|");
|
|
2680
2899
|
const regionKeys = /* @__PURE__ */ new Set();
|
|
@@ -2748,10 +2967,11 @@ var StateMachine = class _StateMachine {
|
|
|
2748
2967
|
return (...args) => {
|
|
2749
2968
|
const targetAdaptee = args.length >= 2 ? args[0] : adaptee;
|
|
2750
2969
|
const error = args.length >= 2 ? args[1] : args[0];
|
|
2751
|
-
return handler(targetAdaptee, error);
|
|
2970
|
+
return handler(this.resolveCallbackOwner(targetAdaptee), error);
|
|
2752
2971
|
};
|
|
2753
2972
|
}
|
|
2754
2973
|
async callAction(obj, actionName, ...args) {
|
|
2974
|
+
const targetOwner = this.resolveCallbackOwner(obj);
|
|
2755
2975
|
const _callActionState = this.getCurrentState(
|
|
2756
2976
|
obj
|
|
2757
2977
|
);
|
|
@@ -2766,16 +2986,16 @@ var StateMachine = class _StateMachine {
|
|
|
2766
2986
|
if (this.context && this.context[actionName]) {
|
|
2767
2987
|
const action = this.context[actionName];
|
|
2768
2988
|
if (typeof action === "function") {
|
|
2769
|
-
const result = action(
|
|
2989
|
+
const result = action(targetOwner, ...args);
|
|
2770
2990
|
return result instanceof Promise ? await result : result;
|
|
2771
2991
|
}
|
|
2772
2992
|
} else if (typeof actionName === "function") {
|
|
2773
|
-
const result = actionName(
|
|
2993
|
+
const result = actionName(targetOwner, ...args);
|
|
2774
2994
|
return result instanceof Promise ? await result : result;
|
|
2775
2995
|
} else if (obj.get(actionName)) {
|
|
2776
2996
|
const action = obj.get(actionName);
|
|
2777
2997
|
if (typeof action === "function") {
|
|
2778
|
-
const result = action(
|
|
2998
|
+
const result = action(targetOwner, ...args);
|
|
2779
2999
|
return result instanceof Promise ? await result : result;
|
|
2780
3000
|
}
|
|
2781
3001
|
}
|
|
@@ -2847,8 +3067,12 @@ var StateMachine = class _StateMachine {
|
|
|
2847
3067
|
if (fromState === "*") return true;
|
|
2848
3068
|
const regionKey = this.getRegionKey(fromState);
|
|
2849
3069
|
const currentStateForRegion = currentStates.get(regionKey);
|
|
2850
|
-
if (
|
|
2851
|
-
|
|
3070
|
+
if (currentStateForRegion && (currentStateForRegion === fromState || this.isParentState(currentStateForRegion, fromState))) {
|
|
3071
|
+
return true;
|
|
3072
|
+
}
|
|
3073
|
+
return Array.from(currentStates.values()).some(
|
|
3074
|
+
(leaf) => leaf === fromState || this.isParentState(fromState, leaf)
|
|
3075
|
+
);
|
|
2852
3076
|
});
|
|
2853
3077
|
}
|
|
2854
3078
|
isParentState(parentState, childState) {
|
|
@@ -2864,6 +3088,26 @@ var StateMachine = class _StateMachine {
|
|
|
2864
3088
|
const targetState = transition.to === "*" ? currentState : transition.to;
|
|
2865
3089
|
this._isTransitioning = true;
|
|
2866
3090
|
this._targetState = targetState;
|
|
3091
|
+
let newState;
|
|
3092
|
+
let enterStates;
|
|
3093
|
+
let exitStates;
|
|
3094
|
+
try {
|
|
3095
|
+
newState = this.updateState(currentState, targetState);
|
|
3096
|
+
const sets = this.computeEnterExitSets(currentState, newState);
|
|
3097
|
+
enterStates = sets.enterStates;
|
|
3098
|
+
exitStates = sets.exitStates;
|
|
3099
|
+
} catch (error) {
|
|
3100
|
+
this.logger.warn("Transition aborted: invalid target configuration", {
|
|
3101
|
+
state: currentState,
|
|
3102
|
+
target: String(targetState),
|
|
3103
|
+
error
|
|
3104
|
+
});
|
|
3105
|
+
this._isTransitioning = false;
|
|
3106
|
+
this._targetState = void 0;
|
|
3107
|
+
return void 0;
|
|
3108
|
+
}
|
|
3109
|
+
const exitFireOrder = exitStates.length > 0 ? exitStates : [transition.from];
|
|
3110
|
+
const enterFireOrder = enterStates.length > 0 ? enterStates : [transition.to];
|
|
2867
3111
|
try {
|
|
2868
3112
|
if (transition.guard) {
|
|
2869
3113
|
const allow = await this.callAction(obj, transition.guard, ...args).catch(
|
|
@@ -2889,7 +3133,9 @@ var StateMachine = class _StateMachine {
|
|
|
2889
3133
|
);
|
|
2890
3134
|
}
|
|
2891
3135
|
try {
|
|
2892
|
-
|
|
3136
|
+
for (const exitStateName of exitFireOrder) {
|
|
3137
|
+
await this.executeExitActions(obj, exitStateName, args, context);
|
|
3138
|
+
}
|
|
2893
3139
|
} catch (error) {
|
|
2894
3140
|
if (this.abortOnExitError) {
|
|
2895
3141
|
this.logger.warn("Transition aborted due to onExit error", { state: currentState, error });
|
|
@@ -2909,12 +3155,14 @@ var StateMachine = class _StateMachine {
|
|
|
2909
3155
|
);
|
|
2910
3156
|
}
|
|
2911
3157
|
try {
|
|
2912
|
-
|
|
3158
|
+
for (const enterStateName of enterFireOrder) {
|
|
3159
|
+
await this.executeEnterActions(obj, enterStateName, args, context);
|
|
3160
|
+
}
|
|
2913
3161
|
} catch (error) {
|
|
2914
3162
|
if (this.errorState) {
|
|
2915
3163
|
this.logger.error(`Failed to enter state '${targetState}'. Fallback to error state '${this.errorState}'`, { error });
|
|
2916
|
-
const
|
|
2917
|
-
this.setCurrentState(
|
|
3164
|
+
const errorNewState = this.updateState(currentState, this.errorState);
|
|
3165
|
+
this.setCurrentState(errorNewState, obj);
|
|
2918
3166
|
return this.states.get(this.errorState);
|
|
2919
3167
|
}
|
|
2920
3168
|
throw error;
|
|
@@ -2929,12 +3177,12 @@ var StateMachine = class _StateMachine {
|
|
|
2929
3177
|
event.onError,
|
|
2930
3178
|
this.onError
|
|
2931
3179
|
);
|
|
2932
|
-
errorHandler(obj, error);
|
|
3180
|
+
errorHandler(this.resolveCallbackOwner(obj), error);
|
|
2933
3181
|
}
|
|
2934
3182
|
}
|
|
2935
3183
|
const transitionStartTime = Date.now();
|
|
2936
|
-
const newState = this.updateState(currentState, targetState);
|
|
2937
3184
|
this.setCurrentState(newState, obj);
|
|
3185
|
+
this.checkCompletion(obj, currentState, newState);
|
|
2938
3186
|
const transitionTime = Date.now() - transitionStartTime;
|
|
2939
3187
|
this.monitor.recordTransition(transitionTime, true);
|
|
2940
3188
|
return this.states.get(targetState);
|
|
@@ -3110,7 +3358,7 @@ var StateMachine = class _StateMachine {
|
|
|
3110
3358
|
updateState(currentState, toState) {
|
|
3111
3359
|
const currentStateMap = this.parseCompositeState(currentState);
|
|
3112
3360
|
const toStateParts = toState.split("|");
|
|
3113
|
-
if (toStateParts.length === 1 && !toState.includes(".")) {
|
|
3361
|
+
if (toStateParts.length === 1 && !toState.includes(".") && !this.states.get(toState)?.regions) {
|
|
3114
3362
|
currentStateMap.clear();
|
|
3115
3363
|
currentStateMap.set(toState, toState);
|
|
3116
3364
|
return toState;
|
|
@@ -3492,6 +3740,7 @@ var ConfigValidator = class {
|
|
|
3492
3740
|
this.validateInitialState(smConfig);
|
|
3493
3741
|
this.validateCrossReferences(smConfig);
|
|
3494
3742
|
this.validatePerformanceConstraints(smConfig);
|
|
3743
|
+
this.validateFinalStates(smConfig);
|
|
3495
3744
|
this.runCustomRules(smConfig);
|
|
3496
3745
|
} catch (error) {
|
|
3497
3746
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -3853,6 +4102,10 @@ var ConfigValidator = class {
|
|
|
3853
4102
|
}
|
|
3854
4103
|
}
|
|
3855
4104
|
}
|
|
4105
|
+
const finalStatePaths = this.collectFinalStatePaths(smConfig.states);
|
|
4106
|
+
for (const finalPath of finalStatePaths) {
|
|
4107
|
+
reachableStates.add(finalPath);
|
|
4108
|
+
}
|
|
3856
4109
|
for (const stateName of allStateNames) {
|
|
3857
4110
|
if (!reachableStates.has(stateName)) {
|
|
3858
4111
|
this.addWarning(
|
|
@@ -3863,8 +4116,9 @@ var ConfigValidator = class {
|
|
|
3863
4116
|
}
|
|
3864
4117
|
}
|
|
3865
4118
|
for (const [eventName, event] of Object.entries(smConfig.events)) {
|
|
4119
|
+
const isDoneStateEvent = eventName.startsWith("done.state.");
|
|
3866
4120
|
const hasValidTransitions = event.transitions.some(
|
|
3867
|
-
(t) => allStateNames.has(t.from) && allStateNames.has(t.to)
|
|
4121
|
+
(t) => (allStateNames.has(t.from) || isDoneStateEvent && allStateNames.has(eventName.slice("done.state.".length))) && allStateNames.has(t.to)
|
|
3868
4122
|
);
|
|
3869
4123
|
if (!hasValidTransitions) {
|
|
3870
4124
|
this.addWarning(
|
|
@@ -3892,6 +4146,168 @@ var ConfigValidator = class {
|
|
|
3892
4146
|
}
|
|
3893
4147
|
}
|
|
3894
4148
|
}
|
|
4149
|
+
/**
|
|
4150
|
+
* Collects every state in the tree as {dottedPath, config} entries, walking
|
|
4151
|
+
* region containers (region names are NOT registered states, so they are part
|
|
4152
|
+
* of the path but never emitted as an entry — mirroring runtime expansion).
|
|
4153
|
+
*/
|
|
4154
|
+
collectStateEntries(states, prefix, collector) {
|
|
4155
|
+
for (const [stateName, state] of Object.entries(states)) {
|
|
4156
|
+
const fullName = prefix ? `${prefix}.${stateName}` : stateName;
|
|
4157
|
+
collector.push({ path: fullName, state });
|
|
4158
|
+
if (state.regions) {
|
|
4159
|
+
for (const [regionName, regionStates] of Object.entries(
|
|
4160
|
+
state.regions
|
|
4161
|
+
)) {
|
|
4162
|
+
this.collectStateEntries(
|
|
4163
|
+
regionStates,
|
|
4164
|
+
`${fullName}.${regionName}`,
|
|
4165
|
+
collector
|
|
4166
|
+
);
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
/**
|
|
4172
|
+
* True when `composite` has at least one `final:true` atomic substate reachable
|
|
4173
|
+
* through its (possibly nested) region tree — i.e. some region of the composite
|
|
4174
|
+
* can become done. Used by REGION_NO_REACHABLE_FINAL.
|
|
4175
|
+
*/
|
|
4176
|
+
hasReachableFinal(composite) {
|
|
4177
|
+
if (!composite.regions) return false;
|
|
4178
|
+
for (const regionStates of Object.values(composite.regions)) {
|
|
4179
|
+
for (const state of Object.values(regionStates)) {
|
|
4180
|
+
if (state.final) return true;
|
|
4181
|
+
if (state.regions && this.hasReachableFinal(state)) return true;
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
return false;
|
|
4185
|
+
}
|
|
4186
|
+
/**
|
|
4187
|
+
* SCXML/UML final-state and all-regions-final (done.state.<C>) join validation.
|
|
4188
|
+
*
|
|
4189
|
+
* - FINAL_STATE_HAS_OUTGOING (error): a `final:true` state is the `from` of a
|
|
4190
|
+
* transition (a final pseudo-state cannot itself transition out).
|
|
4191
|
+
* - FINAL_ON_COMPOSITE (warning): `final:true` on a state that has regions
|
|
4192
|
+
* (final is meaningful only on an atomic leaf; ignored at runtime).
|
|
4193
|
+
* - REGION_NO_REACHABLE_FINAL (warning): a `done.state.<C>` transition exists
|
|
4194
|
+
* but composite C declares no `final` substate, so the join can never fire.
|
|
4195
|
+
* - DONE_VS_PARALLEL_EXIT_AMBIGUITY (warning): the same composite C has BOTH a
|
|
4196
|
+
* `done.state.C` join AND a plain `from:'C'` user-event parallel-exit, which
|
|
4197
|
+
* can preempt the all-final join (the user event is eligible on ANY leaf).
|
|
4198
|
+
* - REGION_MISSING_INITIAL (advisory warning, valid:true): a region omits an
|
|
4199
|
+
* explicit `initial`, so expansion relies on first-key insertion order.
|
|
4200
|
+
*/
|
|
4201
|
+
validateFinalStates(smConfig) {
|
|
4202
|
+
const entries = [];
|
|
4203
|
+
this.collectStateEntries(smConfig.states, "", entries);
|
|
4204
|
+
const finalStatePaths = /* @__PURE__ */ new Set();
|
|
4205
|
+
for (const { path, state } of entries) {
|
|
4206
|
+
if (!state.final) continue;
|
|
4207
|
+
if (state.regions) {
|
|
4208
|
+
this.addWarning(
|
|
4209
|
+
"FINAL_ON_COMPOSITE",
|
|
4210
|
+
`Final flag on composite state "${path}" is ignored; mark an atomic leaf inside a region as final instead`,
|
|
4211
|
+
`states.${path}.final`,
|
|
4212
|
+
void 0,
|
|
4213
|
+
"Remove `final: true` from this composite and set it on the atomic leaf state that represents the region's completion."
|
|
4214
|
+
);
|
|
4215
|
+
} else {
|
|
4216
|
+
finalStatePaths.add(path);
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
const userFromStates = /* @__PURE__ */ new Set();
|
|
4220
|
+
const doneStateComposites = /* @__PURE__ */ new Set();
|
|
4221
|
+
for (const [eventName, event] of Object.entries(smConfig.events)) {
|
|
4222
|
+
if (!event.transitions || !Array.isArray(event.transitions)) continue;
|
|
4223
|
+
if (eventName.startsWith("done.state.")) {
|
|
4224
|
+
const compositeId = eventName.slice("done.state.".length);
|
|
4225
|
+
if (compositeId) doneStateComposites.add(compositeId);
|
|
4226
|
+
}
|
|
4227
|
+
for (const transition of event.transitions) {
|
|
4228
|
+
if (typeof transition.from !== "string") continue;
|
|
4229
|
+
for (const fromPart of transition.from.split("|")) {
|
|
4230
|
+
if (finalStatePaths.has(fromPart)) {
|
|
4231
|
+
this.addError(
|
|
4232
|
+
"FINAL_STATE_HAS_OUTGOING",
|
|
4233
|
+
`Final state "${fromPart}" cannot be the source of transition on event "${eventName}"; a final pseudo-state has no outgoing transitions`,
|
|
4234
|
+
`events.${eventName}`,
|
|
4235
|
+
void 0,
|
|
4236
|
+
`Remove the outgoing transition from "${fromPart}", or clear its \`final: true\` flag if it is not actually a final state.`
|
|
4237
|
+
);
|
|
4238
|
+
}
|
|
4239
|
+
if (!eventName.startsWith("done.state.")) {
|
|
4240
|
+
userFromStates.add(fromPart);
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
const entryByPath = new Map(entries.map((e) => [e.path, e.state]));
|
|
4246
|
+
for (const compositeId of doneStateComposites) {
|
|
4247
|
+
const composite = entryByPath.get(compositeId);
|
|
4248
|
+
if (!composite) {
|
|
4249
|
+
continue;
|
|
4250
|
+
}
|
|
4251
|
+
if (!this.hasReachableFinal(composite)) {
|
|
4252
|
+
this.addWarning(
|
|
4253
|
+
"REGION_NO_REACHABLE_FINAL",
|
|
4254
|
+
`Join event "done.state.${compositeId}" can never fire: composite "${compositeId}" has no \`final\` substate in any region`,
|
|
4255
|
+
`events.done.state.${compositeId}`,
|
|
4256
|
+
void 0,
|
|
4257
|
+
`Mark the completion leaf of each region under "${compositeId}" with \`final: true\`, or remove the done.state join.`
|
|
4258
|
+
);
|
|
4259
|
+
}
|
|
4260
|
+
if (userFromStates.has(compositeId)) {
|
|
4261
|
+
this.addWarning(
|
|
4262
|
+
"DONE_VS_PARALLEL_EXIT_AMBIGUITY",
|
|
4263
|
+
`Composite "${compositeId}" has both a done.state join and a user-event transition with from:"${compositeId}"; the user event can preempt the all-final join because it is eligible on any active leaf`,
|
|
4264
|
+
`events.done.state.${compositeId}`,
|
|
4265
|
+
void 0,
|
|
4266
|
+
`Disambiguate by using the done.state.${compositeId} join exclusively, or scope the user-event transition to a specific leaf instead of the bare composite.`
|
|
4267
|
+
);
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
for (const { path, state } of entries) {
|
|
4271
|
+
if (!state.regions) continue;
|
|
4272
|
+
for (const [regionName, regionStates] of Object.entries(state.regions)) {
|
|
4273
|
+
const regionKeys = Object.keys(regionStates);
|
|
4274
|
+
if (regionKeys.length === 0) continue;
|
|
4275
|
+
const parentInitialInRegion = state.initial !== void 0 && regionKeys.includes(state.initial);
|
|
4276
|
+
if (!parentInitialInRegion) {
|
|
4277
|
+
this.addWarning(
|
|
4278
|
+
"REGION_MISSING_INITIAL",
|
|
4279
|
+
`Region "${regionName}" of composite "${path}" has no explicit initial; expansion falls back to the first key "${regionKeys[0]}" (insertion-order dependent)`,
|
|
4280
|
+
`states.${path}.regions.${regionName}`,
|
|
4281
|
+
void 0,
|
|
4282
|
+
`Set \`initial\` on composite "${path}" to a leaf of region "${regionName}", to make the starting substate explicit and order-independent.`
|
|
4283
|
+
);
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
/**
|
|
4289
|
+
* Collects the dotted paths of every `final:true` atomic leaf in the tree.
|
|
4290
|
+
*/
|
|
4291
|
+
collectFinalStatePaths(states, prefix = "", collector = /* @__PURE__ */ new Set()) {
|
|
4292
|
+
for (const [stateName, state] of Object.entries(states)) {
|
|
4293
|
+
const fullName = prefix ? `${prefix}.${stateName}` : stateName;
|
|
4294
|
+
if (state.final && !state.regions) {
|
|
4295
|
+
collector.add(fullName);
|
|
4296
|
+
}
|
|
4297
|
+
if (state.regions) {
|
|
4298
|
+
for (const [regionName, regionStates] of Object.entries(
|
|
4299
|
+
state.regions
|
|
4300
|
+
)) {
|
|
4301
|
+
this.collectFinalStatePaths(
|
|
4302
|
+
regionStates,
|
|
4303
|
+
`${fullName}.${regionName}`,
|
|
4304
|
+
collector
|
|
4305
|
+
);
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
return collector;
|
|
4310
|
+
}
|
|
3895
4311
|
validatePerformanceConstraints(smConfig) {
|
|
3896
4312
|
const stateCount = this.countStates(smConfig.states);
|
|
3897
4313
|
const eventCount = Object.keys(smConfig.events).length;
|