effect-machine 0.11.0 → 0.13.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 (68) hide show
  1. package/README.md +128 -324
  2. package/dist/actor.d.ts +52 -31
  3. package/dist/actor.js +218 -283
  4. package/dist/cluster/adapters/in-memory.d.ts +28 -0
  5. package/dist/cluster/adapters/in-memory.js +79 -0
  6. package/dist/cluster/entity-actor-ref.d.ts +56 -0
  7. package/dist/cluster/entity-actor-ref.js +33 -0
  8. package/dist/cluster/entity-machine.d.ts +31 -49
  9. package/dist/cluster/entity-machine.js +178 -52
  10. package/dist/cluster/index.d.ts +5 -2
  11. package/dist/cluster/index.js +4 -1
  12. package/dist/cluster/persistence.d.ts +49 -0
  13. package/dist/cluster/persistence.js +18 -0
  14. package/dist/cluster/to-entity.d.ts +9 -3
  15. package/dist/cluster/to-entity.js +16 -4
  16. package/dist/errors.d.ts +25 -17
  17. package/dist/errors.js +10 -5
  18. package/dist/index.d.ts +6 -4
  19. package/dist/index.js +4 -3
  20. package/dist/internal/brands.d.ts +14 -1
  21. package/dist/internal/runtime.d.ts +142 -0
  22. package/dist/internal/runtime.js +357 -0
  23. package/dist/internal/transition.d.ts +10 -4
  24. package/dist/internal/transition.js +24 -12
  25. package/dist/internal/utils.d.ts +42 -6
  26. package/dist/internal/utils.js +27 -1
  27. package/dist/machine.d.ts +89 -55
  28. package/dist/machine.js +80 -68
  29. package/dist/schema.d.ts +35 -34
  30. package/dist/schema.js +33 -4
  31. package/dist/supervision.d.ts +97 -0
  32. package/dist/supervision.js +42 -0
  33. package/dist/testing.d.ts +17 -8
  34. package/dist/testing.js +22 -23
  35. package/package.json +7 -7
  36. package/v3/dist/actor.d.ts +54 -37
  37. package/v3/dist/actor.js +209 -277
  38. package/v3/dist/cluster/adapters/in-memory.d.ts +15 -0
  39. package/v3/dist/cluster/adapters/in-memory.js +62 -0
  40. package/v3/dist/cluster/entity-actor-ref.d.ts +49 -0
  41. package/v3/dist/cluster/entity-actor-ref.js +19 -0
  42. package/v3/dist/cluster/entity-machine.d.ts +34 -49
  43. package/v3/dist/cluster/entity-machine.js +134 -50
  44. package/v3/dist/cluster/index.d.ts +5 -2
  45. package/v3/dist/cluster/index.js +4 -1
  46. package/v3/dist/cluster/persistence.d.ts +48 -0
  47. package/v3/dist/cluster/persistence.js +14 -0
  48. package/v3/dist/cluster/to-entity.d.ts +5 -2
  49. package/v3/dist/cluster/to-entity.js +12 -4
  50. package/v3/dist/errors.d.ts +18 -8
  51. package/v3/dist/errors.js +9 -4
  52. package/v3/dist/index.d.ts +6 -4
  53. package/v3/dist/index.js +3 -2
  54. package/v3/dist/internal/brands.d.ts +15 -1
  55. package/v3/dist/internal/runtime.d.ts +142 -0
  56. package/v3/dist/internal/runtime.js +335 -0
  57. package/v3/dist/internal/transition.d.ts +10 -4
  58. package/v3/dist/internal/transition.js +23 -11
  59. package/v3/dist/internal/utils.d.ts +42 -6
  60. package/v3/dist/internal/utils.js +27 -1
  61. package/v3/dist/machine.d.ts +35 -47
  62. package/v3/dist/machine.js +62 -64
  63. package/v3/dist/schema.d.ts +35 -34
  64. package/v3/dist/schema.js +29 -3
  65. package/v3/dist/supervision.d.ts +97 -0
  66. package/v3/dist/supervision.js +42 -0
  67. package/v3/dist/testing.d.ts +18 -9
  68. package/v3/dist/testing.js +21 -22
