@mantaq/core 0.0.3 → 0.2.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
@@ -10,18 +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
- | 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` |
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` |
25
26
 
26
27
  ## Contents
27
28
 
@@ -41,6 +42,7 @@ npm install @mantaq/core
41
42
  - [Any Handler](#any-handler)
42
43
  - [Testing with VirtualClock](#testing-with-virtualclock)
43
44
  - [Development](#development)
45
+ - [Testing](#testing)
44
46
  - [License](#license)
45
47
 
46
48
  ## Quick Start
@@ -94,20 +96,34 @@ const actor = new Actor({
94
96
  context: {} as AuthContext,
95
97
  setup: (m) => {
96
98
  m.on(idleState, signInEvent, (event, opts) => {
97
- opts!.context.phoneNumber = event.phoneNumber; // ✅ typed
99
+ // ✅ typed — context.get()/set() flow the constructor type
100
+ opts!.context.set({ ...opts!.context.get(), phoneNumber: event.payload.phoneNumber });
98
101
  return { state: signingInState };
99
102
  });
100
103
  },
101
104
  });
102
105
  ```
103
106
 
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:
108
+
109
+ ```ts
110
+ opts.context.set({ ...opts.context.get(), phoneNumber: event.payload.phoneNumber });
111
+ ```
112
+
113
+ **Never mutate in place:**
114
+
115
+ ```ts
116
+ // ❌ mutation without set() — never signals; subscribers and persistence stay silent
117
+ opts!.context.phoneNumber = event.payload.phoneNumber;
118
+ ```
119
+
104
120
  **Anti-pattern — casting in every handler:**
105
121
 
106
122
  ```ts
107
123
  setup: (m) => {
108
124
  m.on(idleState, signInEvent, (event, opts) => {
109
125
  const c = opts!.context as AuthContext; // ❌ unnecessary — already typed
110
- c.phoneNumber = event.phoneNumber;
126
+ c.set({ ...c.get(), phoneNumber: event.payload.phoneNumber });
111
127
  return { state: signingInState };
112
128
  });
113
129
  },
@@ -132,7 +148,7 @@ Handlers receive the correct payload type:
132
148
  ```ts
133
149
  setup: (m) => {
134
150
  m.on(idleState, signInEvent, (event) => {
135
- event.phoneNumber; // ✅ string
151
+ event.payload.phoneNumber; // ✅ string
136
152
  return { state: signingInState };
137
153
  });
138
154
  },
@@ -155,12 +171,12 @@ const goodEvent = event("SIGN_IN")<{ phoneNumber: string }>();
155
171
  actor.send(signInEvent.create({ phoneNumber: "+1234567890" }));
156
172
 
157
173
  // ❌ bypasses type checking
158
- actor.send({ id: "SIGN_IN", phoneNumber: "+1234567890" });
174
+ actor.send({ type: "SIGN_IN", payload: { phoneNumber: "+1234567890" } });
159
175
  ```
160
176
 
161
177
  ### Effects and Event Typing
162
178
 
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.
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.
164
180
 
165
181
  **Pass data to effects via state payload.** Set the payload in the transition return, read it from `state.payload` in the effect.
166
182
 
@@ -184,7 +200,7 @@ const actor = new Actor({
184
200
  });
185
201
  });
186
202
  m.on(idleState, fetchEvent, (event) => {
187
- return { state: loadingState.create({ url: event.url }) };
203
+ return { state: loadingState.create({ url: event.payload.url }) };
188
204
  });
189
205
  m.on(loadingState, doneEvent, () => ({ state: loadedState }));
190
206
  },
@@ -195,10 +211,10 @@ const actor = new Actor({
195
211
 
196
212
  ```ts
197
213
  // ✅ state payload — scoped to this state entry
198
- return { state: loadingState.create({ url: event.url }) };
214
+ return { state: loadingState.create({ url: event.payload.url }) };
199
215
 
200
216
  // ⚠️ context — works but shared across all states
201
- opts!.context.url = event.url;
217
+ opts!.context.set({ ...opts!.context.get(), url: event.payload.url });
202
218
  return { state: loadingState };
203
219
  ```
204
220
 
@@ -206,9 +222,8 @@ return { state: loadingState };
206
222
 
207
223
  ```ts
208
224
  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
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
212
227
  });
213
228
  ```
214
229
 
@@ -289,7 +304,10 @@ function createActor() {
289
304
  m.on(idleState, startEvent, () => ({ state: workingState }));
290
305
  m.on(workingState, doneEvent, () => ({ state: doneState }));
291
306
  m.on(workingState, failedEvent, (_event, opts) => {
292
- opts!.context.retryCount++;
307
+ opts!.context.set({
308
+ ...opts!.context.get(),
309
+ retryCount: opts!.context.get().retryCount + 1,
310
+ });
293
311
  return { state: errorState };
294
312
  });
295
313
  },
@@ -320,45 +338,29 @@ m.effect(workingState, ({ signal, emit, clock }) => {
320
338
 
321
339
  ### Snapshot & Restore
322
340
 
323
- `actor.snapshot()` returns a serializable `Snapshot` — `{ path, regions, done? }`. It does **not** include context. Use it to save/restore actor state across sessions.
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.
324
342
 
325
343
  ```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);
344
+ // Snapshot carries path, context, and regions — serialize it directly
345
+ const saved = actor.snapshot();
341
346
  localStorage.setItem("actor", JSON.stringify(saved));
342
347
 
343
348
  const raw = localStorage.getItem("actor");
344
349
  if (raw) {
345
- const data = JSON.parse(raw) as SerializedActor;
346
- // restore from data.path and data.context
350
+ const data = JSON.parse(raw) as Snapshot;
351
+ // rebuild the actor from data.path (re-seed context from data.context if needed)
347
352
  }
348
353
  ```
349
354
 
350
- **Anti-pattern — trying to snapshot context via `snapshot()`:**
355
+ **Anti-pattern — re-serializing context by hand:**
351
356
 
352
357
  ```ts
353
- // ❌ snapshot() only returns path + regions, not context
358
+ // ❌ snapshot() already includes context — no manual step needed
354
359
  const snap = actor.snapshot();
355
- snap.context; // doesn't exist
360
+ snap.context; // exists
356
361
 
357
- // ✅ serialize context yourself
358
- const stateData = {
359
- path: actor.snapshot().path,
360
- context: { ...actor.context },
361
- };
362
+ // ✅ save the snapshot as-is
363
+ const stateData = actor.snapshot();
362
364
  ```
363
365
 
364
366
  ### Dynamic Children (Regions)
@@ -374,10 +376,10 @@ const healthMonitor = new Actor({
374
376
  initial: unknownState,
375
377
  setup: (m) => {
376
378
  m.on(unknownState, healthCheckResult, (event) => ({
377
- state: event.healthy ? healthyState : degradedState,
379
+ state: event.payload.healthy ? healthyState : degradedState,
378
380
  }));
379
381
  m.on(healthyState, healthCheckResult, (event) => ({
380
- state: event.healthy ? healthyState : degradedState,
382
+ state: event.payload.healthy ? healthyState : degradedState,
381
383
  }));
382
384
  },
383
385
  });
@@ -391,7 +393,7 @@ const manager = new Actor({
391
393
  setup: (m) => {
392
394
  m.on(connectedState, healthCheckResult, (event, opts) => {
393
395
  // access child via actor.regions
394
- manager.regions.health.send(healthCheckResult.create({ healthy: event.healthy }));
396
+ manager.regions.health.send(healthCheckResult.create({ healthy: event.payload.healthy }));
395
397
  return {};
396
398
  });
397
399
  },
@@ -424,7 +426,23 @@ const parent = new Actor({
424
426
 
425
427
  ### Error Handling
426
428
 
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.
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.
430
+
431
+ ```ts
432
+ const snap = actor.snapshot();
433
+ if (snap.error) {
434
+ snap.error.reason; // "transition" | "effect" | "budget" | "output" | "internal" | "async" | "unhandled"
435
+ snap.error.state.name; // the state at the point of failure
436
+ snap.error.event.type; // the bad event
437
+ }
438
+ ```
439
+
440
+ Two deliberate exceptions to "every failure is loud":
441
+
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.
443
+ - **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
+
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.
428
446
 
429
447
  Catch errors inside effects and emit recovery events:
430
448
 
@@ -448,7 +466,7 @@ setup: (m) => {
448
466
  m.on(workingState, doneEvent, () => ({ state: doneState }));
449
467
  m.on(workingState, failedEvent, (event) => {
450
468
  // handle error in transition, not in effect
451
- return { state: errorState.create({ error: event.error }) };
469
+ return { state: errorState.create({ error: event.payload.error }) };
452
470
  });
453
471
  },
454
472
  ```
@@ -460,7 +478,7 @@ try {
460
478
  const result = riskyOperation();
461
479
  emit(doneEvent.create());
462
480
  } catch (err) {
463
- throw err; // ❌ throw in effect — no handler, machine misbehaves
481
+ throw err; // ❌ throw in effect — the machine dies into __error
464
482
  }
465
483
  ```
466
484
 
@@ -468,7 +486,7 @@ try {
468
486
 
469
487
  ```ts
470
488
  m.on(idleState, submitEvent, (event) => {
471
- if (!event.data) throw new Error("missing data"); // ❌ breaks the machine
489
+ if (!event.payload.data) throw new Error("missing data"); // ❌ kills the machine
472
490
  return { state: doneState };
473
491
  });
474
492
  ```
@@ -477,8 +495,8 @@ Prefer storing the error in context and transitioning to an error state:
477
495
 
478
496
  ```ts
479
497
  m.on(idleState, submitEvent, (event, opts) => {
480
- if (!event.data) {
481
- opts!.context.error = "missing data";
498
+ if (!event.payload.data) {
499
+ opts!.context.set({ ...opts!.context.get(), error: "missing data" });
482
500
  return { state: errorState };
483
501
  }
484
502
  return { state: doneState };
@@ -493,7 +511,7 @@ Use `onAny` to intercept events across all states — useful for universal error
493
511
  setup: (m) => {
494
512
  // CANCEL works from any state
495
513
  m.onAny(cancelEvent, (_event, opts) => {
496
- opts!.context.cancelled = true;
514
+ opts!.context.set({ ...opts!.context.get(), cancelled: true });
497
515
  return { state: cancelledState };
498
516
  });
499
517
  // State-specific handlers still run for their state
@@ -507,13 +525,13 @@ setup: (m) => {
507
525
  setup: (m) => {
508
526
  // ❌ state-specific logic in onAny defeats the purpose
509
527
  m.onAny(submitBasicInfoEvent, (event, opts) => {
510
- opts!.context.basicInfo = event; // wrong — only makes sense in basicInfo state
528
+ opts!.context.set({ ...opts!.context.get(), basicInfo: event }); // wrong — only makes sense in basicInfo state
511
529
  return { state: shippingAddressState };
512
530
  });
513
531
 
514
532
  // ✅ keep state-specific logic in state handlers
515
533
  m.on(basicInfoState, submitBasicInfoEvent, (event, opts) => {
516
- opts!.context.basicInfo = event;
534
+ opts!.context.set({ ...opts!.context.get(), basicInfo: event });
517
535
  return { state: shippingAddressState };
518
536
  });
519
537
  },
@@ -652,6 +670,25 @@ vp test
652
670
  vp pack
653
671
  ```
654
672
 
673
+ ## Testing
674
+
675
+ The package ships with a full test suite you can run and audit:
676
+
677
+ ```bash
678
+ vp test # feature + error + property tests
679
+ vp check # format, lint, typecheck (includes type-level contract tests)
680
+ vp run mutation:core # mutation score must stay ≥ 90
681
+ ```
682
+
683
+ **Test contract.** Behavior is pinned by four kinds of tests, split by filename:
684
+
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.
689
+
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.
691
+
655
692
  ## License
656
693
 
657
694
  MIT
@@ -0,0 +1,152 @@
1
+ //#region src/state.d.ts
2
+ declare function state<const T extends string>(id: T): <Payload = unknown>() => StateRef<T, Payload, false>;
3
+ type AnyStateRef = StateRef<string, unknown, boolean>;
4
+ interface RegionOptions {
5
+ initial: string;
6
+ states: Record<string, AnyStateRef>;
7
+ }
8
+ type RegionsOptions = Record<string, RegionOptions>;
9
+ declare class StateRef<T extends string, Payload = unknown, IsFinal extends boolean = false> {
10
+ readonly name: T;
11
+ readonly isFinal: IsFinal;
12
+ /** @internal */ _regions: RegionsOptions | undefined;
13
+ constructor(name: T, isFinal: IsFinal);
14
+ regions(options: RegionsOptions): this;
15
+ final(): StateRef<T, Payload, true>;
16
+ create(payload: Payload): {
17
+ state: StateRef<T, Payload, IsFinal>;
18
+ payload: Payload;
19
+ };
20
+ }
21
+ //#endregion
22
+ //#region src/event.d.ts
23
+ declare function event<const T extends string>(type: T): <Payload extends object | void = void>() => EventRef<T, Payload>;
24
+ type AnyEventRef = EventRef<string, object | void>;
25
+ type InternalEvent = {
26
+ type: string;
27
+ payload?: unknown;
28
+ };
29
+ type CreatedOfEvent<T extends string, P> = P extends void ? {
30
+ type: T;
31
+ } : {
32
+ type: T;
33
+ payload: P;
34
+ };
35
+ declare class EventRef<const T extends string, Payload extends object | void = void> {
36
+ readonly type: T;
37
+ constructor(type: T);
38
+ is(anyEvent: unknown): anyEvent is CreatedOfEvent<T, Payload>;
39
+ create(): Payload extends void ? {
40
+ type: T;
41
+ } : void;
42
+ create(payload: Payload): Payload extends void ? {
43
+ type: T;
44
+ } : {
45
+ type: T;
46
+ payload: Payload;
47
+ };
48
+ }
49
+ //#endregion
50
+ //#region src/clock.d.ts
51
+ interface Clock {
52
+ setTimeout(ms: number, cb: () => void, options?: {
53
+ signal?: AbortSignal;
54
+ eventName?: string;
55
+ }): number;
56
+ clearTimeout(id: number): void;
57
+ setInterval(ms: number, cb: () => void, options?: {
58
+ signal?: AbortSignal;
59
+ }): number;
60
+ clearInterval(id: number): void;
61
+ now(): number;
62
+ /** Optional hook for draining queues after clock advances (VirtualClock). */
63
+ setDrain?(fn: () => void): void;
64
+ }
65
+ //#endregion
66
+ //#region src/context.d.ts
67
+ declare class Context<T> {
68
+ #private;
69
+ constructor(get: () => T, set: (value: T) => void);
70
+ get(): T;
71
+ set(value: T): void;
72
+ }
73
+ //#endregion
74
+ //#region src/actor-types.d.ts
75
+ type CreatedOf<E extends AnyEventRef> = E extends EventRef<infer Type, infer P> ? CreatedOfEvent<Type, P> : never;
76
+ type NonFinalStateRef<States extends AnyStateRef[]> = Extract<States[number], {
77
+ isFinal: false;
78
+ }>;
79
+ interface EffectInput<ActorContext, Payload = unknown> {
80
+ signal: AbortSignal;
81
+ state: {
82
+ name: string;
83
+ payload: Payload;
84
+ };
85
+ event: InternalEvent;
86
+ context: Context<ActorContext>;
87
+ emit: (event: InternalEvent) => void;
88
+ clock: Clock;
89
+ }
90
+ type EffectFn<ActorContext, Payload = unknown> = (input: EffectInput<ActorContext, Payload>) => void | Promise<void>;
91
+ type ErrorReason = "transition" | "effect" | "budget" | "output" | "internal" | "async" | "unhandled";
92
+ interface ErrorInfo {
93
+ error: unknown;
94
+ state: AnyStateRef;
95
+ context: unknown;
96
+ event: InternalEvent;
97
+ reason: ErrorReason;
98
+ }
99
+ interface TransitionInfo {
100
+ event: InternalEvent;
101
+ from: string;
102
+ to: string;
103
+ transitioned: boolean;
104
+ }
105
+ type ErrorState = StateRef<"__error", unknown, true>;
106
+ type TransitionResult<AllowedState extends AnyStateRef = AnyStateRef, AllowedEmit extends string = string> = {
107
+ state?: AllowedState | {
108
+ state: AllowedState;
109
+ payload?: unknown;
110
+ };
111
+ payload?: unknown;
112
+ emit?: Array<{
113
+ type: AllowedEmit;
114
+ payload?: unknown;
115
+ }>;
116
+ };
117
+ //#endregion
118
+ //#region src/actor-internal.d.ts
119
+ interface Snapshot<C = unknown> {
120
+ path: string[];
121
+ context: C;
122
+ payload?: unknown;
123
+ regions: Record<string, Snapshot<unknown>>;
124
+ done?: boolean;
125
+ error?: ErrorInfo;
126
+ }
127
+ interface AnyActor<C = Record<string, unknown>> {
128
+ state: AnyStateRef;
129
+ clock: Clock;
130
+ regions: Record<string, AnyActor>;
131
+ send(event: AnyEventRef | InternalEvent): void;
132
+ snapshot(): Snapshot<C>;
133
+ on(event: "change", fn: (snapshot: Snapshot<C>, prev: Snapshot<C>) => void): () => void;
134
+ on(event: "done", fn: () => void): () => void;
135
+ on(event: "transition", fn: (info: TransitionInfo) => void): () => void;
136
+ recover(target: {
137
+ state: AnyStateRef;
138
+ context: C;
139
+ }): void;
140
+ settled(): Promise<void>;
141
+ context?: C;
142
+ options?: {
143
+ transitions?: Record<string, Record<string, unknown>>;
144
+ effects?: Record<string, unknown[]>;
145
+ states?: ReadonlyArray<{
146
+ name: string;
147
+ isFinal: boolean;
148
+ }>;
149
+ };
150
+ }
151
+ //#endregion
152
+ export { InternalEvent as _, EffectInput as a, StateRef as b, ErrorState as c, TransitionResult as d, Context as f, EventRef as g, CreatedOfEvent as h, EffectFn as i, NonFinalStateRef as l, AnyEventRef as m, Snapshot as n, ErrorInfo as o, Clock as p, CreatedOf as r, ErrorReason as s, AnyActor as t, TransitionInfo as u, event as v, state as x, AnyStateRef as y };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as CreatedOfEvent, c as event, d as state, i as AnyEventRef, l as AnyStateRef, n as Snapshot, o as EventRef, r as Clock, s as InternalEvent, t as AnyActor, u as StateRef } from "./actor-internal-CBU_EDPZ.mjs";
1
+ import { _ as InternalEvent, a as EffectInput, b as StateRef, c as ErrorState, d as TransitionResult, f as Context, g as EventRef, h as CreatedOfEvent, i as EffectFn, l as NonFinalStateRef, m as AnyEventRef, n as Snapshot, o as ErrorInfo, p as Clock, r as CreatedOf, s as ErrorReason, t as AnyActor, u as TransitionInfo, v as event, x as state, y as AnyStateRef } from "./actor-internal-B4iAxUG_.mjs";
2
2
  //#region src/real-clock.d.ts
3
3
  declare class RealClock implements Clock {
4
4
  #private;
@@ -38,44 +38,8 @@ declare class VirtualClock implements Clock {
38
38
  setDrain(fn: () => void): void;
39
39
  }
40
40
  //#endregion
41
- //#region src/context.d.ts
42
- declare class Context<T> {
43
- #private;
44
- constructor(get: () => T, set: (value: T) => void);
45
- get(): T;
46
- set(value: T): void;
47
- }
48
- //#endregion
49
- //#region src/actor-types.d.ts
50
- type CreatedOf<E extends AnyEventRef> = E extends EventRef<infer Id, infer P> ? CreatedOfEvent<Id, P> : never;
51
- type NonFinalStateRef<States extends AnyStateRef[]> = Extract<States[number], {
52
- isFinal: false;
53
- }>;
54
- interface EffectInput<ActorContext, Payload = unknown> {
55
- signal: AbortSignal;
56
- state: {
57
- name: string;
58
- payload: Payload;
59
- };
60
- event: InternalEvent;
61
- context: Context<ActorContext>;
62
- emit: (event: InternalEvent) => void;
63
- clock: Clock;
64
- }
65
- type EffectFn<ActorContext, Payload = unknown> = (input: EffectInput<ActorContext, Payload>) => void;
66
- type TransitionResult<AllowedState extends AnyStateRef = AnyStateRef, AllowedEmit extends string = string> = {
67
- state?: AllowedState | {
68
- state: AllowedState;
69
- payload?: unknown;
70
- };
71
- payload?: unknown;
72
- emit?: Array<{
73
- id: AllowedEmit;
74
- }>;
75
- };
76
- //#endregion
77
41
  //#region src/builder.d.ts
78
- type EventIdOf<E extends AnyEventRef> = E extends EventRef<infer Id, object | void> ? Id : never;
42
+ type EventTypeOf<E extends AnyEventRef> = E extends EventRef<infer Type, object | void> ? Type : never;
79
43
  type PayloadOf<S extends AnyStateRef> = S extends StateRef<infer _Name, infer Payload, infer _IsFinal> ? Payload : never;
80
44
  type TransitionHandler<States extends readonly AnyStateRef[], ActorContext> = (event: InternalEvent, opts: {
81
45
  context: Context<ActorContext>;
@@ -87,25 +51,21 @@ interface BuiltMaps<States extends readonly AnyStateRef[], ActorContext> {
87
51
  }
88
52
  declare class ActorBuilder<States extends readonly AnyStateRef[], Inputs extends readonly AnyEventRef[], Internal extends readonly AnyEventRef[], Outputs extends readonly AnyEventRef[], ActorContext> {
89
53
  #private;
90
- on<S extends States[number], E extends Inputs[number] | Internal[number]>(stateRef: S, eventRef: E, fn: (event: E extends EventRef<infer Id, infer P> ? CreatedOfEvent<Id, P> : never, opts: {
54
+ on<S extends States[number], E extends Inputs[number] | Internal[number]>(stateRef: S, eventRef: E, fn: (event: E extends EventRef<infer Type, infer P> ? CreatedOfEvent<Type, P> : never, opts: {
91
55
  context: Context<ActorContext>;
92
56
  actor: AnyActor;
93
- }) => TransitionResult<States[number], EventIdOf<Outputs[number]>>): this;
94
- onAny<E extends Inputs[number] | Internal[number]>(eventRef: E, fn: (event: E extends EventRef<infer Id, infer P> ? CreatedOfEvent<Id, P> : never, opts: {
57
+ }) => TransitionResult<States[number], EventTypeOf<Outputs[number]>>): this;
58
+ onAny<E extends Inputs[number] | Internal[number]>(eventRef: E, fn: (event: E extends EventRef<infer Type, infer P> ? CreatedOfEvent<Type, P> : never, opts: {
95
59
  context: Context<ActorContext>;
96
60
  actor: AnyActor;
97
- }) => TransitionResult<States[number], EventIdOf<Outputs[number]>>): this;
61
+ }) => TransitionResult<States[number], EventTypeOf<Outputs[number]>>): this;
98
62
  effect<S extends States[number]>(stateRef: S, fn: EffectFn<ActorContext, PayloadOf<S>>): this;
99
63
  /** @internal */ build(): BuiltMaps<States, ActorContext>;
100
64
  }
101
65
  type SetupFn<States extends readonly AnyStateRef[], Inputs extends readonly AnyEventRef[], Internal extends readonly AnyEventRef[], Outputs extends readonly AnyEventRef[], ActorContext> = (m: ActorBuilder<States, Inputs, Internal, Outputs, ActorContext>) => void;
102
66
  //#endregion
103
67
  //#region src/actor.d.ts
104
- type CreatedOf$1<E extends AnyEventRef> = E extends EventRef<infer Id, infer P> ? (P extends void ? {
105
- id: Id;
106
- } : P & {
107
- id: Id;
108
- }) : never;
68
+ type CreatedOf$1<E extends AnyEventRef> = E extends EventRef<infer Type, infer P> ? CreatedOfEvent<Type, P> : never;
109
69
  type InitialState<S extends AnyStateRef> = S extends StateRef<infer _N extends string, infer P> ? [unknown] extends [P] ? S | {
110
70
  state: S;
111
71
  payload?: P;
@@ -125,18 +85,9 @@ interface ActorOptions<States extends readonly AnyStateRef[], Inputs extends rea
125
85
  setup: SetupFn<States, Inputs, Internal, Outputs, ActorContext>;
126
86
  regions?: Record<string, AnyActor>;
127
87
  }
128
- interface InternalActorOptions<States extends readonly AnyStateRef[], Inputs extends readonly AnyEventRef[], Internal extends readonly AnyEventRef[], Outputs extends readonly AnyEventRef[], ActorContext = Record<string, unknown>> {
129
- inputs: Inputs;
130
- outputs?: Outputs;
131
- internal?: Internal;
132
- states: States;
133
- context?: ActorContext;
134
- initial: InitialState<States[number]>;
135
- clock?: Clock;
136
- internalBudget?: number;
88
+ interface InternalActorOptions<States extends readonly AnyStateRef[], Inputs extends readonly AnyEventRef[], Internal extends readonly AnyEventRef[], Outputs extends readonly AnyEventRef[], ActorContext = Record<string, unknown>> extends Omit<ActorOptions<States, Inputs, Internal, Outputs, ActorContext>, "setup"> {
137
89
  transitions: TransitionDispatch<States, ActorContext>;
138
90
  effects: Record<string, Array<EffectFn<ActorContext>>>;
139
- regions?: Record<string, AnyActor>;
140
91
  }
141
92
  type TransitionDispatch<States extends readonly AnyStateRef[], ActorContext> = Record<string, Record<string, ((event: InternalEvent, options: {
142
93
  context: Context<ActorContext>;
@@ -144,7 +95,7 @@ type TransitionDispatch<States extends readonly AnyStateRef[], ActorContext> = R
144
95
  }) => TransitionResult<States[number], string>) | undefined>>;
145
96
  declare class Actor<const States extends readonly AnyStateRef[], const Inputs extends readonly AnyEventRef[], const Internal extends readonly AnyEventRef[] = readonly [], const Outputs extends readonly AnyEventRef[] = readonly [], ActorContext = Record<string, unknown>> {
146
97
  #private;
147
- state: States[number];
98
+ state: States[number] | ErrorState;
148
99
  readonly clock: Clock;
149
100
  get context(): ActorContext;
150
101
  get regions(): Record<string, AnyActor>;
@@ -152,9 +103,20 @@ declare class Actor<const States extends readonly AnyStateRef[], const Inputs ex
152
103
  constructor(options: ActorOptions<States, Inputs, Internal, Outputs, ActorContext>);
153
104
  on(event: "change", fn: (snapshot: Snapshot<ActorContext>, prev: Snapshot<ActorContext>) => void): () => void;
154
105
  on(event: "done", fn: () => void): () => void;
106
+ on(event: "transition", fn: (info: TransitionInfo) => void): () => void;
155
107
  settled(): Promise<void>;
156
108
  send(event: CreatedOf$1<Inputs[number]>): void;
109
+ /**
110
+ * Manually restore the actor to a live state after a fatal `__error`.
111
+ * DANGER: the caller supplies state and context, so the trajectory is no
112
+ * longer deterministic ("same inputs, same trace"). Effects are not re-run
113
+ * and timers are not re-armed — processing resumes on the next event.
114
+ */
115
+ recover(target: {
116
+ state: States[number];
117
+ context: ActorContext;
118
+ }): void;
157
119
  snapshot(): Snapshot<ActorContext>;
158
120
  }
159
121
  //#endregion
160
- export { Actor, type ActorBuilder, type AnyActor, type AnyEventRef, type AnyStateRef, type Clock, Context, type CreatedOf, type CreatedOfEvent, type EffectFn, type EffectInput, EventRef, type InternalEvent, type NonFinalStateRef, RealClock, type SetupFn, type Snapshot, StateRef, VirtualClock, event, state };
122
+ export { Actor, type ActorBuilder, type ActorOptions, type AnyActor, type AnyEventRef, type AnyStateRef, type BuiltMaps, type Clock, Context, type CreatedOf, type CreatedOfEvent, type EffectFn, type EffectInput, type ErrorInfo, type ErrorReason, type ErrorState, EventRef, type EventTypeOf, type InitialState, type InternalEvent, type NonFinalStateRef, type PayloadOf, RealClock, type SetupFn, type Snapshot, StateRef, type TransitionHandler, type TransitionInfo, type TransitionResult, VirtualClock, event, state };
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { registerActor, setOutputHandler } from "./internal.mjs";
2
+ import { Either } from "@mantaq/utils";
2
3
  //#region src/state.ts
3
4
  function state(id) {
4
5
  return () => new StateRef(id, false);
@@ -85,13 +86,23 @@ var VirtualClock = class {
85
86
  now() {
86
87
  return this.#now;
87
88
  }
89
+ /**
90
+ * Delay normalization: NaN and ±Infinity throw (programmer error); negative
91
+ * and 0 clamp to 0 for timeouts like the platform, to a 1ms floor for
92
+ * intervals (a 0ms interval would spin the synchronous advance loop
93
+ * forever). Finite positive values schedule at their real deadline.
94
+ */
95
+ #delay(ms, method, interval) {
96
+ if (!Number.isFinite(ms)) throw new RangeError(`[VirtualClock] invalid ${method} ms value: ${ms}`);
97
+ return ms <= 0 ? interval ? 1 : 0 : ms;
98
+ }
88
99
  setTimeout(ms, cb, options) {
89
100
  const signal = options?.signal;
90
101
  if (signal?.aborted) return -1;
91
102
  const id = this.#nextId++;
92
103
  const onAbort = trackAbort(signal, id, this.#timers);
93
104
  this.#timers.set(id, {
94
- deadline: this.#now + ms,
105
+ deadline: this.#now + this.#delay(ms, "setTimeout", false),
95
106
  cb,
96
107
  signal,
97
108
  onAbort,
@@ -111,9 +122,10 @@ var VirtualClock = class {
111
122
  if (signal?.aborted) return -1;
112
123
  const id = this.#nextId++;
113
124
  const onAbort = trackAbort(signal, id, this.#intervals);
125
+ const d = this.#delay(ms, "setInterval", true);
114
126
  this.#intervals.set(id, {
115
- ms,
116
- next: this.#now + ms,
127
+ ms: d,
128
+ next: this.#now + d,
117
129
  cb,
118
130
  signal,
119
131
  onAbort
@@ -164,7 +176,8 @@ var VirtualClock = class {
164
176
  }
165
177
  }
166
178
  advance(ms) {
167
- const target = this.#now + ms;
179
+ if (!Number.isFinite(ms)) throw new RangeError(`[VirtualClock] invalid advance ms value: ${ms}`);
180
+ const target = this.#now + Math.max(0, ms);
168
181
  while (true) {
169
182
  const deadline = this.#findEarliestDeadline(target);
170
183
  if (deadline === null) break;
@@ -203,9 +216,6 @@ var InternalQueue = class {
203
216
  get length() {
204
217
  return this.#queue.length - this.#index;
205
218
  }
206
- get isProcessing() {
207
- return this.#processing;
208
- }
209
219
  push(...events) {
210
220
  if (this.#stopped) return;
211
221
  this.#queue.push(...events);
@@ -216,12 +226,6 @@ var InternalQueue = class {
216
226
  this.#settledResolvers.push(resolve);
217
227
  });
218
228
  }
219
- process(processEvent) {
220
- this.#process((event) => {
221
- processEvent(event);
222
- return true;
223
- });
224
- }
225
229
  processCancellable(processEvent) {
226
230
  this.#process(processEvent);
227
231
  }
@@ -254,35 +258,48 @@ var InternalQueue = class {
254
258
  var Subscribers = class {
255
259
  change = /* @__PURE__ */ new Set();
256
260
  done = /* @__PURE__ */ new Set();
261
+ transition = /* @__PURE__ */ new Set();
257
262
  #last = null;
258
263
  seed(snapshot) {
259
264
  this.#last = snapshot;
260
265
  }
261
266
  addChange(fn) {
262
267
  this.change.add(fn);
263
- if (this.#last) fn(this.#last, this.#last);
268
+ const last = this.#last;
269
+ if (last) this.#safe(() => fn(last, last));
264
270
  return () => this.change.delete(fn);
265
271
  }
266
272
  addDone(fn) {
267
273
  this.done.add(fn);
268
274
  return () => this.done.delete(fn);
269
275
  }
276
+ addTransition(fn) {
277
+ this.transition.add(fn);
278
+ return () => this.transition.delete(fn);
279
+ }
270
280
  emitChange(snapshot) {
271
281
  const prev = this.#last ?? snapshot;
272
282
  this.#last = snapshot;
273
- for (const fn of this.change) fn(snapshot, prev);
283
+ for (const fn of this.change) this.#safe(() => fn(snapshot, prev));
274
284
  }
275
285
  emitDone() {
276
- for (const fn of this.done) fn();
286
+ for (const fn of this.done) this.#safe(fn);
287
+ }
288
+ emitTransition(info) {
289
+ for (const fn of this.transition) this.#safe(() => fn(info));
277
290
  }
278
291
  clear() {
279
292
  this.change.clear();
280
293
  this.done.clear();
294
+ this.transition.clear();
295
+ }
296
+ #safe(fn) {
297
+ Either.from(fn);
281
298
  }
282
299
  };
283
300
  //#endregion
284
301
  //#region src/snapshot.ts
285
- function buildSnapshot(s, regions, context) {
302
+ function buildSnapshot(s, regions, context, opts = {}) {
286
303
  const path = [s.name];
287
304
  const regionSnapshots = {};
288
305
  for (const [regionName, child] of Object.entries(regions)) regionSnapshots[regionName] = child.snapshot();
@@ -291,28 +308,41 @@ function buildSnapshot(s, regions, context) {
291
308
  context,
292
309
  regions: regionSnapshots
293
310
  };
311
+ if (opts.payload !== void 0) snap.payload = opts.payload;
294
312
  if (s.isFinal) snap.done = true;
313
+ if (opts.error !== void 0) snap.error = opts.error;
295
314
  return snap;
296
315
  }
297
316
  //#endregion
298
317
  //#region src/effects.ts
299
318
  function runEffects(options) {
300
- if (options.state.isFinal) return null;
319
+ const pending = [];
301
320
  const list = options.effects[options.state.name];
302
- if (!list || list.length === 0) return null;
303
- const abort = new AbortController();
304
- for (const effectFn of list) effectFn({
305
- signal: abort.signal,
306
- state: {
307
- name: options.state.name,
308
- payload: options.statePayload
309
- },
310
- event: options.event,
311
- context: options.context,
312
- emit: options.emit,
313
- clock: options.clock
314
- });
315
- return abort;
321
+ if (!list) return { pending };
322
+ for (const effectFn of list) {
323
+ if (options.abort.signal.aborted) break;
324
+ const attempt = Either.from(() => effectFn({
325
+ signal: options.abort.signal,
326
+ state: {
327
+ name: options.state.name,
328
+ payload: options.statePayload
329
+ },
330
+ event: options.event,
331
+ context: options.context,
332
+ emit: options.emit,
333
+ clock: options.clock
334
+ }));
335
+ if (attempt[0] !== void 0) {
336
+ options.onError(attempt[0], options.lastGood);
337
+ break;
338
+ }
339
+ const out = attempt[1];
340
+ if (out instanceof Promise) pending.push(out.catch((error) => {
341
+ if (options.abort.signal.aborted) return;
342
+ options.onError(error, options.lastGood);
343
+ }));
344
+ }
345
+ return { pending };
316
346
  }
317
347
  //#endregion
318
348
  //#region src/dispatch.ts
@@ -350,13 +380,13 @@ var ActorBuilder = class {
350
380
  #effects = {};
351
381
  on(stateRef, eventRef, fn) {
352
382
  const sName = stateRef.name;
353
- const eId = eventRef.id;
354
- (this.#transitions[sName] ??= {})[eId] = fn;
383
+ const eType = eventRef.type;
384
+ (this.#transitions[sName] ??= {})[eType] = fn;
355
385
  return this;
356
386
  }
357
387
  onAny(eventRef, fn) {
358
- const eId = eventRef.id;
359
- (this.#transitions["Any"] ??= {})[eId] = fn;
388
+ const eType = eventRef.type;
389
+ (this.#transitions["Any"] ??= {})[eType] = fn;
360
390
  return this;
361
391
  }
362
392
  effect(stateRef, fn) {
@@ -372,11 +402,8 @@ var ActorBuilder = class {
372
402
  };
373
403
  //#endregion
374
404
  //#region src/actor.ts
375
- function pickState(initial) {
376
- return initial instanceof StateRef ? initial : initial.state;
377
- }
378
405
  function resolveInitial(initial) {
379
- return pickState(initial);
406
+ return initial instanceof StateRef ? initial : initial.state;
380
407
  }
381
408
  var Actor = class {
382
409
  state;
@@ -412,7 +439,7 @@ var Actor = class {
412
439
  setOutputHandler: (fn) => {
413
440
  this.#outputHandler = fn;
414
441
  },
415
- pushInternal: (event) => this.#pushEvent(event),
442
+ pushInternal: (event) => this.#queue.push(event),
416
443
  drainInternal: () => this.#drainInternal(),
417
444
  abortEffects: () => this.#abortEffects()
418
445
  });
@@ -424,47 +451,73 @@ var Actor = class {
424
451
  transitions: built.transitions,
425
452
  effects: built.effects
426
453
  };
427
- this.#internalIds = new Set((options.internal ?? []).map((e) => e.id));
428
- this.#inputIds = new Set(options.inputs.map((e) => e.id));
454
+ this.#internalIds = new Set((options.internal ?? []).map((e) => e.type));
455
+ this.#inputIds = new Set(options.inputs.map((e) => e.type));
429
456
  this.#internalBudget = options.internalBudget ?? 1e4;
430
457
  this.clock = options.clock ?? new RealClock();
431
458
  this.clock.setDrain?.(() => this.#drainInternal());
432
459
  const initState = resolveInitial(options.initial);
433
460
  const stateNames = new Set(options.states.map((s) => s.name));
434
- if (!stateNames.has(initState.name)) console.warn(`[Actor] initial state "${initState.name}" not found in declared states [${[...stateNames].join(", ")}]`);
461
+ if (!stateNames.has(initState.name)) throw new Error(`[Actor] initial state "${initState.name}" not found in declared states [${[...stateNames].join(", ")}]`);
435
462
  this.state = initState;
463
+ this.#statePayload = options.initial instanceof StateRef ? void 0 : options.initial.payload;
436
464
  this.#context = options.context ?? {};
437
465
  this.#lastState = initState;
438
466
  this.#contextHandle = new Context(() => this.#context, (value) => {
439
467
  this.#context = value;
440
468
  this.#contextWritten = true;
441
469
  });
442
- this.#subs.seed(this.snapshot());
443
470
  if (options.regions) for (const [key, child] of Object.entries(options.regions)) {
444
471
  this.#regions[key] = child;
445
472
  const result = setOutputHandler(child, (event) => {
446
473
  this.#queue.push(event);
447
474
  this.#drainInternal();
448
475
  });
449
- if (result[0] !== void 0) console.error(result[0].message);
476
+ if (result[0] !== void 0) throw new Error(result[0].message);
450
477
  this.#children.set(key, child);
451
478
  }
479
+ this.#runEffects({ type: "__init" }, this.#statePayload);
480
+ this.#subs.seed(this.snapshot());
452
481
  }
453
482
  on(event, fn) {
454
483
  if (event === "change") {
455
484
  const cb = fn;
456
485
  return this.#subs.addChange(cb);
457
486
  }
487
+ if (event === "done") {
488
+ const cb = fn;
489
+ return this.#subs.addDone(cb);
490
+ }
458
491
  const cb = fn;
459
- return this.#subs.addDone(cb);
492
+ return this.#subs.addTransition(cb);
460
493
  }
461
494
  settled() {
462
- return this.#queue.settled();
495
+ return Promise.all([this.#queue.settled(), ...this.#pendingEffects]).then(() => void 0);
463
496
  }
464
497
  send(event) {
465
498
  this.#dispatch(event);
466
499
  this.#emitChangeIfDirty();
467
500
  }
501
+ /**
502
+ * Manually restore the actor to a live state after a fatal `__error`.
503
+ * DANGER: the caller supplies state and context, so the trajectory is no
504
+ * longer deterministic ("same inputs, same trace"). Effects are not re-run
505
+ * and timers are not re-armed — processing resumes on the next event.
506
+ */
507
+ recover(target) {
508
+ if (this.#error === null) return;
509
+ this.#error = null;
510
+ this.#queue = new InternalQueue();
511
+ this.#effectAbort = null;
512
+ this.state = target.state;
513
+ this.#statePayload = void 0;
514
+ this.#context = target.context;
515
+ this.#lastState = target.state;
516
+ this.#contextWritten = false;
517
+ this.#entry = null;
518
+ this.#pendingEffects = [];
519
+ this.#subs.emitChange(this.snapshot());
520
+ }
468
521
  #emitChangeIfDirty() {
469
522
  if (this.state === this.#lastState && !this.#contextWritten) return;
470
523
  this.#lastState = this.state;
@@ -472,120 +525,240 @@ var Actor = class {
472
525
  this.#subs.emitChange(this.snapshot());
473
526
  }
474
527
  #dispatch(event) {
475
- if (this.state.isFinal) return;
476
- const transitions = this.#options.transitions;
477
- const stateTransition = transitions[this.state.name]?.[event.id];
478
- const anyTransition = transitions["Any"]?.[event.id];
479
- const transitionApplied = stateTransition ? this.#applyStateStep(event, stateTransition) : false;
480
- if ((anyTransition ? this.#applyAnyStep(event, anyTransition, !transitionApplied) : false) || this.#queue.length > 0) this.#drainInternal();
481
- else if (!stateTransition && !anyTransition) console.warn(`[Actor] no transition for event "${event.id}" in state "${this.state.name}". Event dropped.`);
528
+ if (this.#error !== null || this.state.isFinal) return;
529
+ const normalized = {
530
+ ...event,
531
+ payload: event.payload ?? {}
532
+ };
533
+ const from = this.state.name;
534
+ const prev = this.#entry;
535
+ this.#entry = {
536
+ state: this.state,
537
+ context: this.#context
538
+ };
539
+ try {
540
+ const transitions = this.#options.transitions;
541
+ const stateTransition = transitions[this.state.name]?.[event.type];
542
+ const anyTransition = transitions["Any"]?.[event.type];
543
+ const result = this.#step(normalized, stateTransition, anyTransition);
544
+ this.#maybeEmitTransition(normalized, from, Boolean(stateTransition || anyTransition), result);
545
+ if (result.emitted || this.#queue.length > 0) this.#drainInternal();
546
+ else if (!stateTransition && !anyTransition) {
547
+ if (this.#internalIds.has(event.type)) this.#enterError("unhandled", event, /* @__PURE__ */ new Error(`[Actor] internal event "${event.type}" emitted but no handler in state "${this.state.name}"`));
548
+ }
549
+ } finally {
550
+ this.#entry = prev;
551
+ }
552
+ }
553
+ #step(normalized, stateTransition, anyTransition) {
554
+ const stateOutcome = stateTransition ? this.#applyStateStep(normalized, stateTransition) : { applied: false };
555
+ if (this.#error === null) {
556
+ const anyOutcome = this.#applyAnyStep(normalized, anyTransition, !stateOutcome.applied);
557
+ return {
558
+ emitted: anyOutcome.emitted,
559
+ transitioned: stateOutcome.applied || anyOutcome.transitioned,
560
+ target: stateOutcome.target ?? anyOutcome.target
561
+ };
562
+ }
563
+ return {
564
+ emitted: false,
565
+ transitioned: false
566
+ };
567
+ }
568
+ #maybeEmitTransition(event, from, handled, outcome) {
569
+ if (this.#error !== null) return;
570
+ if (!handled) return;
571
+ this.#subs.emitTransition({
572
+ event,
573
+ from,
574
+ to: outcome.target ?? this.state.name,
575
+ transitioned: outcome.transitioned
576
+ });
482
577
  }
483
578
  #applyStateStep(event, transition) {
484
- const step = transition(event, {
485
- context: this.#contextHandle,
486
- actor: this
579
+ let applied = false;
580
+ let target;
581
+ this.#safe("transition", event, () => {
582
+ const step = transition(event, {
583
+ context: this.#contextHandle,
584
+ actor: this
585
+ });
586
+ if (step.emit) this.#queue.push(...step.emit);
587
+ if (step.state) {
588
+ target = this.#applyTransition(event, step);
589
+ applied = true;
590
+ }
487
591
  });
488
- if (step.emit) this.#queue.push(...step.emit);
489
- if (step.state) {
490
- this.#applyTransition(event, step);
491
- return true;
492
- }
493
- return false;
592
+ return {
593
+ applied,
594
+ target
595
+ };
494
596
  }
495
597
  #applyAnyStep(event, transition, allowTransition) {
496
- const step = transition(event, {
497
- context: this.#contextHandle,
498
- actor: this
598
+ if (transition === void 0) return {
599
+ emitted: false,
600
+ transitioned: false
601
+ };
602
+ let emitted = false;
603
+ let transitioned = false;
604
+ let target;
605
+ this.#safe("transition", event, () => {
606
+ const step = transition(event, {
607
+ context: this.#contextHandle,
608
+ actor: this
609
+ });
610
+ if (step.state && allowTransition) {
611
+ target = this.#applyTransition(event, step);
612
+ transitioned = true;
613
+ }
614
+ if (step.emit) {
615
+ this.#queue.push(...step.emit);
616
+ emitted = true;
617
+ }
499
618
  });
500
- if (step.state && allowTransition) this.#applyTransition(event, step);
501
- if (step.emit) {
502
- this.#queue.push(...step.emit);
503
- return true;
504
- }
505
- return false;
619
+ return {
620
+ emitted,
621
+ transitioned,
622
+ target
623
+ };
506
624
  }
507
625
  snapshot() {
508
- return buildSnapshot(this.state, this.#regions, this.#context);
509
- }
510
- #pushEvent(event) {
511
- this.#queue.push(event);
626
+ return buildSnapshot(this.state, this.#regions, this.#context, {
627
+ error: this.#error ?? void 0,
628
+ payload: this.#statePayload
629
+ });
512
630
  }
513
631
  #abortEffects() {
514
632
  this.#effectAbort?.abort();
515
633
  this.#subs.clear();
516
634
  }
517
635
  #applyTransition(event, step) {
518
- if (!step.state) return;
519
- this.#effectAbort?.abort();
636
+ if (this.#error !== null || !step.state) return void 0;
520
637
  const resolved = parseTarget(step);
521
- if (!resolved) return;
638
+ if (!resolved) return void 0;
639
+ if (!(resolved.state instanceof StateRef)) {
640
+ this.#enterError("transition", event, /* @__PURE__ */ new Error("invalid transition target"));
641
+ return;
642
+ }
643
+ this.#effectAbort?.abort();
522
644
  this.state = resolved.state;
645
+ const target = this.state.name;
646
+ this.#statePayload = resolved.payload;
647
+ this.#entry = {
648
+ state: this.state,
649
+ context: this.#context
650
+ };
523
651
  this.#runEffects(event, resolved.payload);
524
- if (this.state.isFinal) this.#subs.emitDone();
652
+ if (resolved.state.isFinal && this.#error === null) this.#subs.emitDone();
653
+ return target;
525
654
  }
526
655
  #runEffects(event, statePayload) {
527
656
  const list = this.#options.effects[this.state.name];
528
- if (!list || list.length === 0) {
657
+ if (!list) {
529
658
  this.#effectAbort = null;
530
659
  return;
531
660
  }
532
- const abort = runEffects({
661
+ const abort = new AbortController();
662
+ this.#effectAbort = abort;
663
+ const lastGood = this.#entry ?? {
664
+ state: this.state,
665
+ context: this.#context
666
+ };
667
+ const result = runEffects({
533
668
  effects: { [this.state.name]: list },
534
669
  state: this.state,
535
670
  statePayload,
536
671
  event,
537
672
  context: this.#contextHandle,
538
673
  emit: (e) => {
674
+ if (this.#error !== null || abort.signal.aborted) return;
539
675
  this.#queue.push(e);
540
676
  this.#drainInternal();
541
677
  },
542
- clock: this.clock
678
+ clock: this.clock,
679
+ abort,
680
+ lastGood,
681
+ onError: (error) => this.#enterError("effect", event, error, lastGood)
543
682
  });
544
- this.#effectAbort = abort;
683
+ this.#pendingEffects.push(...result.pending);
545
684
  }
546
685
  #drainInternal() {
547
686
  if (this.#draining) return;
548
687
  this.#draining = true;
549
- let budgetExceeded = false;
550
688
  try {
551
689
  const budget = this.#internalBudget;
552
690
  let count = 0;
553
691
  this.#queue.processCancellable((event) => {
692
+ if (this.#error !== null) return false;
554
693
  if (count >= budget) {
555
- budgetExceeded = true;
556
- this.#abortEffects();
694
+ this.#enterError("budget", event, /* @__PURE__ */ new Error("internal event budget exceeded"));
557
695
  return false;
558
696
  }
559
697
  count++;
560
- if (this.#internalIds.has(event.id) || this.#inputIds.has(event.id)) this.#dispatch(event);
561
- else if (this.#outputHandler) this.#outputHandler(event);
698
+ if (this.#internalIds.has(event.type) || this.#inputIds.has(event.type)) this.#dispatch(event);
699
+ else {
700
+ const outputHandler = this.#outputHandler;
701
+ if (outputHandler) {
702
+ if (!this.#safe("output", event, () => outputHandler(event))) return false;
703
+ }
704
+ }
562
705
  return true;
563
706
  });
564
707
  } finally {
565
708
  this.#draining = false;
566
709
  this.#emitChangeIfDirty();
567
710
  }
568
- if (budgetExceeded) console.warn(`[Actor] internal event budget (${this.#internalBudget}) exceeded — possible emit loop. Actor halting.`);
711
+ }
712
+ #statePayload;
713
+ #pendingEffects = [];
714
+ #errorState = new StateRef("__error", true);
715
+ #error = null;
716
+ #entry = null;
717
+ #safe(reason, event, fn) {
718
+ const attempt = Either.from(fn);
719
+ if (attempt[0] === void 0) return true;
720
+ this.#enterError(reason, event, attempt[0]);
721
+ return false;
722
+ }
723
+ #enterError(reason, event, error, lastGood) {
724
+ if (this.#error !== null) return;
725
+ const entry = lastGood ?? this.#entry ?? {
726
+ state: this.state,
727
+ context: this.#context
728
+ };
729
+ this.#error = {
730
+ error,
731
+ state: entry.state,
732
+ context: entry.context,
733
+ event,
734
+ reason
735
+ };
736
+ this.#effectAbort?.abort();
737
+ this.state = this.#errorState;
738
+ this.#statePayload = void 0;
739
+ this.#lastState = this.#errorState;
740
+ this.#contextWritten = false;
741
+ this.#subs.emitChange(this.snapshot());
742
+ this.#subs.emitDone();
569
743
  }
570
744
  };
571
745
  //#endregion
572
746
  //#region src/event.ts
573
- function event(id) {
574
- return () => new EventRef(id);
747
+ function event(type) {
748
+ return () => new EventRef(type);
575
749
  }
576
750
  var EventRef = class {
577
- id;
578
- constructor(id) {
579
- this.id = id;
751
+ type;
752
+ constructor(type) {
753
+ this.type = type;
580
754
  }
581
755
  is(anyEvent) {
582
- return !!anyEvent && typeof anyEvent === "object" && "id" in anyEvent && anyEvent.id === this.id;
756
+ return !!anyEvent && typeof anyEvent === "object" && "type" in anyEvent && anyEvent.type === this.type;
583
757
  }
584
758
  create(payload) {
585
- if (payload === void 0) return { id: this.id };
586
- return {
587
- ...payload,
588
- id: this.id
759
+ return payload === void 0 ? { type: this.type } : {
760
+ type: this.type,
761
+ payload
589
762
  };
590
763
  }
591
764
  };
@@ -1,4 +1,4 @@
1
- import { s as InternalEvent, t as AnyActor } from "./actor-internal-CBU_EDPZ.mjs";
1
+ import { _ as InternalEvent, t as AnyActor } from "./actor-internal-B4iAxUG_.mjs";
2
2
  import { Either } from "@mantaq/utils";
3
3
  //#region src/internal-registry.d.ts
4
4
  interface ActorInternal {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantaq/core",
3
- "version": "0.0.3",
3
+ "version": "0.2.0",
4
4
  "description": "State machine runtime with actor model, event system, state hierarchy, effects, and virtual clock.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,7 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "@mantaq/utils": "0.0.1"
24
+ "@mantaq/utils": "0.0.2"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@stryker-mutator/core": "^9",
@@ -1,90 +0,0 @@
1
- //#region src/state.d.ts
2
- declare function state<const T extends string>(id: T): <Payload = unknown>() => StateRef<T, Payload, false>;
3
- type AnyStateRef = StateRef<string, unknown, boolean>;
4
- interface RegionOptions {
5
- initial: string;
6
- states: Record<string, AnyStateRef>;
7
- }
8
- type RegionsOptions = Record<string, RegionOptions>;
9
- declare class StateRef<T extends string, Payload = unknown, IsFinal extends boolean = false> {
10
- readonly name: T;
11
- readonly isFinal: IsFinal;
12
- /** @internal */ _regions: RegionsOptions | undefined;
13
- constructor(name: T, isFinal: IsFinal);
14
- regions(options: RegionsOptions): this;
15
- final(): StateRef<T, Payload, true>;
16
- create(payload: Payload): {
17
- state: StateRef<T, Payload, IsFinal>;
18
- payload: Payload;
19
- };
20
- }
21
- //#endregion
22
- //#region src/event.d.ts
23
- declare function event<const T extends string>(id: T): <Payload extends object | void = void>() => EventRef<T, Payload>;
24
- type AnyEventRef = EventRef<string, object | void>;
25
- type InternalEvent = {
26
- id: string;
27
- } & Record<string, unknown>;
28
- type CreatedOfEvent<Id extends string, P> = P extends void ? {
29
- id: Id;
30
- } : P & {
31
- id: Id;
32
- };
33
- declare class EventRef<const T extends string, Payload extends object | void = void> {
34
- readonly id: T;
35
- constructor(id: T);
36
- is(anyEvent: unknown): anyEvent is CreatedOfEvent<T, Payload>;
37
- create(): Payload extends void ? {
38
- id: T;
39
- } : void;
40
- create(payload: Payload): Payload extends void ? {
41
- id: T;
42
- } : Payload & {
43
- id: T;
44
- };
45
- }
46
- //#endregion
47
- //#region src/clock.d.ts
48
- interface Clock {
49
- setTimeout(ms: number, cb: () => void, options?: {
50
- signal?: AbortSignal;
51
- eventName?: string;
52
- }): number;
53
- clearTimeout(id: number): void;
54
- setInterval(ms: number, cb: () => void, options?: {
55
- signal?: AbortSignal;
56
- }): number;
57
- clearInterval(id: number): void;
58
- now(): number;
59
- /** Optional hook for draining queues after clock advances (VirtualClock). */
60
- setDrain?(fn: () => void): void;
61
- }
62
- //#endregion
63
- //#region src/actor-internal.d.ts
64
- interface Snapshot<C = unknown> {
65
- path: string[];
66
- context: C;
67
- regions: Record<string, Snapshot<unknown>>;
68
- done?: boolean;
69
- }
70
- interface AnyActor<C = Record<string, unknown>> {
71
- state: AnyStateRef;
72
- clock: Clock;
73
- regions: Record<string, AnyActor>;
74
- send(event: AnyEventRef | InternalEvent): void;
75
- snapshot(): Snapshot<C>;
76
- on(event: "change", fn: (snapshot: Snapshot<C>, prev: Snapshot<C>) => void): () => void;
77
- on(event: "done", fn: () => void): () => void;
78
- settled(): Promise<void>;
79
- context?: C;
80
- options?: {
81
- transitions?: Record<string, Record<string, unknown>>;
82
- effects?: Record<string, unknown[]>;
83
- states?: ReadonlyArray<{
84
- name: string;
85
- isFinal: boolean;
86
- }>;
87
- };
88
- }
89
- //#endregion
90
- export { CreatedOfEvent as a, event as c, state as d, AnyEventRef as i, AnyStateRef as l, Snapshot as n, EventRef as o, Clock as r, InternalEvent as s, AnyActor as t, StateRef as u };