@vedmalex/statemachine 1.0.0-beta.2 → 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/README.md CHANGED
@@ -3,7 +3,17 @@
3
3
  [![CI](https://github.com/vedmalex/statemachine/actions/workflows/ci.yml/badge.svg)](https://github.com/vedmalex/statemachine/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/@vedmalex/statemachine?label=npm)](https://www.npmjs.com/package/@vedmalex/statemachine)
5
5
 
6
- Hierarchical state machine for TypeScript with monitoring, validation, and persistence.
6
+ Hierarchical state machine for TypeScript with orthogonal (parallel) regions, SCXML/UML entry-exit semantics, monitoring, validation, and persistence.
7
+
8
+ **Features:**
9
+
10
+ - Hierarchical (nested) states addressed by dotted paths
11
+ - Orthogonal **parallel regions** with SCXML ancestor-first entry / descendant-first exit
12
+ - **UML all-regions-final join** via `final` states, the engine-raised `done.state.<id>` event, and the `isDone()` guard
13
+ - Parallel-exit (LCCA) — a transition from a composite parent preempts and exits all active regions
14
+ - Guards, before/enter/after + exit/transition actions, and timed `invoke` transitions
15
+ - Pluggable monitoring, validation, persistence, and timer scheduling (7 extension points)
16
+ - ESM + CJS dual bundle; DI-free lite surface
7
17
 
8
18
  The package ships only the DI-free lite surface. The legacy DI-aware factory from `@grainjs/statemachine` is intentionally not carried over.
9
19
 
@@ -61,7 +71,20 @@ const sm = createMachine({
61
71
  - **Parallel-exit** — a plain transition `from: 'proc'` on a user event preempts and exits all active regions immediately (LCCA).
62
72
  - **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
73
 
64
- See [`docs/regions-and-parallel.md`](./docs/regions-and-parallel.md) for the full model, ordering rules, and validation.
74
+ Author the join either as the `done.state.<id>` transition above, **or** as a guard on any event:
75
+
76
+ ```ts
77
+ events: {
78
+ tryFinish: {
79
+ // fires only once every region of `proc` has reached a `final` state
80
+ transitions: [{ from: 'proc', to: 'complete', guard: () => sm.isDone('proc') }],
81
+ },
82
+ }
83
+ ```
84
+
85
+ Composites nest: a parent's `done.state` is raised only after every region — **including any nested composite** — is final, innermost-first. `done.state.<id>` is edge-triggered (raised once on entering the done configuration, not re-raised while the composite merely stays all-final).
86
+
87
+ See [`docs/regions-and-parallel.md`](./docs/regions-and-parallel.md) for the full model, ordering rules, nesting, and validation.
65
88
 
66
89
  ## Documentation
67
90
 
@@ -71,13 +94,90 @@ Full API documentation: [https://vedmalex.github.io/statemachine/](https://vedma
71
94
 
72
95
  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.
73
96
 
97
+ ## Deterministic testing (DST)
98
+
99
+ Machines that use `invoke` delays or a `transitionTimeout` normally depend on real wall-clock time (`Date.now` + `setTimeout`). That makes tests slow, flaky, and sensitive to scheduling jitter. The DST API swaps the clock and the timer scheduler for virtual counterparts so timer-driven behavior replays deterministically with **zero** real time elapsed.
100
+
101
+ ```ts
102
+ import { StateMachine, createVirtualScheduler } from '@vedmalex/statemachine'
103
+
104
+ let t = 0
105
+ const clock = () => t
106
+ const scheduler = createVirtualScheduler(clock)
107
+
108
+ const sm = new StateMachine(config, adapter, { clock, scheduler })
109
+ // ... arm the initial state's invoke timers
110
+ await Promise.resolve() // flush microtasks
111
+
112
+ t = 1000
113
+ scheduler.process() // fire every timer whose deadline <= 1000
114
+ await Promise.resolve() // flush microtasks so the transition settles
115
+ // assert sm.currentState === 'next'
116
+ ```
117
+
118
+ ### How it works
119
+
120
+ - `clock` replaces `Date.now` for `stateEntryTimes`, `resumeTimers`, and `getQueuedEvents` age math (and for the event-queue timestamps those ages are measured against, so age stays coherent under virtual time).
121
+ - `createVirtualScheduler(clock)` returns an `ITimerScheduler` whose `isActive()` is always `true`, so the `StateMachine` routes **all** `invoke` timers and the `transitionTimeout` through it.
122
+ - An **explicitly provided** scheduler is always used — the machine never falls back to real `setTimeout` while one is injected.
123
+ - `scheduler.process(now?)` drains every timer whose deadline `<= now` (default `now` is `clock()`), advancing zero real time. It is idempotent — draining twice does not re-fire a timer.
124
+ - `invoke` callbacks are async (they raise an event and queue the transition on a microtask), so after each `process()` you must flush microtasks (`await Promise.resolve()`, a few times for chained transitions) before asserting.
125
+
126
+ ### Replaying serialized state
127
+
128
+ `toJSON()` / `fromJSON()` round-trips the recorded entry times as raw numbers. Restore into a fresh machine whose clock already reads the serialize time, and the remaining invoke delay is recomputed correctly:
129
+
130
+ ```ts
131
+ // Original machine, invoke delay 1000ms, entered at t=0:
132
+ let t = 0
133
+ const clock = () => t
134
+ const sm = new StateMachine(config, adapter, { clock, scheduler: createVirtualScheduler(clock) })
135
+
136
+ t = 400
137
+ const json = sm.toJSON() // snapshot 400ms in
138
+
139
+ const scheduler2 = createVirtualScheduler(clock)
140
+ const sm2 = StateMachine.fromJSON(json, freshAdapter, { clock, scheduler: scheduler2 })
141
+ // 600ms remain:
142
+ t = 1000
143
+ scheduler2.process() // the invoke fires here, not at t=1400
144
+ ```
145
+
146
+ ### transitionTimeout under virtual time
147
+
148
+ The `transitionTimeout` deadline is also routed through the injected scheduler, so it triggers on a virtual-time advance rather than a real timer:
149
+
150
+ ```ts
151
+ const sm = new StateMachine(config, adapter, { clock, scheduler, transitionTimeout: 500 })
152
+ const fired = sm.fireEvent('go') // enters a state whose action never resolves
153
+ await Promise.resolve()
154
+ t = 500
155
+ scheduler.process() // the race rejects deterministically
156
+ await expect(fired).rejects.toThrow(/timeout/i)
157
+ ```
158
+
159
+ When the action wins the race instead, the pending timeout token is auto-cancelled so no ghost rejection fires on a later `process()`.
160
+
161
+ ### Back-compatibility
162
+
163
+ > Omitting **both** `clock` and `scheduler` keeps runtime behavior byte-identical to prior releases: `createDefaultScheduler()` uses `Date.now`, the `isActive()`-gated `setTimer` fallback to native `setTimeout` is unchanged, and `process()`'s default argument resolves to the same value it did before. The DST machinery only engages when you opt in by injecting a scheduler.
164
+
165
+ ### API reference
166
+
167
+ | Symbol | Kind | Purpose |
168
+ | --- | --- | --- |
169
+ | `createVirtualScheduler(clock)` | function | Build a deterministic, non-real-time `ITimerScheduler`. |
170
+ | `Clock` | type | `() => number`; matches the `clock` option signature. |
171
+ | `StateMachineOptions.clock?` | option | Inject a virtual clock (default `Date.now`). |
172
+ | `ITimerScheduler.process?(now?)` | method | Optional manual drain; implemented by `createVirtualScheduler`. |
173
+
74
174
  ## Stability policy
75
175
 
76
- 5 firm `@stable` symbols: `createMachine`, `StateMachine`, `StateMachineConfig`, `Transition`, `State`. Other exports are `@unstable` and may evolve between minor versions. See [`STABILITY.md`](./STABILITY.md) for the full policy.
176
+ 5 firm `@stable` symbols: `createMachine`, `StateMachine`, `StateMachineConfig`, `Transition`, `State`. The all-regions-final join API lives on these stable symbols — `State.final?: boolean`, `StateMachine.isDone(compositeId)`, and the engine-raised `done.state.<id>` event (all reflected in `etc/statemachine.api.md`). Other exports are `@unstable` and may evolve between minor versions. See [`STABILITY.md`](./STABILITY.md) for the full policy.
77
177
 
78
178
  ## Status & module format
79
179
 
80
- `1.0.0-beta.x`. Stability: experimental. The full API surface is currently `@unstable` per the package's STABILITY policy; per-symbol stability tagging arrives before `1.0.0` stable.
180
+ `1.0.0-beta.x` (current published version: see the npm badge above; both the `latest` and `beta` dist-tags track the newest release). Stability: experimental. SCXML/UML parallel regions, ancestor-first / descendant-first ordering, and the all-regions-final join landed in **`1.0.0-beta.2`**. The full API surface is `@unstable` per the package's STABILITY policy except the 5 firm `@stable` symbols; per-symbol stability tagging completes before `1.0.0` stable.
81
181
 
82
182
  **Module format**: ESM + CJS dual bundle (TASK-005). `require('@vedmalex/statemachine')` works in CommonJS runtimes via `dist/index.cjs`. `import` works via `dist/index.js`. The `exports` map resolves automatically.
83
183
 
package/dist/index.cjs CHANGED
@@ -35,6 +35,7 @@ __export(index_exports, {
35
35
  StateMachineError: () => StateMachineError,
36
36
  createEnhancedError: () => createEnhancedError,
37
37
  createMachine: () => createMachine,
38
+ createVirtualScheduler: () => createVirtualScheduler,
38
39
  isAdapter: () => isAdapter,
39
40
  isRecoverableError: () => isRecoverableError,
40
41
  isValidConfig: () => isValidConfig,
@@ -53,7 +54,9 @@ var TimerScheduler = class {
53
54
  intervalId = null;
54
55
  pollingInterval = 100;
55
56
  // ms
56
- constructor() {
57
+ clock;
58
+ constructor(clock = Date.now) {
59
+ this.clock = clock;
57
60
  }
58
61
  /**
59
62
  * Настройка режима опроса
@@ -77,7 +80,7 @@ var TimerScheduler = class {
77
80
  */
78
81
  start() {
79
82
  if (this.intervalId) return;
80
- this.intervalId = setInterval(() => this.process(), this.pollingInterval);
83
+ this.intervalId = setInterval(() => this.process(this.clock()), this.pollingInterval);
81
84
  }
82
85
  /**
83
86
  * Остановка единого таймера
@@ -96,7 +99,7 @@ var TimerScheduler = class {
96
99
  */
97
100
  schedule(delay, callback) {
98
101
  const token = {};
99
- const executeAt = Date.now() + delay;
102
+ const executeAt = this.clock() + delay;
100
103
  const task = { token, executeAt, callback };
101
104
  this.activeTokens.add(token);
102
105
  this.insert(task);
@@ -112,7 +115,7 @@ var TimerScheduler = class {
112
115
  /**
113
116
  * Обработать очередь (вызывается таймером или вручную)
114
117
  */
115
- process(now = Date.now()) {
118
+ process(now = this.clock()) {
116
119
  while (this.heap.length > 0) {
117
120
  const task = this.heap[0];
118
121
  if (task === void 0) break;
@@ -203,6 +206,23 @@ var TimerScheduler = class {
203
206
  function createDefaultScheduler() {
204
207
  return new TimerScheduler();
205
208
  }
209
+ function createVirtualScheduler(clock) {
210
+ const inner = new TimerScheduler(clock);
211
+ return {
212
+ isActive() {
213
+ return true;
214
+ },
215
+ schedule(delay, callback) {
216
+ return inner.schedule(delay, callback);
217
+ },
218
+ cancel(token) {
219
+ inner.cancel(token);
220
+ },
221
+ process(now) {
222
+ inner.process(now ?? clock());
223
+ }
224
+ };
225
+ }
206
226
 
207
227
  // src/logger.ts
208
228
  var LogLevel = {
@@ -1749,6 +1769,8 @@ var StateMachine = class _StateMachine {
1749
1769
  monitor;
1750
1770
  scheduler;
1751
1771
  errorHandler;
1772
+ clock;
1773
+ schedulerProvided;
1752
1774
  // Свойства
1753
1775
  states;
1754
1776
  events;
@@ -1799,7 +1821,9 @@ var StateMachine = class _StateMachine {
1799
1821
  this.initialState = config.initialState;
1800
1822
  this.logger = options?.logger ?? ConsoleLogger;
1801
1823
  this.monitor = options?.monitor ?? createDefaultMonitor();
1824
+ this.schedulerProvided = options?.scheduler !== void 0;
1802
1825
  this.scheduler = options?.scheduler ?? createDefaultScheduler();
1826
+ this.clock = options?.clock ?? Date.now;
1803
1827
  this.errorHandler = options?.errorHandler ?? createDefaultErrorHandler();
1804
1828
  if (adaptee) {
1805
1829
  if (!isAdapter(adaptee)) {
@@ -1873,7 +1897,7 @@ var StateMachine = class _StateMachine {
1873
1897
  args,
1874
1898
  resolve,
1875
1899
  reject,
1876
- timestamp: Date.now(),
1900
+ timestamp: this.clock(),
1877
1901
  type: "external"
1878
1902
  });
1879
1903
  this.scheduleProcessing();
@@ -1884,7 +1908,7 @@ var StateMachine = class _StateMachine {
1884
1908
  eventName,
1885
1909
  obj,
1886
1910
  args,
1887
- timestamp: Date.now(),
1911
+ timestamp: this.clock(),
1888
1912
  type: "internal"
1889
1913
  });
1890
1914
  return Promise.resolve(true);
@@ -1895,7 +1919,7 @@ var StateMachine = class _StateMachine {
1895
1919
  eventName,
1896
1920
  obj,
1897
1921
  args,
1898
- timestamp: Date.now(),
1922
+ timestamp: this.clock(),
1899
1923
  type: "internal"
1900
1924
  });
1901
1925
  }
@@ -2056,7 +2080,7 @@ var StateMachine = class _StateMachine {
2056
2080
  };
2057
2081
  }
2058
2082
  getQueuedEvents() {
2059
- const now = Date.now();
2083
+ const now = this.clock();
2060
2084
  const mapEvent = (evt) => ({
2061
2085
  id: evt.id,
2062
2086
  event: evt.eventName,
@@ -2175,53 +2199,51 @@ var StateMachine = class _StateMachine {
2175
2199
  return currentParts.every((part, index) => part === expectedParts[index]);
2176
2200
  }
2177
2201
  attachToObject(object, eventMap) {
2178
- for (const objectEventName in eventMap) {
2179
- if (eventMap.hasOwnProperty(objectEventName)) {
2180
- const stateMachineEventName = eventMap[objectEventName];
2181
- if (stateMachineEventName === void 0) continue;
2182
- if (typeof object.addEventListener === "function") {
2183
- object.addEventListener(objectEventName, (...args) => {
2184
- this.fireEvent(stateMachineEventName, object, ...args).catch(
2185
- (e) => this.logger.error(
2186
- "Error firing event",
2187
- {
2188
- objectEventName,
2189
- stateMachineEventName
2190
- },
2191
- /* c8 ignore next */
2192
- e instanceof Error ? e : new Error(String(e))
2193
- )
2194
- );
2195
- });
2196
- } else if (typeof object.on === "function") {
2197
- object.on(objectEventName, (...args) => {
2198
- this.fireEvent(stateMachineEventName, object, ...args).catch(
2199
- (e) => this.logger.error(
2200
- "Error firing event",
2201
- {
2202
- objectEventName,
2203
- stateMachineEventName
2204
- },
2205
- /* c8 ignore next */
2206
- e instanceof Error ? e : new Error(String(e))
2207
- )
2208
- );
2209
- });
2210
- } else {
2211
- object[`on${objectEventName}`] = async (...args) => {
2212
- return this.fireEvent(stateMachineEventName, object, ...args).catch(
2213
- (e) => this.logger.error(
2214
- "Error firing event",
2215
- {
2216
- objectEventName,
2217
- stateMachineEventName
2218
- },
2219
- /* c8 ignore next */
2220
- e instanceof Error ? e : new Error(String(e))
2221
- )
2222
- );
2223
- };
2224
- }
2202
+ for (const objectEventName of Object.keys(eventMap)) {
2203
+ const stateMachineEventName = eventMap[objectEventName];
2204
+ if (stateMachineEventName === void 0) continue;
2205
+ if (typeof object.addEventListener === "function") {
2206
+ object.addEventListener(objectEventName, (...args) => {
2207
+ this.fireEvent(stateMachineEventName, object, ...args).catch(
2208
+ (e) => this.logger.error(
2209
+ "Error firing event",
2210
+ {
2211
+ objectEventName,
2212
+ stateMachineEventName
2213
+ },
2214
+ /* c8 ignore next */
2215
+ e instanceof Error ? e : new Error(String(e))
2216
+ )
2217
+ );
2218
+ });
2219
+ } else if (typeof object.on === "function") {
2220
+ object.on(objectEventName, (...args) => {
2221
+ this.fireEvent(stateMachineEventName, object, ...args).catch(
2222
+ (e) => this.logger.error(
2223
+ "Error firing event",
2224
+ {
2225
+ objectEventName,
2226
+ stateMachineEventName
2227
+ },
2228
+ /* c8 ignore next */
2229
+ e instanceof Error ? e : new Error(String(e))
2230
+ )
2231
+ );
2232
+ });
2233
+ } else {
2234
+ object[`on${objectEventName}`] = async (...args) => {
2235
+ return this.fireEvent(stateMachineEventName, object, ...args).catch(
2236
+ (e) => this.logger.error(
2237
+ "Error firing event",
2238
+ {
2239
+ objectEventName,
2240
+ stateMachineEventName
2241
+ },
2242
+ /* c8 ignore next */
2243
+ e instanceof Error ? e : new Error(String(e))
2244
+ )
2245
+ );
2246
+ };
2225
2247
  }
2226
2248
  }
2227
2249
  }
@@ -3011,19 +3033,22 @@ var StateMachine = class _StateMachine {
3011
3033
  }
3012
3034
  };
3013
3035
  if (this.transitionTimeout && this.transitionTimeout > 0) {
3014
- return Promise.race([
3015
- executeAction(),
3016
- new Promise(
3017
- (_, reject) => setTimeout(
3018
- () => reject(new StateMachineError("Transition timeout", {
3019
- /* c8 ignore next */
3020
- action: typeof actionName === "string" ? actionName : "anonymous",
3021
- phase: "action"
3022
- })),
3023
- this.transitionTimeout
3024
- )
3025
- )
3026
- ]);
3036
+ const timeoutMs = this.transitionTimeout;
3037
+ let timeoutHandle;
3038
+ const timeoutPromise = new Promise((_, reject) => {
3039
+ const fire = () => reject(new StateMachineError("Transition timeout", {
3040
+ /* c8 ignore next */
3041
+ action: typeof actionName === "string" ? actionName : "anonymous",
3042
+ phase: "action"
3043
+ }));
3044
+ timeoutHandle = this.setTimer(fire, timeoutMs);
3045
+ });
3046
+ if (this.schedulerProvided) {
3047
+ return Promise.race([executeAction(), timeoutPromise]).finally(() => {
3048
+ this.clearTimer(timeoutHandle);
3049
+ });
3050
+ }
3051
+ return Promise.race([executeAction(), timeoutPromise]);
3027
3052
  }
3028
3053
  return executeAction();
3029
3054
  }
@@ -3240,7 +3265,7 @@ var StateMachine = class _StateMachine {
3240
3265
  }
3241
3266
  if (toState.invoke && toState.invoke.length > 0) {
3242
3267
  if (!this.stateEntryTimes.has(toStateName)) {
3243
- this.stateEntryTimes.set(toStateName, Date.now());
3268
+ this.stateEntryTimes.set(toStateName, this.clock());
3244
3269
  }
3245
3270
  const timers = [];
3246
3271
  for (const invocation of toState.invoke) {
@@ -3286,6 +3311,9 @@ var StateMachine = class _StateMachine {
3286
3311
  */
3287
3312
  setTimer(callback, delay) {
3288
3313
  const scheduler = this.scheduler;
3314
+ if (this.schedulerProvided) {
3315
+ return scheduler.schedule(delay, callback);
3316
+ }
3289
3317
  if (scheduler.isActive()) {
3290
3318
  return scheduler.schedule(delay, callback);
3291
3319
  }
@@ -3296,6 +3324,10 @@ var StateMachine = class _StateMachine {
3296
3324
  */
3297
3325
  clearTimer(timerId) {
3298
3326
  const scheduler = this.scheduler;
3327
+ if (this.schedulerProvided) {
3328
+ if (timerId !== void 0) scheduler.cancel(timerId);
3329
+ return;
3330
+ }
3299
3331
  if (scheduler.isActive() && typeof timerId === "object" && timerId !== null && !("ref" in timerId)) {
3300
3332
  scheduler.cancel(timerId);
3301
3333
  } else {
@@ -3453,13 +3485,13 @@ var StateMachine = class _StateMachine {
3453
3485
  this.stateEntryTimes.delete(stateName);
3454
3486
  }
3455
3487
  }
3456
- const now = Date.now();
3488
+ const now = this.clock();
3457
3489
  for (const stateName of activeStates) {
3458
3490
  const state = this.states.get(stateName);
3459
3491
  if (!state || !state.invoke || state.invoke.length === 0) continue;
3460
3492
  const entryTime = this.stateEntryTimes.get(stateName);
3461
- const startTime = entryTime || now;
3462
- if (!entryTime) {
3493
+ const startTime = entryTime ?? now;
3494
+ if (entryTime === void 0) {
3463
3495
  this.stateEntryTimes.set(stateName, startTime);
3464
3496
  }
3465
3497
  const elapsed = now - startTime;
@@ -4396,6 +4428,7 @@ function isValidConfig(config) {
4396
4428
  StateMachineError,
4397
4429
  createEnhancedError,
4398
4430
  createMachine,
4431
+ createVirtualScheduler,
4399
4432
  isAdapter,
4400
4433
  isRecoverableError,
4401
4434
  isValidConfig,