@@ -0,0 +1,357 @@
1
+ import { INTERNAL_INIT_EVENT } from "./utils.js";
2
+ import { processEventCore, runSpawnEffects, shouldPostpone } from "./transition.js";
3
+ import { NoReplyError } from "../errors.js";
4
+ import { ActorExit } from "../supervision.js";
5
+ import { ActorSystem } from "../actor.js";
6
+ import { Cause, Deferred, Effect, Exit, Fiber, Option, Queue, Ref, Schema, Scope, SubscriptionRef } from "effect";
7
+ //#region src/internal/runtime.ts
8
+ /**
9
+ * Shared runtime kernel for machine event processing.
10
+ *
11
+ * Provides a single-queue event loop with:
12
+ * - Sequential event processing (no split-mailbox race)
13
+ * - Postpone buffer with drain-on-state-change (gen_statem)
14
+ * - Background effect lifecycle (under actorScope fault boundary)
15
+ * - Spawn effect lifecycle (per-state scope)
16
+ * - Final state detection → stop
17
+ * - Reply settlement (call/ask Deferreds)
18
+ * - Reply schema validation
19
+ * - Lifecycle hooks for actor-specific concerns (inspection, listeners, etc.)
20
+ * - ActorExit with exit reason (Final/Stopped/Defect) via exitDeferred
21
+ *
22
+ * Used by entity-machine and local actor (actor.ts delegates here).
23
+ *
24
+ * @internal
25
+ */
26
+ /**
27
+ * Create a runtime for a machine. Returns a handle for sending events
28
+ * and querying state. The runtime owns:
29
+ * - Event loop fiber
30
+ * - Postpone buffer
31
+ * - Background effects (under actorScope)
32
+ * - State scope (spawn effects)
33
+ * - Final state detection
34
+ * - Exit reason via exitDeferred
35
+ *
36
+ * Resources (stateRef, eventQueue, stoppedRef) are either cell-provided
37
+ * or allocated fresh by the runtime.
38
+ *
39
+ * @internal
40
+ */
41
+ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (machine, system, config) {
42
+ const { actorId, hooks, lifecycle } = config;
43
+ const services = yield* Effect.services();
44
+ const fork = Effect.runForkWith(services);
45
+ const stateRef = config.cellResources?.stateRef ?? (yield* SubscriptionRef.make(machine.initial));
46
+ const stoppedRef = config.cellResources?.stoppedRef ?? (yield* Ref.make(false));
47
+ const eventQueue = config.cellResources?.eventQueue ?? (yield* config.queueFactory ?? Queue.unbounded());
48
+ const exitDeferred = yield* Deferred.make();
49
+ const actorScope = yield* Scope.make();
50
+ const deferredReplyRef = { current: void 0 };
51
+ const selfSend = Effect.fn("effect-machine.runtime.self.send")(function* (event) {
52
+ if (!(yield* Ref.get(stoppedRef))) yield* Queue.offer(eventQueue, {
53
+ _tag: "send",
54
+ event
55
+ });
56
+ });
57
+ const childPrefix = config.childIdPrefix ?? "";
58
+ const defaultSpawn = (childId, childMachine) => system.spawn(`${childPrefix}${childId}`, childMachine).pipe(Effect.provideService(ActorSystem, system));
59
+ const onChildSpawned = config.onChildSpawned;
60
+ const self = {
61
+ send: selfSend,
62
+ cast: selfSend,
63
+ spawn: onChildSpawned !== void 0 ? (childId, childMachine) => defaultSpawn(childId, childMachine).pipe(Effect.tap((child) => onChildSpawned(childId, child))) : defaultSpawn,
64
+ reply: (value) => Effect.sync(() => {
65
+ const deferred = deferredReplyRef.current;
66
+ if (deferred !== void 0) {
67
+ deferredReplyRef.current = void 0;
68
+ fork(Deferred.succeed(deferred, value));
69
+ return true;
70
+ }
71
+ return false;
72
+ })
73
+ };
74
+ const stateScopeRef = { current: yield* Scope.make() };
75
+ const backgroundFibers = [];
76
+ const initEvent = { _tag: INTERNAL_INIT_EVENT };
77
+ const ctx = {
78
+ actorId,
79
+ state: machine.initial,
80
+ event: initEvent,
81
+ self,
82
+ system
83
+ };
84
+ const { effects: effectSlots } = machine._slots;
85
+ for (const bg of machine.backgroundEffects) {
86
+ const fiber = yield* bg.handler({
87
+ actorId,
88
+ state: machine.initial,
89
+ event: initEvent,
90
+ self,
91
+ effects: effectSlots,
92
+ system
93
+ }).pipe(Effect.provideService(machine.Context, ctx), Effect.forkIn(actorScope));
94
+ backgroundFibers.push(fiber);
95
+ }
96
+ if (lifecycle?.onInitialSpawnEffects !== void 0) yield* lifecycle.onInitialSpawnEffects(machine.initial);
97
+ const loopFiberRef = { current: void 0 };
98
+ const initialSpawnDefectSignal = (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => loopFiberRef.current !== void 0 ? Fiber.interrupt(loopFiberRef.current) : Effect.void)), Effect.asVoid);
99
+ yield* runSpawnEffects(machine, machine.initial, initEvent, self, stateScopeRef.current, system, actorId, hooks?.onError, initialSpawnDefectSignal).pipe(Effect.catchCause((cause) => {
100
+ return Effect.gen(function* () {
101
+ yield* Ref.set(stoppedRef, true);
102
+ yield* Scope.close(stateScopeRef.current, Exit.void);
103
+ yield* Scope.close(actorScope, Exit.void);
104
+ yield* Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn"));
105
+ return yield* Effect.failCause(cause);
106
+ });
107
+ }));
108
+ /** Set the exit deferred exactly once. */
109
+ const setExit = (exit) => Deferred.succeed(exitDeferred, exit).pipe(Effect.asVoid);
110
+ if (machine.finalStates.has(machine.initial._tag)) {
111
+ if (lifecycle?.onFinal !== void 0) yield* lifecycle.onFinal(machine.initial);
112
+ yield* Ref.set(stoppedRef, true);
113
+ yield* Scope.close(stateScopeRef.current, Exit.void);
114
+ yield* Scope.close(actorScope, Exit.void);
115
+ yield* setExit(ActorExit.Final(machine.initial));
116
+ return makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope);
117
+ }
118
+ const augmentedHooks = {
119
+ ...hooks,
120
+ onSpawnDefect: (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => loopFiberRef.current !== void 0 ? Fiber.interrupt(loopFiberRef.current) : Effect.void)), Effect.asVoid)
121
+ };
122
+ const loopFiber = yield* Effect.forkDetach(runtimeEventLoop(machine, stateRef, eventQueue, stoppedRef, self, stateScopeRef, actorId, system, exitDeferred, augmentedHooks, deferredReplyRef, lifecycle, config.wrapProcess, fork));
123
+ loopFiberRef.current = loopFiber;
124
+ if (backgroundFibers.length > 0) yield* Effect.raceAll(backgroundFibers.map((fiber) => Fiber.await(fiber).pipe(Effect.flatMap((exit) => {
125
+ if (exit._tag === "Failure" && !Cause.hasInterruptsOnly(exit.cause)) return setExit(ActorExit.Defect(exit.cause, "background")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Fiber.interrupt(loopFiber)));
126
+ return Effect.never;
127
+ })))).pipe(Effect.forkIn(actorScope));
128
+ yield* Effect.forkDetach(Effect.gen(function* () {
129
+ const loopExit = yield* Fiber.await(loopFiber);
130
+ if (loopExit._tag === "Success") yield* Scope.close(actorScope, Exit.void);
131
+ else yield* Scope.close(actorScope, loopExit);
132
+ }));
133
+ const stop = Effect.gen(function* () {
134
+ if (yield* Ref.get(stoppedRef)) return;
135
+ if (lifecycle?.onShutdown !== void 0) yield* lifecycle.onShutdown();
136
+ yield* Ref.set(stoppedRef, true);
137
+ yield* Fiber.interrupt(loopFiber);
138
+ yield* Scope.close(stateScopeRef.current, Exit.void);
139
+ yield* Scope.close(actorScope, Exit.void);
140
+ yield* setExit(ActorExit.Stopped);
141
+ }).pipe(Effect.asVoid);
142
+ if (config.skipFinalizer !== true) yield* Effect.addFinalizer(() => stop);
143
+ return {
144
+ ...makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope),
145
+ stop
146
+ };
147
+ });
148
+ /**
149
+ * Build the runtime handle (send/ask/getState/isStopped).
150
+ * Shared between initial-final and normal paths.
151
+ */
152
+ const makeHandle = (stateRef, stoppedRef, eventQueue, exitDeferred, actorScope) => ({
153
+ send: (event) => Effect.gen(function* () {
154
+ if (!(yield* Ref.get(stoppedRef))) yield* Queue.offer(eventQueue, {
155
+ _tag: "send",
156
+ event
157
+ });
158
+ }),
159
+ sendWait: (event) => Effect.gen(function* () {
160
+ if (!(yield* Ref.get(stoppedRef))) {
161
+ const done = yield* Deferred.make();
162
+ yield* Queue.offer(eventQueue, {
163
+ _tag: "sendWait",
164
+ event,
165
+ done
166
+ });
167
+ yield* Deferred.await(done);
168
+ }
169
+ }),
170
+ ask: (event) => Effect.gen(function* () {
171
+ if (yield* Ref.get(stoppedRef)) return yield* new NoReplyError({
172
+ actorId: "stopped",
173
+ eventTag: event._tag
174
+ });
175
+ const reply = yield* Deferred.make();
176
+ yield* Queue.offer(eventQueue, {
177
+ _tag: "ask",
178
+ event,
179
+ reply
180
+ });
181
+ return yield* Deferred.await(reply);
182
+ }),
183
+ getState: SubscriptionRef.get(stateRef),
184
+ stateRef,
185
+ isStopped: Ref.get(stoppedRef),
186
+ stop: Effect.void,
187
+ _queue: eventQueue,
188
+ _stoppedRef: stoppedRef,
189
+ exitDeferred,
190
+ actorScope
191
+ });
192
+ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* (machine, stateRef, eventQueue, stoppedRef, self, stateScopeRef, actorId, system, exitDeferred, hooks, deferredReplyRef, lifecycle, wrapProcess, fork) {
193
+ const forkEffect = fork ?? Effect.runFork;
194
+ /** Set the exit deferred exactly once. */
195
+ const setExit = (exit) => Deferred.succeed(exitDeferred, exit).pipe(Effect.asVoid);
196
+ const postponed = [];
197
+ const hasPostponeRules = machine.postponeRules.length > 0;
198
+ const processQueued = Effect.fn("effect-machine.runtime.processQueued")(function* (queued) {
199
+ const event = queued.event;
200
+ const currentState = yield* SubscriptionRef.get(stateRef);
201
+ if (hasPostponeRules && shouldPostpone(machine, currentState._tag, event._tag)) {
202
+ if (queued._tag === "call") {
203
+ const postponedResult = {
204
+ newState: currentState,
205
+ previousState: currentState,
206
+ transitioned: false,
207
+ lifecycleRan: false,
208
+ isFinal: false,
209
+ hasReply: false,
210
+ deferReply: false,
211
+ reply: void 0,
212
+ postponed: true
213
+ };
214
+ yield* Deferred.succeed(queued.reply, postponedResult);
215
+ }
216
+ if (queued._tag === "sendWait") yield* Deferred.succeed(queued.done, void 0);
217
+ postponed.push({
218
+ _tag: "send",
219
+ event
220
+ });
221
+ return {
222
+ shouldStop: false,
223
+ stateChanged: false,
224
+ result: {
225
+ newState: currentState,
226
+ previousState: currentState,
227
+ transitioned: false,
228
+ lifecycleRan: false,
229
+ isFinal: false,
230
+ hasReply: false,
231
+ deferReply: false,
232
+ reply: void 0,
233
+ postponed: true
234
+ }
235
+ };
236
+ }
237
+ if (lifecycle?.onEvent !== void 0) yield* lifecycle.onEvent(currentState, event);
238
+ const result = yield* processEventCore(machine, currentState, event, self, stateScopeRef, system, actorId, hooks);
239
+ if (result.transitioned) yield* SubscriptionRef.set(stateRef, result.newState);
240
+ if (lifecycle?.onStateChange !== void 0 && result.transitioned) yield* lifecycle.onStateChange(result, event);
241
+ switch (queued._tag) {
242
+ case "call":
243
+ yield* Deferred.succeed(queued.reply, result);
244
+ break;
245
+ case "sendWait":
246
+ yield* Deferred.succeed(queued.done, void 0);
247
+ break;
248
+ case "ask":
249
+ if (result.hasReply) {
250
+ const replySchema = machine._replySchemas?.get(event._tag);
251
+ if (replySchema !== void 0) {
252
+ let decoded;
253
+ try {
254
+ decoded = Schema.decodeUnknownSync(replySchema)(result.reply);
255
+ } catch (decodeError) {
256
+ yield* Deferred.die(queued.reply, decodeError);
257
+ return yield* Effect.die(decodeError);
258
+ }
259
+ yield* Deferred.succeed(queued.reply, decoded);
260
+ } else yield* Deferred.succeed(queued.reply, result.reply);
261
+ } else if (result.deferReply && deferredReplyRef !== void 0) deferredReplyRef.current = queued.reply;
262
+ else yield* Deferred.fail(queued.reply, new NoReplyError({
263
+ actorId,
264
+ eventTag: event._tag
265
+ }));
266
+ break;
267
+ }
268
+ if (lifecycle?.onProcessed !== void 0 && result.transitioned) yield* lifecycle.onProcessed(result, event);
269
+ const shouldStop = result.isFinal && result.lifecycleRan;
270
+ if (shouldStop && lifecycle?.onFinal !== void 0) yield* lifecycle.onFinal(result.newState);
271
+ return {
272
+ shouldStop,
273
+ stateChanged: result.lifecycleRan,
274
+ result
275
+ };
276
+ });
277
+ const shutdown = (exitReason) => Effect.gen(function* () {
278
+ yield* Ref.set(stoppedRef, true);
279
+ if (lifecycle?.onShutdown !== void 0) yield* lifecycle.onShutdown();
280
+ settlePostponed(postponed, actorId, forkEffect);
281
+ const remaining = [];
282
+ let next = yield* Queue.poll(eventQueue);
283
+ while (Option.isSome(next)) {
284
+ remaining.push(next.value);
285
+ next = yield* Queue.poll(eventQueue);
286
+ }
287
+ for (const entry of remaining) if (entry._tag === "sendWait") forkEffect(Deferred.succeed(entry.done, void 0));
288
+ else if (entry._tag === "ask") forkEffect(Deferred.fail(entry.reply, new NoReplyError({
289
+ actorId,
290
+ eventTag: entry.event._tag
291
+ })));
292
+ else if (entry._tag === "call") {
293
+ const currentState = yield* SubscriptionRef.get(stateRef);
294
+ forkEffect(Deferred.succeed(entry.reply, {
295
+ newState: currentState,
296
+ previousState: currentState,
297
+ transitioned: false,
298
+ lifecycleRan: false,
299
+ isFinal: machine.finalStates.has(currentState._tag),
300
+ hasReply: false,
301
+ deferReply: false,
302
+ reply: void 0,
303
+ postponed: false
304
+ }));
305
+ }
306
+ yield* Scope.close(stateScopeRef.current, Exit.void);
307
+ yield* setExit(exitReason);
308
+ });
309
+ while (true) {
310
+ const queued = yield* Queue.take(eventQueue);
311
+ if (queued._tag === "drain") {
312
+ yield* shutdown(ActorExit.Stopped);
313
+ yield* Deferred.succeed(queued.done, void 0);
314
+ return;
315
+ }
316
+ const eventQueued = queued;
317
+ const processInner = processQueued(eventQueued);
318
+ const { shouldStop, stateChanged } = yield* (wrapProcess !== void 0 ? Effect.gen(function* () {
319
+ return yield* wrapProcess(yield* SubscriptionRef.get(stateRef), eventQueued.event, processInner);
320
+ }) : processInner).pipe(Effect.catchCause((cause) => {
321
+ if (queued._tag === "sendWait") forkEffect(Deferred.failCause(queued.done, cause));
322
+ else if (queued._tag === "ask") forkEffect(Deferred.die(queued.reply, cause));
323
+ else if (queued._tag === "call") forkEffect(Deferred.failCause(queued.reply, cause));
324
+ return shutdown(ActorExit.Defect(cause, "transition")).pipe(Effect.andThen(Effect.failCause(cause)));
325
+ }));
326
+ if (shouldStop) {
327
+ const finalState = yield* SubscriptionRef.get(stateRef);
328
+ yield* shutdown(ActorExit.Final(finalState));
329
+ return;
330
+ }
331
+ let drainTriggered = stateChanged;
332
+ while (drainTriggered && postponed.length > 0) {
333
+ drainTriggered = false;
334
+ const drained = postponed.splice(0);
335
+ for (const entry of drained) {
336
+ const drain = yield* processQueued(entry);
337
+ if (drain.shouldStop) {
338
+ const finalState = yield* SubscriptionRef.get(stateRef);
339
+ yield* shutdown(ActorExit.Final(finalState));
340
+ return;
341
+ }
342
+ if (drain.stateChanged) drainTriggered = true;
343
+ }
344
+ }
345
+ }
346
+ });
347
+ /** Settle all pending Deferreds in the postpone buffer on shutdown. */
348
+ const settlePostponed = (postponed, actorId, forkFn) => {
349
+ for (const entry of postponed) if (entry._tag === "ask") forkFn(Deferred.fail(entry.reply, new NoReplyError({
350
+ actorId,
351
+ eventTag: entry.event._tag
352
+ })));
353
+ else if (entry._tag === "sendWait") forkFn(Deferred.succeed(entry.done, void 0));
354
+ postponed.length = 0;
355
+ };
356
+ //#endregion
357
+ export { createRuntime };
@@ -1,5 +1,5 @@
1
1
  import { EffectsDef, GuardsDef, MachineContext } from "../slot.js";
