effect-machine 0.9.0 → 0.10.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.
Files changed (62) hide show
  1. package/README.md +76 -16
  2. package/dist/actor.d.ts +55 -89
  3. package/dist/actor.js +135 -30
  4. package/dist/cluster/entity-machine.js +5 -3
  5. package/dist/errors.d.ts +12 -1
  6. package/dist/errors.js +8 -1
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +2 -2
  9. package/dist/internal/transition.d.ts +26 -2
  10. package/dist/internal/transition.js +37 -8
  11. package/dist/internal/utils.d.ts +7 -2
  12. package/dist/machine.d.ts +66 -3
  13. package/dist/machine.js +65 -0
  14. package/dist/persistence/persistent-actor.js +52 -16
  15. package/dist/testing.js +57 -3
  16. package/package.json +9 -8
  17. package/{dist-v3 → v3/dist}/actor.d.ts +65 -78
  18. package/{dist-v3 → v3/dist}/actor.js +173 -35
  19. package/{dist-v3 → v3/dist}/cluster/entity-machine.d.ts +1 -1
  20. package/{dist-v3 → v3/dist}/cluster/entity-machine.js +8 -6
  21. package/{dist-v3 → v3/dist}/cluster/to-entity.d.ts +1 -1
  22. package/{dist-v3 → v3/dist}/cluster/to-entity.js +1 -1
  23. package/v3/dist/errors.d.ts +76 -0
  24. package/{dist-v3 → v3/dist}/errors.js +9 -2
  25. package/v3/dist/index.d.ts +13 -0
  26. package/v3/dist/index.js +13 -0
  27. package/{dist-v3 → v3/dist}/inspection.d.ts +53 -8
  28. package/v3/dist/inspection.js +156 -0
  29. package/{dist-v3 → v3/dist}/internal/brands.d.ts +1 -1
  30. package/{dist-v3 → v3/dist}/internal/inspection.d.ts +1 -1
  31. package/v3/dist/internal/inspection.js +20 -0
  32. package/{dist-v3 → v3/dist}/internal/transition.d.ts +35 -11
  33. package/{dist-v3 → v3/dist}/internal/transition.js +47 -15
  34. package/{dist-v3 → v3/dist}/internal/utils.d.ts +9 -4
  35. package/{dist-v3 → v3/dist}/internal/utils.js +1 -1
  36. package/{dist-v3 → v3/dist}/machine.d.ts +86 -9
  37. package/{dist-v3 → v3/dist}/machine.js +128 -2
  38. package/{dist-v3 → v3/dist}/persistence/adapter.d.ts +18 -5
  39. package/{dist-v3 → v3/dist}/persistence/adapter.js +1 -1
  40. package/{dist-v3 → v3/dist}/persistence/adapters/in-memory.d.ts +1 -1
  41. package/{dist-v3 → v3/dist}/persistence/adapters/in-memory.js +1 -1
  42. package/{dist-v3 → v3/dist}/persistence/persistent-actor.d.ts +7 -6
  43. package/{dist-v3 → v3/dist}/persistence/persistent-actor.js +58 -19
  44. package/{dist-v3 → v3/dist}/persistence/persistent-machine.d.ts +1 -1
  45. package/{dist-v3 → v3/dist}/persistence/persistent-machine.js +1 -1
  46. package/{dist-v3 → v3/dist}/schema.d.ts +1 -1
  47. package/{dist-v3 → v3/dist}/schema.js +5 -2
  48. package/{dist-v3 → v3/dist}/slot.d.ts +4 -3
  49. package/{dist-v3 → v3/dist}/slot.js +1 -1
  50. package/{dist-v3 → v3/dist}/testing.d.ts +14 -8
  51. package/{dist-v3 → v3/dist}/testing.js +60 -6
  52. package/dist-v3/errors.d.ts +0 -27
  53. package/dist-v3/index.d.ts +0 -12
  54. package/dist-v3/index.js +0 -13
  55. package/dist-v3/inspection.js +0 -48
  56. package/dist-v3/internal/inspection.js +0 -13
  57. /package/{dist-v3 → v3/dist}/_virtual/_rolldown/runtime.js +0 -0
  58. /package/{dist-v3 → v3/dist}/cluster/index.d.ts +0 -0
  59. /package/{dist-v3 → v3/dist}/cluster/index.js +0 -0
  60. /package/{dist-v3 → v3/dist}/internal/brands.js +0 -0
  61. /package/{dist-v3 → v3/dist}/persistence/index.d.ts +0 -0
  62. /package/{dist-v3 → v3/dist}/persistence/index.js +0 -0
