@mantaq/core 0.0.1
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/LICENSE +21 -0
- package/README.md +657 -0
- package/dist/actor-internal-CfZDmHJ3.d.mts +89 -0
- package/dist/index.d.mts +151 -0
- package/dist/index.mjs +557 -0
- package/dist/internal.d.mts +26 -0
- package/dist/internal.mjs +31 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anders Can
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
# @mantaq/core
|
|
2
|
+
|
|
3
|
+
State machine library. Small API surface — cook the primitives, not the framework.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @mantaq/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Reference
|
|
12
|
+
|
|
13
|
+
| Pattern | Correct | Anti-pattern |
|
|
14
|
+
| --------------- | ---------------------------------- | -------------------------------------- |
|
|
15
|
+
| Type context | `context: {} as MyContext` | casting in every handler |
|
|
16
|
+
| Type events | `event("ID")<Payload>()` | `event("ID")()` without generic |
|
|
17
|
+
| Send events | `actor.send(event.create(data))` | raw `{ id: "ID", ... }` objects |
|
|
18
|
+
| Effect data | Use state payload | Depend on `event` in effect |
|
|
19
|
+
| Error handling | Emit internal event | Throw in effect or transition |
|
|
20
|
+
| Internal events | Declare in `internal: [...]` | Put user events in internal |
|
|
21
|
+
| Signal check | `if (signal.aborted) return` | Skip abort check in async work |
|
|
22
|
+
| Regions | Child outputs in parent `internal` | Forget to declare child outputs |
|
|
23
|
+
| Snapshot | Save path + context manually | Expect `snapshot()` to include context |
|
|
24
|
+
| Any handler | Universal events only (CANCEL) | State-specific logic in `onAny` |
|
|
25
|
+
|
|
26
|
+
## Contents
|
|
27
|
+
|
|
28
|
+
- [Install](#install)
|
|
29
|
+
- [Quick Reference](#quick-reference)
|
|
30
|
+
- [Quick Start](#quick-start)
|
|
31
|
+
- [Docs](#docs)
|
|
32
|
+
- [Patterns](#patterns)
|
|
33
|
+
- [Typed Actor Context](#typed-actor-context)
|
|
34
|
+
- [Proper Event Typing](#proper-event-typing)
|
|
35
|
+
- [Effects and Event Typing](#effects-and-event-typing)
|
|
36
|
+
- [Two-Queue Architecture](#two-queue-architecture)
|
|
37
|
+
- [Effect Pattern](#effect-pattern)
|
|
38
|
+
- [Snapshot & Restore](#snapshot--restore)
|
|
39
|
+
- [Dynamic Children (Regions)](#dynamic-children-regions)
|
|
40
|
+
- [Error Handling](#error-handling)
|
|
41
|
+
- [Any Handler](#any-handler)
|
|
42
|
+
- [Testing with VirtualClock](#testing-with-virtualclock)
|
|
43
|
+
- [Development](#development)
|
|
44
|
+
- [License](#license)
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
Transitions and effects are registered in a `setup` callback. The builder is
|
|
49
|
+
type-safe: targets validate against your declared `states`, `inputs`, and
|
|
50
|
+
`internal` events at compile time.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { Actor, state, event } from "@mantaq/core";
|
|
54
|
+
|
|
55
|
+
const idle = state("idle")();
|
|
56
|
+
const active = state("active")();
|
|
57
|
+
const toggle = event("TOGGLE")();
|
|
58
|
+
|
|
59
|
+
const actor = new Actor({
|
|
60
|
+
inputs: [toggle],
|
|
61
|
+
states: [idle, active],
|
|
62
|
+
initial: idle,
|
|
63
|
+
setup: (m) => {
|
|
64
|
+
m.on(idle, toggle, () => ({ state: active }));
|
|
65
|
+
m.on(active, toggle, () => ({ state: idle }));
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
actor.on("change", (snap) => console.log(snap.path)); // fires immediately: ["idle"]
|
|
70
|
+
actor.send(toggle.create());
|
|
71
|
+
// "change" fires again: ["active"]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Docs
|
|
75
|
+
|
|
76
|
+
See [anderscan.github.io/mantaq](https://anderscan.github.io/mantaq) for full documentation.
|
|
77
|
+
|
|
78
|
+
## Patterns
|
|
79
|
+
|
|
80
|
+
### Typed Actor Context
|
|
81
|
+
|
|
82
|
+
Cast the context type once at the constructor level. Handlers receive the typed context automatically — no casting needed per-handler.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
interface AuthContext {
|
|
86
|
+
user?: User;
|
|
87
|
+
phoneNumber?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const actor = new Actor({
|
|
91
|
+
inputs: [signInEvent],
|
|
92
|
+
states: [idleState, signingInState],
|
|
93
|
+
initial: idleState,
|
|
94
|
+
context: {} as AuthContext,
|
|
95
|
+
setup: (m) => {
|
|
96
|
+
m.on(idleState, signInEvent, (event, opts) => {
|
|
97
|
+
opts!.context.phoneNumber = event.phoneNumber; // ✅ typed
|
|
98
|
+
return { state: signingInState };
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Anti-pattern — casting in every handler:**
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
setup: (m) => {
|
|
108
|
+
m.on(idleState, signInEvent, (event, opts) => {
|
|
109
|
+
const c = opts!.context as AuthContext; // ❌ unnecessary — already typed
|
|
110
|
+
c.phoneNumber = event.phoneNumber;
|
|
111
|
+
return { state: signingInState };
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Proper Event Typing
|
|
117
|
+
|
|
118
|
+
Define events with `event("ID")<Payload>()`. The payload type flows through to handlers automatically.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const signInEvent = event("SIGN_IN")<{ phoneNumber: string }>();
|
|
122
|
+
const signOutEvent = event("SIGN_OUT")(); // no payload
|
|
123
|
+
const dataEvent = event("DATA_LOADED")<{ items: string[]; count: number }>();
|
|
124
|
+
|
|
125
|
+
// Creating events
|
|
126
|
+
actor.send(signInEvent.create({ phoneNumber: "+1234567890" }));
|
|
127
|
+
actor.send(signOutEvent.create()); // no payload
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Handlers receive the correct payload type:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
setup: (m) => {
|
|
134
|
+
m.on(idleState, signInEvent, (event) => {
|
|
135
|
+
event.phoneNumber; // ✅ string
|
|
136
|
+
return { state: signingInState };
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Anti-pattern — skipping the generic parameter:**
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// ❌ payload is `unknown`, no type safety
|
|
145
|
+
const badEvent = event("SIGN_IN")();
|
|
146
|
+
|
|
147
|
+
// ✅ always provide the payload type
|
|
148
|
+
const goodEvent = event("SIGN_IN")<{ phoneNumber: string }>();
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Anti-pattern — sending raw objects instead of using `.create()`:**
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// ✅ typed
|
|
155
|
+
actor.send(signInEvent.create({ phoneNumber: "+1234567890" }));
|
|
156
|
+
|
|
157
|
+
// ❌ bypasses type checking
|
|
158
|
+
actor.send({ id: "SIGN_IN", phoneNumber: "+1234567890" });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Effects and Event Typing
|
|
162
|
+
|
|
163
|
+
Effects run when entering a state. The `event` parameter in effects is typed as the union of all possible events (`Inputs[number] | Internal[number]`) — not the specific event that triggered the transition. This is by design: effects live on states, not transitions. Multiple transitions can lead to the same state, so the effect cannot know which event caused entry.
|
|
164
|
+
|
|
165
|
+
**Pass data to effects via state payload.** Set the payload in the transition return, read it from `state.payload` in the effect.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const loadingState = state("loading")<{ url: string }>();
|
|
169
|
+
const loadedState = state("loaded")();
|
|
170
|
+
const fetchEvent = event("FETCH")<{ url: string }>();
|
|
171
|
+
const doneEvent = event("LOADED")();
|
|
172
|
+
|
|
173
|
+
const actor = new Actor({
|
|
174
|
+
inputs: [fetchEvent],
|
|
175
|
+
internal: [doneEvent],
|
|
176
|
+
states: [idleState, loadingState, loadedState],
|
|
177
|
+
initial: idleState,
|
|
178
|
+
setup: (m) => {
|
|
179
|
+
m.effect(loadingState, ({ state, emit, clock }) => {
|
|
180
|
+
// state.payload is unknown — cast to the known shape
|
|
181
|
+
const { url } = state.payload as { url: string };
|
|
182
|
+
clock.setTimeout(1000, () => {
|
|
183
|
+
emit(doneEvent.create());
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
m.on(idleState, fetchEvent, (event) => {
|
|
187
|
+
return { state: loadingState.create({ url: event.url }) };
|
|
188
|
+
});
|
|
189
|
+
m.on(loadingState, doneEvent, () => ({ state: loadedState }));
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Context is also possible but less precise.** Context is shared across all states — writing to context in one state leaks into others. Prefer state payload for data that belongs to a specific state entry.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// ✅ state payload — scoped to this state entry
|
|
198
|
+
return { state: loadingState.create({ url: event.url }) };
|
|
199
|
+
|
|
200
|
+
// ⚠️ context — works but shared across all states
|
|
201
|
+
opts!.context.url = event.url;
|
|
202
|
+
return { state: loadingState };
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Anti-pattern — depending on `event` in effects:**
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
m.effect(loadingState, ({ event, emit }) => {
|
|
209
|
+
// ❌ event is the union type, not the specific triggering event
|
|
210
|
+
// event.url doesn't exist on the union
|
|
211
|
+
emit(doneEvent.create()); // fine — but event.url would be a type error
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The `event` parameter exists for convenience (e.g., logging), not for business logic. Use state payload or context to pass data to effects.
|
|
216
|
+
|
|
217
|
+
### Two-Queue Architecture
|
|
218
|
+
|
|
219
|
+
Mantaq uses two event queues: **external** (user-sent) and **internal** (effect-emitted). Understanding the difference prevents subtle bugs.
|
|
220
|
+
|
|
221
|
+
**External events** are sent via `actor.send()` — they trigger transitions on the current state.
|
|
222
|
+
|
|
223
|
+
**Internal events** are emitted from effects via `emit()` — they are queued and processed after the current transition completes.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const tickEvent = event("TICK")();
|
|
227
|
+
const connectEvent = event("CONNECT")();
|
|
228
|
+
|
|
229
|
+
const actor = new Actor({
|
|
230
|
+
inputs: [connectEvent], // external — user triggers
|
|
231
|
+
internal: [tickEvent], // internal — effects emit
|
|
232
|
+
states: [idleState, connectedState],
|
|
233
|
+
initial: idleState,
|
|
234
|
+
setup: (m) => {
|
|
235
|
+
m.effect(idleState, ({ emit, clock }) => {
|
|
236
|
+
clock.setTimeout(1000, () => {
|
|
237
|
+
emit(tickEvent.create()); // goes to internal queue
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
m.on(idleState, connectEvent, () => ({ state: connectedState })); // external handler
|
|
241
|
+
m.on(idleState, tickEvent, () => ({})); // internal handler
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Ordering:** External events process one at a time. Internal events emitted during a transition process depth-first after that transition. Internal events from effects process after the effect completes — this is a common source of bugs. If an effect emits an internal event and the actor is in a different state by the time that event processes, the handler may not exist or may behave unexpectedly.
|
|
247
|
+
|
|
248
|
+
**Anti-pattern — mixing internal/external incorrectly:**
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
// ❌ declaring a user-sent event as internal
|
|
252
|
+
internal: [connectEvent], // user can't send internal events via actor.send()
|
|
253
|
+
|
|
254
|
+
// ✅ keep external events in inputs
|
|
255
|
+
inputs: [connectEvent],
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Effect Pattern
|
|
259
|
+
|
|
260
|
+
Effects run on state entry. They receive typed context, an AbortSignal, and an emit function.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
type MyContext = { retryCount: number; maxRetries: number };
|
|
264
|
+
|
|
265
|
+
const startEvent = event("START")();
|
|
266
|
+
const doneEvent = event("WORK_DONE")();
|
|
267
|
+
const failedEvent = event("WORK_FAILED")<{ error: string }>();
|
|
268
|
+
|
|
269
|
+
function createActor() {
|
|
270
|
+
return new Actor({
|
|
271
|
+
inputs: [startEvent],
|
|
272
|
+
internal: [doneEvent, failedEvent],
|
|
273
|
+
states: [idleState, workingState, doneState, errorState],
|
|
274
|
+
initial: idleState,
|
|
275
|
+
context: { retryCount: 0, maxRetries: 3 } as MyContext,
|
|
276
|
+
setup: (m) => {
|
|
277
|
+
m.effect(workingState, ({ signal, context, emit, clock }) => {
|
|
278
|
+
// context is MyContext — typed from the constructor generic
|
|
279
|
+
if (context.retryCount >= context.maxRetries) {
|
|
280
|
+
emit(failedEvent.create({ error: "Max retries exceeded" }));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
clock.setTimeout(2000, () => {
|
|
285
|
+
if (signal.aborted) return; // ✅ check signal before emitting
|
|
286
|
+
emit(doneEvent.create());
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
m.on(idleState, startEvent, () => ({ state: workingState }));
|
|
290
|
+
m.on(workingState, doneEvent, () => ({ state: doneState }));
|
|
291
|
+
m.on(workingState, failedEvent, (_event, opts) => {
|
|
292
|
+
opts!.context.retryCount++;
|
|
293
|
+
return { state: errorState };
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Anti-pattern — not checking `signal.aborted`:**
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
m.effect(workingState, ({ signal, emit, clock }) => {
|
|
304
|
+
// ❌ if state changes before timeout, this still fires
|
|
305
|
+
clock.setTimeout(2000, () => {
|
|
306
|
+
emit(doneEvent.create());
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
m.effect(workingState, ({ signal, emit, clock }) => {
|
|
313
|
+
// ✅ guard with abort check
|
|
314
|
+
clock.setTimeout(2000, () => {
|
|
315
|
+
if (signal.aborted) return;
|
|
316
|
+
emit(doneEvent.create());
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Snapshot & Restore
|
|
322
|
+
|
|
323
|
+
`actor.snapshot()` returns a serializable `Snapshot` — `{ path, regions, done? }`. It does **not** include context. Use it to save/restore actor state across sessions.
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
interface SerializedActor {
|
|
327
|
+
path: string[];
|
|
328
|
+
regions: Record<string, unknown>;
|
|
329
|
+
context: Record<string, unknown>;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function saveActor(actor: { snapshot(): Snapshot; context: unknown }): SerializedActor {
|
|
333
|
+
return {
|
|
334
|
+
...actor.snapshot(),
|
|
335
|
+
context: { ...(actor.context as Record<string, unknown>) },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Usage
|
|
340
|
+
const saved = saveActor(actor);
|
|
341
|
+
localStorage.setItem("actor", JSON.stringify(saved));
|
|
342
|
+
|
|
343
|
+
const raw = localStorage.getItem("actor");
|
|
344
|
+
if (raw) {
|
|
345
|
+
const data = JSON.parse(raw) as SerializedActor;
|
|
346
|
+
// restore from data.path and data.context
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**Anti-pattern — trying to snapshot context via `snapshot()`:**
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
// ❌ snapshot() only returns path + regions, not context
|
|
354
|
+
const snap = actor.snapshot();
|
|
355
|
+
snap.context; // doesn't exist
|
|
356
|
+
|
|
357
|
+
// ✅ serialize context yourself
|
|
358
|
+
const stateData = {
|
|
359
|
+
path: actor.snapshot().path,
|
|
360
|
+
context: { ...actor.context },
|
|
361
|
+
};
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Dynamic Children (Regions)
|
|
365
|
+
|
|
366
|
+
Regions let you compose actors. The parent actor manages child lifecycle; child outputs are routed as parent internal events.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
const healthCheckResult = event("HEALTH_CHECK_RESULT")<{ healthy: boolean }>();
|
|
370
|
+
|
|
371
|
+
const healthMonitor = new Actor({
|
|
372
|
+
inputs: [healthCheckResult],
|
|
373
|
+
states: [unknownState, healthyState, degradedState],
|
|
374
|
+
initial: unknownState,
|
|
375
|
+
setup: (m) => {
|
|
376
|
+
m.on(unknownState, healthCheckResult, (event) => ({
|
|
377
|
+
state: event.healthy ? healthyState : degradedState,
|
|
378
|
+
}));
|
|
379
|
+
m.on(healthyState, healthCheckResult, (event) => ({
|
|
380
|
+
state: event.healthy ? healthyState : degradedState,
|
|
381
|
+
}));
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const manager = new Actor({
|
|
386
|
+
inputs: [connectEvent],
|
|
387
|
+
internal: [healthCheckResult], // child output must be declared here
|
|
388
|
+
states: [disconnectedState, connectedState],
|
|
389
|
+
initial: disconnectedState,
|
|
390
|
+
regions: { health: healthMonitor }, // child named "health"
|
|
391
|
+
setup: (m) => {
|
|
392
|
+
m.on(connectedState, healthCheckResult, (event, opts) => {
|
|
393
|
+
// access child via actor.regions
|
|
394
|
+
manager.regions.health.send(healthCheckResult.create({ healthy: event.healthy }));
|
|
395
|
+
return {};
|
|
396
|
+
});
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Query child state with dot notation (matches is from @mantaq/sugar)
|
|
401
|
+
matches(manager, "connected.health.healthy"); // ✅
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
**Anti-pattern — child outputs not declared as parent internal events:**
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
const child = new Actor({
|
|
408
|
+
outputs: [someOutputEvent], // child emits this
|
|
409
|
+
// ...
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ❌ parent doesn't declare it as internal — event gets dropped
|
|
413
|
+
const parent = new Actor({
|
|
414
|
+
internal: [], // missing!
|
|
415
|
+
regions: { child },
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ✅ child output must be in parent's internal array
|
|
419
|
+
const parent = new Actor({
|
|
420
|
+
internal: [someOutputEvent],
|
|
421
|
+
regions: { child },
|
|
422
|
+
});
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### Error Handling
|
|
426
|
+
|
|
427
|
+
Never throw from effects or transitions. Emit an error as an internal event and let a transition handle it. Errors thrown inside a handler or effect have no subscriber — the machine just stops applying it.
|
|
428
|
+
|
|
429
|
+
Catch errors inside effects and emit recovery events:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
const doneEvent = event("WORK_DONE")();
|
|
433
|
+
const failedEvent = event("WORK_FAILED")<{ error: string }>();
|
|
434
|
+
|
|
435
|
+
setup: (m) => {
|
|
436
|
+
m.effect(workingState, ({ signal, emit, clock }) => {
|
|
437
|
+
clock.setTimeout(100, () => {
|
|
438
|
+
if (signal.aborted) return;
|
|
439
|
+
try {
|
|
440
|
+
const result = riskyOperation();
|
|
441
|
+
emit(doneEvent.create());
|
|
442
|
+
} catch (err) {
|
|
443
|
+
// ✅ emit error as internal event — lets transition handle it
|
|
444
|
+
emit(failedEvent.create({ error: String(err) }));
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
m.on(workingState, doneEvent, () => ({ state: doneState }));
|
|
449
|
+
m.on(workingState, failedEvent, (event) => {
|
|
450
|
+
// handle error in transition, not in effect
|
|
451
|
+
return { state: errorState.create({ error: event.error }) };
|
|
452
|
+
});
|
|
453
|
+
},
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
**Anti-pattern — re-throwing in effects:**
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
try {
|
|
460
|
+
const result = riskyOperation();
|
|
461
|
+
emit(doneEvent.create());
|
|
462
|
+
} catch (err) {
|
|
463
|
+
throw err; // ❌ throw in effect — no handler, machine misbehaves
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**Anti-pattern — throwing in transition handlers:**
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
m.on(idleState, submitEvent, (event) => {
|
|
471
|
+
if (!event.data) throw new Error("missing data"); // ❌ breaks the machine
|
|
472
|
+
return { state: doneState };
|
|
473
|
+
});
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Prefer storing the error in context and transitioning to an error state:
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
m.on(idleState, submitEvent, (event, opts) => {
|
|
480
|
+
if (!event.data) {
|
|
481
|
+
opts!.context.error = "missing data";
|
|
482
|
+
return { state: errorState };
|
|
483
|
+
}
|
|
484
|
+
return { state: doneState };
|
|
485
|
+
});
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### Any Handler
|
|
489
|
+
|
|
490
|
+
Use `onAny` to intercept events across all states — useful for universal error handling, cleanup, or logging.
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
setup: (m) => {
|
|
494
|
+
// CANCEL works from any state
|
|
495
|
+
m.onAny(cancelEvent, (_event, opts) => {
|
|
496
|
+
opts!.context.cancelled = true;
|
|
497
|
+
return { state: cancelledState };
|
|
498
|
+
});
|
|
499
|
+
// State-specific handlers still run for their state
|
|
500
|
+
m.on(idleState, startEvent, () => ({ state: runningState }));
|
|
501
|
+
},
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
**Anti-pattern — using `onAny` for everything:**
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
setup: (m) => {
|
|
508
|
+
// ❌ state-specific logic in onAny defeats the purpose
|
|
509
|
+
m.onAny(submitBasicInfoEvent, (event, opts) => {
|
|
510
|
+
opts!.context.basicInfo = event; // wrong — only makes sense in basicInfo state
|
|
511
|
+
return { state: shippingAddressState };
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ✅ keep state-specific logic in state handlers
|
|
515
|
+
m.on(basicInfoState, submitBasicInfoEvent, (event, opts) => {
|
|
516
|
+
opts!.context.basicInfo = event;
|
|
517
|
+
return { state: shippingAddressState };
|
|
518
|
+
});
|
|
519
|
+
},
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### Testing with VirtualClock
|
|
523
|
+
|
|
524
|
+
`VirtualClock` replaces real timers for deterministic tests. Advance time manually, verify state instantly.
|
|
525
|
+
|
|
526
|
+
#### Basic Usage
|
|
527
|
+
|
|
528
|
+
```ts
|
|
529
|
+
import { Actor, VirtualClock, state, event } from "@mantaq/core";
|
|
530
|
+
|
|
531
|
+
const clock = new VirtualClock();
|
|
532
|
+
const timer = event("timer")();
|
|
533
|
+
|
|
534
|
+
const idle = state("idle")();
|
|
535
|
+
const timedOut = state("timedOut")();
|
|
536
|
+
|
|
537
|
+
const actor = new Actor({
|
|
538
|
+
inputs: [],
|
|
539
|
+
internal: [timer],
|
|
540
|
+
states: [idle, timedOut],
|
|
541
|
+
initial: idle,
|
|
542
|
+
clock,
|
|
543
|
+
setup: (m) => {
|
|
544
|
+
m.effect(idle, ({ emit, clock }) => {
|
|
545
|
+
clock.setTimeout(5000, () => emit(timer.create()));
|
|
546
|
+
});
|
|
547
|
+
m.on(idle, timer, () => ({ state: timedOut }));
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
expect(actor.state.name).toBe("idle");
|
|
552
|
+
|
|
553
|
+
clock.advance(5000);
|
|
554
|
+
expect(actor.state.name).toBe("timedOut");
|
|
555
|
+
expect(clock.hasPending()).toBe(false);
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
**Anti-pattern — using `new Date()` or `setTimeout` directly:**
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
// ❌ real timers — slow, flaky, non-deterministic
|
|
562
|
+
setTimeout(() => {
|
|
563
|
+
expect(actor.state.name).toBe("timedOut");
|
|
564
|
+
}, 5000);
|
|
565
|
+
|
|
566
|
+
// ✅ VirtualClock — instant, deterministic
|
|
567
|
+
clock.advance(5000);
|
|
568
|
+
expect(actor.state.name).toBe("timedOut");
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
#### Abort Signal Cleanup
|
|
572
|
+
|
|
573
|
+
The effect's abort signal fires on state exit. Clear timers in an abort listener so they don't fire later.
|
|
574
|
+
|
|
575
|
+
```ts
|
|
576
|
+
const cancel = event("cancel")();
|
|
577
|
+
const done = event("done")();
|
|
578
|
+
const loading = state("loading")();
|
|
579
|
+
const success = state("success")();
|
|
580
|
+
|
|
581
|
+
const actor = new Actor({
|
|
582
|
+
inputs: [cancel],
|
|
583
|
+
internal: [done],
|
|
584
|
+
states: [loading, success],
|
|
585
|
+
initial: loading,
|
|
586
|
+
clock,
|
|
587
|
+
setup: (m) => {
|
|
588
|
+
m.effect(loading, ({ signal, clock }) => {
|
|
589
|
+
const id = clock.setTimeout(5000, () => {
|
|
590
|
+
/* ... */
|
|
591
|
+
});
|
|
592
|
+
signal.addEventListener("abort", () => clock.clearTimeout(id));
|
|
593
|
+
});
|
|
594
|
+
m.on(loading, cancel, () => ({ state: success }));
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// transition to success before the timer fires — abort signal clears it
|
|
599
|
+
actor.send(cancel.create());
|
|
600
|
+
clock.advance(10000);
|
|
601
|
+
expect(clock.hasPending()).toBe(false);
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
#### Interval Testing
|
|
605
|
+
|
|
606
|
+
`advance()` fires intervals at each elapsed tick. Multiple calls accumulate.
|
|
607
|
+
|
|
608
|
+
```ts
|
|
609
|
+
const tick = event("tick")();
|
|
610
|
+
let count = 0;
|
|
611
|
+
|
|
612
|
+
const active = state("active")();
|
|
613
|
+
|
|
614
|
+
const actor = new Actor({
|
|
615
|
+
inputs: [],
|
|
616
|
+
internal: [tick],
|
|
617
|
+
states: [active],
|
|
618
|
+
initial: active,
|
|
619
|
+
clock,
|
|
620
|
+
setup: (m) => {
|
|
621
|
+
m.effect(active, ({ emit, clock }) => {
|
|
622
|
+
clock.setInterval(1000, () => emit(tick.create()));
|
|
623
|
+
});
|
|
624
|
+
m.on(active, tick, () => {
|
|
625
|
+
count++;
|
|
626
|
+
return {};
|
|
627
|
+
});
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
clock.advance(3500);
|
|
632
|
+
expect(count).toBe(3); // fired at 1000, 2000, 3000
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
**Anti-pattern — expecting single `advance` to fire interval once:**
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
// ❌ advance(5000) fires interval at 1000, 2000, 3000, 4000, 5000
|
|
639
|
+
clock.advance(5000);
|
|
640
|
+
expect(count).toBe(1); // fails — count is 5
|
|
641
|
+
|
|
642
|
+
// ✅ match exact ticks or use setTimeout for single fire
|
|
643
|
+
clock.advance(1000);
|
|
644
|
+
expect(count).toBe(1);
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
## Development
|
|
648
|
+
|
|
649
|
+
```bash
|
|
650
|
+
vp install
|
|
651
|
+
vp test
|
|
652
|
+
vp pack
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
## License
|
|
656
|
+
|
|
657
|
+
MIT
|