2
- import { BuiltMachine, Machine, MachineRef, SpawnEffect, Transition } from "../machine.js";
2
+ import { Machine, MachineRef, SpawnEffect, Transition } from "../machine.js";
3
3
  import { ActorSystem } from "../actor.js";
4
4
  import { Cause, Effect, Scope } from "effect";
5
5
 
@@ -32,6 +32,7 @@ declare const runTransitionHandler: <S extends {
32
32
  }, R, GD extends GuardsDef, EFD extends EffectsDef>(machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>, transition: Transition<S, E, GD, EFD, R>, state: S, event: E, self: MachineRef<E>, system: ActorSystem, actorId: string) => Effect.Effect<{
33
33
  newState: S;
34
34
  hasReply: boolean;
35
+ deferReply: boolean;
35
36
  reply: unknown;
36
37
  }, never, Exclude<R, MachineContext<S, E, MachineRef<E>>>>;
37
38
  /**
@@ -54,6 +55,7 @@ declare const executeTransition: <S extends {
54
55
  transitioned: boolean;
55
56
  reenter: boolean;
56
57
  hasReply: boolean;
58
+ deferReply: boolean;
57
59
  reply: unknown;
58
60
  }, never, Exclude<R, MachineContext<S, E, MachineRef<E>>>>;
59
61
  /**
@@ -66,6 +68,8 @@ interface ProcessEventHooks<S, E> {
66
68
  readonly onTransition?: (from: S, to: S, event: E) => Effect.Effect<void>;
67
69
  /** Called when a transition handler or spawn effect fails with a defect */
