@mantaq/core 0.0.3 → 0.1.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 | Re-serialize context by hand |
25
+ | Any handler | Universal events only (CANCEL) | State-specific logic in `onAny` |
25
26
 
26
27
  ## Contents
27
28
 
@@ -94,20 +95,34 @@ const actor = new Actor({
94
95
  context: {} as AuthContext,
95
96
  setup: (m) => {
96
97
  m.on(idleState, signInEvent, (event, opts) => {
97
- opts!.context.phoneNumber = event.phoneNumber; // ✅ typed
98
+ // ✅ typed — context.get()/set() flow the constructor type
99
+ opts!.context.set({ ...opts!.context.get(), phoneNumber: event.payload.phoneNumber });
98
100
  return { state: signingInState };
99
101
  });
100
102
  },
101
103
  });
102
104
  ```
103
105
 
106
+ `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:
107
+
108
+ ```ts
109
+ opts.context.set({ ...opts.context.get(), phoneNumber: event.payload.phoneNumber });
110
+ ```
111
+
112
+ **Never mutate in place:**
113
+
114
+ ```ts
115
+ // ❌ mutation without set() — never signals; subscribers and persistence stay silent
116
+ opts!.context.phoneNumber = event.payload.phoneNumber;
117
+ ```
118
+
104
119
  **Anti-pattern — casting in every handler:**
105
120
 
106
121
  ```ts
107
122
  setup: (m) => {
108
123
  m.on(idleState, signInEvent, (event, opts) => {
109
124
  const c = opts!.context as AuthContext; // ❌ unnecessary — already typed
110
- c.phoneNumber = event.phoneNumber;
125
+ c.set({ ...c.get(), phoneNumber: event.payload.phoneNumber });
111
126
  return { state: signingInState };
112
127
  });
113
128
  },
@@ -132,7 +147,7 @@ Handlers receive the correct payload type:
132
147
  ```ts
133
148
  setup: (m) => {
134
149
  m.on(idleState, signInEvent, (event) => {
135
- event.phoneNumber; // ✅ string
150
+ event.payload.phoneNumber; // ✅ string
136
151
  return { state: signingInState };
137
152
  });
138
153
  },
@@ -155,12 +170,12 @@ const goodEvent = event("SIGN_IN")<{ phoneNumber: string }>();
155
170
  actor.send(signInEvent.create({ phoneNumber: "+1234567890" }));
156
171
 
157
172
  // ❌ bypasses type checking
158
- actor.send({ id: "SIGN_IN", phoneNumber: "+1234567890" });
173
+ actor.send({ type: "SIGN_IN", payload: { phoneNumber: "+1234567890" } });
159
174
  ```
160
175
 
161
176
  ### Effects and Event Typing
162
177
 
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.
178
+ 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
179
 
165
180
  **Pass data to effects via state payload.** Set the payload in the transition return, read it from `state.payload` in the effect.
166
181
 
@@ -184,7 +199,7 @@ const actor = new Actor({
184
199
  });
185
200
  });
186
201
  m.on(idleState, fetchEvent, (event) => {
187
- return { state: loadingState.create({ url: event.url }) };
202
+ return { state: loadingState.create({ url: event.payload.url }) };
188
203
  });
189
204
  m.on(loadingState, doneEvent, () => ({ state: loadedState }));
190
205
  },
@@ -195,10 +210,10 @@ const actor = new Actor({
195
210
 
196
211
  ```ts
197
212
  // ✅ state payload — scoped to this state entry
198
- return { state: loadingState.create({ url: event.url }) };
213
+ return { state: loadingState.create({ url: event.payload.url }) };
199
214
 
200
215
  // ⚠️ context — works but shared across all states
201
- opts!.context.url = event.url;
216
+ opts!.context.set({ ...opts!.context.get(), url: event.payload.url });
202
217
  return { state: loadingState };
203
218
  ```
204
219
 
@@ -206,9 +221,8 @@ return { state: loadingState };
206
221
 
207
222
  ```ts
