@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/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
 
@@ -28,21 +38,146 @@ const sm = createMachine({
28
38
  })
29
39
  ```
30
40
 
41
+ ## Hierarchical regions, parallel states & join
42
+
43
+ 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.
44
+
45
+ ```ts
46
+ const sm = createMachine({
47
+ name: 'proc',
48
+ initialState: 'proc',
49
+ states: {
50
+ proc: {
51
+ initial: 'a.run|b.run', // both regions active in parallel
52
+ onEnter: () => {/* parent runs BEFORE region children (ancestor-first) */},
53
+ regions: {
54
+ a: { run: {}, done: { final: true } },
55
+ b: { run: {}, done: { final: true } },
56
+ },
57
+ },
58
+ complete: {},
59
+ },
60
+ events: {
61
+ finishA: { transitions: [{ from: 'proc.a.run', to: 'proc.a.done' }] },
62
+ finishB: { transitions: [{ from: 'proc.b.run', to: 'proc.b.done' }] },
63
+ // Join: raised automatically once EVERY region reached a `final` state.
64
+ 'done.state.proc': { transitions: [{ from: 'proc', to: 'complete' }] },
65
+ },
66
+ })
67
+ ```
68
+
69
+ - **Expansion** — entering `proc` (as initial state *or* via a transition) expands to the parallel configuration `proc.a.run|proc.b.run`.
70
+ - **Ordering** — entry is ancestor-first (`proc` then region children); exit is descendant-first (region children then `proc`).
71
+ - **Parallel-exit** — a plain transition `from: 'proc'` on a user event preempts and exits all active regions immediately (LCCA).
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.)
73
+
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.
88
+
31
89
  ## Documentation
32
90
 
33
91
  Full API documentation: [https://vedmalex.github.io/statemachine/](https://vedmalex.github.io/statemachine/)
34
92
 
35
93
  ## Extension Points
36
94
 
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.
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.
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`. |
38
173
 
39
174
  ## Stability policy
40
175
 
41
- 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.
42
177
 
43
178
  ## Status & module format
44
179
 
45
- `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.
46
181
 
47
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.
48
183