68
70
  readonly onError?: (info: ProcessEventError<S, E>) => Effect.Effect<void>;
71
+ /** Called when a forked spawn fiber defects — signals the runtime to set exitDeferred */
72
+ readonly onSpawnDefect?: (cause: Cause.Cause<unknown>) => Effect.Effect<void>;
69
73
  }
70
74
  /**
71
75
  * Error info for inspection hooks.
@@ -92,6 +96,8 @@ interface ProcessEventResult<S> {
92
96
  readonly isFinal: boolean;
93
97
  /** Whether the handler provided a reply (structural, not value-based) */
94
98
  readonly hasReply: boolean;
99
+ /** Whether the handler deferred the reply to a spawn handler (Machine.deferReply) */
100
+ readonly deferReply: boolean;
95
101
  /** Domain reply value from handler (used by ask). Only meaningful when hasReply is true. */
96
102
  readonly reply?: unknown;
97
103
  /** Whether the event was postponed (buffered for retry after next state change) */
@@ -131,6 +137,7 @@ declare const processEventCore: <S extends {
131
137
  lifecycleRan: boolean;
132
138
  isFinal: boolean;
133
139
  hasReply: boolean;
140
+ deferReply: boolean;
134
141
  reply: unknown;
135
142
  postponed: boolean;
136
143
  }, never, Exclude<R, MachineContext<S, E, MachineRef<E>>> | Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>>;
