effect-machine 0.11.0 → 0.12.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 (58) hide show
  1. package/dist/actor.d.ts +10 -4
  2. package/dist/actor.js +33 -5
  3. package/dist/cluster/adapters/in-memory.d.ts +28 -0
  4. package/dist/cluster/adapters/in-memory.js +79 -0
  5. package/dist/cluster/entity-actor-ref.d.ts +56 -0
  6. package/dist/cluster/entity-actor-ref.js +33 -0
  7. package/dist/cluster/entity-machine.d.ts +31 -49
  8. package/dist/cluster/entity-machine.js +167 -52
  9. package/dist/cluster/index.d.ts +5 -2
  10. package/dist/cluster/index.js +4 -1
  11. package/dist/cluster/persistence.d.ts +49 -0
  12. package/dist/cluster/persistence.js +18 -0
  13. package/dist/cluster/to-entity.d.ts +9 -3
  14. package/dist/cluster/to-entity.js +16 -4
  15. package/dist/errors.d.ts +12 -1
  16. package/dist/errors.js +8 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/internal/brands.d.ts +14 -1
  19. package/dist/internal/runtime.d.ts +67 -0
  20. package/dist/internal/runtime.js +248 -0
  21. package/dist/internal/transition.d.ts +5 -0
  22. package/dist/internal/transition.js +15 -3
  23. package/dist/internal/utils.d.ts +42 -6
  24. package/dist/internal/utils.js +27 -1
  25. package/dist/machine.d.ts +26 -13
  26. package/dist/machine.js +14 -3
  27. package/dist/schema.d.ts +35 -34
  28. package/dist/schema.js +32 -3
  29. package/dist/testing.js +4 -2
  30. package/package.json +3 -3
  31. package/v3/dist/actor.d.ts +4 -3
  32. package/v3/dist/actor.js +15 -3
  33. package/v3/dist/cluster/adapters/in-memory.d.ts +15 -0
  34. package/v3/dist/cluster/adapters/in-memory.js +62 -0
  35. package/v3/dist/cluster/entity-actor-ref.d.ts +49 -0
  36. package/v3/dist/cluster/entity-actor-ref.js +19 -0
  37. package/v3/dist/cluster/entity-machine.d.ts +34 -49
  38. package/v3/dist/cluster/entity-machine.js +134 -50
  39. package/v3/dist/cluster/index.d.ts +5 -2
  40. package/v3/dist/cluster/index.js +4 -1
  41. package/v3/dist/cluster/persistence.d.ts +48 -0
  42. package/v3/dist/cluster/persistence.js +14 -0
  43. package/v3/dist/cluster/to-entity.d.ts +5 -2
  44. package/v3/dist/cluster/to-entity.js +12 -4
  45. package/v3/dist/errors.d.ts +16 -1
  46. package/v3/dist/errors.js +8 -1
  47. package/v3/dist/index.d.ts +3 -2
  48. package/v3/dist/internal/brands.d.ts +15 -1
  49. package/v3/dist/internal/runtime.d.ts +65 -0
  50. package/v3/dist/internal/runtime.js +236 -0
  51. package/v3/dist/internal/transition.d.ts +5 -0
  52. package/v3/dist/internal/transition.js +15 -3
  53. package/v3/dist/internal/utils.d.ts +42 -6
  54. package/v3/dist/internal/utils.js +27 -1
  55. package/v3/dist/machine.d.ts +19 -13
  56. package/v3/dist/machine.js +10 -2
  57. package/v3/dist/schema.d.ts +35 -34
  58. package/v3/dist/schema.js +29 -3