208
223
  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
224
+ // ❌ event is InternalEvent — payload is unknown, not the triggering event's payload
225
+ emit(doneEvent.create()); // fine — but event.payload.url would be a type error
212
226
  });
213
227
  ```
214
228
 
@@ -289,7 +303,10 @@ function createActor() {
289
303
  m.on(idleState, startEvent, () => ({ state: workingState }));
290
304
  m.on(workingState, doneEvent, () => ({ state: doneState }));
291
305
  m.on(workingState, failedEvent, (_event, opts) => {
292
- opts!.context.retryCount++;
306
+ opts!.context.set({
307
+ ...opts!.context.get(),
308
+ retryCount: opts!.context.get().retryCount + 1,
309
+ });
293
310
  return { state: errorState };
294
311
  });
295
312
  },
@@ -320,45 +337,29 @@ m.effect(workingState, ({ signal, emit, clock }) => {
320
337
 
321
338
  ### Snapshot & Restore
322
339
 
323
- `actor.snapshot()` returns a serializable `Snapshot` — `{ path, regions, done? }`. It does **not** include context. Use it to save/restore actor state across sessions.
340
+ `actor.snapshot()` returns a `Snapshot` — `{ path, context, regions, done?, error? }`. Context is included. Use it to save/restore actor state across sessions.
324
341
 
325
342
  ```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);
343
+ // Snapshot carries path, context, and regions — serialize it directly
344
+ const saved = actor.snapshot();
341
345
  localStorage.setItem("actor", JSON.stringify(saved));
342
346
 
343
347
  const raw = localStorage.getItem("actor");
344
348
  if (raw) {
345
- const data = JSON.parse(raw) as SerializedActor;
346
- // restore from data.path and data.context
349
+ const data = JSON.parse(raw) as Snapshot;
350
+ // rebuild the actor from data.path (re-seed context from data.context if needed)
347
351
  }
348
352
  ```
349
353
 
350
- **Anti-pattern — trying to snapshot context via `snapshot()`:**
354
+ **Anti-pattern — re-serializing context by hand:**
351
355
 
352
356
  ```ts
353
- // ❌ snapshot() only returns path + regions, not context
357
+ // ❌ snapshot() already includes context — no manual step needed
354
358
  const snap = actor.snapshot();
355
- snap.context; // doesn't exist
359
+ snap.context; // exists
356
360
 
357
- // ✅ serialize context yourself
358
- const stateData = {
359
- path: actor.snapshot().path,
360
- context: { ...actor.context },
361
- };
361
+ // ✅ save the snapshot as-is
362
+ const stateData = actor.snapshot();
362
363
  ```
363
364
 
364
365
  ### Dynamic Children (Regions)
@@ -374,10 +375,10 @@ const healthMonitor = new Actor({
374
375
  initial: unknownState,
375
376
  setup: (m) => {
376
377
  m.on(unknownState, healthCheckResult, (event) => ({
377
- state: event.healthy ? healthyState : degradedState,
378
+ state: event.payload.healthy ? healthyState : degradedState,
378
379
  }));
379
380
  m.on(healthyState, healthCheckResult, (event) => ({
380
- state: event.healthy ? healthyState : degradedState,
381
+ state: event.payload.healthy ? healthyState : degradedState,
381
382
  }));
382
383
  },
383
384
  });
@@ -391,7 +392,7 @@ const manager = new Actor({
391
392
  setup: (m) => {
392
393
  m.on(connectedState, healthCheckResult, (event, opts) => {
393
394
  // access child via actor.regions
394
- manager.regions.health.send(healthCheckResult.create({ healthy: event.healthy }));
395
+ manager.regions.health.send(healthCheckResult.create({ healthy: event.payload.healthy }));
395
396
  return {};
396
397
  });
397
398
  },
@@ -424,7 +425,16 @@ const parent = new Actor({
424
425
 
425
426
  ### Error Handling
426
427
 
427
- Never throw from effects or transitions. Emit an error as an internal event and let a transition handle it. Errors thrown inside a handler or effect have no subscriber — the machine just stops applying it.
428
+ 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 last known good state/context, the bad event), drops remaining events, and ignores later `send`s. Errors never escape `send()` and the machine stays deterministic — same inputs, same trace.
429
+
430
+ ```ts
431
+ const snap = actor.snapshot();
432
+ if (snap.error) {
433
+ snap.error.reason; // "transition" | "effect" | "budget" | "output" | "internal" | "async"
434
+ snap.error.state.name; // last known good state
435
+ snap.error.event.type; // the bad event
436
+ }
437
+ ```
428
438
 
429
439
  Catch errors inside effects and emit recovery events:
430
440
 
@@ -448,7 +458,7 @@ setup: (m) => {
448
458
  m.on(workingState, doneEvent, () => ({ state: doneState }));
449
459
  m.on(workingState, failedEvent, (event) => {
450
460
  // handle error in transition, not in effect
451
- return { state: errorState.create({ error: event.error }) };
461
+ return { state: errorState.create({ error: event.payload.error }) };
452
462
  });
453
463
  },
454
464
  ```