package/README.md CHANGED
@@ -74,8 +74,12 @@ const orderMachine = Machine.make({
74
74
  const program = Effect.gen(function* () {
75
75
  const actor = yield* Machine.spawn(orderMachine);
76
76
 
77
+ // fire-and-forget
77
78
  yield* actor.send(OrderEvent.Process);
78
- yield* actor.send(OrderEvent.Ship({ trackingId: "TRACK-123" }));
79
+
80
+ // request-reply — get ProcessEventResult back
81
+ const result = yield* actor.call(OrderEvent.Ship({ trackingId: "TRACK-123" }));
82
+ console.log(result.transitioned); // true
79
83
 
80
84
  const state = yield* actor.waitFor(OrderState.Shipped);
81
85
  console.log(state); // Shipped { orderId: "order-1", trackingId: "TRACK-123" }
@@ -183,6 +187,53 @@ machine.task(State.Loading, ({ effects, state }) => effects.fetchData({ url: sta
183
187
  });
184
188
  ```
185
189
 
190
+ ### State Timeouts
191
+
192
+ `.timeout()` — gen_statem-style state timeouts. Timer starts on state entry, cancels on exit:
193
+
194
+ ```ts
195
+ machine
196
+ .timeout(State.Loading, {
197
+ duration: Duration.seconds(30),
198
+ event: Event.Timeout,
199
+ })
200
+ // Dynamic duration from state
201
+ .timeout(State.Retrying, {
202
+ duration: (state) => Duration.seconds(state.backoff),
203
+ event: Event.GiveUp,
204
+ });
205
+ ```
206
+
207
+ `.reenter()` restarts the timer with fresh state values.
208
+
209
+ ### Event Postpone
210
+
211
+ `.postpone()` — gen_statem-style event postpone. When a matching event arrives in the given state, it is buffered. After the next state transition (tag change), buffered events drain in FIFO order:
212
+
213
+ ```ts
214
+ machine
215
+ .postpone(State.Connecting, Event.Data) // single event
216
+ .postpone(State.Connecting, [Event.Data, Event.Cmd]); // multiple events
217
+ ```
218
+
219
+ Reply-bearing events (`call`/`ask`) in the postpone buffer are settled with `ActorStoppedError` on stop/interrupt/final-state.
220
+
221
+ ### ask / reply
222
+
223
+ Handlers can return a domain reply via `{ state, reply }`:
224
+
225
+ ```ts
226
+ .on(State.Active, Event.GetCount, ({ state }) => ({
227
+ state, // stay in same state
228
+ reply: state.count, // domain value returned to caller
229
+ }))
230
+
231
+ // Caller side:
232
+ const count = yield* actor.ask<number>(Event.GetCount);
233
+ ```
234
+
235
+ `ask` fails with `NoReplyError` if the handler doesn't provide a reply, and `ActorStoppedError` if the actor stops while the request is pending.
236
+
186
237
  ### Child Actors
187
238
 
188
239
  Spawn children from `.spawn()` handlers with `self.spawn`. Children are state-scoped — auto-stopped on state exit:
@@ -253,7 +304,7 @@ See the [primer](./primer/) for comprehensive documentation:
253
304
  | ----------- | ----------------------------------------- | ------------------------------ |
254
305
  | Overview | [index.md](./primer/index.md) | Navigation and quick reference |
255
306
  | Basics | [basics.md](./primer/basics.md) | Core concepts |
256
- | Handlers | [handlers.md](./primer/handlers.md) | Transitions and guards |
307
+ | Handlers | [handlers.md](./primer/handlers.md) | Transitions, guards, reply |
257
308
  | Effects | [effects.md](./primer/effects.md) | spawn, background, timeouts |
258
309
  | Testing | [testing.md](./primer/testing.md) | simulate, harness, assertions |
259
310
  | Actors | [actors.md](./primer/actors.md) | ActorSystem, ActorRef |
@@ -273,6 +324,8 @@ See the [primer](./primer/) for comprehensive documentation:
273
324
  | `.reenter(State.X, Event.Y, handler)` | Force re-entry on same state |
274
325
  | `.spawn(State.X, handler)` | State-scoped effect |
275
326
  | `.task(State.X, run, { onSuccess })` | State-scoped task |
327
+ | `.timeout(State.X, { duration, event })` | State timeout (gen_statem) |
328
+ | `.postpone(State.X, Event.Y)` | Postpone event in state (gen_statem) |
276
329
  | `.background(handler)` | Machine-lifetime effect |
277
330
  | `.final(State.X)` | Mark final state |
278
331
  | `.build({ slot: impl })` | Provide implementations, returns `BuiltMachine` (terminal) |
@@ -308,20 +361,27 @@ See the [primer](./primer/) for comprehensive documentation:
308
361
 
309
362
  ### Actor
310
363
 
311
- | Method | Description |
312
- | -------------------------------- | ---------------------------------- |
313
- | `actor.send(event)` | Queue event |
314
- | `actor.sendSync(event)` | Fire-and-forget (sync, for UI) |
315
- | `actor.snapshot` | Get current state |
316
- | `actor.matches(tag)` | Check state tag |
317
- | `actor.can(event)` | Can handle event? |
318
- | `actor.changes` | Stream of changes |
319
- | `actor.waitFor(State.X)` | Wait for state (constructor or fn) |
320
- | `actor.awaitFinal` | Wait final state |
321
- | `actor.sendAndWait(ev, State.X)` | Send + wait for state |
322
- | `actor.subscribe(fn)` | Sync callback |
323
- | `actor.system` | Access the actor's `ActorSystem` |
324
- | `actor.children` | Child actors (`ReadonlyMap`) |
364
+ | Method | Description |
365
+ | -------------------------------- | ------------------------------------------- |
366
+ | `actor.send(event)` | Fire-and-forget (queue event) |
367
+ | `actor.cast(event)` | Alias for send (OTP gen_server:cast) |
368
+ | `actor.call(event)` | Request-reply, returns `ProcessEventResult` |
369
+ | `actor.ask<R>(event)` | Typed domain reply from handler |
370
+ | `actor.snapshot` | Get current state |
371
+ | `actor.matches(tag)` | Check state tag |
372
+ | `actor.can(event)` | Can handle event? |
373
+ | `actor.changes` | Stream of changes |
374
+ | `actor.waitFor(State.X)` | Wait for state (constructor or fn) |
375
+ | `actor.awaitFinal` | Wait final state |
376
+ | `actor.sendAndWait(ev, State.X)` | Send + wait for state |
377
+ | `actor.subscribe(fn)` | Sync callback |
378
+ | `actor.sync.send(event)` | Sync fire-and-forget (for UI) |
379
+ | `actor.sync.stop()` | Sync stop |
380
+ | `actor.sync.snapshot()` | Sync get state |
381
+ | `actor.sync.matches(tag)` | Sync check state tag |
382
+ | `actor.sync.can(event)` | Sync can handle event? |
383
+ | `actor.system` | Access the actor's `ActorSystem` |
384
+ | `actor.children` | Child actors (`ReadonlyMap`) |
325
385
 
326
386
  ### ActorSystem
327
387
 
package/dist/actor.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { EffectsDef, GuardsDef, MachineContext } from "./slot.js";
2
2
  import { PersistentMachine } from "./persistence/persistent-machine.js";
3
- import { DuplicateActorError } from "./errors.js";
3
+ import { ActorStoppedError, DuplicateActorError, NoReplyError } from "./errors.js";
4
4
  import { ProcessEventError, ProcessEventHooks, ProcessEventResult, processEventCore, resolveTransition, runSpawnEffects } from "./internal/transition.js";
5
5
  import { PersistentActorRef } from "./persistence/persistent-actor.js";
6
6
  import { ActorMetadata, PersistenceAdapterTag, PersistenceError, RestoreResult, VersionConflictError } from "./persistence/adapter.js";
@@ -9,87 +9,77 @@ import { Deferred, Effect, Layer, Option, Queue, Ref, Scope, ServiceMap, Stream,
9
9
  import * as effect_Tracer0 from "effect/Tracer";
10
10
 
11
11
  //#region src/actor.d.ts
12
- /** Queued event with optional reply channel */
13
- interface QueuedEvent<E> {
12
+ /** Discriminated mailbox request */
13
+ type QueuedEvent<E> = {
14
+ readonly _tag: "send";
14
15
  readonly event: E;
15
- readonly reply?: Deferred.Deferred<ProcessEventResult<{
16
+ } | {
17
+ readonly _tag: "call";
18
+ readonly event: E;
19
+ readonly reply: Deferred.Deferred<ProcessEventResult<{
16
20
  readonly _tag: string;
17
- }>>;
18
- }
21
+ }>, ActorStoppedError>;
22
+ } | {
23
+ readonly _tag: "ask";
24
+ readonly event: E;
25
+ readonly reply: Deferred.Deferred<unknown, NoReplyError | ActorStoppedError>;
26
+ };
19
27
  /**
20
28
  * Reference to a running actor.
21
29
  */
30
+ /**
31
+ * Sync projection of ActorRef for non-Effect boundaries (React hooks, framework callbacks).
32
+ */
33
+ interface ActorRefSync<State extends {
34
+ readonly _tag: string;
35
+ }, Event> {
36
+ readonly send: (event: Event) => void;
37
+ readonly stop: () => void;
38
+ readonly snapshot: () => State;
39
+ readonly matches: (tag: State["_tag"]) => boolean;
40
+ readonly can: (event: Event) => boolean;
41
+ }
22
42
  interface ActorRef<State extends {
23
43
  readonly _tag: string;
24
44
  }, Event> {
25
- /**
26
- * Unique identifier for this actor
27
- */
28
45
  readonly id: string;
29
- /**
30
- * Send an event to the actor
31
- */
46
+ /** Send an event (fire-and-forget). */
32
47
  readonly send: (event: Event) => Effect.Effect<void>;
48
+ /** Fire-and-forget alias for send (OTP gen_server:cast). */
49
+ readonly cast: (event: Event) => Effect.Effect<void>;
33
50
  /**
34
- * Observable state of the actor
51
+ * Serialized request-reply (OTP gen_server:call).
52
+ * Event is processed through the queue; caller gets ProcessEventResult back.
35
53
  */
36
- readonly state: SubscriptionRef.SubscriptionRef<State>;
54
+ readonly call: (event: Event) => Effect.Effect<ProcessEventResult<State>>;
37
55
  /**
38
- * Stop the actor gracefully
56
+ * Typed request-reply. Event is processed through the queue; caller gets
57
+ * the domain value returned by the handler's `reply` field.
58
+ * Fails with NoReplyError if the handler doesn't provide a reply.
39
59
  */
60
+ readonly ask: <R>(event: Event) => Effect.Effect<R, NoReplyError | ActorStoppedError>;
61
+ /** Observable state. */
62
+ readonly state: SubscriptionRef.SubscriptionRef<State>;
63
+ /** Stop the actor gracefully. */
40
64
  readonly stop: Effect.Effect<void>;
41
- /**
42
- * Stop the actor (fire-and-forget).
43
- * Signals graceful shutdown without waiting for completion.
44
- * Use when stopping from sync contexts (e.g. framework cleanup hooks).
45
- */
46
- readonly stopSync: () => void;
47
- /**
48
- * Get current state snapshot (Effect)
49
- */
65
+ /** Get current state snapshot. */
50
66
  readonly snapshot: Effect.Effect<State>;
51
- /**
52
- * Get current state snapshot (sync)
53
- */
54
- readonly snapshotSync: () => State;
55
- /**
56
- * Check if current state matches tag (Effect)
57
- */
67
+ /** Check if current state matches tag. */
58
68
  readonly matches: (tag: State["_tag"]) => Effect.Effect<boolean>;
59
- /**
60
- * Check if current state matches tag (sync)
61
- */
62
- readonly matchesSync: (tag: State["_tag"]) => boolean;
63
- /**
64
- * Check if event can be handled in current state (Effect)
65
- */
69
+ /** Check if event can be handled in current state. */
66
70
  readonly can: (event: Event) => Effect.Effect<boolean>;
67
- /**
68
- * Check if event can be handled in current state (sync)
69
- */
70
- readonly canSync: (event: Event) => boolean;
71
- /**
72
- * Stream of state changes
73
- */
71
+ /** Stream of state changes. */
74
72
  readonly changes: Stream.Stream<State>;
75
- /**
76
- * Wait for a state that matches predicate or state variant (includes current snapshot).
77
- * Accepts a predicate function or a state constructor/value (e.g. `State.Active`).
78
- */
73
+ /** Wait for a state matching predicate or variant (includes current snapshot). */
79
74
  readonly waitFor: {
80
75
  (predicate: (state: State) => boolean): Effect.Effect<State>;
81
76
  (state: {
82
77
  readonly _tag: State["_tag"];
83
78
  }): Effect.Effect<State>;
84
79
  };
85
- /**
86
- * Wait for a final state (includes current snapshot)
87
- */
80
+ /** Wait for a final state (includes current snapshot). */
88
81
  readonly awaitFinal: Effect.Effect<State>;
89
- /**
90
- * Send event and wait for predicate, state variant, or final state.
91
- * Accepts a predicate function or a state constructor/value (e.g. `State.Active`).
92
- */
82
+ /** Send event and wait for predicate, state variant, or final state. */
93
83
  readonly sendAndWait: {
94
84
  (event: Event, predicate: (state: State) => boolean): Effect.Effect<State>;
95
85
  (event: Event, state: {
@@ -97,39 +87,13 @@ interface ActorRef<State extends {
97
87
  }): Effect.Effect<State>;
98
88
  (event: Event): Effect.Effect<State>;
99
89
  };
100
- /**
101
- * Send event synchronously (fire-and-forget).
102
- * No-op on stopped actors. Use when you need to send from sync contexts
103
- * (e.g. framework hooks, event handlers).
104
- */
105
- readonly sendSync: (event: Event) => void;
106
- /**
107
- * Send event and wait for the transition result (synchronous processing).
108
- * The event is processed through the queue (preserving serialization)
109
- * but the caller gets back the ProcessEventResult.
110
- *
111
- * OTP gen_server:call equivalent — use when you need to know what happened.
112
- */
113
- readonly dispatch: (event: Event) => Effect.Effect<ProcessEventResult<State>>;
114
- /**
115
- * Promise-based dispatch — send event and get back the transition result.
116
- * Use at non-Effect boundaries (React event handlers, framework hooks, tests).
117
- */
118
- readonly dispatchPromise: (event: Event) => Promise<ProcessEventResult<State>>;
119
- /**
120
- * Subscribe to state changes (sync callback)
121
- * Returns unsubscribe function
122
- */
90
+ /** Subscribe to state changes (sync callback). Returns unsubscribe function. */
123
91
  readonly subscribe: (fn: (state: State) => void) => () => void;
124
- /**
125
- * The actor system this actor belongs to.
126
- * Every actor always has a system either inherited from context or implicitly created.
127
- */
92
+ /** Sync helpers for non-Effect boundaries. */
93
+ readonly sync: ActorRefSync<State, Event>;
94
+ /** The actor system this actor belongs to. */
128
95
  readonly system: ActorSystem;
129
- /**
130
- * Child actors spawned via `self.spawn` in this actor's handlers.
131
- * State-scoped children are auto-removed on state exit.
132
- */
96
+ /** Child actors spawned via `self.spawn` in this actor's handlers. */
133
97
  readonly children: ReadonlyMap<string, ActorRef<AnyState, unknown>>;
134
98
  }
135
99
  /** Base type for stored actors (internal) */
@@ -295,7 +259,7 @@ declare const buildActorRefCore: <S extends {
295
259
  readonly _tag: string;
296
260
  }, E extends {
297
261
  readonly _tag: string;
298
- }, R, GD extends GuardsDef, EFD extends EffectsDef>(id: string, machine: Machine<S, E, R, any, any, GD, EFD>, stateRef: SubscriptionRef.SubscriptionRef<S>, eventQueue: Queue.Queue<QueuedEvent<E>>, stoppedRef: Ref.Ref<boolean>, listeners: Listeners<S>, stop: Effect.Effect<void>, system: ActorSystem, childrenMap: ReadonlyMap<string, ActorRef<AnyState, unknown>>) => ActorRef<S, E>;
262
+ }, R, GD extends GuardsDef, EFD extends EffectsDef>(id: string, machine: Machine<S, E, R, any, any, GD, EFD>, stateRef: SubscriptionRef.SubscriptionRef<S>, eventQueue: Queue.Queue<QueuedEvent<E>>, stoppedRef: Ref.Ref<boolean>, listeners: Listeners<S>, stop: Effect.Effect<void>, system: ActorSystem, childrenMap: ReadonlyMap<string, ActorRef<AnyState, unknown>>, pendingReplies: Set<Deferred.Deferred<unknown, unknown>>) => ActorRef<S, E>;
299
263
  /**
300
264
  * Create and start an actor for a machine
301
265
  */
@@ -304,9 +268,11 @@ declare const createActor: <S extends {
304
268
  }, E extends {
305
269
  readonly _tag: string;
306
270
  }, R, GD extends GuardsDef, EFD extends EffectsDef>(id: string, machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>) => Effect.Effect<ActorRef<S, E>, never, Exclude<R, MachineContext<S, E, MachineRef<E>>> | Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, effect_Tracer0.ParentSpan> | Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope> | Exclude<Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>, effect_Tracer0.ParentSpan>>;
271
+ /** Fail all pending call/ask Deferreds with ActorStoppedError. Safe to call multiple times. */
272
+ declare const settlePendingReplies: (pendingReplies: Set<Deferred.Deferred<unknown, unknown>>, actorId: string) => Effect.Effect<void, never, never>;
307
273
  /**
308
274
  * Default ActorSystem layer
309
275
  */
310
276
  declare const Default: Layer.Layer<ActorSystem, never, never>;
311
277
  //#endregion
312
- export { ActorRef, ActorSystem, Default, Listeners, type ProcessEventError, type ProcessEventHooks, type ProcessEventResult, QueuedEvent, SystemEvent, SystemEventListener, buildActorRefCore, createActor, notifyListeners, processEventCore, resolveTransition, runSpawnEffects };
278
+ export { ActorRef, ActorRefSync, ActorSystem, Default, Listeners, type ProcessEventError, type ProcessEventHooks, type ProcessEventResult, QueuedEvent, SystemEvent, SystemEventListener, buildActorRefCore, createActor, notifyListeners, processEventCore, resolveTransition, runSpawnEffects, settlePendingReplies };
package/dist/actor.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Inspector } from "./inspection.js";
2
2
  import { INTERNAL_INIT_EVENT } from "./internal/utils.js";
3
- import { DuplicateActorError } from "./errors.js";
3
+ import { ActorStoppedError, DuplicateActorError, NoReplyError } from "./errors.js";
4
4
  import { isPersistentMachine } from "./persistence/persistent-machine.js";
5
5
  import { emitWithTimestamp } from "./internal/inspection.js";
6
- import { processEventCore, resolveTransition, runSpawnEffects } from "./internal/transition.js";
6
+ import { processEventCore, resolveTransition, runSpawnEffects, shouldPostpone } from "./internal/transition.js";
7
7
  import { PersistenceAdapterTag, PersistenceError } from "./persistence/adapter.js";
8
8
  import { createPersistentActor, restorePersistentActor } from "./persistence/persistent-actor.js";
9
9
  import { Cause, Deferred, Effect, Exit, Fiber, Layer, MutableHashMap, Option, PubSub, Queue, Ref, Scope, Semaphore, ServiceMap, Stream, SubscriptionRef } from "effect";
@@ -31,12 +31,15 @@ const notifyListeners = (listeners, state) => {
31
31
  /**
32
32
  * Build core ActorRef methods shared between regular and persistent actors.
33
33
  */
34
- const buildActorRefCore = (id, machine, stateRef, eventQueue, stoppedRef, listeners, stop, system, childrenMap) => {
34
+ const buildActorRefCore = (id, machine, stateRef, eventQueue, stoppedRef, listeners, stop, system, childrenMap, pendingReplies) => {
35
35
  const send = Effect.fn("effect-machine.actor.send")(function* (event) {
36
36
  if (yield* Ref.get(stoppedRef)) return;
37
- yield* Queue.offer(eventQueue, { event });
37
+ yield* Queue.offer(eventQueue, {
38
+ _tag: "send",
39
+ event
40
+ });
38
41
  });
39
- const dispatch = Effect.fn("effect-machine.actor.dispatch")(function* (event) {
42
+ const call = Effect.fn("effect-machine.actor.call")(function* (event) {
40
43
  if (yield* Ref.get(stoppedRef)) {
41
44
  const currentState = yield* SubscriptionRef.get(stateRef);
42
45
  return {
@@ -48,11 +51,30 @@ const buildActorRefCore = (id, machine, stateRef, eventQueue, stoppedRef, listen
48
51
  };
49
52
  }
50
53
  const reply = yield* Deferred.make();
54
+ pendingReplies.add(reply);
55
+ yield* Queue.offer(eventQueue, {
56
+ _tag: "call",
57
+ event,
58
+ reply
59
+ });
60
+ return yield* Deferred.await(reply).pipe(Effect.ensuring(Effect.sync(() => pendingReplies.delete(reply))), Effect.catchTag("ActorStoppedError", () => SubscriptionRef.get(stateRef).pipe(Effect.map((currentState) => ({
61
+ newState: currentState,
62
+ previousState: currentState,
63
+ transitioned: false,
64
+ lifecycleRan: false,
65
+ isFinal: machine.finalStates.has(currentState._tag)
66
+ })))));
67
+ });
68
+ const ask = Effect.fn("effect-machine.actor.ask")(function* (event) {
69
+ if (yield* Ref.get(stoppedRef)) return yield* new ActorStoppedError({ actorId: id });
70
+ const reply = yield* Deferred.make();
71
+ pendingReplies.add(reply);
51
72
  yield* Queue.offer(eventQueue, {
73
+ _tag: "ask",
52
74
  event,
53
75
  reply
54
76
  });
55
- return yield* Deferred.await(reply);
77
+ return yield* Deferred.await(reply).pipe(Effect.ensuring(Effect.sync(() => pendingReplies.delete(reply))));
56
78
  });
57
79
  const snapshot = SubscriptionRef.get(stateRef).pipe(Effect.withSpan("effect-machine.actor.snapshot"));
58
80
  const matches = Effect.fn("effect-machine.actor.matches")(function* (tag) {
@@ -88,32 +110,38 @@ const buildActorRefCore = (id, machine, stateRef, eventQueue, stoppedRef, listen
88
110
  return {
89
111
  id,
90
112
  send,
113
+ cast: send,
114
+ call,
115
+ ask,
91
116
  state: stateRef,
92
117
  stop,
93
- stopSync: () => Effect.runFork(stop),
94
118
  snapshot,
95
- snapshotSync: () => Effect.runSync(SubscriptionRef.get(stateRef)),
96
119
  matches,
97
- matchesSync: (tag) => Effect.runSync(SubscriptionRef.get(stateRef))._tag === tag,
98
120
  can,
99
- canSync: (event) => {
100
- return resolveTransition(machine, Effect.runSync(SubscriptionRef.get(stateRef)), event) !== void 0;
101
- },
102
121
  changes: SubscriptionRef.changes(stateRef),
103
122
  waitFor,
104
123
  awaitFinal,
105
124
  sendAndWait,
106
- sendSync: (event) => {
107
- if (!Effect.runSync(Ref.get(stoppedRef))) Effect.runSync(Queue.offer(eventQueue, { event }));
108
- },
109
- dispatch,
110
- dispatchPromise: (event) => Effect.runPromise(dispatch(event)),
111
125
  subscribe: (fn) => {
112
126
  listeners.add(fn);
113
127
  return () => {
114
128
  listeners.delete(fn);
115
129
  };
116
130
  },
131
+ sync: {
132
+ send: (event) => {
133
+ if (!Effect.runSync(Ref.get(stoppedRef))) Effect.runSync(Queue.offer(eventQueue, {
134
+ _tag: "send",
135
+ event
136
+ }));
137
+ },
138
+ stop: () => Effect.runFork(stop),
139
+ snapshot: () => Effect.runSync(SubscriptionRef.get(stateRef)),
140
+ matches: (tag) => Effect.runSync(SubscriptionRef.get(stateRef))._tag === tag,
141
+ can: (event) => {
142
+ return resolveTransition(machine, Effect.runSync(SubscriptionRef.get(stateRef)), event) !== void 0;
143
+ }
144
+ },
117
145
  system,
118
146
  children: childrenMap
119
147
  };
@@ -136,11 +164,16 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
136
164
  const eventQueue = yield* Queue.unbounded();
137
165
  const stoppedRef = yield* Ref.make(false);
138
166
  const childrenMap = /* @__PURE__ */ new Map();
167
+ const selfSend = Effect.fn("effect-machine.actor.self.send")(function* (event) {
168
+ if (yield* Ref.get(stoppedRef)) return;
169
+ yield* Queue.offer(eventQueue, {
170
+ _tag: "send",
171
+ event
172
+ });
173
+ });
139
174
  const self = {
140
- send: Effect.fn("effect-machine.actor.self.send")(function* (event) {
141
- if (yield* Ref.get(stoppedRef)) return;
142
- yield* Queue.offer(eventQueue, { event });
143
- }),
175
+ send: selfSend,
176
+ cast: selfSend,
144
177
  spawn: (childId, childMachine) => Effect.gen(function* () {
145
178
  const child = yield* system.spawn(childId, childMachine).pipe(Effect.provideService(ActorSystem, system));
146
179
  childrenMap.set(childId, child);
@@ -194,9 +227,10 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
194
227
  }));
195
228
  yield* Ref.set(stoppedRef, true);
196
229
  if (implicitSystemScope !== void 0) yield* Scope.close(implicitSystemScope, Exit.void);
197
- return buildActorRefCore(id, machine, stateRef, eventQueue, stoppedRef, listeners, Ref.set(stoppedRef, true).pipe(Effect.withSpan("effect-machine.actor.stop"), Effect.asVoid), system, childrenMap);
230
+ return buildActorRefCore(id, machine, stateRef, eventQueue, stoppedRef, listeners, Ref.set(stoppedRef, true).pipe(Effect.withSpan("effect-machine.actor.stop"), Effect.asVoid), system, childrenMap, /* @__PURE__ */ new Set());
198
231
  }
199
- const loopFiber = yield* Effect.forkDetach(eventLoop(machine, stateRef, eventQueue, stoppedRef, self, listeners, backgroundFibers, stateScopeRef, id, inspectorValue, system));
232
+ const pendingReplies = /* @__PURE__ */ new Set();
233
+ const loopFiber = yield* Effect.forkDetach(eventLoop(machine, stateRef, eventQueue, stoppedRef, self, listeners, backgroundFibers, stateScopeRef, id, inspectorValue, system, pendingReplies));
200
234
  return buildActorRefCore(id, machine, stateRef, eventQueue, stoppedRef, listeners, Effect.gen(function* () {
201
235
  const finalState = yield* SubscriptionRef.get(stateRef);
202
236
  yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
@@ -207,33 +241,104 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
207
241
  }));
208
242
  yield* Ref.set(stoppedRef, true);
209
243
  yield* Fiber.interrupt(loopFiber);
244
+ yield* settlePendingReplies(pendingReplies, id);
210
245
  yield* Scope.close(stateScopeRef.current, Exit.void);
211
246
  yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
212
247
  if (implicitSystemScope !== void 0) yield* Scope.close(implicitSystemScope, Exit.void);
213
- }).pipe(Effect.withSpan("effect-machine.actor.stop"), Effect.asVoid), system, childrenMap);
248
+ }).pipe(Effect.withSpan("effect-machine.actor.stop"), Effect.asVoid), system, childrenMap, pendingReplies);
249
+ });
250
+ /** Fail all pending call/ask Deferreds with ActorStoppedError. Safe to call multiple times. */
251
+ const settlePendingReplies = (pendingReplies, actorId) => Effect.sync(() => {
252
+ const error = new ActorStoppedError({ actorId });
253
+ for (const deferred of pendingReplies) Effect.runFork(Deferred.fail(deferred, error));
254
+ pendingReplies.clear();
214
255
  });
215
256
  /**
216
- * Main event loop for the actor
257
+ * Main event loop for the actor.
258
+ * Includes postpone buffer — events matching postpone rules are buffered
259
+ * and drained after state tag changes (gen_statem semantics).
217
260
  */
218
- const eventLoop = Effect.fn("effect-machine.actor.eventLoop")(function* (machine, stateRef, eventQueue, stoppedRef, self, listeners, backgroundFibers, stateScopeRef, actorId, inspector, system) {
219
- while (true) {
220
- const { event, reply } = yield* Queue.take(eventQueue);
261
+ const eventLoop = Effect.fn("effect-machine.actor.eventLoop")(function* (machine, stateRef, eventQueue, stoppedRef, self, listeners, backgroundFibers, stateScopeRef, actorId, inspector, system, pendingReplies) {
262
+ const postponed = [];
263
+ const hasPostponeRules = machine.postponeRules.length > 0;
264
+ const processQueued = Effect.fn("effect-machine.actor.processQueued")(function* (queued) {
265
+ const event = queued.event;
221
266
  const currentState = yield* SubscriptionRef.get(stateRef);
267
+ if (hasPostponeRules && shouldPostpone(machine, currentState._tag, event._tag)) {
268
+ postponed.push(queued);
269
+ if (queued._tag === "call") {
270
+ const postponedResult = {
271
+ newState: currentState,
272
+ previousState: currentState,
273
+ transitioned: false,
274
+ lifecycleRan: false,
275
+ isFinal: false,
276
+ hasReply: false,
277
+ reply: void 0,
278
+ postponed: true
279
+ };
280
+ yield* Deferred.succeed(queued.reply, postponedResult);
281
+ }
282
+ return {
283
+ shouldStop: false,
284
+ stateChanged: false
285
+ };
286
+ }
222
287
  const { shouldStop, result } = yield* Effect.withSpan("effect-machine.event.process", { attributes: {
223
288
  "effect_machine.actor.id": actorId,
224
289
  "effect_machine.state.current": currentState._tag,
225
290
  "effect_machine.event.type": event._tag
226
291
  } })(processEvent(machine, currentState, event, stateRef, self, listeners, stateScopeRef, actorId, inspector, system));
227
- if (reply !== void 0) yield* Deferred.succeed(reply, result);
292
+ switch (queued._tag) {
293
+ case "call":
294
+ yield* Deferred.succeed(queued.reply, result);
295
+ break;
296
+ case "ask":
297
+ if (result.hasReply) yield* Deferred.succeed(queued.reply, result.reply);
298
+ else yield* Deferred.fail(queued.reply, new NoReplyError({
299
+ actorId,
300
+ eventTag: event._tag
301
+ }));
302
+ break;
303
+ }
304
+ return {
305
+ shouldStop,
306
+ stateChanged: result.lifecycleRan
307
+ };
308
+ });
309
+ while (true) {
310
+ const { shouldStop, stateChanged } = yield* processQueued(yield* Queue.take(eventQueue));
228
311
  if (shouldStop) {
229
312
  yield* Ref.set(stoppedRef, true);
313
+ settlePostponedBuffer(postponed, pendingReplies, actorId);
314
+ yield* settlePendingReplies(pendingReplies, actorId);
230
315
  yield* Scope.close(stateScopeRef.current, Exit.void);
231
316
  yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
232
317
  return;
233
318
  }
319
+ if (stateChanged && postponed.length > 0) {
320
+ const drained = postponed.splice(0);
321
+ for (const entry of drained) if ((yield* processQueued(entry)).shouldStop) {
322
+ yield* Ref.set(stoppedRef, true);
323
+ settlePostponedBuffer(postponed, pendingReplies, actorId);
324
+ yield* settlePendingReplies(pendingReplies, actorId);
325
+ yield* Scope.close(stateScopeRef.current, Exit.void);
326
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
327
+ return;
328
+ }
329
+ }
234
330
  }
235
331
  });
236
332
  /**
333
+ * Settle all reply-bearing entries in the postpone buffer on shutdown.
334
+ * Call entries already had their Deferred settled with the postponed result
335
+ * (so their pendingReplies entry is already removed). Ask/send entries
336
+ * with Deferreds are settled via the pendingReplies registry.
337
+ */
338
+ const settlePostponedBuffer = (postponed, _pendingReplies, _actorId) => {
339
+ postponed.length = 0;
340
+ };
341
+ /**
237
342
  * Process a single event, returning true if the actor should stop.
238
343
  * Wraps processEventCore with actor-specific concerns (inspection, listeners, state ref).
239
344
  */
@@ -485,4 +590,4 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
485
590
  */
486
591
  const Default = Layer.effect(ActorSystem, make());
487
592
  //#endregion
488
- export { ActorSystem, Default, buildActorRefCore, createActor, notifyListeners, processEventCore, resolveTransition, runSpawnEffects };
593
+ export { ActorSystem, Default, buildActorRefCore, createActor, notifyListeners, processEventCore, resolveTransition, runSpawnEffects, settlePendingReplies };
@@ -54,10 +54,12 @@ const EntityMachine = { layer: (entity, machine, options) => {
54
54
  if (Option.isNone(existingSystem)) return yield* Effect.die("EntityMachine requires ActorSystem in context");
55
55
  const system = existingSystem.value;
56
56
  const internalQueue = yield* Queue.unbounded();
57
+ const clusterSend = Effect.fn("effect-machine.cluster.self.send")(function* (event) {
58
+ yield* Queue.offer(internalQueue, event);
59
+ });
57
60
  const self = {
58
- send: Effect.fn("effect-machine.cluster.self.send")(function* (event) {
59
- yield* Queue.offer(internalQueue, event);
60
- }),
61
+ send: clusterSend,
62
+ cast: clusterSend,
61
63
  spawn: (childId, childMachine) => system.spawn(childId, childMachine).pipe(Effect.provideService(ActorSystem, system))
62
64
  };
63
65
  const stateRef = yield* Ref.make(initialState);
package/dist/errors.d.ts CHANGED
@@ -42,5 +42,16 @@ declare const AssertionError_base: Schema.ErrorClass<AssertionError, Schema.Tagg
42
42
  }>, effect_Cause0.YieldableError>;
43
43
  /** Assertion failed in testing utilities */
44
44
  declare class AssertionError extends AssertionError_base {}
45
+ declare const ActorStoppedError_base: Schema.ErrorClass<ActorStoppedError, Schema.TaggedStruct<"ActorStoppedError", {
46
+ readonly actorId: Schema.String;
47
+ }>, effect_Cause0.YieldableError>;
48
+ /** Actor was stopped while a call/ask was pending */
49
+ declare class ActorStoppedError extends ActorStoppedError_base {}
50
+ declare const NoReplyError_base: Schema.ErrorClass<NoReplyError, Schema.TaggedStruct<"NoReplyError", {
51
+ readonly actorId: Schema.String;
52
+ readonly eventTag: Schema.String;
53
+ }>, effect_Cause0.YieldableError>;
54
+ /** ask() was used but the transition handler did not call reply */
55
+ declare class NoReplyError extends NoReplyError_base {}
45
56
  //#endregion
46
- export { AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, ProvisionValidationError, SlotProvisionError, UnprovidedSlotsError };
57
+ export { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, ProvisionValidationError, SlotProvisionError, UnprovidedSlotsError };
package/dist/errors.js CHANGED
@@ -32,5 +32,12 @@ var ProvisionValidationError = class extends Schema.TaggedErrorClass()("Provisio
32
32
  }) {};
33
33
  /** Assertion failed in testing utilities */
34
34
  var AssertionError = class extends Schema.TaggedErrorClass()("AssertionError", { message: Schema.String }) {};
35
+ /** Actor was stopped while a call/ask was pending */
36
+ var ActorStoppedError = class extends Schema.TaggedErrorClass()("ActorStoppedError", { actorId: Schema.String }) {};
37
+ /** ask() was used but the transition handler did not call reply */
38
+ var NoReplyError = class extends Schema.TaggedErrorClass()("NoReplyError", {
39
+ actorId: Schema.String,
40
+ eventTag: Schema.String
41
+ }) {};
35
42
  //#endregion
36
- export { AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, ProvisionValidationError, SlotProvisionError, UnprovidedSlotsError };
43
+ export { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, ProvisionValidationError, SlotProvisionError, UnprovidedSlotsError };