@@ -0,0 +1,248 @@
1
+ import { INTERNAL_INIT_EVENT } from "./utils.js";
2
+ import { NoReplyError } from "../errors.js";
3
+ import { processEventCore, runSpawnEffects, shouldPostpone } from "./transition.js";
4
+ import { ActorSystem } from "../actor.js";
5
+ import { Deferred, Effect, Exit, Fiber, Queue, Ref, Schema, Scope, SubscriptionRef } from "effect";
6
+ //#region src/internal/runtime.ts
7
+ /**
8
+ * Shared runtime kernel for machine event processing.
9
+ *
10
+ * Provides a single-queue event loop with:
11
+ * - Sequential event processing (no split-mailbox race)
12
+ * - Postpone buffer with drain-on-state-change (gen_statem)
13
+ * - Background effect lifecycle
14
+ * - Spawn effect lifecycle (per-state scope)
15
+ * - Final state detection → stop
16
+ * - Reply settlement (call/ask Deferreds)
17
+ * - Reply schema validation
18
+ *
19
+ * Used by entity-machine. Local actor (actor.ts) has its own event loop
20
+ * with additional concerns (inspection, listeners, subscription ref, etc.)
21
+ * that will be migrated to use this kernel in a future refactor.
22
+ *
23
+ * @internal
24
+ */
25
+ /**
26
+ * Create a runtime for a machine. Returns a handle for sending events
27
+ * and querying state. The runtime owns:
28
+ * - Single event queue (all events serialized)
29
+ * - Event loop fiber
30
+ * - Postpone buffer
31
+ * - Background effects
32
+ * - State scope (spawn effects)
33
+ * - Final state detection
34
+ *
35
+ * @internal
36
+ */
37
+ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (machine, system, config) {
38
+ const { actorId, hooks } = config;
39
+ const stateRef = yield* SubscriptionRef.make(machine.initial);
40
+ const stoppedRef = yield* Ref.make(false);
41
+ const eventQueue = yield* config.queueFactory ?? Queue.unbounded();
42
+ const deferredReplyRef = { current: void 0 };
43
+ const selfSend = Effect.fn("effect-machine.runtime.self.send")(function* (event) {
44
+ if (!(yield* Ref.get(stoppedRef))) yield* Queue.offer(eventQueue, {
45
+ _tag: "send",
46
+ event
47
+ });
48
+ });
49
+ const self = {
50
+ send: selfSend,
51
+ cast: selfSend,
52
+ spawn: (childId, childMachine) => system.spawn(`${actorId}/${childId}`, childMachine).pipe(Effect.provideService(ActorSystem, system)),
53
+ reply: (value) => Effect.sync(() => {
54
+ const deferred = deferredReplyRef.current;
55
+ if (deferred !== void 0) {
56
+ deferredReplyRef.current = void 0;
57
+ Effect.runFork(Deferred.succeed(deferred, value));
58
+ return true;
59
+ }
60
+ return false;
61
+ })
62
+ };
63
+ const stateScopeRef = { current: yield* Scope.make() };
64
+ const backgroundFibers = [];
65
+ const initEvent = { _tag: INTERNAL_INIT_EVENT };
66
+ const ctx = {
67
+ actorId,
68
+ state: machine.initial,
69
+ event: initEvent,
70
+ self,
71
+ system
72
+ };
73
+ const { effects: effectSlots } = machine._slots;
74
+ for (const bg of machine.backgroundEffects) {
75
+ const fiber = yield* Effect.forkDetach(bg.handler({
76
+ actorId,
77
+ state: machine.initial,
78
+ event: initEvent,
79
+ self,
80
+ effects: effectSlots,
81
+ system
82
+ }).pipe(Effect.provideService(machine.Context, ctx)));
83
+ backgroundFibers.push(fiber);
84
+ }
85
+ yield* runSpawnEffects(machine, machine.initial, initEvent, self, stateScopeRef.current, system, actorId, hooks?.onError);
86
+ if (machine.finalStates.has(machine.initial._tag)) {
87
+ yield* Ref.set(stoppedRef, true);
88
+ yield* Scope.close(stateScopeRef.current, Exit.void);
89
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
90
+ return makeHandle(stateRef, stoppedRef, eventQueue, machine);
91
+ }
92
+ const loopFiber = yield* Effect.forkDetach(runtimeEventLoop(machine, stateRef, eventQueue, stoppedRef, self, backgroundFibers, stateScopeRef, actorId, system, hooks, deferredReplyRef));
93
+ const stop = Effect.gen(function* () {
94
+ if (yield* Ref.get(stoppedRef)) return;
95
+ yield* Ref.set(stoppedRef, true);
96
+ yield* Fiber.interrupt(loopFiber);
97
+ yield* Scope.close(stateScopeRef.current, Exit.void);
98
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
99
+ }).pipe(Effect.asVoid);
100
+ yield* Effect.addFinalizer(() => stop);
101
+ return {
102
+ ...makeHandle(stateRef, stoppedRef, eventQueue, machine),
103
+ stop
104
+ };
105
+ });
106
+ /**
107
+ * Build the runtime handle (send/ask/getState/isStopped).
108
+ * Shared between initial-final and normal paths.
109
+ */
110
+ const makeHandle = (stateRef, stoppedRef, eventQueue, _machine) => ({
111
+ send: (event) => Effect.gen(function* () {
112
+ if (!(yield* Ref.get(stoppedRef))) yield* Queue.offer(eventQueue, {
113
+ _tag: "send",
114
+ event
115
+ });
116
+ }),
117
+ sendWait: (event) => Effect.gen(function* () {
118
+ if (!(yield* Ref.get(stoppedRef))) {
119
+ const done = yield* Deferred.make();
120
+ yield* Queue.offer(eventQueue, {
121
+ _tag: "sendWait",
122
+ event,
123
+ done
124
+ });
125
+ yield* Deferred.await(done);
126
+ }
127
+ }),
128
+ ask: (event) => Effect.gen(function* () {
129
+ if (yield* Ref.get(stoppedRef)) return yield* new NoReplyError({
130
+ actorId: "stopped",
131
+ eventTag: event._tag
132
+ });
133
+ const reply = yield* Deferred.make();
134
+ yield* Queue.offer(eventQueue, {
135
+ _tag: "ask",
136
+ event,
137
+ reply
138
+ });
139
+ return yield* Deferred.await(reply);
140
+ }),
141
+ getState: SubscriptionRef.get(stateRef),
142
+ stateRef,
143
+ isStopped: Ref.get(stoppedRef),
144
+ stop: Effect.void
145
+ });
146
+ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* (machine, stateRef, eventQueue, stoppedRef, self, backgroundFibers, stateScopeRef, actorId, system, hooks, deferredReplyRef) {
147
+ const postponed = [];
148
+ const hasPostponeRules = machine.postponeRules.length > 0;
149
+ const processQueued = Effect.fn("effect-machine.runtime.processQueued")(function* (queued) {
150
+ const event = queued.event;
151
+ const currentState = yield* SubscriptionRef.get(stateRef);
152
+ if (hasPostponeRules && shouldPostpone(machine, currentState._tag, event._tag)) {
153
+ postponed.push({
154
+ _tag: "send",
155
+ event
156
+ });
157
+ if (queued._tag === "sendWait") yield* Deferred.succeed(queued.done, void 0);
158
+ return {
159
+ shouldStop: false,
160
+ stateChanged: false
161
+ };
162
+ }
163
+ const result = yield* processEventCore(machine, currentState, event, self, stateScopeRef, system, actorId, hooks);
164
+ if (result.transitioned) yield* SubscriptionRef.set(stateRef, result.newState);
165
+ switch (queued._tag) {
166
+ case "sendWait":
167
+ yield* Deferred.succeed(queued.done, void 0);
168
+ break;
169
+ case "ask":
170
+ if (result.hasReply) {
171
+ const replySchema = machine._replySchemas?.get(event._tag);
172
+ if (replySchema !== void 0) {
173
+ let decoded;
174
+ try {
175
+ decoded = Schema.decodeUnknownSync(replySchema)(result.reply);
176
+ } catch (decodeError) {
177
+ yield* Deferred.die(queued.reply, decodeError);
178
+ return yield* Effect.die(decodeError);
179
+ }
180
+ yield* Deferred.succeed(queued.reply, decoded);
181
+ } else yield* Deferred.succeed(queued.reply, result.reply);
182
+ } else if (result.deferReply && deferredReplyRef !== void 0) deferredReplyRef.current = queued.reply;
183
+ else yield* Deferred.fail(queued.reply, new NoReplyError({
184
+ actorId,
185
+ eventTag: event._tag
186
+ }));
187
+ break;
188
+ }
189
+ return {
190
+ shouldStop: result.isFinal && result.lifecycleRan,
191
+ stateChanged: result.lifecycleRan
192
+ };
193
+ });
194
+ while (true) {
195
+ const queued = yield* Queue.take(eventQueue);
196
+ const { shouldStop, stateChanged } = yield* processQueued(queued).pipe(Effect.catchCause((cause) => {
197
+ if (queued._tag === "sendWait") Effect.runFork(Deferred.failCause(queued.done, cause));
198
+ else if (queued._tag === "ask") Effect.runFork(Deferred.die(queued.reply, cause));
199
+ return Effect.failCause(cause);
200
+ }));
201
+ if (shouldStop) {
202
+ yield* Ref.set(stoppedRef, true);
203
+ settlePostponed(postponed, actorId);
204
+ const remaining = yield* Queue.takeAll(eventQueue);
205
+ for (const entry of remaining) if (entry._tag === "sendWait") Effect.runFork(Deferred.succeed(entry.done, void 0));
206
+ else if (entry._tag === "ask") Effect.runFork(Deferred.fail(entry.reply, new NoReplyError({
207
+ actorId,
208
+ eventTag: entry.event._tag
209
+ })));
210
+ yield* Scope.close(stateScopeRef.current, Exit.void);
211
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
212
+ return;
213
+ }
214
+ let drainTriggered = stateChanged;
215
+ while (drainTriggered && postponed.length > 0) {
216
+ drainTriggered = false;
217
+ const drained = postponed.splice(0);
218
+ for (const entry of drained) {
219
+ const drain = yield* processQueued(entry);
220
+ if (drain.shouldStop) {
221
+ yield* Ref.set(stoppedRef, true);
222
+ settlePostponed(postponed, actorId);
223
+ const remaining2 = yield* Queue.takeAll(eventQueue);
224
+ for (const r of remaining2) if (r._tag === "sendWait") Effect.runFork(Deferred.succeed(r.done, void 0));
225
+ else if (r._tag === "ask") Effect.runFork(Deferred.fail(r.reply, new NoReplyError({
226
+ actorId,
227
+ eventTag: r.event._tag
228
+ })));
229
+ yield* Scope.close(stateScopeRef.current, Exit.void);
230
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
231
+ return;
232
+ }
233
+ if (drain.stateChanged) drainTriggered = true;
234
+ }
235
+ }
236
+ }
237
+ });
238
+ /** Settle all pending Deferreds in the postpone buffer on shutdown. */
239
+ const settlePostponed = (postponed, actorId) => {
240
+ for (const entry of postponed) if (entry._tag === "ask") Effect.runFork(Deferred.fail(entry.reply, new NoReplyError({
241
+ actorId,
242
+ eventTag: entry.event._tag
243
+ })));
244
+ else if (entry._tag === "sendWait") Effect.runFork(Deferred.succeed(entry.done, void 0));
245
+ postponed.length = 0;
246
+ };
247
+ //#endregion
248
+ export { createRuntime };
@@ -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
  /**
@@ -92,6 +94,8 @@ interface ProcessEventResult<S> {
92
94
  readonly isFinal: boolean;
93
95
  /** Whether the handler provided a reply (structural, not value-based) */
