@vedmalex/statemachine 1.0.0-beta.1 → 1.0.0-beta.3

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
@@ -8,7 +8,9 @@ var TimerScheduler = class {
8
8
  intervalId = null;
9
9
  pollingInterval = 100;
10
10
  // ms
11
- constructor() {
11
+ clock;
12
+ constructor(clock = Date.now) {
13
+ this.clock = clock;
12
14
  }
13
15
  /**
14
16
  * Настройка режима опроса
@@ -32,7 +34,7 @@ var TimerScheduler = class {
32
34
  */
33
35
  start() {
34
36
  if (this.intervalId) return;
35
- this.intervalId = setInterval(() => this.process(), this.pollingInterval);
37
+ this.intervalId = setInterval(() => this.process(this.clock()), this.pollingInterval);
36
38
  }
37
39
  /**
38
40
  * Остановка единого таймера
@@ -51,7 +53,7 @@ var TimerScheduler = class {
51
53
  */
52
54
  schedule(delay, callback) {
53
55
  const token = {};
54
- const executeAt = Date.now() + delay;
56
+ const executeAt = this.clock() + delay;
55
57
  const task = { token, executeAt, callback };
56
58
  this.activeTokens.add(token);
57
59
  this.insert(task);
@@ -67,7 +69,7 @@ var TimerScheduler = class {
67
69
  /**
68
70
  * Обработать очередь (вызывается таймером или вручную)
69
71
  */
70
- process(now = Date.now()) {
72
+ process(now = this.clock()) {
71
73
  while (this.heap.length > 0) {
72
74
  const task = this.heap[0];
73
75
  if (task === void 0) break;
@@ -158,6 +160,23 @@ var TimerScheduler = class {
158
160
  function createDefaultScheduler() {
159
161
  return new TimerScheduler();
160
162
  }
163
+ function createVirtualScheduler(clock) {
164
+ const inner = new TimerScheduler(clock);
165
+ return {
166
+ isActive() {
167
+ return true;
168
+ },
169
+ schedule(delay, callback) {
170
+ return inner.schedule(delay, callback);
171
+ },
172
+ cancel(token) {
173
+ inner.cancel(token);
174
+ },
175
+ process(now) {
176
+ inner.process(now ?? clock());
177
+ }
178
+ };
179
+ }
161
180
 
162
181
  // src/logger.ts
163
182
  var LogLevel = {
@@ -1704,6 +1723,8 @@ var StateMachine = class _StateMachine {
1704
1723
  monitor;
1705
1724
  scheduler;
1706
1725
  errorHandler;
1726
+ clock;
1727
+ schedulerProvided;
1707
1728
  // Свойства
1708
1729
  states;
1709
1730
  events;
@@ -1754,7 +1775,9 @@ var StateMachine = class _StateMachine {
1754
1775
  this.initialState = config.initialState;
1755
1776
  this.logger = options?.logger ?? ConsoleLogger;
1756
1777
  this.monitor = options?.monitor ?? createDefaultMonitor();
1778
+ this.schedulerProvided = options?.scheduler !== void 0;
1757
1779
  this.scheduler = options?.scheduler ?? createDefaultScheduler();
1780
+ this.clock = options?.clock ?? Date.now;
1758
1781
  this.errorHandler = options?.errorHandler ?? createDefaultErrorHandler();
1759
1782
  if (adaptee) {
1760
1783
  if (!isAdapter(adaptee)) {
@@ -1805,6 +1828,9 @@ var StateMachine = class _StateMachine {
1805
1828
  setContext(context) {
1806
1829
  this.context = context;
1807
1830
  }
1831
+ resolveCallbackOwner(value) {
1832
+ return isAdapter(value) ? value.adaptee : value;
1833
+ }
1808
1834
  enqueueEvent(eventName, obj, args, type) {
1809
1835
  if (this.externalQueue.length + this.internalQueue.length >= this.maxQueueDepth) {
1810
1836
  const _s0 = this.getCurrentState(obj);
@@ -1825,7 +1851,7 @@ var StateMachine = class _StateMachine {
1825
1851
  args,
1826
1852
  resolve,
1827
1853
  reject,
1828
- timestamp: Date.now(),
1854
+ timestamp: this.clock(),
1829
1855
  type: "external"
1830
1856
  });
1831
1857
  this.scheduleProcessing();
@@ -1836,7 +1862,7 @@ var StateMachine = class _StateMachine {
1836
1862
  eventName,
1837
1863
  obj,
1838
1864
  args,
1839
- timestamp: Date.now(),
1865
+ timestamp: this.clock(),
1840
1866
  type: "internal"
1841
1867
  });
1842
1868
  return Promise.resolve(true);
@@ -1847,7 +1873,7 @@ var StateMachine = class _StateMachine {
1847
1873
  eventName,
1848
1874
  obj,
1849
1875
  args,
1850
- timestamp: Date.now(),
1876
+ timestamp: this.clock(),
1851
1877
  type: "internal"
1852
1878
  });
1853
1879
  }
@@ -1913,7 +1939,8 @@ var StateMachine = class _StateMachine {
1913
1939
  let transitions = event ? event.transitions.filter(
1914
1940
  (t) => this.isTransitionPossible(t, currentState)
1915
1941
  ) : [];
1916
- if (!transitions.length) {
1942
+ const isEngineDoneEvent = String(eventName).startsWith("done.state.");
1943
+ if (!transitions.length && !isEngineDoneEvent) {
1917
1944
  const wildcardEvent = this.events.get("*");
1918
1945
  if (wildcardEvent) {
1919
1946
  const wildcardTransitions = wildcardEvent.transitions.filter(
@@ -2007,7 +2034,7 @@ var StateMachine = class _StateMachine {
2007
2034
  };
2008
2035
  }
2009
2036
  getQueuedEvents() {
2010
- const now = Date.now();
2037
+ const now = this.clock();
2011
2038
  const mapEvent = (evt) => ({
2012
2039
  id: evt.id,
2013
2040
  event: evt.eventName,
@@ -2118,57 +2145,59 @@ var StateMachine = class _StateMachine {
2118
2145
  if (!currentState) return expectedState === "";
2119
2146
  const currentParts = currentState.split("|").sort();
2120
2147
  const expectedParts = expectedState.split("|").sort();
2148
+ const ancestorMatch = expectedParts.every(
2149
+ (expectedPart) => currentParts.some((leaf) => this.isParentState(expectedPart, leaf))
2150
+ );
2151
+ if (ancestorMatch) return true;
2121
2152
  if (currentParts.length !== expectedParts.length) return false;
2122
2153
  return currentParts.every((part, index) => part === expectedParts[index]);
2123
2154
  }
2124
2155
  attachToObject(object, eventMap) {
2125
- for (const objectEventName in eventMap) {
2126
- if (eventMap.hasOwnProperty(objectEventName)) {
2127
- const stateMachineEventName = eventMap[objectEventName];
2128
- if (stateMachineEventName === void 0) continue;
2129
- if (typeof object.addEventListener === "function") {
2130
- object.addEventListener(objectEventName, (...args) => {
2131
- this.fireEvent(stateMachineEventName, object, ...args).catch(
2132
- (e) => this.logger.error(
2133
- "Error firing event",
2134
- {
2135
- objectEventName,
2136
- stateMachineEventName
2137
- },
2138
- /* c8 ignore next */
2139
- e instanceof Error ? e : new Error(String(e))
2140
- )
2141
- );
2142
- });
2143
- } else if (typeof object.on === "function") {
2144
- object.on(objectEventName, (...args) => {
2145
- this.fireEvent(stateMachineEventName, object, ...args).catch(
2146
- (e) => this.logger.error(
2147
- "Error firing event",
2148
- {
2149
- objectEventName,
2150
- stateMachineEventName
2151
- },
2152
- /* c8 ignore next */
2153
- e instanceof Error ? e : new Error(String(e))
2154
- )
2155
- );
2156
- });
2157
- } else {
2158
- object[`on${objectEventName}`] = async (...args) => {
2159
- return this.fireEvent(stateMachineEventName, object, ...args).catch(
2160
- (e) => this.logger.error(
2161
- "Error firing event",
2162
- {
2163
- objectEventName,
2164
- stateMachineEventName
2165
- },
2166
- /* c8 ignore next */
2167
- e instanceof Error ? e : new Error(String(e))
2168
- )
2169
- );
2170
- };
2171
- }
2156
+ for (const objectEventName of Object.keys(eventMap)) {
2157
+ const stateMachineEventName = eventMap[objectEventName];
2158
+ if (stateMachineEventName === void 0) continue;
2159
+ if (typeof object.addEventListener === "function") {
2160
+ object.addEventListener(objectEventName, (...args) => {
2161
+ this.fireEvent(stateMachineEventName, object, ...args).catch(
2162
+ (e) => this.logger.error(
2163
+ "Error firing event",
2164
+ {
2165
+ objectEventName,
2166
+ stateMachineEventName
2167
+ },
2168
+ /* c8 ignore next */
2169
+ e instanceof Error ? e : new Error(String(e))
2170
+ )
2171
+ );
2172
+ });
2173
+ } else if (typeof object.on === "function") {
2174
+ object.on(objectEventName, (...args) => {
2175
+ this.fireEvent(stateMachineEventName, object, ...args).catch(
2176
+ (e) => this.logger.error(
2177
+ "Error firing event",
2178
+ {
2179
+ objectEventName,
2180
+ stateMachineEventName
2181
+ },
2182
+ /* c8 ignore next */
2183
+ e instanceof Error ? e : new Error(String(e))
2184
+ )
2185
+ );
2186
+ });
2187
+ } else {
2188
+ object[`on${objectEventName}`] = async (...args) => {
2189
+ return this.fireEvent(stateMachineEventName, object, ...args).catch(
2190
+ (e) => this.logger.error(
2191
+ "Error firing event",
2192
+ {
2193
+ objectEventName,
2194
+ stateMachineEventName
2195
+ },
2196
+ /* c8 ignore next */
2197
+ e instanceof Error ? e : new Error(String(e))
2198
+ )
2199
+ );
2200
+ };
2172
2201
  }
2173
2202
  }
2174
2203
  }
@@ -2496,7 +2525,7 @@ var StateMachine = class _StateMachine {
2496
2525
  const currentState = this.getCurrentState(adaptee) || "";
2497
2526
  const currentStateMap = this.parseCompositeState(currentState);
2498
2527
  const newStateParts = state.split("|");
2499
- const isRootState = newStateParts.length === 1 && !state.includes(".");
2528
+ const isRootState = newStateParts.length === 1 && !state.includes(".") && !this.states.get(state)?.regions;
2500
2529
  if (isRootState) {
2501
2530
  currentStateMap.clear();
2502
2531
  currentStateMap.set(state, state);
@@ -2572,12 +2601,13 @@ var StateMachine = class _StateMachine {
2572
2601
  initialStates = initialState;
2573
2602
  }
2574
2603
  this.setCurrentState(initialStates, targetAdaptee);
2575
- const stateParts = initialStates.split("|");
2604
+ const { enterStates } = this.computeEnterExitSets("", initialStates);
2605
+ const enterFireOrder = enterStates.length > 0 ? enterStates : [initialStates];
2576
2606
  const context = {
2577
2607
  state: initialStates,
2578
2608
  phase: "enter"
2579
2609
  };
2580
- for (const statePart of stateParts) {
2610
+ for (const statePart of enterFireOrder) {
2581
2611
  this.executeEnterActions(
2582
2612
  targetAdaptee,
2583
2613
  statePart,
@@ -2591,6 +2621,11 @@ var StateMachine = class _StateMachine {
2591
2621
  );
2592
2622
  });
2593
2623
  }
2624
+ this.checkCompletion(
2625
+ targetAdaptee,
2626
+ "",
2627
+ initialStates
2628
+ );
2594
2629
  }
2595
2630
  getInitialCompositeState(initialState) {
2596
2631
  const stateConfig = this.states.get(initialState);
@@ -2630,6 +2665,211 @@ var StateMachine = class _StateMachine {
2630
2665
  }
2631
2666
  return regionStates.join("|");
2632
2667
  }
2668
+ /**
2669
+ * Whether `leaf` is a UML/SCXML `<final>` atomic state of its region.
2670
+ *
2671
+ * Reads the `final` marker straight from the flattened state map populated by
2672
+ * processStates, so it works for any registered atomic leaf regardless of
2673
+ * nesting depth. Returns `false` for unregistered names and for composites
2674
+ * (the all-regions-final join derives doneness from atomic leaves, not from a
2675
+ * `final` flag on a composite parent).
2676
+ */
2677
+ isStateFinal(leaf) {
2678
+ return Boolean(this.states.get(leaf)?.final);
2679
+ }
2680
+ /**
2681
+ * Whether composite `compositeId` has reached its UML/SCXML "done"
2682
+ * configuration: every one of its regions has its active atomic leaf in a
2683
+ * `final` state (recursively, for nested composites).
2684
+ *
2685
+ * D10 (mustFix): doneness is derived by scanning the active atomic `|`-leaves
2686
+ * against the STATIC regions tree (`this.states.get(C)?.regions`), NEVER via a
2687
+ * region-key Map lookup (`configMap.get`) — that map keys leaves by their
2688
+ * deepest region container and so cannot answer "which leaf is active in
2689
+ * region X of composite C". For each region we locate the active leaf via the
2690
+ * `C.region.` dotted prefix; the region is final iff that leaf is `final`, or
2691
+ * the leaf lives under a nested composite that is itself `isCompositeDone`.
2692
+ *
2693
+ * Returns `false` for a non-composite id, for a composite with no active leaf
2694
+ * in some region, or when no substate under it is ever `final` (cheap miss).
2695
+ */
2696
+ isCompositeDone(compositeId, atomicLeaves) {
2697
+ const regions = this.states.get(compositeId)?.regions;
2698
+ if (!regions) return false;
2699
+ for (const regionName of Object.keys(regions)) {
2700
+ const regionPrefix = `${compositeId}.${regionName}.`;
2701
+ const activeLeaf = atomicLeaves.find(
2702
+ (leaf) => leaf.startsWith(regionPrefix)
2703
+ );
2704
+ if (!activeLeaf) return false;
2705
+ const nestedComposite = this.regionComposite(activeLeaf, regionPrefix);
2706
+ if (nestedComposite) {
2707
+ if (this.isCompositeDone(nestedComposite, atomicLeaves)) continue;
2708
+ return false;
2709
+ }
2710
+ if (this.isStateFinal(activeLeaf)) continue;
2711
+ return false;
2712
+ }
2713
+ return true;
2714
+ }
2715
+ /**
2716
+ * The OUTERMOST registered composite (regions-bearing) ancestor of `leaf`
2717
+ * that lives strictly under `regionPrefix`, or `undefined` if the region
2718
+ * holds a simple atomic state directly. Used by {@link isCompositeDone} to
2719
+ * delegate a region's completeness to its nested composite (recursing over
2720
+ * every parallel branch), independent of whether any single branch leaf
2721
+ * happens to carry the `final` flag.
2722
+ *
2723
+ * ancestorChain is root-to-leaf, so the FIRST matching ancestor is the
2724
+ * region's direct composite child (e.g. `C.p.D` for leaf `C.p.D.s.s2` under
2725
+ * region prefix `C.p.`); isCompositeDone then recurses into its regions.
2726
+ */
2727
+ regionComposite(leaf, regionPrefix) {
2728
+ for (const ancestor of this.ancestorChain(leaf)) {
2729
+ if (ancestor !== leaf && ancestor.startsWith(regionPrefix) && this.states.get(ancestor)?.regions) {
2730
+ return ancestor;
2731
+ }
2732
+ }
2733
+ return void 0;
2734
+ }
2735
+ /**
2736
+ * Whether composite `compositeId` has reached its all-regions-final ("done")
2737
+ * configuration in the CURRENT active state. Public guard surface (`@stable`)
2738
+ * for authoring a join as `guard: () => sm.isDone('C')` instead of (or
2739
+ * alongside) listening on the engine `done.state.<C>` event.
2740
+ *
2741
+ * @param compositeId - The dotted id of the composite/parallel state.
2742
+ * @returns `true` iff every region's active atomic leaf is `final`.
2743
+ */
2744
+ isDone(compositeId, adaptee) {
2745
+ const currentState = this.getCurrentState(adaptee);
2746
+ if (!currentState) return false;
2747
+ const atomicLeaves = currentState.split("|").filter(Boolean);
2748
+ return this.isCompositeDone(compositeId, atomicLeaves);
2749
+ }
2750
+ /**
2751
+ * SCXML completion hook (D10/D11/D12): after a new configuration is written,
2752
+ * raise `done.state.<C>` for each composite that became all-regions-final.
2753
+ *
2754
+ * - Scans only composites that GAINED an active leaf (the ancestor chain of
2755
+ * each `|`-leaf of `newState`), so unaffected composites are not re-checked.
2756
+ * - Emits INNERMOST-first (a deeper composite's `done.state` precedes its
2757
+ * parent's), matching SCXML's inner-before-outer completion ordering, via a
2758
+ * per-config emitted-id Set so each id is raised at most once per call.
2759
+ * - Gates each emission on `this.events.has('done.state.'+C)` (D11 mustFix):
2760
+ * raising an undeclared event would hit the `Invalid event` throw
2761
+ * (executeQueuedTransition) as an unhandled microtask rejection. No declared
2762
+ * `done.state.<C>` event => no emission, no crash, no observable effect.
2763
+ * - Uses the internal queue (`raiseEvent`) + `scheduleProcessing` so the
2764
+ * completion event is processed before subsequent external events.
2765
+ */
2766
+ checkCompletion(obj, oldState, newState) {
2767
+ const atomicLeaves = newState.split("|").filter(Boolean);
2768
+ if (atomicLeaves.length === 0) return;
2769
+ const oldLeaves = oldState.split("|").filter(Boolean);
2770
+ const seen = /* @__PURE__ */ new Set();
2771
+ for (const leaf of atomicLeaves) {
2772
+ for (const ancestor of this.ancestorChain(leaf)) {
2773
+ if (ancestor === leaf) continue;
2774
+ if (seen.has(ancestor)) continue;
2775
+ if (!this.states.get(ancestor)?.regions) continue;
2776
+ seen.add(ancestor);
2777
+ }
2778
+ }
2779
+ const candidates = Array.from(seen).sort(
2780
+ (a, b) => b.split(".").length - a.split(".").length
2781
+ );
2782
+ const emitted = /* @__PURE__ */ new Set();
2783
+ for (const compositeId of candidates) {
2784
+ if (emitted.has(compositeId)) continue;
2785
+ if (!this.isCompositeDone(compositeId, atomicLeaves)) continue;
2786
+ if (this.isCompositeDone(compositeId, oldLeaves)) continue;
2787
+ emitted.add(compositeId);
2788
+ const doneEvent = `done.state.${compositeId}`;
2789
+ if (!this.events.has(doneEvent)) continue;
2790
+ this.raiseEvent(doneEvent, obj);
2791
+ this.scheduleProcessing();
2792
+ }
2793
+ }
2794
+ /**
2795
+ * Build the registered ancestor chain for an atomic leaf, ordered root-to-leaf.
2796
+ *
2797
+ * Walks every dot-prefix of `leaf` and keeps only the ones that are real
2798
+ * registered states (`this.states.has`). Region containers are never
2799
+ * registered (only composite parents and atomic leaves are — see
2800
+ * processStates/processRegions), so they are filtered out automatically. The
2801
+ * result is exactly `[parent..leaf]` for a leaf inside a composite, and
2802
+ * `[leaf]` for a flat state.
2803
+ *
2804
+ * Example: `ancestorChain('a.r1.c1')` -> `['a', 'a.r1.c1']` (the `a.r1`
2805
+ * region container is excluded). Nested:
2806
+ * `ancestorChain('a.r1.c1.r3.x')` -> `['a', 'a.r1.c1', 'a.r1.c1.r3.x']`.
2807
+ */
2808
+ ancestorChain(leaf) {
2809
+ const chain = [];
2810
+ let dotIndex = leaf.indexOf(".");
2811
+ while (dotIndex !== -1) {
2812
+ const prefix = leaf.substring(0, dotIndex);
2813
+ if (this.states.has(prefix)) {
2814
+ chain.push(prefix);
2815
+ }
2816
+ dotIndex = leaf.indexOf(".", dotIndex + 1);
2817
+ }
2818
+ if (this.states.has(leaf)) {
2819
+ chain.push(leaf);
2820
+ }
2821
+ return chain;
2822
+ }
2823
+ /**
2824
+ * Compute the ordered enter/exit sets between two composite configurations
2825
+ * (SCXML ancestor-first entry / descendant-first exit).
2826
+ *
2827
+ * For each `|`-separated atomic leaf in `oldComposite` and `newComposite` the
2828
+ * union of its {@link ancestorChain} forms the old/new active ancestry. A
2829
+ * state shared by both ancestries lands in NEITHER diff, so a surviving
2830
+ * ancestor is never re-entered nor exited (no onEnter/onExit re-fire, no
2831
+ * timer re-arm/leak).
2832
+ *
2833
+ * - `enterStates` = new ancestry MINUS old, sorted ascending by depth then
2834
+ * document (insertion) order -> root-to-leaf entry.
2835
+ * - `exitStates` = old ancestry MINUS new, sorted descending by depth ->
2836
+ * leaf-to-root exit.
2837
+ *
2838
+ * Consumed by BOTH R1 (applyTransition / setInitialState / reset) and R2.
2839
+ */
2840
+ computeEnterExitSets(oldComposite, newComposite) {
2841
+ const collectAncestry = (composite) => {
2842
+ const ancestry = /* @__PURE__ */ new Set();
2843
+ if (!composite) return ancestry;
2844
+ for (const leaf of composite.split("|")) {
2845
+ if (!leaf) continue;
2846
+ for (const ancestor of this.ancestorChain(leaf)) {
2847
+ ancestry.add(ancestor);
2848
+ }
2849
+ }
2850
+ return ancestry;
2851
+ };
2852
+ const oldAncestry = collectAncestry(oldComposite);
2853
+ const newAncestry = collectAncestry(newComposite);
2854
+ const depthOf = (state) => {
2855
+ let depth = 0;
2856
+ for (let i = 0; i < state.length; i++) {
2857
+ if (state[i] === ".") depth++;
2858
+ }
2859
+ return depth;
2860
+ };
2861
+ const enterRaw = [];
2862
+ for (const state of newAncestry) {
2863
+ if (!oldAncestry.has(state)) enterRaw.push(state);
2864
+ }
2865
+ const exitRaw = [];
2866
+ for (const state of oldAncestry) {
2867
+ if (!newAncestry.has(state)) exitRaw.push(state);
2868
+ }
2869
+ 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);
2870
+ 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);
2871
+ return { enterStates, exitStates };
2872
+ }
2633
2873
  validateCompositeState(compositeState) {
2634
2874
  const stateParts = compositeState.split("|");
2635
2875
  const regionKeys = /* @__PURE__ */ new Set();
@@ -2703,10 +2943,11 @@ var StateMachine = class _StateMachine {
2703
2943
  return (...args) => {
2704
2944
  const targetAdaptee = args.length >= 2 ? args[0] : adaptee;
2705
2945
  const error = args.length >= 2 ? args[1] : args[0];
2706
- return handler(targetAdaptee, error);
2946
+ return handler(this.resolveCallbackOwner(targetAdaptee), error);
2707
2947
  };
2708
2948
  }
2709
2949
  async callAction(obj, actionName, ...args) {
2950
+ const targetOwner = this.resolveCallbackOwner(obj);
2710
2951
  const _callActionState = this.getCurrentState(
2711
2952
  obj
2712
2953
  );
@@ -2721,16 +2962,16 @@ var StateMachine = class _StateMachine {
2721
2962
  if (this.context && this.context[actionName]) {
2722
2963
  const action = this.context[actionName];
2723
2964
  if (typeof action === "function") {
2724
- const result = action(this.context, ...args);
2965
+ const result = action(targetOwner, ...args);
2725
2966
  return result instanceof Promise ? await result : result;
2726
2967
  }
2727
2968
  } else if (typeof actionName === "function") {
2728
- const result = actionName(obj, ...args);
2969
+ const result = actionName(targetOwner, ...args);
2729
2970
  return result instanceof Promise ? await result : result;
2730
2971
  } else if (obj.get(actionName)) {
2731
2972
  const action = obj.get(actionName);
2732
2973
  if (typeof action === "function") {
2733
- const result = action(obj, ...args);
2974
+ const result = action(targetOwner, ...args);
2734
2975
  return result instanceof Promise ? await result : result;
2735
2976
  }
2736
2977
  }
@@ -2746,19 +2987,22 @@ var StateMachine = class _StateMachine {
2746
2987
  }
2747
2988
  };
2748
2989
  if (this.transitionTimeout && this.transitionTimeout > 0) {
2749
- return Promise.race([
2750
- executeAction(),
2751
- new Promise(
2752
- (_, reject) => setTimeout(
2753
- () => reject(new StateMachineError("Transition timeout", {
2754
- /* c8 ignore next */
2755
- action: typeof actionName === "string" ? actionName : "anonymous",
2756
- phase: "action"
2757
- })),
2758
- this.transitionTimeout
2759
- )
2760
- )
2761
- ]);
2990
+ const timeoutMs = this.transitionTimeout;
2991
+ let timeoutHandle;
2992
+ const timeoutPromise = new Promise((_, reject) => {
2993
+ const fire = () => reject(new StateMachineError("Transition timeout", {
2994
+ /* c8 ignore next */
2995
+ action: typeof actionName === "string" ? actionName : "anonymous",
2996
+ phase: "action"
2997
+ }));
2998
+ timeoutHandle = this.setTimer(fire, timeoutMs);
2999
+ });
3000
+ if (this.schedulerProvided) {
3001
+ return Promise.race([executeAction(), timeoutPromise]).finally(() => {
3002
+ this.clearTimer(timeoutHandle);
3003
+ });
3004
+ }
3005
+ return Promise.race([executeAction(), timeoutPromise]);
2762
3006
  }
2763
3007
  return executeAction();
2764
3008
  }
@@ -2802,8 +3046,12 @@ var StateMachine = class _StateMachine {
2802
3046
  if (fromState === "*") return true;
2803
3047
  const regionKey = this.getRegionKey(fromState);
2804
3048
  const currentStateForRegion = currentStates.get(regionKey);
2805
- if (!currentStateForRegion) return false;
2806
- return currentStateForRegion === fromState || this.isParentState(currentStateForRegion, fromState);
3049
+ if (currentStateForRegion && (currentStateForRegion === fromState || this.isParentState(currentStateForRegion, fromState))) {
3050
+ return true;
3051
+ }
3052
+ return Array.from(currentStates.values()).some(
3053
+ (leaf) => leaf === fromState || this.isParentState(fromState, leaf)
3054
+ );
2807
3055
  });
2808
3056
  }
2809
3057
  isParentState(parentState, childState) {
@@ -2819,6 +3067,26 @@ var StateMachine = class _StateMachine {
2819
3067
  const targetState = transition.to === "*" ? currentState : transition.to;
2820
3068
  this._isTransitioning = true;
2821
3069
  this._targetState = targetState;
3070
+ let newState;
3071
+ let enterStates;
3072
+ let exitStates;
3073
+ try {
3074
+ newState = this.updateState(currentState, targetState);
3075
+ const sets = this.computeEnterExitSets(currentState, newState);
3076
+ enterStates = sets.enterStates;
3077
+ exitStates = sets.exitStates;
3078
+ } catch (error) {
3079
+ this.logger.warn("Transition aborted: invalid target configuration", {
3080
+ state: currentState,
3081
+ target: String(targetState),
3082
+ error
3083
+ });
3084
+ this._isTransitioning = false;
3085
+ this._targetState = void 0;
3086
+ return void 0;
3087
+ }
3088
+ const exitFireOrder = exitStates.length > 0 ? exitStates : [transition.from];
3089
+ const enterFireOrder = enterStates.length > 0 ? enterStates : [transition.to];
2822
3090
  try {
2823
3091
  if (transition.guard) {
2824
3092
  const allow = await this.callAction(obj, transition.guard, ...args).catch(
@@ -2844,7 +3112,9 @@ var StateMachine = class _StateMachine {
2844
3112
  );
2845
3113
  }
2846
3114
  try {
2847
- await this.executeExitActions(obj, transition.from, args, context);
3115
+ for (const exitStateName of exitFireOrder) {
3116
+ await this.executeExitActions(obj, exitStateName, args, context);
3117
+ }
2848
3118
  } catch (error) {
2849
3119
  if (this.abortOnExitError) {
2850
3120
  this.logger.warn("Transition aborted due to onExit error", { state: currentState, error });
@@ -2864,12 +3134,14 @@ var StateMachine = class _StateMachine {
2864
3134
  );
2865
3135
  }
2866
3136
  try {
2867
- await this.executeEnterActions(obj, transition.to, args, context);
3137
+ for (const enterStateName of enterFireOrder) {
3138
+ await this.executeEnterActions(obj, enterStateName, args, context);
3139
+ }
2868
3140
  } catch (error) {
2869
3141
  if (this.errorState) {
2870
3142
  this.logger.error(`Failed to enter state '${targetState}'. Fallback to error state '${this.errorState}'`, { error });
2871
- const newState2 = this.updateState(currentState, this.errorState);
2872
- this.setCurrentState(newState2, obj);
3143
+ const errorNewState = this.updateState(currentState, this.errorState);
3144
+ this.setCurrentState(errorNewState, obj);
2873
3145
  return this.states.get(this.errorState);
2874
3146
  }
2875
3147
  throw error;
@@ -2884,12 +3156,12 @@ var StateMachine = class _StateMachine {
2884
3156
  event.onError,
2885
3157
  this.onError
2886
3158
  );
2887
- errorHandler(obj, error);
3159
+ errorHandler(this.resolveCallbackOwner(obj), error);
2888
3160
  }
2889
3161
  }
2890
3162
  const transitionStartTime = Date.now();
2891
- const newState = this.updateState(currentState, targetState);
2892
3163
  this.setCurrentState(newState, obj);
3164
+ this.checkCompletion(obj, currentState, newState);
2893
3165
  const transitionTime = Date.now() - transitionStartTime;
2894
3166
  this.monitor.recordTransition(transitionTime, true);
2895
3167
  return this.states.get(targetState);
@@ -2947,7 +3219,7 @@ var StateMachine = class _StateMachine {
2947
3219
  }
2948
3220
  if (toState.invoke && toState.invoke.length > 0) {
2949
3221
  if (!this.stateEntryTimes.has(toStateName)) {
2950
- this.stateEntryTimes.set(toStateName, Date.now());
3222
+ this.stateEntryTimes.set(toStateName, this.clock());
2951
3223
  }
2952
3224
  const timers = [];
2953
3225
  for (const invocation of toState.invoke) {
@@ -2993,6 +3265,9 @@ var StateMachine = class _StateMachine {
2993
3265
  */
2994
3266
  setTimer(callback, delay) {
2995
3267
  const scheduler = this.scheduler;
3268
+ if (this.schedulerProvided) {
3269
+ return scheduler.schedule(delay, callback);
3270
+ }
2996
3271
  if (scheduler.isActive()) {
2997
3272
  return scheduler.schedule(delay, callback);
2998
3273
  }
@@ -3003,6 +3278,10 @@ var StateMachine = class _StateMachine {
3003
3278
  */
3004
3279
  clearTimer(timerId) {
3005
3280
  const scheduler = this.scheduler;
3281
+ if (this.schedulerProvided) {
3282
+ if (timerId !== void 0) scheduler.cancel(timerId);
3283
+ return;
3284
+ }
3006
3285
  if (scheduler.isActive() && typeof timerId === "object" && timerId !== null && !("ref" in timerId)) {
3007
3286
  scheduler.cancel(timerId);
3008
3287
  } else {
@@ -3065,7 +3344,7 @@ var StateMachine = class _StateMachine {
3065
3344
  updateState(currentState, toState) {
3066
3345
  const currentStateMap = this.parseCompositeState(currentState);
3067
3346
  const toStateParts = toState.split("|");
3068
- if (toStateParts.length === 1 && !toState.includes(".")) {
3347
+ if (toStateParts.length === 1 && !toState.includes(".") && !this.states.get(toState)?.regions) {
3069
3348
  currentStateMap.clear();
3070
3349
  currentStateMap.set(toState, toState);
3071
3350
  return toState;
@@ -3160,13 +3439,13 @@ var StateMachine = class _StateMachine {
3160
3439
  this.stateEntryTimes.delete(stateName);
3161
3440
  }
3162
3441
  }
3163
- const now = Date.now();
3442
+ const now = this.clock();
3164
3443
  for (const stateName of activeStates) {
3165
3444
  const state = this.states.get(stateName);
3166
3445
  if (!state || !state.invoke || state.invoke.length === 0) continue;
3167
3446
  const entryTime = this.stateEntryTimes.get(stateName);
3168
- const startTime = entryTime || now;
3169
- if (!entryTime) {
3447
+ const startTime = entryTime ?? now;
3448
+ if (entryTime === void 0) {
3170
3449
  this.stateEntryTimes.set(stateName, startTime);
3171
3450
  }
3172
3451
  const elapsed = now - startTime;
@@ -3447,6 +3726,7 @@ var ConfigValidator = class {
3447
3726
  this.validateInitialState(smConfig);
3448
3727
  this.validateCrossReferences(smConfig);
3449
3728
  this.validatePerformanceConstraints(smConfig);
3729
+ this.validateFinalStates(smConfig);
3450
3730
  this.runCustomRules(smConfig);
3451
3731
  } catch (error) {
3452
3732
  const msg = error instanceof Error ? error.message : String(error);
@@ -3808,6 +4088,10 @@ var ConfigValidator = class {
3808
4088
  }
3809
4089
  }
3810
4090
  }
4091
+ const finalStatePaths = this.collectFinalStatePaths(smConfig.states);
4092
+ for (const finalPath of finalStatePaths) {
4093
+ reachableStates.add(finalPath);
4094
+ }
3811
4095
  for (const stateName of allStateNames) {
3812
4096
  if (!reachableStates.has(stateName)) {
3813
4097
  this.addWarning(
@@ -3818,8 +4102,9 @@ var ConfigValidator = class {
3818
4102
  }
3819
4103
  }
3820
4104
  for (const [eventName, event] of Object.entries(smConfig.events)) {
4105
+ const isDoneStateEvent = eventName.startsWith("done.state.");
3821
4106
  const hasValidTransitions = event.transitions.some(
3822
- (t) => allStateNames.has(t.from) && allStateNames.has(t.to)
4107
+ (t) => (allStateNames.has(t.from) || isDoneStateEvent && allStateNames.has(eventName.slice("done.state.".length))) && allStateNames.has(t.to)
3823
4108
  );
3824
4109
  if (!hasValidTransitions) {
3825
4110
  this.addWarning(
@@ -3847,6 +4132,168 @@ var ConfigValidator = class {
3847
4132
  }
3848
4133
  }
3849
4134
  }
4135
+ /**
4136
+ * Collects every state in the tree as {dottedPath, config} entries, walking
4137
+ * region containers (region names are NOT registered states, so they are part
4138
+ * of the path but never emitted as an entry — mirroring runtime expansion).
4139
+ */
4140
+ collectStateEntries(states, prefix, collector) {
4141
+ for (const [stateName, state] of Object.entries(states)) {
4142
+ const fullName = prefix ? `${prefix}.${stateName}` : stateName;
4143
+ collector.push({ path: fullName, state });
4144
+ if (state.regions) {
4145
+ for (const [regionName, regionStates] of Object.entries(
4146
+ state.regions
4147
+ )) {
4148
+ this.collectStateEntries(
4149
+ regionStates,
4150
+ `${fullName}.${regionName}`,
4151
+ collector
4152
+ );
4153
+ }
4154
+ }
4155
+ }
4156
+ }
4157
+ /**
4158
+ * True when `composite` has at least one `final:true` atomic substate reachable
4159
+ * through its (possibly nested) region tree — i.e. some region of the composite
4160
+ * can become done. Used by REGION_NO_REACHABLE_FINAL.
4161
+ */
4162
+ hasReachableFinal(composite) {
4163
+ if (!composite.regions) return false;
4164
+ for (const regionStates of Object.values(composite.regions)) {
4165
+ for (const state of Object.values(regionStates)) {
4166
+ if (state.final) return true;
4167
+ if (state.regions && this.hasReachableFinal(state)) return true;
4168
+ }
4169
+ }
4170
+ return false;
4171
+ }
4172
+ /**
4173
+ * SCXML/UML final-state and all-regions-final (done.state.<C>) join validation.
4174
+ *
4175
+ * - FINAL_STATE_HAS_OUTGOING (error): a `final:true` state is the `from` of a
4176
+ * transition (a final pseudo-state cannot itself transition out).
4177
+ * - FINAL_ON_COMPOSITE (warning): `final:true` on a state that has regions
4178
+ * (final is meaningful only on an atomic leaf; ignored at runtime).
4179
+ * - REGION_NO_REACHABLE_FINAL (warning): a `done.state.<C>` transition exists
4180
+ * but composite C declares no `final` substate, so the join can never fire.
4181
+ * - DONE_VS_PARALLEL_EXIT_AMBIGUITY (warning): the same composite C has BOTH a
4182
+ * `done.state.C` join AND a plain `from:'C'` user-event parallel-exit, which
4183
+ * can preempt the all-final join (the user event is eligible on ANY leaf).
4184
+ * - REGION_MISSING_INITIAL (advisory warning, valid:true): a region omits an
4185
+ * explicit `initial`, so expansion relies on first-key insertion order.
4186
+ */
4187
+ validateFinalStates(smConfig) {
4188
+ const entries = [];
4189
+ this.collectStateEntries(smConfig.states, "", entries);
4190
+ const finalStatePaths = /* @__PURE__ */ new Set();
4191
+ for (const { path, state } of entries) {
4192
+ if (!state.final) continue;
4193
+ if (state.regions) {
4194
+ this.addWarning(
4195
+ "FINAL_ON_COMPOSITE",
4196
+ `Final flag on composite state "${path}" is ignored; mark an atomic leaf inside a region as final instead`,
4197
+ `states.${path}.final`,
4198
+ void 0,
4199
+ "Remove `final: true` from this composite and set it on the atomic leaf state that represents the region's completion."
4200
+ );
4201
+ } else {
4202
+ finalStatePaths.add(path);
4203
+ }
4204
+ }
4205
+ const userFromStates = /* @__PURE__ */ new Set();
4206
+ const doneStateComposites = /* @__PURE__ */ new Set();
4207
+ for (const [eventName, event] of Object.entries(smConfig.events)) {
4208
+ if (!event.transitions || !Array.isArray(event.transitions)) continue;
4209
+ if (eventName.startsWith("done.state.")) {
4210
+ const compositeId = eventName.slice("done.state.".length);
4211
+ if (compositeId) doneStateComposites.add(compositeId);
4212
+ }
4213
+ for (const transition of event.transitions) {
4214
+ if (typeof transition.from !== "string") continue;
4215
+ for (const fromPart of transition.from.split("|")) {
4216
+ if (finalStatePaths.has(fromPart)) {
4217
+ this.addError(
4218
+ "FINAL_STATE_HAS_OUTGOING",
4219
+ `Final state "${fromPart}" cannot be the source of transition on event "${eventName}"; a final pseudo-state has no outgoing transitions`,
4220
+ `events.${eventName}`,
4221
+ void 0,
4222
+ `Remove the outgoing transition from "${fromPart}", or clear its \`final: true\` flag if it is not actually a final state.`
4223
+ );
4224
+ }
4225
+ if (!eventName.startsWith("done.state.")) {
4226
+ userFromStates.add(fromPart);
4227
+ }
4228
+ }
4229
+ }
4230
+ }
4231
+ const entryByPath = new Map(entries.map((e) => [e.path, e.state]));
4232
+ for (const compositeId of doneStateComposites) {
4233
+ const composite = entryByPath.get(compositeId);
4234
+ if (!composite) {
4235
+ continue;
4236
+ }
4237
+ if (!this.hasReachableFinal(composite)) {
4238
+ this.addWarning(
4239
+ "REGION_NO_REACHABLE_FINAL",
4240
+ `Join event "done.state.${compositeId}" can never fire: composite "${compositeId}" has no \`final\` substate in any region`,
4241
+ `events.done.state.${compositeId}`,
4242
+ void 0,
4243
+ `Mark the completion leaf of each region under "${compositeId}" with \`final: true\`, or remove the done.state join.`
4244
+ );
4245
+ }
4246
+ if (userFromStates.has(compositeId)) {
4247
+ this.addWarning(
4248
+ "DONE_VS_PARALLEL_EXIT_AMBIGUITY",
4249
+ `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`,
4250
+ `events.done.state.${compositeId}`,
4251
+ void 0,
4252
+ `Disambiguate by using the done.state.${compositeId} join exclusively, or scope the user-event transition to a specific leaf instead of the bare composite.`
4253
+ );
4254
+ }
4255
+ }
4256
+ for (const { path, state } of entries) {
4257
+ if (!state.regions) continue;
4258
+ for (const [regionName, regionStates] of Object.entries(state.regions)) {
4259
+ const regionKeys = Object.keys(regionStates);
4260
+ if (regionKeys.length === 0) continue;
4261
+ const parentInitialInRegion = state.initial !== void 0 && regionKeys.includes(state.initial);
4262
+ if (!parentInitialInRegion) {
4263
+ this.addWarning(
4264
+ "REGION_MISSING_INITIAL",
4265
+ `Region "${regionName}" of composite "${path}" has no explicit initial; expansion falls back to the first key "${regionKeys[0]}" (insertion-order dependent)`,
4266
+ `states.${path}.regions.${regionName}`,
4267
+ void 0,
4268
+ `Set \`initial\` on composite "${path}" to a leaf of region "${regionName}", to make the starting substate explicit and order-independent.`
4269
+ );
4270
+ }
4271
+ }
4272
+ }
4273
+ }
4274
+ /**
4275
+ * Collects the dotted paths of every `final:true` atomic leaf in the tree.
4276
+ */
4277
+ collectFinalStatePaths(states, prefix = "", collector = /* @__PURE__ */ new Set()) {
4278
+ for (const [stateName, state] of Object.entries(states)) {
4279
+ const fullName = prefix ? `${prefix}.${stateName}` : stateName;
4280
+ if (state.final && !state.regions) {
4281
+ collector.add(fullName);
4282
+ }
4283
+ if (state.regions) {
4284
+ for (const [regionName, regionStates] of Object.entries(
4285
+ state.regions
4286
+ )) {
4287
+ this.collectFinalStatePaths(
4288
+ regionStates,
4289
+ `${fullName}.${regionName}`,
4290
+ collector
4291
+ );
4292
+ }
4293
+ }
4294
+ }
4295
+ return collector;
4296
+ }
3850
4297
  validatePerformanceConstraints(smConfig) {
3851
4298
  const stateCount = this.countStates(smConfig.states);
3852
4299
  const eventCount = Object.keys(smConfig.events).length;
@@ -3934,6 +4381,7 @@ export {
3934
4381
  StateMachineError,
3935
4382
  createEnhancedError,
3936
4383
  createMachine,
4384
+ createVirtualScheduler,
3937
4385
  isAdapter,
3938
4386
  isRecoverableError,
3939
4387
  isValidConfig,