@mantaq/core 0.2.0 → 0.4.0
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 +158 -113
- package/dist/index.d.mts +237 -71
- package/dist/index.mjs +1 -766
- package/package.json +5 -5
- package/dist/actor-internal-B4iAxUG_.d.mts +0 -152
- package/dist/internal.d.mts +0 -26
- package/dist/internal.mjs +0 -31
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mantaq/core
|
|
2
2
|
|
|
3
|
-
State machine library. Small API surface
|
|
3
|
+
State machine library. Small API surface. Cook the primitives, not the framework.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -10,19 +10,19 @@ npm install @mantaq/core
|
|
|
10
10
|
|
|
11
11
|
## Quick Reference
|
|
12
12
|
|
|
13
|
-
| Pattern | Correct
|
|
14
|
-
| --------------- |
|
|
15
|
-
| Type context | `context: {} as MyContext`
|
|
16
|
-
| Write context | `context.
|
|
17
|
-
| Type events | `event("ID")<Payload>()`
|
|
18
|
-
| Send events | `actor.send(signInEvent.create(data))`
|
|
19
|
-
| Effect data | Use state payload
|
|
20
|
-
| Error handling | Emit internal event
|
|
21
|
-
| Internal events | Declare in `internal: [...]`
|
|
22
|
-
| Signal check | `if (signal.aborted) return`
|
|
23
|
-
| Regions | Child outputs in parent `internal`
|
|
24
|
-
| Snapshot | `snapshot()` carries path + context + payload
|
|
25
|
-
| Any handler | Universal events only (CANCEL)
|
|
13
|
+
| Pattern | Correct | Anti-pattern |
|
|
14
|
+
| --------------- | -------------------------------------------------- | ------------------------------------- |
|
|
15
|
+
| Type context | `context: {} as MyContext` | casting in every handler |
|
|
16
|
+
| Write context | `const s = context.get(); s.x = y; context.set(s)` | mutate without `set()` |
|
|
17
|
+
| Type events | `event("ID")<Payload>()` | `event("ID")()` without generic |
|
|
18
|
+
| Send events | `actor.send(signInEvent.create(data))` | raw `{ type: "ID", payload }` objects |
|
|
19
|
+
| Effect data | Use state payload | Depend on `event` in effect |
|
|
20
|
+
| Error handling | Emit internal event | Throw in effect or transition |
|
|
21
|
+
| Internal events | Declare in `internal: [...]` | Put user events in internal |
|
|
22
|
+
| Signal check | `if (signal.aborted) return` | Skip abort check in async work |
|
|
23
|
+
| Regions | Child outputs in parent `internal` | Forget to declare child outputs |
|
|
24
|
+
| Snapshot | `snapshot()` carries path + context + payload | Re-serialize context by hand |
|
|
25
|
+
| Any handler | Universal events only (CANCEL) | State-specific logic in `onAny` |
|
|
26
26
|
|
|
27
27
|
## Contents
|
|
28
28
|
|
|
@@ -81,7 +81,7 @@ See [anderscan.github.io/mantaq](https://anderscan.github.io/mantaq) for full do
|
|
|
81
81
|
|
|
82
82
|
### Typed Actor Context
|
|
83
83
|
|
|
84
|
-
Cast the context type once at the constructor level. Handlers receive the typed context automatically
|
|
84
|
+
Cast the context type once at the constructor level. Handlers receive the typed context automatically. No casting needed per-handler.
|
|
85
85
|
|
|
86
86
|
```ts
|
|
87
87
|
interface AuthContext {
|
|
@@ -97,33 +97,39 @@ const actor = new Actor({
|
|
|
97
97
|
setup: (m) => {
|
|
98
98
|
m.on(idleState, signInEvent, (event, opts) => {
|
|
99
99
|
// ✅ typed — context.get()/set() flow the constructor type
|
|
100
|
-
|
|
100
|
+
const s = opts!.context.get();
|
|
101
|
+
s.phoneNumber = event.payload.phoneNumber;
|
|
102
|
+
opts!.context.set(s);
|
|
101
103
|
return { state: signingInState };
|
|
102
104
|
});
|
|
103
105
|
},
|
|
104
106
|
});
|
|
105
107
|
```
|
|
106
108
|
|
|
107
|
-
`set()` is the write signal
|
|
109
|
+
Context is user land. Mutate it freely. `set()` is the write signal: any `set()` triggers `change`, even with the same reference. Call it after you mutate:
|
|
108
110
|
|
|
109
111
|
```ts
|
|
110
|
-
|
|
112
|
+
const s = opts.context.get();
|
|
113
|
+
s.phoneNumber = event.payload.phoneNumber;
|
|
114
|
+
opts.context.set(s);
|
|
111
115
|
```
|
|
112
116
|
|
|
113
|
-
**Never mutate
|
|
117
|
+
**Never mutate without `set()`:**
|
|
114
118
|
|
|
115
119
|
```ts
|
|
116
120
|
// ❌ mutation without set() — never signals; subscribers and persistence stay silent
|
|
117
121
|
opts!.context.phoneNumber = event.payload.phoneNumber;
|
|
118
122
|
```
|
|
119
123
|
|
|
120
|
-
**Anti-pattern
|
|
124
|
+
**Anti-pattern. Casting in every handler:**
|
|
121
125
|
|
|
122
126
|
```ts
|
|
123
127
|
setup: (m) => {
|
|
124
128
|
m.on(idleState, signInEvent, (event, opts) => {
|
|
125
129
|
const c = opts!.context as AuthContext; // ❌ unnecessary — already typed
|
|
126
|
-
|
|
130
|
+
const s = c.get();
|
|
131
|
+
s.phoneNumber = event.payload.phoneNumber;
|
|
132
|
+
c.set(s);
|
|
127
133
|
return { state: signingInState };
|
|
128
134
|
});
|
|
129
135
|
},
|
|
@@ -154,7 +160,7 @@ setup: (m) => {
|
|
|
154
160
|
},
|
|
155
161
|
```
|
|
156
162
|
|
|
157
|
-
**Anti-pattern
|
|
163
|
+
**Anti-pattern. Skipping the generic parameter:**
|
|
158
164
|
|
|
159
165
|
```ts
|
|
160
166
|
// ❌ payload is `unknown`, no type safety
|
|
@@ -164,7 +170,7 @@ const badEvent = event("SIGN_IN")();
|
|
|
164
170
|
const goodEvent = event("SIGN_IN")<{ phoneNumber: string }>();
|
|
165
171
|
```
|
|
166
172
|
|
|
167
|
-
**Anti-pattern
|
|
173
|
+
**Anti-pattern. Sending raw objects instead of using `.create()`:**
|
|
168
174
|
|
|
169
175
|
```ts
|
|
170
176
|
// ✅ typed
|
|
@@ -176,7 +182,7 @@ actor.send({ type: "SIGN_IN", payload: { phoneNumber: "+1234567890" } });
|
|
|
176
182
|
|
|
177
183
|
### Effects and Event Typing
|
|
178
184
|
|
|
179
|
-
Effects run when entering a state. The `event` parameter in effects is typed `InternalEvent
|
|
185
|
+
Effects run when entering a state. The `event` parameter in effects is typed `InternalEvent`, `{ type, payload? }` with `payload: unknown`, 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.
|
|
180
186
|
|
|
181
187
|
**Pass data to effects via state payload.** Set the payload in the transition return, read it from `state.payload` in the effect.
|
|
182
188
|
|
|
@@ -192,12 +198,15 @@ const actor = new Actor({
|
|
|
192
198
|
states: [idleState, loadingState, loadedState],
|
|
193
199
|
initial: idleState,
|
|
194
200
|
setup: (m) => {
|
|
195
|
-
m.effect(loadingState,
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
m.effect(loadingState, {
|
|
202
|
+
name: "fetchUrl",
|
|
203
|
+
fn: ({ state, emit, clock }) => {
|
|
204
|
+
// state.payload is typed to the state's declared payload: { url: string }
|
|
205
|
+
const url = state.payload.url;
|
|
206
|
+
clock.setTimeout(1000, () => {
|
|
207
|
+
emit(doneEvent.create());
|
|
208
|
+
});
|
|
209
|
+
},
|
|
201
210
|
});
|
|
202
211
|
m.on(idleState, fetchEvent, (event) => {
|
|
203
212
|
return { state: loadingState.create({ url: event.payload.url }) };
|
|
@@ -207,23 +216,28 @@ const actor = new Actor({
|
|
|
207
216
|
});
|
|
208
217
|
```
|
|
209
218
|
|
|
210
|
-
**Context is also possible but less precise.** Context is shared across all states
|
|
219
|
+
**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.
|
|
211
220
|
|
|
212
221
|
```ts
|
|
213
222
|
// ✅ state payload — scoped to this state entry
|
|
214
223
|
return { state: loadingState.create({ url: event.payload.url }) };
|
|
215
224
|
|
|
216
225
|
// ⚠️ context — works but shared across all states
|
|
217
|
-
|
|
226
|
+
const s = opts!.context.get();
|
|
227
|
+
s.url = event.payload.url;
|
|
228
|
+
opts!.context.set(s);
|
|
218
229
|
return { state: loadingState };
|
|
219
230
|
```
|
|
220
231
|
|
|
221
|
-
**Anti-pattern
|
|
232
|
+
**Anti-pattern. Depending on `event` in effects:**
|
|
222
233
|
|
|
223
234
|
```ts
|
|
224
|
-
m.effect(loadingState,
|
|
225
|
-
|
|
226
|
-
|
|
235
|
+
m.effect(loadingState, {
|
|
236
|
+
name: "fetchUrl",
|
|
237
|
+
fn: ({ event, emit }) => {
|
|
238
|
+
// ❌ event is InternalEvent — payload is unknown, not the triggering event's payload
|
|
239
|
+
emit(doneEvent.create()); // fine — but event.payload.url would be a type error
|
|
240
|
+
},
|
|
227
241
|
});
|
|
228
242
|
```
|
|
229
243
|
|
|
@@ -233,9 +247,9 @@ The `event` parameter exists for convenience (e.g., logging), not for business l
|
|
|
233
247
|
|
|
234
248
|
Mantaq uses two event queues: **external** (user-sent) and **internal** (effect-emitted). Understanding the difference prevents subtle bugs.
|
|
235
249
|
|
|
236
|
-
**External events** are sent via `actor.send()
|
|
250
|
+
**External events** are sent via `actor.send()`. They trigger transitions on the current state.
|
|
237
251
|
|
|
238
|
-
**Internal events** are emitted from effects via `emit()
|
|
252
|
+
**Internal events** are emitted from effects via `emit()`. They are queued and processed after the current transition completes.
|
|
239
253
|
|
|
240
254
|
```ts
|
|
241
255
|
const tickEvent = event("TICK")();
|
|
@@ -247,10 +261,13 @@ const actor = new Actor({
|
|
|
247
261
|
states: [idleState, connectedState],
|
|
248
262
|
initial: idleState,
|
|
249
263
|
setup: (m) => {
|
|
250
|
-
m.effect(idleState,
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
264
|
+
m.effect(idleState, {
|
|
265
|
+
name: "scheduleTick",
|
|
266
|
+
fn: ({ emit, clock }) => {
|
|
267
|
+
clock.setTimeout(1000, () => {
|
|
268
|
+
emit(tickEvent.create()); // goes to internal queue
|
|
269
|
+
});
|
|
270
|
+
},
|
|
254
271
|
});
|
|
255
272
|
m.on(idleState, connectEvent, () => ({ state: connectedState })); // external handler
|
|
256
273
|
m.on(idleState, tickEvent, () => ({})); // internal handler
|
|
@@ -258,9 +275,9 @@ const actor = new Actor({
|
|
|
258
275
|
});
|
|
259
276
|
```
|
|
260
277
|
|
|
261
|
-
**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
|
|
278
|
+
**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.
|
|
262
279
|
|
|
263
|
-
**Anti-pattern
|
|
280
|
+
**Anti-pattern. Mixing internal/external incorrectly:**
|
|
264
281
|
|
|
265
282
|
```ts
|
|
266
283
|
// ❌ declaring a user-sent event as internal
|
|
@@ -272,7 +289,7 @@ inputs: [connectEvent],
|
|
|
272
289
|
|
|
273
290
|
### Effect Pattern
|
|
274
291
|
|
|
275
|
-
Effects run on state entry. They receive typed context, an AbortSignal, and an emit function.
|
|
292
|
+
Effects run on state entry. They receive typed context, an AbortSignal, and an emit function. Each effect carries a required camelCase `name` describing what it does — tests can assert on it (`harness.assertEffectRan(stateName, effectName)`) and executed effects appear in history as `{ stateName, effectName }`.
|
|
276
293
|
|
|
277
294
|
```ts
|
|
278
295
|
type MyContext = { retryCount: number; maxRetries: number };
|
|
@@ -289,25 +306,27 @@ function createActor() {
|
|
|
289
306
|
initial: idleState,
|
|
290
307
|
context: { retryCount: 0, maxRetries: 3 } as MyContext,
|
|
291
308
|
setup: (m) => {
|
|
292
|
-
m.effect(workingState,
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
309
|
+
m.effect(workingState, {
|
|
310
|
+
name: "attemptWork",
|
|
311
|
+
fn: ({ signal, context, emit, clock }) => {
|
|
312
|
+
// context is MyContext — typed from the constructor generic
|
|
313
|
+
if (context.retryCount >= context.maxRetries) {
|
|
314
|
+
emit(failedEvent.create({ error: "Max retries exceeded" }));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
clock.setTimeout(2000, () => {
|
|
319
|
+
if (signal.aborted) return; // ✅ check signal before emitting
|
|
320
|
+
emit(doneEvent.create());
|
|
321
|
+
});
|
|
322
|
+
},
|
|
303
323
|
});
|
|
304
324
|
m.on(idleState, startEvent, () => ({ state: workingState }));
|
|
305
325
|
m.on(workingState, doneEvent, () => ({ state: doneState }));
|
|
306
326
|
m.on(workingState, failedEvent, (_event, opts) => {
|
|
307
|
-
opts!.context.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
});
|
|
327
|
+
const s = opts!.context.get();
|
|
328
|
+
s.retryCount += 1;
|
|
329
|
+
opts!.context.set(s);
|
|
311
330
|
return { state: errorState };
|
|
312
331
|
});
|
|
313
332
|
},
|
|
@@ -315,30 +334,36 @@ function createActor() {
|
|
|
315
334
|
}
|
|
316
335
|
```
|
|
317
336
|
|
|
318
|
-
**Anti-pattern
|
|
337
|
+
**Anti-pattern. Not checking `signal.aborted`:**
|
|
319
338
|
|
|
320
339
|
```ts
|
|
321
|
-
m.effect(workingState,
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
340
|
+
m.effect(workingState, {
|
|
341
|
+
name: "attemptWork",
|
|
342
|
+
fn: ({ signal, emit, clock }) => {
|
|
343
|
+
// ❌ if state changes before timeout, this still fires
|
|
344
|
+
clock.setTimeout(2000, () => {
|
|
345
|
+
emit(doneEvent.create());
|
|
346
|
+
});
|
|
347
|
+
},
|
|
326
348
|
});
|
|
327
349
|
```
|
|
328
350
|
|
|
329
351
|
```ts
|
|
330
|
-
m.effect(workingState,
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
352
|
+
m.effect(workingState, {
|
|
353
|
+
name: "attemptWork",
|
|
354
|
+
fn: ({ signal, emit, clock }) => {
|
|
355
|
+
// ✅ guard with abort check
|
|
356
|
+
clock.setTimeout(2000, () => {
|
|
357
|
+
if (signal.aborted) return;
|
|
358
|
+
emit(doneEvent.create());
|
|
359
|
+
});
|
|
360
|
+
},
|
|
336
361
|
});
|
|
337
362
|
```
|
|
338
363
|
|
|
339
364
|
### Snapshot & Restore
|
|
340
365
|
|
|
341
|
-
`actor.snapshot()` returns a `Snapshot
|
|
366
|
+
`actor.snapshot()` returns a `Snapshot`: `{ path, context, payload?, regions, done?, error? }`. Context is included; `payload` is the payload the current state was entered with (present only when the transition carried one). Use it to save/restore actor state across sessions.
|
|
342
367
|
|
|
343
368
|
```ts
|
|
344
369
|
// Snapshot carries path, context, and regions — serialize it directly
|
|
@@ -352,7 +377,7 @@ if (raw) {
|
|
|
352
377
|
}
|
|
353
378
|
```
|
|
354
379
|
|
|
355
|
-
**Anti-pattern
|
|
380
|
+
**Anti-pattern. Re-serializing context by hand:**
|
|
356
381
|
|
|
357
382
|
```ts
|
|
358
383
|
// ❌ snapshot() already includes context — no manual step needed
|
|
@@ -403,7 +428,7 @@ const manager = new Actor({
|
|
|
403
428
|
matches(manager, "connected.health.healthy"); // ✅
|
|
404
429
|
```
|
|
405
430
|
|
|
406
|
-
**Anti-pattern
|
|
431
|
+
**Anti-pattern. Child outputs not declared as parent internal events:**
|
|
407
432
|
|
|
408
433
|
```ts
|
|
409
434
|
const child = new Actor({
|
|
@@ -426,7 +451,7 @@ const parent = new Actor({
|
|
|
426
451
|
|
|
427
452
|
### Error Handling
|
|
428
453
|
|
|
429
|
-
Never throw from effects or transitions. Emit an error as an internal event and let a transition handle it. If user code throws anyway, the machine does **not** misbehave silently: it dies into a built-in terminal `__error` state, records `snapshot().error` (the thrown value, the state/context at the point of failure, the bad event), drops remaining events, and ignores later `send`s. Errors never escape `send()` and the machine stays deterministic
|
|
454
|
+
Never throw from effects or transitions. Emit an error as an internal event and let a transition handle it. If user code throws anyway, the machine does **not** misbehave silently: it dies into a built-in terminal `__error` state, records `snapshot().error` (the thrown value, the state/context at the point of failure, the bad event), drops remaining events, and ignores later `send`s. Errors never escape `send()` and the machine stays deterministic. Same inputs, same trace.
|
|
430
455
|
|
|
431
456
|
```ts
|
|
432
457
|
const snap = actor.snapshot();
|
|
@@ -439,10 +464,10 @@ if (snap.error) {
|
|
|
439
464
|
|
|
440
465
|
Two deliberate exceptions to "every failure is loud":
|
|
441
466
|
|
|
442
|
-
- **Subscribers only watch.** They read snapshots and never change the machine, so a throwing `on("change")`/`on("done")`/`on("transition")` callback is swallowed
|
|
467
|
+
- **Subscribers only watch.** They read snapshots and never change the machine, so a throwing `on("change")`/`on("done")`/`on("transition")`/`on("error")` callback is swallowed. The machine and its callers are unaffected.
|
|
443
468
|
- **Unhandled external events are ignored.** An event with no handler in the current state is dropped by design (broadcast fan-out, cross-state sends). An **internal** event with no handler, however, is a machine-authoring bug and routes to the error state (`reason: "unhandled"`).
|
|
444
469
|
|
|
445
|
-
A dead machine can be manually resumed with `actor.recover({ state, context })
|
|
470
|
+
A dead machine can be manually resumed with `actor.recover({ state, context })`. An explicit, **inherently dangerous** escape hatch (the caller injects state and context, so determinism no longer holds). Effects are not re-run and timers are not re-armed; processing resumes on the next event. Prefer fixing the root cause and recreating the actor.
|
|
446
471
|
|
|
447
472
|
Catch errors inside effects and emit recovery events:
|
|
448
473
|
|
|
@@ -451,17 +476,20 @@ const doneEvent = event("WORK_DONE")();
|
|
|
451
476
|
const failedEvent = event("WORK_FAILED")<{ error: string }>();
|
|
452
477
|
|
|
453
478
|
setup: (m) => {
|
|
454
|
-
m.effect(workingState,
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
479
|
+
m.effect(workingState, {
|
|
480
|
+
name: "runRiskyOperation",
|
|
481
|
+
fn: ({ signal, emit, clock }) => {
|
|
482
|
+
clock.setTimeout(100, () => {
|
|
483
|
+
if (signal.aborted) return;
|
|
484
|
+
try {
|
|
485
|
+
const result = riskyOperation();
|
|
486
|
+
emit(doneEvent.create());
|
|
487
|
+
} catch (err) {
|
|
488
|
+
// ✅ emit error as internal event — lets transition handle it
|
|
489
|
+
emit(failedEvent.create({ error: String(err) }));
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
},
|
|
465
493
|
});
|
|
466
494
|
m.on(workingState, doneEvent, () => ({ state: doneState }));
|
|
467
495
|
m.on(workingState, failedEvent, (event) => {
|
|
@@ -471,7 +499,7 @@ setup: (m) => {
|
|
|
471
499
|
},
|
|
472
500
|
```
|
|
473
501
|
|
|
474
|
-
**Anti-pattern
|
|
502
|
+
**Anti-pattern. Re-throwing in effects:**
|
|
475
503
|
|
|
476
504
|
```ts
|
|
477
505
|
try {
|
|
@@ -482,7 +510,7 @@ try {
|
|
|
482
510
|
}
|
|
483
511
|
```
|
|
484
512
|
|
|
485
|
-
**Anti-pattern
|
|
513
|
+
**Anti-pattern. Throwing in transition handlers:**
|
|
486
514
|
|
|
487
515
|
```ts
|
|
488
516
|
m.on(idleState, submitEvent, (event) => {
|
|
@@ -496,7 +524,9 @@ Prefer storing the error in context and transitioning to an error state:
|
|
|
496
524
|
```ts
|
|
497
525
|
m.on(idleState, submitEvent, (event, opts) => {
|
|
498
526
|
if (!event.payload.data) {
|
|
499
|
-
|
|
527
|
+
const s = opts!.context.get();
|
|
528
|
+
s.error = "missing data";
|
|
529
|
+
opts!.context.set(s);
|
|
500
530
|
return { state: errorState };
|
|
501
531
|
}
|
|
502
532
|
return { state: doneState };
|
|
@@ -505,13 +535,15 @@ m.on(idleState, submitEvent, (event, opts) => {
|
|
|
505
535
|
|
|
506
536
|
### Any Handler
|
|
507
537
|
|
|
508
|
-
Use `onAny` to intercept events across all states
|
|
538
|
+
Use `onAny` to intercept events across all states. Useful for universal error handling, cleanup, or logging.
|
|
509
539
|
|
|
510
540
|
```ts
|
|
511
541
|
setup: (m) => {
|
|
512
542
|
// CANCEL works from any state
|
|
513
543
|
m.onAny(cancelEvent, (_event, opts) => {
|
|
514
|
-
|
|
544
|
+
const s = opts!.context.get();
|
|
545
|
+
s.cancelled = true;
|
|
546
|
+
opts!.context.set(s);
|
|
515
547
|
return { state: cancelledState };
|
|
516
548
|
});
|
|
517
549
|
// State-specific handlers still run for their state
|
|
@@ -519,19 +551,23 @@ setup: (m) => {
|
|
|
519
551
|
},
|
|
520
552
|
```
|
|
521
553
|
|
|
522
|
-
**Anti-pattern
|
|
554
|
+
**Anti-pattern. Using `onAny` for everything:**
|
|
523
555
|
|
|
524
556
|
```ts
|
|
525
557
|
setup: (m) => {
|
|
526
558
|
// ❌ state-specific logic in onAny defeats the purpose
|
|
527
559
|
m.onAny(submitBasicInfoEvent, (event, opts) => {
|
|
528
|
-
|
|
560
|
+
const s = opts!.context.get();
|
|
561
|
+
s.basicInfo = event; // wrong — only makes sense in basicInfo state
|
|
562
|
+
opts!.context.set(s);
|
|
529
563
|
return { state: shippingAddressState };
|
|
530
564
|
});
|
|
531
565
|
|
|
532
566
|
// ✅ keep state-specific logic in state handlers
|
|
533
567
|
m.on(basicInfoState, submitBasicInfoEvent, (event, opts) => {
|
|
534
|
-
|
|
568
|
+
const s = opts!.context.get();
|
|
569
|
+
s.basicInfo = event;
|
|
570
|
+
opts!.context.set(s);
|
|
535
571
|
return { state: shippingAddressState };
|
|
536
572
|
});
|
|
537
573
|
},
|
|
@@ -559,8 +595,11 @@ const actor = new Actor({
|
|
|
559
595
|
initial: idle,
|
|
560
596
|
clock,
|
|
561
597
|
setup: (m) => {
|
|
562
|
-
m.effect(idle,
|
|
563
|
-
|
|
598
|
+
m.effect(idle, {
|
|
599
|
+
name: "armIdleTimeout",
|
|
600
|
+
fn: ({ emit, clock }) => {
|
|
601
|
+
clock.setTimeout(5000, () => emit(timer.create()));
|
|
602
|
+
},
|
|
564
603
|
});
|
|
565
604
|
m.on(idle, timer, () => ({ state: timedOut }));
|
|
566
605
|
},
|
|
@@ -573,7 +612,7 @@ expect(actor.state.name).toBe("timedOut");
|
|
|
573
612
|
expect(clock.hasPending()).toBe(false);
|
|
574
613
|
```
|
|
575
614
|
|
|
576
|
-
**Anti-pattern
|
|
615
|
+
**Anti-pattern. Using `new Date()` or `setTimeout` directly:**
|
|
577
616
|
|
|
578
617
|
```ts
|
|
579
618
|
// ❌ real timers — slow, flaky, non-deterministic
|
|
@@ -603,11 +642,14 @@ const actor = new Actor({
|
|
|
603
642
|
initial: loading,
|
|
604
643
|
clock,
|
|
605
644
|
setup: (m) => {
|
|
606
|
-
m.effect(loading,
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
645
|
+
m.effect(loading, {
|
|
646
|
+
name: "scheduleWork",
|
|
647
|
+
fn: ({ signal, clock }) => {
|
|
648
|
+
const id = clock.setTimeout(5000, () => {
|
|
649
|
+
/* ... */
|
|
650
|
+
});
|
|
651
|
+
signal.addEventListener("abort", () => clock.clearTimeout(id));
|
|
652
|
+
},
|
|
611
653
|
});
|
|
612
654
|
m.on(loading, cancel, () => ({ state: success }));
|
|
613
655
|
},
|
|
@@ -636,8 +678,11 @@ const actor = new Actor({
|
|
|
636
678
|
initial: active,
|
|
637
679
|
clock,
|
|
638
680
|
setup: (m) => {
|
|
639
|
-
m.effect(active,
|
|
640
|
-
|
|
681
|
+
m.effect(active, {
|
|
682
|
+
name: "startTicker",
|
|
683
|
+
fn: ({ emit, clock }) => {
|
|
684
|
+
clock.setInterval(1000, () => emit(tick.create()));
|
|
685
|
+
},
|
|
641
686
|
});
|
|
642
687
|
m.on(active, tick, () => {
|
|
643
688
|
count++;
|
|
@@ -650,7 +695,7 @@ clock.advance(3500);
|
|
|
650
695
|
expect(count).toBe(3); // fired at 1000, 2000, 3000
|
|
651
696
|
```
|
|
652
697
|
|
|
653
|
-
**Anti-pattern
|
|
698
|
+
**Anti-pattern. Expecting single `advance` to fire interval once:**
|
|
654
699
|
|
|
655
700
|
```ts
|
|
656
701
|
// ❌ advance(5000) fires interval at 1000, 2000, 3000, 4000, 5000
|
|
@@ -682,12 +727,12 @@ vp run mutation:core # mutation score must stay ≥ 90
|
|
|
682
727
|
|
|
683
728
|
**Test contract.** Behavior is pinned by four kinds of tests, split by filename:
|
|
684
729
|
|
|
685
|
-
- `*.test.ts
|
|
686
|
-
- `*.error.test.ts
|
|
687
|
-
- `*.property.test.ts
|
|
688
|
-
- `*.mutation.test.ts
|
|
730
|
+
- `*.test.ts`. Features and happy paths, hand-maintained.
|
|
731
|
+
- `*.error.test.ts`. Failure paths: warnings, budgets, abort, unregistered, the `__error` state.
|
|
732
|
+
- `*.property.test.ts`. Property-based invariants against a reference model (fast-check, replayable via `MANTAQ_SEED`).
|
|
733
|
+
- `*.mutation.test.ts`. Directed tests that kill specific mutants. Regenerable and thrown away between stryker runs.
|
|
689
734
|
|
|
690
|
-
Coverage is analyzed per test (stryker `perTest` coverage analysis) so every mutant is attributed to the test that pins it. The mutation break threshold is 90
|
|
735
|
+
Coverage is analyzed per test (stryker `perTest` coverage analysis) so every mutant is attributed to the test that pins it. The mutation break threshold is 90. A change that drops the score below 90 fails the build.
|
|
691
736
|
|
|
692
737
|
## License
|
|
693
738
|
|