94
96
  readonly hasReply: boolean;
97
+ /** Whether the handler deferred the reply to a spawn handler (Machine.deferReply) */
98
+ readonly deferReply: boolean;
95
99
  /** Domain reply value from handler (used by ask). Only meaningful when hasReply is true. */
96
100
  readonly reply?: unknown;
97
101
  /** Whether the event was postponed (buffered for retry after next state change) */
@@ -131,6 +135,7 @@ declare const processEventCore: <S extends {
131
135
  lifecycleRan: boolean;
132
136
  isFinal: boolean;
133
137
  hasReply: boolean;
138
+ deferReply: boolean;
134
139
  reply: unknown;
135
140
  postponed: boolean;
136
141
  }, never, Exclude<R, MachineContext<S, E, MachineRef<E>>> | Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>>;
@@ -1,4 +1,4 @@
1
- import { INTERNAL_ENTER_EVENT, isEffect } from "./utils.js";
1
+ import { INTERNAL_ENTER_EVENT, isDeferReplyResult, isEffect, isReplyResult } from "./utils.js";
2
2
  import { BuiltMachine } from "../machine.js";
3
3
  import { Cause, Effect, Exit, Scope } from "effect";
4
4
  //#region src/internal/transition.ts
@@ -39,14 +39,22 @@ const runTransitionHandler = Effect.fn("effect-machine.runTransitionHandler")(fu
39
39
  };
