@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 +139 -4
- package/dist/index.cjs +539 -90
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +538 -90
- 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 +33 -0
- package/types/scheduler.d.ts +31 -1
- package/types/state_machine.d.ts +103 -0
- package/types/types.d.ts +24 -2
package/README.md
CHANGED
|
@@ -3,7 +3,17 @@
|
|
|
3
3
|
[](https://github.com/vedmalex/statemachine/actions/workflows/ci.yml)
|
|
4
4
|
[](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
|
|
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
|
|