@@ -143,7 +150,7 @@ declare const runSpawnEffects: <S extends {
143
150
  readonly _tag: string;
144
151
  }, E extends {
145
152
  readonly _tag: string;
146
- }, R, GD extends GuardsDef, EFD extends EffectsDef>(machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>, state: S, event: E, self: MachineRef<E>, stateScope: Scope.Closeable, system: ActorSystem, actorId: string, onError?: ((info: ProcessEventError<S, E>) => Effect.Effect<void>) | undefined) => Effect.Effect<void, never, Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>>;
153
+ }, R, GD extends GuardsDef, EFD extends EffectsDef>(machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>, state: S, event: E, self: MachineRef<E>, stateScope: Scope.Closeable, system: ActorSystem, actorId: string, onError?: ((info: ProcessEventError<S, E>) => Effect.Effect<void>) | undefined, onSpawnDefect?: ((cause: Cause.Cause<unknown>) => Effect.Effect<void>) | undefined) => Effect.Effect<void, never, Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>>;
147
154
  /**
148
155
  * Resolve which transition should fire for a given state and event.
149
156
  * Uses indexed O(1) lookup. First matching transition wins.
@@ -161,14 +168,13 @@ declare const invalidateIndex: (machine: object) => void;
161
168
  * Find all transitions matching a state/event pair.
162
169
  * Returns empty array if no matches.
163
170
  *
164
- * Accepts both `Machine` and `BuiltMachine`.
165
171
  * O(1) lookup after first access (index is lazily built).
166
172
  */