40
40
  const raw = transition.handler(handlerCtx);
41
41
  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 {
42
+ if (isReplyResult(resolved)) return {
43
43
  newState: resolved.state,
44
44
  hasReply: true,
45
+ deferReply: false,
45
46
  reply: resolved.reply
46
47
  };
48
+ if (isDeferReplyResult(resolved)) return {
49
+ newState: resolved.state,
50
+ hasReply: false,
51
+ deferReply: true,
52
+ reply: void 0
53
+ };
47
54
  return {
48
55
  newState: resolved,
49
56
  hasReply: false,
57
+ deferReply: false,
50
58
  reply: void 0
51
59
  };
52
60
  });
@@ -68,14 +76,16 @@ const executeTransition = Effect.fn("effect-machine.executeTransition")(function
68
76
  transitioned: false,
69
77
  reenter: false,
70
78
  hasReply: false,
79
+ deferReply: false,
71
80
  reply: void 0
72
81
  };
73
- const { newState, hasReply, reply } = yield* runTransitionHandler(machine, transition, currentState, event, self, system, actorId);
82
+ const { newState, hasReply, deferReply, reply } = yield* runTransitionHandler(machine, transition, currentState, event, self, system, actorId);
74
83
  return {
75
84
  newState,
76
85
  transitioned: true,
77
86
  reenter: transition.reenter === true,
78
87
  hasReply,
88
+ deferReply,
79
89
  reply
80
90
  };
