@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mantaq/core
2
2
 
3
- State machine library. Small API surface — cook the primitives, not the framework.
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 | Anti-pattern |
14
- | --------------- | --------------------------------------------- | ------------------------------------- |
15
- | Type context | `context: {} as MyContext` | casting in every handler |
16
- | Write context | `context.set({ ...context.get() })` | 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` |
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 — no casting needed per-handler.
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
- opts!.context.set({ ...opts!.context.get(), phoneNumber: event.payload.phoneNumber });
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 — always call it to write, even when the new value is the same reference. Any `set()` triggers `change`. Spread the current value:
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
- opts.context.set({ ...opts.context.get(), phoneNumber: event.payload.phoneNumber });
112
+ const s = opts.context.get();
113
+ s.phoneNumber = event.payload.phoneNumber;
114
+ opts.context.set(s);
111
115
  ```
112
116
 
113
- **Never mutate in place:**
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 — casting in every handler:**
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
- c.set({ ...c.get(), phoneNumber: event.payload.phoneNumber });
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 — skipping the generic parameter:**
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 — sending raw objects instead of using `.create()`:**
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` — `{ 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.
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, ({ state, emit, clock }) => {
196
- // state.payload is typed to the state's declared payload: { url: string }
197
- const url = state.payload.url;
198
- clock.setTimeout(1000, () => {
199
- emit(doneEvent.create());
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 — writing to context in one state leaks into others. Prefer state payload for data that belongs to a specific state entry.
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
- opts!.context.set({ ...opts!.context.get(), url: event.payload.url });
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 — depending on `event` in effects:**
232
+ **Anti-pattern. Depending on `event` in effects:**
222
233
 
223
234
  ```ts
224
- m.effect(loadingState, ({ event, emit }) => {
225
- // ❌ event is InternalEvent — payload is unknown, not the triggering event's payload
226
- emit(doneEvent.create()); // fine — but event.payload.url would be a type error
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()` — they trigger transitions on the current state.
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()` — they are queued and processed after the current transition completes.
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, ({ emit, clock }) => {
251
- clock.setTimeout(1000, () => {
252
- emit(tickEvent.create()); // goes to internal queue
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 — 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.
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 — mixing internal/external incorrectly:**
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, ({ signal, context, emit, clock }) => {
293
- // context is MyContext — typed from the constructor generic
294
- if (context.retryCount >= context.maxRetries) {
295
- emit(failedEvent.create({ error: "Max retries exceeded" }));
296
- return;
297
- }
298
-
299
- clock.setTimeout(2000, () => {
300
- if (signal.aborted) return; // ✅ check signal before emitting
301
- emit(doneEvent.create());
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.set({
308
- ...opts!.context.get(),
309
- retryCount: opts!.context.get().retryCount + 1,
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 — not checking `signal.aborted`:**
337
+ **Anti-pattern. Not checking `signal.aborted`:**
319
338
 
320
339
  ```ts
321
- m.effect(workingState, ({ signal, emit, clock }) => {
322
- // ❌ if state changes before timeout, this still fires
323
- clock.setTimeout(2000, () => {
324
- emit(doneEvent.create());
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, ({ signal, emit, clock }) => {
331
- // ✅ guard with abort check
332
- clock.setTimeout(2000, () => {
333
- if (signal.aborted) return;
334
- emit(doneEvent.create());
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` — `{ 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.
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 — re-serializing context by hand:**
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 — child outputs not declared as parent internal events:**
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 — same inputs, same trace.
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 — the machine and its callers are unaffected.
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 })` — 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.
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, ({ signal, emit, clock }) => {
455
- clock.setTimeout(100, () => {
456
- if (signal.aborted) return;
457
- try {
458
- const result = riskyOperation();
459
- emit(doneEvent.create());
460
- } catch (err) {
461
- // ✅ emit error as internal event — lets transition handle it
462
- emit(failedEvent.create({ error: String(err) }));
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 — re-throwing in effects:**
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 — throwing in transition handlers:**
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
- opts!.context.set({ ...opts!.context.get(), error: "missing data" });
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 — useful for universal error handling, cleanup, or logging.
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
- opts!.context.set({ ...opts!.context.get(), cancelled: true });
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 — using `onAny` for everything:**
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
- opts!.context.set({ ...opts!.context.get(), basicInfo: event }); // wrong — only makes sense in basicInfo state
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
- opts!.context.set({ ...opts!.context.get(), basicInfo: event });
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, ({ emit, clock }) => {
563
- clock.setTimeout(5000, () => emit(timer.create()));
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 — using `new Date()` or `setTimeout` directly:**
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, ({ signal, clock }) => {
607
- const id = clock.setTimeout(5000, () => {
608
- /* ... */
609
- });
610
- signal.addEventListener("abort", () => clock.clearTimeout(id));
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, ({ emit, clock }) => {
640
- clock.setInterval(1000, () => emit(tick.create()));
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 — expecting single `advance` to fire interval once:**
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` — features and happy paths, hand-maintained.
686
- - `*.error.test.ts` — failure paths: warnings, budgets, abort, unregistered, the `__error` state.
687
- - `*.property.test.ts` — property-based invariants against a reference model (fast-check, replayable via `MANTAQ_SEED`).
688
- - `*.mutation.test.ts` — directed tests that kill specific mutants; regenerable and thrown away between stryker runs.
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 — a change that drops the score below 90 fails the build.
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