167
173
  declare const findTransitions: <S extends {
168
174
  readonly _tag: string;
169
175
  }, E extends {
170
176
  readonly _tag: string;
171
- }, R, GD extends GuardsDef = Record<string, never>, EFD extends EffectsDef = Record<string, never>>(input: Machine<S, E, R, any, any, GD, EFD> | BuiltMachine<S, E, R>, stateTag: string, eventTag: string) => ReadonlyArray<Transition<S, E, GD, EFD, R>>;
177
+ }, R, GD extends GuardsDef = Record<string, never>, EFD extends EffectsDef = Record<string, never>>(machine: Machine<S, E, R, any, any, GD, EFD>, stateTag: string, eventTag: string) => ReadonlyArray<Transition<S, E, GD, EFD, R>>;
172
178
  /**
173
179
  * Find all spawn effects for a state.
174
180
  * Returns empty array if no matches.
@@ -1,5 +1,4 @@
1
- import { INTERNAL_ENTER_EVENT, isEffect } from "./utils.js";
2
- import { BuiltMachine } from "../machine.js";
1
+ import { INTERNAL_ENTER_EVENT, isDeferReplyResult, isEffect, isReplyResult } from "./utils.js";
3
2
  import { Cause, Effect, Exit, Scope } from "effect";
4
3
  //#region src/internal/transition.ts
5
4
  /**
@@ -39,14 +38,22 @@ const runTransitionHandler = Effect.fn("effect-machine.runTransitionHandler")(fu
39
38
  };
40
39
  const raw = transition.handler(handlerCtx);
41
40
  const resolved = isEffect(raw) ? yield* raw.pipe(Effect.provideService(machine.Context, ctx)) : raw;
42
- if (resolved !== null && typeof resolved === "object" && "state" in resolved && "reply" in resolved && !("_tag" in resolved)) return {
41
+ if (isReplyResult(resolved)) return {
43
42
  newState: resolved.state,
44
43
  hasReply: true,
44
+ deferReply: false,
45
45
  reply: resolved.reply
46
46
  };
47
+ if (isDeferReplyResult(resolved)) return {
48
+ newState: resolved.state,
49
+ hasReply: false,
50
+ deferReply: true,
51
+ reply: void 0
52
+ };
47
53
  return {
48
54
  newState: resolved,
49
55
  hasReply: false,
56
+ deferReply: false,
50
57
  reply: void 0
51
58
  };
52
59
  });
@@ -68,14 +75,16 @@ const executeTransition = Effect.fn("effect-machine.executeTransition")(function
68
75
  transitioned: false,
69
76
  reenter: false,
70
77
  hasReply: false,
78
+ deferReply: false,
71
79
  reply: void 0
72
80
  };
73
- const { newState, hasReply, reply } = yield* runTransitionHandler(machine, transition, currentState, event, self, system, actorId);
81
+ const { newState, hasReply, deferReply, reply } = yield* runTransitionHandler(machine, transition, currentState, event, self, system, actorId);
74
82
  return {
75
83
  newState,
76
84
  transitioned: true,
77
85
  reenter: transition.reenter === true,
78
86
  hasReply,
87
+ deferReply,
79
88
  reply
80
89
  };
81
90
  });
@@ -118,6 +127,7 @@ const processEventCore = Effect.fn("effect-machine.processEventCore")(function*
118
127
  lifecycleRan: false,
119
128
  isFinal: false,
120
129
  hasReply: false,
130
+ deferReply: false,
121
131
  reply: void 0,
122
132
  postponed: false
123
133
  };
@@ -128,7 +138,7 @@ const processEventCore = Effect.fn("effect-machine.processEventCore")(function*
128
138
  stateScopeRef.current = yield* Scope.make();
129
139
  if (hooks?.onTransition !== void 0) yield* hooks.onTransition(currentState, newState, event);
130
140
  if (hooks?.onSpawnEffect !== void 0) yield* hooks.onSpawnEffect(newState);
131
- yield* runSpawnEffects(machine, newState, { _tag: INTERNAL_ENTER_EVENT }, self, stateScopeRef.current, system, actorId, hooks?.onError);
141
+ yield* runSpawnEffects(machine, newState, { _tag: INTERNAL_ENTER_EVENT }, self, stateScopeRef.current, system, actorId, hooks?.onError, hooks?.onSpawnDefect);
132
142
  }
133
143
  return {
134
144
  newState,
@@ -137,6 +147,7 @@ const processEventCore = Effect.fn("effect-machine.processEventCore")(function*
137
147
  lifecycleRan: runLifecycle,
138
148
  isFinal: machine.finalStates.has(newState._tag),
139
149
  hasReply: result.hasReply,
150
+ deferReply: result.deferReply,
140
151
  reply: result.reply,
141
152
  postponed: false
142
153
  };
@@ -146,7 +157,7 @@ const processEventCore = Effect.fn("effect-machine.processEventCore")(function*
146
157
  *
147
158
  * @internal
148
159
  */