81
91
  });
@@ -118,6 +128,7 @@ const processEventCore = Effect.fn("effect-machine.processEventCore")(function*
118
128
  lifecycleRan: false,
119
129
  isFinal: false,
120
130
  hasReply: false,
131
+ deferReply: false,
121
132
  reply: void 0,
122
133
  postponed: false
123
134
  };
@@ -137,6 +148,7 @@ const processEventCore = Effect.fn("effect-machine.processEventCore")(function*
137
148
  lifecycleRan: runLifecycle,
138
149
  isFinal: machine.finalStates.has(newState._tag),
139
150
  hasReply: result.hasReply,
151
+ deferReply: result.deferReply,
140
152
  reply: result.reply,
141
153
  postponed: false
142
154
  };
@@ -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 };
package/dist/machine.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { EffectHandlers, EffectSlots, EffectsDef, EffectsSchema, GuardHandlers, GuardSlots, GuardsDef, GuardsSchema, MachineContext } from "./slot.js";
2
- import { TransitionResult } from "./internal/utils.js";
3
- import { BrandedEvent, BrandedState, TaggedOrConstructor } from "./internal/brands.js";
2
+ import { DeferReplyResult, ReplyResult, TransitionResult } from "./internal/utils.js";
3
+ import { BrandedEvent, BrandedState, ExtractReply, TaggedOrConstructor } from "./internal/brands.js";
4
4
  import { MachineEventSchema, MachineStateSchema, VariantsUnion } from "./schema.js";
5
5
  import { DuplicateActorError } from "./errors.js";
6
6
  import { findTransitions } from "./internal/transition.js";
@@ -9,7 +9,7 @@ import { Cause, Duration, Effect, Schema, Scope, ServiceMap } from "effect";
9
9
 
10
10
  //#region src/machine.d.ts
11
11
  declare namespace machine_d_exports {
12
- export { BackgroundEffect, BuiltMachine, HandlerContext, Machine, MachineRef, MakeConfig, ProvideHandlers, SlotContext, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, findTransitions, make, replay, spawn };
12
+ export { BackgroundEffect, BuiltMachine, DeferReplyResult, HandlerContext, Machine, MachineRef, MakeConfig, ProvideHandlers, ReplyResult, SlotContext, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, deferReply, findTransitions, make, replay, reply, spawn };
13
13
  }
14
14
  /**
15
15
  * Self reference for sending events back to the machine
@@ -23,6 +23,12 @@ interface MachineRef<Event> {
23
23
  }, E2 extends {
24
24
  readonly _tag: string;
25
25
  }, R2>(id: string, machine: BuiltMachine<S2, E2, R2>) => Effect.Effect<ActorRef<S2, E2>, DuplicateActorError, R2>;
26
+ /**
27
+ * Settle a deferred reply from a spawn handler.
28
+ * Only usable when the transition handler returned `Machine.deferReply(state)`.
29
+ * Returns true if a pending reply was settled, false if none was pending.
30
+ */
31
+ readonly reply: (value: unknown) => Effect.Effect<boolean>;
26
32
  }