@@ -460,7 +470,7 @@ try {
460
470
  const result = riskyOperation();
461
471
  emit(doneEvent.create());
462
472
  } catch (err) {
463
- throw err; // ❌ throw in effect — no handler, machine misbehaves
473
+ throw err; // ❌ throw in effect — the machine dies into __error
464
474
  }
465
475
  ```
466
476
 
@@ -468,7 +478,7 @@ try {
468
478
 
469
479
  ```ts
470
480
  m.on(idleState, submitEvent, (event) => {
471
- if (!event.data) throw new Error("missing data"); // ❌ breaks the machine
481
+ if (!event.payload.data) throw new Error("missing data"); // ❌ kills the machine
472
482
  return { state: doneState };
473
483
  });
474
484
  ```
@@ -477,8 +487,8 @@ Prefer storing the error in context and transitioning to an error state:
477
487
 
478
488
  ```ts
479
489
  m.on(idleState, submitEvent, (event, opts) => {
480
- if (!event.data) {
481
- opts!.context.error = "missing data";
490
+ if (!event.payload.data) {
491
+ opts!.context.set({ ...opts!.context.get(), error: "missing data" });
482
492
  return { state: errorState };
483
493
  }
484
494
  return { state: doneState };
@@ -493,7 +503,7 @@ Use `onAny` to intercept events across all states — useful for universal error
493
503
  setup: (m) => {
494
504
  // CANCEL works from any state
495
505
  m.onAny(cancelEvent, (_event, opts) => {
496
- opts!.context.cancelled = true;
506
+ opts!.context.set({ ...opts!.context.get(), cancelled: true });
497
507
  return { state: cancelledState };
498
508
  });
499
509
  // State-specific handlers still run for their state
@@ -507,13 +517,13 @@ setup: (m) => {
507
517
  setup: (m) => {
508
518
  // ❌ state-specific logic in onAny defeats the purpose
509
519
  m.onAny(submitBasicInfoEvent, (event, opts) => {
510
- opts!.context.basicInfo = event; // wrong — only makes sense in basicInfo state
520
+ opts!.context.set({ ...opts!.context.get(), basicInfo: event }); // wrong — only makes sense in basicInfo state
511
521
  return { state: shippingAddressState };
512
522
  });
513
523
 
514
524
  // ✅ keep state-specific logic in state handlers
515
525
  m.on(basicInfoState, submitBasicInfoEvent, (event, opts) => {
516
- opts!.context.basicInfo = event;
526
+ opts!.context.set({ ...opts!.context.get(), basicInfo: event });
517
527
  return { state: shippingAddressState };
518
528
  });
519
529
  },
@@ -20,27 +20,30 @@ declare class StateRef<T extends string, Payload = unknown, IsFinal extends bool
20
20
  }
21
21
  //#endregion
22
22
  //#region src/event.d.ts
23
- declare function event<const T extends string>(id: T): <Payload extends object | void = void>() => EventRef<T, Payload>;
23
+ declare function event<const T extends string>(type: T): <Payload extends object | void = void>() => EventRef<T, Payload>;
24
24
  type AnyEventRef = EventRef<string, object | void>;
25
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;
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;
32
34
  };
33
35
  declare class EventRef<const T extends string, Payload extends object | void = void> {
34
- readonly id: T;
35
- constructor(id: T);
36
+ readonly type: T;
37
+ constructor(type: T);
36
38
  is(anyEvent: unknown): anyEvent is CreatedOfEvent<T, Payload>;
37
39
  create(): Payload extends void ? {
38
- id: T;
40
+ type: T;
39
41
  } : void;
40
42
  create(payload: Payload): Payload extends void ? {
41
- id: T;
42
- } : Payload & {
43
- id: T;
43
+ type: T;
44
+ } : {
45
+ type: T;
46
+ payload: Payload;
44
47
  };
45
48
  }
46
49
  //#endregion
@@ -60,12 +63,58 @@ interface Clock {
60
63
  setDrain?(fn: () => void): void;
61
64
  }
62
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";
92
+ interface ErrorInfo {
93
+ error: unknown;
94
+ state: AnyStateRef;
95
+ context: unknown;
96
+ event: InternalEvent;
97
+ reason: ErrorReason;
98
+ }
99
+ type ErrorState = StateRef<"__error", unknown, false>;
100
+ type TransitionResult<AllowedState extends AnyStateRef = AnyStateRef, AllowedEmit extends string = string> = {
101
+ state?: AllowedState | {
102
+ state: AllowedState;
103
+ payload?: unknown;
104
+ };
105
+ payload?: unknown;
106
+ emit?: Array<{
107
+ type: AllowedEmit;
108
+ }>;
109
+ };
110
+ //#endregion
63
111
  //#region src/actor-internal.d.ts
64
112
  interface Snapshot<C = unknown> {
65
113
  path: string[];
66
114
  context: C;
67
115
  regions: Record<string, Snapshot<unknown>>;
68
116
  done?: boolean;
117
+ error?: ErrorInfo;
69
118
  }
70
119
  interface AnyActor<C = Record<string, unknown>> {
71
120
  state: AnyStateRef;
@@ -87,4 +136,4 @@ interface AnyActor<C = Record<string, unknown>> {
87
136
  };
88
137
  }
89
138
  //#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 };
139
+ export { AnyStateRef as _, EffectInput as a, NonFinalStateRef as c, Clock as d, AnyEventRef as f, event as g, InternalEvent as h, EffectFn as i, TransitionResult as l, EventRef as m, Snapshot as n, ErrorInfo as o, CreatedOfEvent as p, CreatedOf as r, ErrorState as s, AnyActor as t, Context as u, StateRef as v, state 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 AnyStateRef, a as EffectInput, c as NonFinalStateRef, d as Clock, f as AnyEventRef, g as event, h as InternalEvent, i as EffectFn, l as TransitionResult, m as EventRef, n as Snapshot, o as ErrorInfo, p as CreatedOfEvent, r as CreatedOf, s as ErrorState, t as AnyActor, u as Context, v as StateRef, y as state } from "./actor-internal-DYAL1iYK.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,17 +95,16 @@ 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>;
151
102
  get options(): InternalActorOptions<States, Inputs, Internal, Outputs, ActorContext>;
152
103
  constructor(options: ActorOptions<States, Inputs, Internal, Outputs, ActorContext>);
153
- on(event: "change", fn: (snapshot: Snapshot<ActorContext>, prev: Snapshot<ActorContext>) => void): () => void;
154
- on(event: "done", fn: () => void): () => void;
104
+ on(event: "change" | "done", fn: ((snapshot: Snapshot<ActorContext>, prev: Snapshot<ActorContext>) => void) | (() => void)): () => void;
155
105
  settled(): Promise<void>;
156
106
  send(event: CreatedOf$1<Inputs[number]>): void;
157
107
  snapshot(): Snapshot<ActorContext>;
158
108
  }
159
109
  //#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 };
110
+ export { Actor, type ActorBuilder, type AnyActor, type AnyEventRef, type AnyStateRef, type Clock, Context, type CreatedOf, type CreatedOfEvent, type EffectFn, type EffectInput, type ErrorInfo, type ErrorState, EventRef, type InternalEvent, type NonFinalStateRef, RealClock, type SetupFn, type Snapshot, StateRef, 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);
@@ -203,9 +204,6 @@ var InternalQueue = class {
203
204
  get length() {
204
205
  return this.#queue.length - this.#index;
205
206
  }
206
- get isProcessing() {
207
- return this.#processing;
208
- }
209
207
  push(...events) {
210
208
  if (this.#stopped) return;
211
209
  this.#queue.push(...events);
@@ -216,12 +214,6 @@ var InternalQueue = class {
216
214
  this.#settledResolvers.push(resolve);
217
215
  });
218
216
  }
219
- process(processEvent) {
220
- this.#process((event) => {
221
- processEvent(event);
222
- return true;
223
- });
224
- }
225
217
  processCancellable(processEvent) {
226
218
  this.#process(processEvent);
227
219
  }
@@ -260,7 +252,8 @@ var Subscribers = class {
260
252
  }
261
253
  addChange(fn) {
262
254
  this.change.add(fn);
263
- if (this.#last) fn(this.#last, this.#last);
255
+ const last = this.#last;
256
+ if (last) this.#safe(() => fn(last, last));
264
257
  return () => this.change.delete(fn);
265
258
  }
266
259
  addDone(fn) {
@@ -270,19 +263,23 @@ var Subscribers = class {
270
263
  emitChange(snapshot) {
271
264
  const prev = this.#last ?? snapshot;
272
265
  this.#last = snapshot;
273
- for (const fn of this.change) fn(snapshot, prev);
266
+ for (const fn of this.change) this.#safe(() => fn(snapshot, prev));
274
267
  }
275
268
  emitDone() {
276
- for (const fn of this.done) fn();
269
+ for (const fn of this.done) this.#safe(fn);
277
270
  }
278
271
  clear() {
279
272
  this.change.clear();
280
273
  this.done.clear();
281
274
  }
275
+ #safe(fn) {
276
+ const err = Either.from(fn);
277
+ if (err[0] !== void 0) console.warn(`[Actor] subscriber threw: ${err[0] instanceof Error ? err[0].message : "unknown error"}`);
278
+ }
282
279
  };
283
280
  //#endregion
284
281
  //#region src/snapshot.ts
285
- function buildSnapshot(s, regions, context) {
282
+ function buildSnapshot(s, regions, context, error) {
286
283
  const path = [s.name];
287
284
  const regionSnapshots = {};
288
285
  for (const [regionName, child] of Object.entries(regions)) regionSnapshots[regionName] = child.snapshot();
@@ -292,27 +289,40 @@ function buildSnapshot(s, regions, context) {
292
289
  regions: regionSnapshots
293
290
  };
294
291
  if (s.isFinal) snap.done = true;
292
+ if (error !== void 0) snap.error = error;
295
293
  return snap;
296
294
  }
297
295
  //#endregion
298
296
  //#region src/effects.ts
299
297
  function runEffects(options) {
300
- if (options.state.isFinal) return null;
298
+ const pending = [];
299
+ if (options.state.isFinal) return { pending };
301
300
  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;
301
+ if (!list) return { pending };
302
+ for (const effectFn of list) {
303
+ if (options.abort.signal.aborted) break;
304
+ const attempt = Either.from(() => effectFn({
305
+ signal: options.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
+ if (attempt[0] !== void 0) {
316
+ options.onError(attempt[0], options.lastGood);
317
+ break;
318
+ }
319
+ const out = attempt[1];
320
+ if (out instanceof Promise) pending.push(out.catch((error) => {
321
+ if (options.abort.signal.aborted) return;
322
+ options.onError(error, options.lastGood);
323
+ }));
324
+ }
325
+ return { pending };
316
326
  }
317
327
  //#endregion
318
328
  //#region src/dispatch.ts
@@ -350,13 +360,13 @@ var ActorBuilder = class {
350
360
  #effects = {};
351
361
  on(stateRef, eventRef, fn) {
352
362
  const sName = stateRef.name;
353
- const eId = eventRef.id;
354
- (this.#transitions[sName] ??= {})[eId] = fn;
363
+ const eType = eventRef.type;
364
+ (this.#transitions[sName] ??= {})[eType] = fn;
355
365
  return this;
356
366
  }
357
367
  onAny(eventRef, fn) {
358
- const eId = eventRef.id;
359
- (this.#transitions["Any"] ??= {})[eId] = fn;
368
+ const eType = eventRef.type;
369
+ (this.#transitions["Any"] ??= {})[eType] = fn;
360
370
  return this;
361
371
  }
362
372
  effect(stateRef, fn) {
@@ -372,11 +382,8 @@ var ActorBuilder = class {
372
382
  };
373
383
  //#endregion
374
384
  //#region src/actor.ts
375
- function pickState(initial) {
376
- return initial instanceof StateRef ? initial : initial.state;
377
- }
378
385
  function resolveInitial(initial) {
379
- return pickState(initial);
386
+ return initial instanceof StateRef ? initial : initial.state;
380
387
  }
381
388
  var Actor = class {
382
389
  state;
@@ -412,7 +419,7 @@ var Actor = class {
412
419
  setOutputHandler: (fn) => {
413
420
  this.#outputHandler = fn;
414
421
  },
415
- pushInternal: (event) => this.#pushEvent(event),
422
+ pushInternal: (event) => this.#queue.push(event),
416
423
  drainInternal: () => this.#drainInternal(),
417
424
  abortEffects: () => this.#abortEffects()
418
425
  });
@@ -424,8 +431,8 @@ var Actor = class {
424
431
  transitions: built.transitions,
425
432
  effects: built.effects
426
433
  };
427
- this.#internalIds = new Set((options.internal ?? []).map((e) => e.id));
428
- this.#inputIds = new Set(options.inputs.map((e) => e.id));
434
+ this.#internalIds = new Set((options.internal ?? []).map((e) => e.type));
435
+ this.#inputIds = new Set(options.inputs.map((e) => e.type));
429
436
  this.#internalBudget = options.internalBudget ?? 1e4;
430
437
  this.clock = options.clock ?? new RealClock();
431
438
  this.clock.setDrain?.(() => this.#drainInternal());
@@ -459,7 +466,7 @@ var Actor = class {
459
466
  return this.#subs.addDone(cb);
460
467
  }
461
468
  settled() {
462
- return this.#queue.settled();
469
+ return Promise.all([this.#queue.settled(), ...this.#pendingEffects]).then(() => void 0);
463
470
  }
464
471
  send(event) {
465
472
  this.#dispatch(event);
@@ -472,120 +479,178 @@ var Actor = class {
472
479
  this.#subs.emitChange(this.snapshot());
473
480
  }
474
481
  #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.`);
482
+ if (this.#error !== null || this.state.isFinal) return;
483
+ const prev = this.#entry;
484
+ this.#entry = {
485
+ state: this.state,
486
+ context: this.#context
487
+ };
488
+ try {
489
+ const transitions = this.#options.transitions;
490
+ const stateTransition = transitions[this.state.name]?.[event.type];
491
+ const anyTransition = transitions["Any"]?.[event.type];
492
+ const transitionApplied = stateTransition ? this.#applyStateStep(event, stateTransition) : false;
493
+ if ((this.#error === null ? this.#applyAnyStep(event, anyTransition, !transitionApplied) : false) || this.#queue.length > 0) this.#drainInternal();
494
+ else if (!stateTransition && !anyTransition) console.warn(`[Actor] no transition for event "${event.type}" in state "${this.state.name}". Event dropped.`);
495
+ } finally {
496
+ this.#entry = prev;
497
+ }
482
498
  }
483
499
  #applyStateStep(event, transition) {
484
- const step = transition(event, {
485
- context: this.#contextHandle,
486
- actor: this
500
+ let applied = false;
501
+ this.#safe("transition", event, () => {
502
+ const step = transition(event, {
503
+ context: this.#contextHandle,
504
+ actor: this
505
+ });
506
+ if (step.emit) this.#queue.push(...step.emit);
507
+ if (step.state) {
508
+ this.#applyTransition(event, step);
509
+ applied = true;
510
+ }
487
511
  });
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;
512
+ return applied;
494
513
  }
495
514
  #applyAnyStep(event, transition, allowTransition) {
496
- const step = transition(event, {
497
- context: this.#contextHandle,
498
- actor: this
515
+ if (transition === void 0) return false;
516
+ let emitted = false;
517
+ this.#safe("transition", event, () => {
518
+ const step = transition(event, {
519
+ context: this.#contextHandle,
520
+ actor: this
521
+ });
522
+ if (step.state && allowTransition) this.#applyTransition(event, step);
523
+ if (step.emit) {
524
+ this.#queue.push(...step.emit);
525
+ emitted = true;
526
+ }
499
527
  });
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;
528
+ return emitted;
506
529
  }
507
530
  snapshot() {
508
- return buildSnapshot(this.state, this.#regions, this.#context);
509
- }
510
- #pushEvent(event) {
511
- this.#queue.push(event);
531
+ return buildSnapshot(this.state, this.#regions, this.#context, this.#error ?? void 0);
512
532
  }
513
533
  #abortEffects() {
514
534
  this.#effectAbort?.abort();
515
535
  this.#subs.clear();
516
536
  }
517
537
  #applyTransition(event, step) {
518
- if (!step.state) return;
519
- this.#effectAbort?.abort();
538
+ if (this.#error !== null || !step.state) return;
520
539
  const resolved = parseTarget(step);
521
540
  if (!resolved) return;
541
+ if (!(resolved.state instanceof StateRef)) {
542
+ this.#enterError("transition", event, /* @__PURE__ */ new Error("invalid transition target"));
543
+ return;
544
+ }
545
+ this.#effectAbort?.abort();
522
546
  this.state = resolved.state;
523
547
  this.#runEffects(event, resolved.payload);
524
548
  if (this.state.isFinal) this.#subs.emitDone();
525
549
  }
526
550
  #runEffects(event, statePayload) {
527
551
  const list = this.#options.effects[this.state.name];
528
- if (!list || list.length === 0) {
552
+ if (!list) {
529
553
  this.#effectAbort = null;
530
554
  return;
531
555
  }
532
- const abort = runEffects({
556
+ const abort = new AbortController();
557
+ this.#effectAbort = abort;
558
+ const lastGood = this.#entry ?? {
559
+ state: this.state,
560
+ context: this.#context
561
+ };
562
+ const result = runEffects({
533
563
  effects: { [this.state.name]: list },
534
564
  state: this.state,
535
565
  statePayload,
536
566
  event,
537
567
  context: this.#contextHandle,
538
568
  emit: (e) => {
569
+ if (this.#error !== null) return;
539
570
  this.#queue.push(e);
540
571
  this.#drainInternal();
541
572
  },
542
- clock: this.clock
573
+ clock: this.clock,
574
+ abort,
575
+ lastGood,
576
+ onError: (error) => this.#enterError("effect", event, error, lastGood)
543
577
  });
544
- this.#effectAbort = abort;
578
+ this.#pendingEffects.push(...result.pending);
545
579
  }
546
580
  #drainInternal() {
547
581
  if (this.#draining) return;
548
582
  this.#draining = true;
549
- let budgetExceeded = false;
550
583
  try {
551
584
  const budget = this.#internalBudget;
552
585
  let count = 0;
553
586
  this.#queue.processCancellable((event) => {
587
+ if (this.#error !== null) return false;
554
588
  if (count >= budget) {
555
- budgetExceeded = true;
556
- this.#abortEffects();
589
+ this.#enterError("budget", event, /* @__PURE__ */ new Error("internal event budget exceeded"));
557
590
  return false;
558
591
  }
559
592
  count++;
560
- if (this.#internalIds.has(event.id) || this.#inputIds.has(event.id)) this.#dispatch(event);
561
- else if (this.#outputHandler) this.#outputHandler(event);
593
+ if (this.#internalIds.has(event.type) || this.#inputIds.has(event.type)) this.#dispatch(event);
594
+ else {
595
+ const outputHandler = this.#outputHandler;
596
+ if (outputHandler) {
597
+ if (!this.#safe("output", event, () => outputHandler(event))) return false;
598
+ }
599
+ }
562
600
  return true;
563
601
  });
564
602
  } finally {
565
603
  this.#draining = false;
566
604
  this.#emitChangeIfDirty();
567
605
  }
568
- if (budgetExceeded) console.warn(`[Actor] internal event budget (${this.#internalBudget}) exceeded — possible emit loop. Actor halting.`);
606
+ }
607
+ #errorState = new StateRef("__error", false);
608
+ #error = null;
609
+ #entry = null;
610
+ #pendingEffects = [];
611
+ #safe(reason, event, fn) {
612
+ const attempt = Either.from(fn);
613
+ if (attempt[0] === void 0) return true;
614
+ this.#enterError(reason, event, attempt[0]);
615
+ return false;
616
+ }
617
+ #enterError(reason, event, error, lastGood) {
618
+ if (this.#error !== null) return;
619
+ const entry = lastGood ?? this.#entry ?? {
620
+ state: this.state,
621
+ context: this.#context
622
+ };
623
+ this.#error = {
624
+ error,
625
+ state: entry.state,
626
+ context: entry.context,
627
+ event,
628
+ reason
629
+ };
630
+ this.#effectAbort?.abort();
631
+ this.state = this.#errorState;
632
+ this.#lastState = this.#errorState;
633
+ this.#contextWritten = false;
634
+ this.#subs.emitChange(this.snapshot());
569
635
  }
570
636
  };
571
637
  //#endregion
572
638
  //#region src/event.ts
573
- function event(id) {
574
- return () => new EventRef(id);
639
+ function event(type) {
640
+ return () => new EventRef(type);
575
641
  }
576
642
  var EventRef = class {
577
- id;
578
- constructor(id) {
579
- this.id = id;
643
+ type;
644
+ constructor(type) {
645
+ this.type = type;
580
646
  }
581
647
  is(anyEvent) {
582
- return !!anyEvent && typeof anyEvent === "object" && "id" in anyEvent && anyEvent.id === this.id;
648
+ return !!anyEvent && typeof anyEvent === "object" && "type" in anyEvent && anyEvent.type === this.type;
583
649
  }
584
650
  create(payload) {
585
- if (payload === void 0) return { id: this.id };
586
- return {
587
- ...payload,
588
- id: this.id
651
+ return payload === void 0 ? { type: this.type } : {
652
+ type: this.type,
653
+ payload
589
654
  };
590
655
  }
591
656
  };
@@ -1,4 +1,4 @@
1
- import { s as InternalEvent, t as AnyActor } from "./actor-internal-CBU_EDPZ.mjs";
1
+ import { h as InternalEvent, t as AnyActor } from "./actor-internal-DYAL1iYK.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.1.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",