149
- const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* (machine, state, event, self, stateScope, system, actorId, onError) {
160
+ const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* (machine, state, event, self, stateScope, system, actorId, onError, onSpawnDefect) {
150
161
  const spawnEffects = findSpawnEffects(machine, state._tag);
151
162
  const ctx = {
152
163
  actorId,
@@ -157,6 +168,7 @@ const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* (m
157
168
  };
158
169
  const { effects: effectSlots } = machine._slots;
159
170
  const reportError = onError;
171
+ const defectSignal = onSpawnDefect;
160
172
  for (const spawnEffect of spawnEffects) {
161
173
  const effect = spawnEffect.handler({
162
174
  actorId,
@@ -167,13 +179,14 @@ const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* (m
167
179
  system
168
180
  }).pipe(Effect.provideService(machine.Context, ctx), Effect.catchCause((cause) => {
169
181
  if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt;
170
- if (reportError === void 0) return Effect.failCause(cause).pipe(Effect.orDie);
171
- return reportError({
182
+ const report = reportError !== void 0 ? reportError({
172
183
  phase: "spawn",
173
184
  state,
174
185
  event,
175
186
  cause
176
- }).pipe(Effect.andThen(Effect.failCause(cause).pipe(Effect.orDie)));
187
+ }) : Effect.void;
188
+ const signal = defectSignal !== void 0 ? defectSignal(cause) : Effect.void;
189
+ return report.pipe(Effect.andThen(signal), Effect.andThen(Effect.failCause(cause).pipe(Effect.orDie)));
177
190
  }));
178
191
  yield* Effect.forkScoped(effect).pipe(Effect.provideService(Scope.Scope, stateScope));
179
192
  }
@@ -246,11 +259,10 @@ const getIndex = (machine) => {
246
259
  * Find all transitions matching a state/event pair.
247
260
  * Returns empty array if no matches.
248
261
  *
249
- * Accepts both `Machine` and `BuiltMachine`.
250
262
  * O(1) lookup after first access (index is lazily built).
251
263
  */
252
- const findTransitions = (input, stateTag, eventTag) => {
253
- const index = getIndex(input instanceof BuiltMachine ? input._inner : input);
264
+ const findTransitions = (machine, stateTag, eventTag) => {
265
+ const index = getIndex(machine);
254
266
  const specific = index.transitions.get(stateTag)?.get(eventTag) ?? [];
255
267
  if (specific.length > 0) return specific;
256
268
  return index.transitions.get("*")?.get(eventTag) ?? [];
@@ -23,15 +23,51 @@ type InstanceOf<C> = C extends ((...args: unknown[]) => infer R) ? R : never;
23
23
  type TaggedConstructor<T extends {
24
24
  readonly _tag: string;
25
25
  }> = (args: Omit<T, "_tag">) => T;
26
+ declare const ReplyResultSymbol: unique symbol;
27
+ type ReplyResultSymbol = typeof ReplyResultSymbol;
26
28
  /**
27
- * Transition handler result - either a new state or Effect producing one
29
+ * Branded reply result from a transition handler.
30
+ * Created via `Machine.reply(state, value)`.
28
31
  */
29
- /** Reply tuple returned from transition handlers for ask support */
30
- interface TransitionReply<State> {
32
+ interface ReplyResult<State, Reply> {
31
33
  readonly state: State;
32
- readonly reply: unknown;
34
+ readonly reply: Reply;
35
+ readonly [ReplyResultSymbol]: true;
33
36
  }
34
- type TransitionResult<State, R> = State | TransitionReply<State> | Effect.Effect<State | TransitionReply<State>, never, R>;
37
+ /**
38
+ * Create a reply result for ask-bearing event handlers.
39
+ */
40
+ declare const makeReply: <State, Reply>(state: State, reply: Reply) => ReplyResult<State, Reply>;
41
+ /**
42
+ * Type guard for ReplyResult (symbol-based, replaces duck-typing).
43
+ */
44
+ declare const isReplyResult: (value: unknown) => value is ReplyResult<unknown, unknown>;
45
+ declare const DeferReplySymbol: unique symbol;
46
+ type DeferReplySymbol = typeof DeferReplySymbol;
47
+ /**
48
+ * Branded deferred reply result from a transition handler.
49
+ * Signals that the reply will be settled later by `self.reply()` in a spawn handler.
50
+ * Created via `Machine.deferReply(state)`.
51
+ */
52
+ interface DeferReplyResult<State> {
53
+ readonly state: State;
54
+ readonly [DeferReplySymbol]: true;
55
+ }
56
+ /**
57
+ * Create a deferred reply result. Handler returns this to signal
58
+ * "spawn handler will call self.reply(value) later".
59
+ */
60
+ declare const makeDeferReply: <State>(state: State) => DeferReplyResult<State>;
61
+ /**
62
+ * Type guard for DeferReplyResult.
63
+ */
64
+ declare const isDeferReplyResult: (value: unknown) => value is DeferReplyResult<unknown>;
65
+ /**
66
+ * Transition handler result.
67
+ * - When Reply is `never`: handler returns plain State (no reply allowed)
68
+ * - When Reply is concrete: handler must return ReplyResult via Machine.reply()
69
+ */
70
+ type TransitionResult<State, R, Reply = never> = [Reply] extends [never] ? State | Effect.Effect<State, never, R> : ReplyResult<State, Reply> | DeferReplyResult<State> | Effect.Effect<ReplyResult<State, Reply> | DeferReplyResult<State>, never, R>;
35
71
  /**
36
72
  * Internal event tags used for lifecycle effect contexts.
37
73
  * Prefixed with $ to distinguish from user events.
@@ -62,4 +98,4 @@ declare const isEffect: (value: unknown) => value is Effect.Effect<unknown, unkn
62
98
  */
63
99
  declare const stubSystem: ActorSystem;
64
100
  //#endregion
65
- export { ArgsOf, INTERNAL_ENTER_EVENT, INTERNAL_INIT_EVENT, InstanceOf, TagOf, TaggedConstructor, TransitionReply, TransitionResult, getTag, isEffect, stubSystem };
101
+ export { ArgsOf, DeferReplyResult, DeferReplySymbol, INTERNAL_ENTER_EVENT, INTERNAL_INIT_EVENT, InstanceOf, ReplyResult, ReplyResultSymbol, TagOf, TaggedConstructor, TransitionResult, getTag, isDeferReplyResult, isEffect, isReplyResult, makeDeferReply, makeReply, stubSystem };
@@ -4,6 +4,32 @@ import { Effect, Stream } from "effect";
4
4
  * Internal utilities for effect-machine.
5
5
  * @internal
6
6
  */
7
+ const ReplyResultSymbol = Symbol.for("effect-machine/ReplyResult");
8
+ /**
9
+ * Create a reply result for ask-bearing event handlers.
10
+ */
11
+ const makeReply = (state, reply) => ({
12
+ state,
13
+ reply,
14
+ [ReplyResultSymbol]: true
15
+ });
16
+ /**
17
+ * Type guard for ReplyResult (symbol-based, replaces duck-typing).
18
+ */
19
+ const isReplyResult = (value) => value !== null && typeof value === "object" && ReplyResultSymbol in value;
20
+ const DeferReplySymbol = Symbol.for("effect-machine/DeferReply");
21
+ /**
22
+ * Create a deferred reply result. Handler returns this to signal
23
+ * "spawn handler will call self.reply(value) later".
24
+ */
25
+ const makeDeferReply = (state) => ({
26
+ state,
27
+ [DeferReplySymbol]: true
28
+ });
29
+ /**
30
+ * Type guard for DeferReplyResult.
31
+ */
32
+ const isDeferReplyResult = (value) => value !== null && typeof value === "object" && DeferReplySymbol in value;
7
33
  /**
8
34
  * Internal event tags used for lifecycle effect contexts.
9
35
  * Prefixed with $ to distinguish from user events.
@@ -46,4 +72,4 @@ const stubSystem = {
46
72
  subscribe: () => () => {}
47
73
  };
48
74
  //#endregion
49
- export { INTERNAL_ENTER_EVENT, INTERNAL_INIT_EVENT, getTag, isEffect, stubSystem };
75
+ export { INTERNAL_ENTER_EVENT, INTERNAL_INIT_EVENT, getTag, isDeferReplyResult, isEffect, isReplyResult, makeDeferReply, makeReply, stubSystem };