27
33
  /**
28
34
  * Handler context passed to transition handlers
@@ -45,9 +51,11 @@ interface StateHandlerContext<State, Event, ED extends EffectsDef> {
45
51
  readonly system: ActorSystem;
46
52
  }
47
53
  /**
48
- * Transition handler function
54
+ * Transition handler function.
55
+ * When Reply is concrete (event has a reply schema), handler must return Machine.reply().
56
+ * When Reply is never, handler returns plain state.
49
57
  */
50
- type TransitionHandler<S, E, NewState, GD extends GuardsDef, ED extends EffectsDef, R> = (ctx: HandlerContext<S, E, GD, ED>) => TransitionResult<NewState, R>;
58
+ type TransitionHandler<S, E, NewState, GD extends GuardsDef, ED extends EffectsDef, R, Reply = never> = (ctx: HandlerContext<S, E, GD, ED>) => TransitionResult<NewState, R, Reply>;
51
59
  /**
52
60
  * State effect handler function
53
61
  */
@@ -171,6 +179,8 @@ declare class Machine<State, Event, R = never, _SD extends Record<string, Schema
171
179
  };
172
180
  readonly stateSchema?: Schema.Schema<State>;
173
181
  readonly eventSchema?: Schema.Schema<Event>;
182
+ /** @internal */
183
+ readonly _replySchemas: ReadonlyMap<string, Schema.Decoder<unknown>>;
174
184
  /**
175
185
  * Context tag for accessing machine state/event/self in slot handlers.
176
186
  * Uses shared module-level tag for all machines.
@@ -186,24 +196,25 @@ declare class Machine<State, Event, R = never, _SD extends Record<string, Schema
186
196
  }>;
187
197
  get guardsSchema(): GuardsSchema<GD> | undefined;
188
198
  get effectsSchema(): EffectsSchema<EFD> | undefined;
199
+ get replySchemas(): ReadonlyMap<string, Schema.Decoder<unknown>>;
189
200
  /** @internal */
190
201
  constructor(initial: State, stateSchema?: Schema.Schema<State>, eventSchema?: Schema.Schema<Event>, guardsSchema?: GuardsSchema<GD>, effectsSchema?: EffectsSchema<EFD>);
191
202
  from<NS extends VariantsUnion<_SD> & BrandedState, R1>(state: TaggedOrConstructor<NS>, build: (scope: TransitionScope<State, Event, R, _SD, _ED, GD, EFD, NS>) => R1): Machine<State, Event, R, _SD, _ED, GD, EFD>;
192
203
  from<NS extends ReadonlyArray<TaggedOrConstructor<VariantsUnion<_SD> & BrandedState>>, R1>(states: NS, build: (scope: TransitionScope<State, Event, R, _SD, _ED, GD, EFD, NS[number] extends TaggedOrConstructor<infer S extends VariantsUnion<_SD> & BrandedState> ? S : never>) => R1): Machine<State, Event, R, _SD, _ED, GD, EFD>;
193
204
  /** @internal */
194
- scopeTransition<NS extends VariantsUnion<_SD> & BrandedState, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(states: ReadonlyArray<TaggedOrConstructor<NS>>, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS, NE, RS, GD, EFD, never>, reenter: boolean): Machine<State, Event, R, _SD, _ED, GD, EFD>;
205
+ scopeTransition<NS extends VariantsUnion<_SD> & BrandedState, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(states: ReadonlyArray<TaggedOrConstructor<NS>>, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS, NE, RS, GD, EFD, never, ExtractReply<NE>>, reenter: boolean): Machine<State, Event, R, _SD, _ED, GD, EFD>;
195
206
  /** Register transition for a single state */
196
- on<NS extends VariantsUnion<_SD> & BrandedState, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(state: TaggedOrConstructor<NS>, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS, NE, RS, GD, EFD, never>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
207
+ on<NS extends VariantsUnion<_SD> & BrandedState, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(state: TaggedOrConstructor<NS>, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS, NE, RS, GD, EFD, never, ExtractReply<NE>>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
197
208
  /** Register transition for multiple states (handler receives union of state types) */
198
- on<NS extends ReadonlyArray<TaggedOrConstructor<VariantsUnion<_SD> & BrandedState>>, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(states: NS, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS[number] extends TaggedOrConstructor<infer S> ? S : never, NE, RS, GD, EFD, never>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
209
+ on<NS extends ReadonlyArray<TaggedOrConstructor<VariantsUnion<_SD> & BrandedState>>, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(states: NS, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS[number] extends TaggedOrConstructor<infer S> ? S : never, NE, RS, GD, EFD, never, ExtractReply<NE>>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
199
210
  /**
200
211
  * Like `on()`, but forces onEnter/spawn to run even when transitioning to the same state tag.
201
212
  * Use this to restart timers, re-run spawned effects, or reset state-scoped effects.
202
213
  */
203
214
  /** Single state */
204
- reenter<NS extends VariantsUnion<_SD> & BrandedState, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(state: TaggedOrConstructor<NS>, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS, NE, RS, GD, EFD, never>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
215
+ reenter<NS extends VariantsUnion<_SD> & BrandedState, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(state: TaggedOrConstructor<NS>, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS, NE, RS, GD, EFD, never, ExtractReply<NE>>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
205
216
  /** Multiple states */
206
- reenter<NS extends ReadonlyArray<TaggedOrConstructor<VariantsUnion<_SD> & BrandedState>>, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(states: NS, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS[number] extends TaggedOrConstructor<infer S> ? S : never, NE, RS, GD, EFD, never>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
217
+ reenter<NS extends ReadonlyArray<TaggedOrConstructor<VariantsUnion<_SD> & BrandedState>>, NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(states: NS, event: TaggedOrConstructor<NE>, handler: TransitionHandler<NS[number] extends TaggedOrConstructor<infer S> ? S : never, NE, RS, GD, EFD, never, ExtractReply<NE>>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
207
218
  /**
208
219
  * Register a wildcard transition that fires from any state when no specific transition matches.
209
220
  * Specific `.on()` transitions always take priority over `.onAny()`.
@@ -314,8 +325,8 @@ declare class TransitionScope<State, Event, R, _SD extends Record<string, Schema
314
325
  private readonly machine;
315
326
  private readonly states;
316
327
  constructor(machine: Machine<State, Event, R, _SD, _ED, GD, EFD>, states: ReadonlyArray<TaggedOrConstructor<SelectedState>>);
317
- on<NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(event: TaggedOrConstructor<NE>, handler: TransitionHandler<SelectedState, NE, RS, GD, EFD, never>): TransitionScope<State, Event, R, _SD, _ED, GD, EFD, SelectedState>;
318
- reenter<NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(event: TaggedOrConstructor<NE>, handler: TransitionHandler<SelectedState, NE, RS, GD, EFD, never>): TransitionScope<State, Event, R, _SD, _ED, GD, EFD, SelectedState>;
328
+ on<NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(event: TaggedOrConstructor<NE>, handler: TransitionHandler<SelectedState, NE, RS, GD, EFD, never, ExtractReply<NE>>): TransitionScope<State, Event, R, _SD, _ED, GD, EFD, SelectedState>;
329
+ reenter<NE extends VariantsUnion<_ED> & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>(event: TaggedOrConstructor<NE>, handler: TransitionHandler<SelectedState, NE, RS, GD, EFD, never, ExtractReply<NE>>): TransitionScope<State, Event, R, _SD, _ED, GD, EFD, SelectedState>;
319
330
  }
320
331
  declare const make: typeof Machine.make;
321
332
  /**
@@ -346,5 +357,7 @@ declare const replay: <S extends {
346
357
  }, R>(machine: BuiltMachine<S, E, R>, events: ReadonlyArray<E>, options?: {
347
358
  from?: S;
348
359
  }) => Effect.Effect<S, never, R>;
360
+ declare const reply: <State, Reply>(state: State, reply: Reply) => ReplyResult<State, Reply>;
361
+ declare const deferReply: <State>(state: State) => DeferReplyResult<State>;
349
362
  //#endregion
350
- export { BackgroundEffect, BuiltMachine, HandlerContext, Machine, MachineRef, MakeConfig, ProvideHandlers, SlotContext, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, findTransitions, machine_d_exports, make, replay, spawn };
363
+ export { BackgroundEffect, BuiltMachine, type DeferReplyResult, HandlerContext, Machine, MachineRef, MakeConfig, ProvideHandlers, type ReplyResult, SlotContext, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, deferReply, findTransitions, machine_d_exports, make, replay, reply, spawn };
package/dist/machine.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { __exportAll } from "./_virtual/_rolldown/runtime.js";
2
2
  import { Inspector } from "./inspection.js";
3
- import { getTag, stubSystem } from "./internal/utils.js";
3
+ import { getTag, makeDeferReply, makeReply, stubSystem } from "./internal/utils.js";
4
4
  import { ProvisionValidationError, SlotProvisionError } from "./errors.js";
5
5
  import { emitWithTimestamp } from "./internal/inspection.js";
6
6
  import { MachineContextTag } from "./slot.js";
@@ -11,9 +11,11 @@ import { Cause, Effect, Exit, Option, Scope } from "effect";
11
11
  var machine_exports = /* @__PURE__ */ __exportAll({
12
12
  BuiltMachine: () => BuiltMachine,
13
13
  Machine: () => Machine,
14
+ deferReply: () => deferReply,
14
15
  findTransitions: () => findTransitions,
15
16
  make: () => make,
16
17
  replay: () => replay,
18
+ reply: () => reply,
17
19
  spawn: () => spawn
18
20
  });
19
21
  const emitTaskInspection = (input) => Effect.flatMap(Effect.serviceOption(Inspector), (inspector) => Option.isNone(inspector) ? Effect.void : emitWithTimestamp(inspector.value, (timestamp) => ({
@@ -69,6 +71,7 @@ var Machine = class Machine {
69
71
  /** @internal */ _slots;
70
72
  stateSchema;
71
73
  eventSchema;
74
+ /** @internal */ _replySchemas;
72
75
  /**
73
76
  * Context tag for accessing machine state/event/self in slot handlers.
74
77
  * Uses shared module-level tag for all machines.
@@ -95,6 +98,9 @@ var Machine = class Machine {
95
98
  get effectsSchema() {
96
99
  return this._effectsSchema;
97
100
  }
101
+ get replySchemas() {
102
+ return this._replySchemas;
103
+ }
98
104
  /** @internal */
99
105
  constructor(initial, stateSchema, eventSchema, guardsSchema, effectsSchema) {
100
106
  this.initial = initial;
@@ -105,6 +111,7 @@ var Machine = class Machine {
105
111
  this._postponeRules = [];
106
112
  this._guardsSchema = guardsSchema;
107
113
  this._effectsSchema = effectsSchema;
114
+ this._replySchemas = eventSchema?._replySchemas ?? /* @__PURE__ */ new Map();
108
115
  this._guardHandlers = /* @__PURE__ */ new Map();
109
116
  this._effectHandlers = /* @__PURE__ */ new Map();
110
117
  this.stateSchema = stateSchema;
@@ -375,6 +382,7 @@ var Machine = class Machine {
375
382
  result._spawnEffects = [...this._spawnEffects];
376
383
  result._backgroundEffects = [...this._backgroundEffects];
377
384
  result._postponeRules = [...this._postponeRules];
385
+ result._replySchemas = this._replySchemas;
378
386
  const anyHandlers = handlers;
379
387
  if (this._guardsSchema !== void 0) for (const name of Object.keys(this._guardsSchema.definitions)) result._guardHandlers.set(name, anyHandlers[name]);
380
388
  if (this._effectsSchema !== void 0) for (const name of Object.keys(this._effectsSchema.definitions)) result._effectHandlers.set(name, anyHandlers[name]);
@@ -430,7 +438,8 @@ const replay = Effect.fn("effect-machine.replay")(function* (built, events, opti
430
438
  const self = {
431
439
  send: dummySend,
432
440
  cast: dummySend,
433
- spawn: () => Effect.die("spawn not supported in replay")
441
+ spawn: () => Effect.die("spawn not supported in replay"),
442
+ reply: () => Effect.succeed(false)
434
443
  };
435
444
  for (const event of events) {
436
445
  if (machine.finalStates.has(state._tag)) break;
@@ -464,5 +473,7 @@ const replay = Effect.fn("effect-machine.replay")(function* (built, events, opti
464
473
  }
465
474
  return state;
466
475
  });
476
+ const reply = makeReply;
477
+ const deferReply = makeDeferReply;
467
478
  //#endregion
468
- export { BuiltMachine, Machine, findTransitions, machine_exports, make, replay, spawn };
479
+ export { BuiltMachine, Machine, deferReply, findTransitions, machine_exports, make, replay, reply, spawn };