effect-machine 0.12.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.
@@ -1,8 +1,9 @@
1
1
  import { INTERNAL_INIT_EVENT } from "./utils.js";
2
- import { NoReplyError } from "../errors.js";
3
2
  import { processEventCore, runSpawnEffects, shouldPostpone } from "./transition.js";
3
+ import { NoReplyError } from "../errors.js";
4
+ import { ActorExit } from "../supervision.js";
4
5
  import { ActorSystem } from "../actor.js";
5
- import { Deferred, Effect, Exit, Fiber, Queue, Ref, Schema, Scope, SubscriptionRef } from "effect";
6
+ import { Cause, Deferred, Effect, Exit, Fiber, Option, Queue, Ref, Schema, Scope, SubscriptionRef } from "effect";
6
7
  //#region src/internal/runtime.ts
7
8
  /**
8
9
  * Shared runtime kernel for machine event processing.
@@ -10,35 +11,42 @@ import { Deferred, Effect, Exit, Fiber, Queue, Ref, Schema, Scope, SubscriptionR
10
11
  * Provides a single-queue event loop with:
11
12
  * - Sequential event processing (no split-mailbox race)
12
13
  * - Postpone buffer with drain-on-state-change (gen_statem)
13
- * - Background effect lifecycle
14
+ * - Background effect lifecycle (under actorScope fault boundary)
14
15
  * - Spawn effect lifecycle (per-state scope)
15
16
  * - Final state detection → stop
16
17
  * - Reply settlement (call/ask Deferreds)
17
18
  * - Reply schema validation
19
+ * - Lifecycle hooks for actor-specific concerns (inspection, listeners, etc.)
20
+ * - ActorExit with exit reason (Final/Stopped/Defect) via exitDeferred
18
21
  *
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
+ * Used by entity-machine and local actor (actor.ts delegates here).
22
23
  *
23
24
  * @internal
24
25
  */
25
26
  /**
26
27
  * Create a runtime for a machine. Returns a handle for sending events
27
28
  * and querying state. The runtime owns:
28
- * - Single event queue (all events serialized)
29
29
  * - Event loop fiber
30
30
  * - Postpone buffer
31
- * - Background effects
31
+ * - Background effects (under actorScope)
32
32
  * - State scope (spawn effects)
33
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.
34
38
  *
35
39
  * @internal
36
40
  */
37
41
  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 { 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();
42
50
  const deferredReplyRef = { current: void 0 };
43
51
  const selfSend = Effect.fn("effect-machine.runtime.self.send")(function* (event) {
44
52
  if (!(yield* Ref.get(stoppedRef))) yield* Queue.offer(eventQueue, {
@@ -46,15 +54,18 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
46
54
  event
47
55
  });
48
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;
49
60
  const self = {
50
61
  send: selfSend,
51
62
  cast: selfSend,
52
- spawn: (childId, childMachine) => system.spawn(`${actorId}/${childId}`, childMachine).pipe(Effect.provideService(ActorSystem, system)),
63
+ spawn: onChildSpawned !== void 0 ? (childId, childMachine) => defaultSpawn(childId, childMachine).pipe(Effect.tap((child) => onChildSpawned(childId, child))) : defaultSpawn,
53
64
  reply: (value) => Effect.sync(() => {
54
65
  const deferred = deferredReplyRef.current;
55
66
  if (deferred !== void 0) {
56
67
  deferredReplyRef.current = void 0;
57
- Effect.runFork(Deferred.succeed(deferred, value));
68
+ fork(Deferred.succeed(deferred, value));
58
69
  return true;
59
70
  }
60
71
  return false;
@@ -72,34 +83,65 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
72
83
  };
73
84
  const { effects: effectSlots } = machine._slots;
74
85
  for (const bg of machine.backgroundEffects) {
75
- const fiber = yield* Effect.forkDetach(bg.handler({
86
+ const fiber = yield* bg.handler({
76
87
  actorId,
77
88
  state: machine.initial,
78
89
  event: initEvent,
79
90
  self,
80
91
  effects: effectSlots,
81
92
  system
82
- }).pipe(Effect.provideService(machine.Context, ctx)));
93
+ }).pipe(Effect.provideService(machine.Context, ctx), Effect.forkIn(actorScope));
83
94
  backgroundFibers.push(fiber);
84
95
  }
85
- yield* runSpawnEffects(machine, machine.initial, initEvent, self, stateScopeRef.current, system, actorId, hooks?.onError);
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);
86
110
  if (machine.finalStates.has(machine.initial._tag)) {
111
+ if (lifecycle?.onFinal !== void 0) yield* lifecycle.onFinal(machine.initial);
87
112
  yield* Ref.set(stoppedRef, true);
88
113
  yield* Scope.close(stateScopeRef.current, Exit.void);
89
- yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
90
- return makeHandle(stateRef, stoppedRef, eventQueue, machine);
114
+ yield* Scope.close(actorScope, Exit.void);
115
+ yield* setExit(ActorExit.Final(machine.initial));
116
+ return makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope);
91
117
  }
92
- const loopFiber = yield* Effect.forkDetach(runtimeEventLoop(machine, stateRef, eventQueue, stoppedRef, self, backgroundFibers, stateScopeRef, actorId, system, hooks, deferredReplyRef));
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
+ }));
93
133
  const stop = Effect.gen(function* () {
94
134
  if (yield* Ref.get(stoppedRef)) return;
135
+ if (lifecycle?.onShutdown !== void 0) yield* lifecycle.onShutdown();
95
136
  yield* Ref.set(stoppedRef, true);
96
137
  yield* Fiber.interrupt(loopFiber);
97
138
  yield* Scope.close(stateScopeRef.current, Exit.void);
98
- yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
139
+ yield* Scope.close(actorScope, Exit.void);
140
+ yield* setExit(ActorExit.Stopped);
99
141
  }).pipe(Effect.asVoid);
100
- yield* Effect.addFinalizer(() => stop);
142
+ if (config.skipFinalizer !== true) yield* Effect.addFinalizer(() => stop);
101
143
  return {
102
- ...makeHandle(stateRef, stoppedRef, eventQueue, machine),
144
+ ...makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope),
103
145
  stop
104
146
  };
105
147
  });
@@ -107,7 +149,7 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
107
149
  * Build the runtime handle (send/ask/getState/isStopped).
108
150
  * Shared between initial-final and normal paths.
109
151
  */
110
- const makeHandle = (stateRef, stoppedRef, eventQueue, _machine) => ({
152
+ const makeHandle = (stateRef, stoppedRef, eventQueue, exitDeferred, actorScope) => ({
111
153
  send: (event) => Effect.gen(function* () {
112
154
  if (!(yield* Ref.get(stoppedRef))) yield* Queue.offer(eventQueue, {
113
155
  _tag: "send",
@@ -141,28 +183,65 @@ const makeHandle = (stateRef, stoppedRef, eventQueue, _machine) => ({
141
183
  getState: SubscriptionRef.get(stateRef),
142
184
  stateRef,
143
185
  isStopped: Ref.get(stoppedRef),
144
- stop: Effect.void
186
+ stop: Effect.void,
187
+ _queue: eventQueue,
188
+ _stoppedRef: stoppedRef,
189
+ exitDeferred,
190
+ actorScope
145
191
  });
146
- const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* (machine, stateRef, eventQueue, stoppedRef, self, backgroundFibers, stateScopeRef, actorId, system, hooks, deferredReplyRef) {
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);
147
196
  const postponed = [];
148
197
  const hasPostponeRules = machine.postponeRules.length > 0;
149
198
  const processQueued = Effect.fn("effect-machine.runtime.processQueued")(function* (queued) {
150
199
  const event = queued.event;
151
200
  const currentState = yield* SubscriptionRef.get(stateRef);
152
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);
153
217
  postponed.push({
154
218
  _tag: "send",
155
219
  event
156
220
  });
157
- if (queued._tag === "sendWait") yield* Deferred.succeed(queued.done, void 0);
158
221
  return {
159
222
  shouldStop: false,
160
- stateChanged: 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
+ }
161
235
  };
162
236
  }
237
+ if (lifecycle?.onEvent !== void 0) yield* lifecycle.onEvent(currentState, event);
163
238
  const result = yield* processEventCore(machine, currentState, event, self, stateScopeRef, system, actorId, hooks);
164
239
  if (result.transitioned) yield* SubscriptionRef.set(stateRef, result.newState);
240
+ if (lifecycle?.onStateChange !== void 0 && result.transitioned) yield* lifecycle.onStateChange(result, event);
165
241
  switch (queued._tag) {
242
+ case "call":
243
+ yield* Deferred.succeed(queued.reply, result);
244
+ break;
166
245
  case "sendWait":
167
246
  yield* Deferred.succeed(queued.done, void 0);
168
247
  break;
@@ -186,29 +265,67 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
186
265
  }));
187
266
  break;
188
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);
189
271
  return {
190
- shouldStop: result.isFinal && result.lifecycleRan,
191
- stateChanged: result.lifecycleRan
272
+ shouldStop,
273
+ stateChanged: result.lifecycleRan,
274
+ result
192
275
  };
193
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
+ });
194
309
  while (true) {
195
310
  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);
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)));
200
325
  }));
201
326
  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" });
327
+ const finalState = yield* SubscriptionRef.get(stateRef);
328
+ yield* shutdown(ActorExit.Final(finalState));
212
329
  return;
213
330
  }
214
331
  let drainTriggered = stateChanged;
@@ -218,16 +335,8 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
218
335
  for (const entry of drained) {
219
336
  const drain = yield* processQueued(entry);
220
337
  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" });
338
+ const finalState = yield* SubscriptionRef.get(stateRef);
339
+ yield* shutdown(ActorExit.Final(finalState));
231
340
  return;
232
341
  }
233
342
  if (drain.stateChanged) drainTriggered = true;
@@ -236,12 +345,12 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
236
345
  }
237
346
  });
238
347
  /** 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({
348
+ const settlePostponed = (postponed, actorId, forkFn) => {
349
+ for (const entry of postponed) if (entry._tag === "ask") forkFn(Deferred.fail(entry.reply, new NoReplyError({
241
350
  actorId,
242
351
  eventTag: entry.event._tag
243
352
  })));
244
- else if (entry._tag === "sendWait") Effect.runFork(Deferred.succeed(entry.done, void 0));
353
+ else if (entry._tag === "sendWait") forkFn(Deferred.succeed(entry.done, void 0));
245
354
  postponed.length = 0;
246
355
  };
247
356
  //#endregion
@@ -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
 
@@ -68,6 +68,8 @@ interface ProcessEventHooks<S, E> {
68
68
  readonly onTransition?: (from: S, to: S, event: E) => Effect.Effect<void>;
69
69
  /** Called when a transition handler or spawn effect fails with a defect */
70
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>;
71
73
  }
72
74
  /**
73
75
  * Error info for inspection hooks.
@@ -148,7 +150,7 @@ declare const runSpawnEffects: <S extends {
148
150
  readonly _tag: string;
149
151
  }, E extends {
150
152
  readonly _tag: string;
151
- }, 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>>;
152
154
  /**
153
155
  * Resolve which transition should fire for a given state and event.
154
156
  * Uses indexed O(1) lookup. First matching transition wins.
@@ -166,14 +168,13 @@ declare const invalidateIndex: (machine: object) => void;
166
168
  * Find all transitions matching a state/event pair.
167
169
  * Returns empty array if no matches.
168
170
  *
169
- * Accepts both `Machine` and `BuiltMachine`.
170
171
  * O(1) lookup after first access (index is lazily built).
171
172
  */
172
173
  declare const findTransitions: <S extends {
173
174
  readonly _tag: string;
174
175
  }, E extends {
175
176
  readonly _tag: string;
176
- }, 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>>;
177
178
  /**
178
179
  * Find all spawn effects for a state.
179
180
  * Returns empty array if no matches.
@@ -1,5 +1,4 @@
1
1
  import { INTERNAL_ENTER_EVENT, isDeferReplyResult, isEffect, isReplyResult } from "./utils.js";
2
- import { BuiltMachine } from "../machine.js";
3
2
  import { Cause, Effect, Exit, Scope } from "effect";
4
3
  //#region src/internal/transition.ts
5
4
  /**
@@ -139,7 +138,7 @@ const processEventCore = Effect.fn("effect-machine.processEventCore")(function*
139
138
  stateScopeRef.current = yield* Scope.make();
140
139
  if (hooks?.onTransition !== void 0) yield* hooks.onTransition(currentState, newState, event);
141
140
  if (hooks?.onSpawnEffect !== void 0) yield* hooks.onSpawnEffect(newState);
142
- 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);
143
142
  }
144
143
  return {
145
144
  newState,
@@ -158,7 +157,7 @@ const processEventCore = Effect.fn("effect-machine.processEventCore")(function*
158
157
  *
159
158
  * @internal
160
159
  */
161
- 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) {
162
161
  const spawnEffects = findSpawnEffects(machine, state._tag);
163
162
  const ctx = {
164
163
  actorId,
@@ -169,6 +168,7 @@ const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* (m
169
168
  };
170
169
  const { effects: effectSlots } = machine._slots;
171
170
  const reportError = onError;
171
+ const defectSignal = onSpawnDefect;
172
172
  for (const spawnEffect of spawnEffects) {
173
173
  const effect = spawnEffect.handler({
174
174
  actorId,
@@ -179,13 +179,14 @@ const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* (m
179
179
  system
180
180
  }).pipe(Effect.provideService(machine.Context, ctx), Effect.catchCause((cause) => {
181
181
  if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt;
182
- if (reportError === void 0) return Effect.failCause(cause).pipe(Effect.orDie);
183
- return reportError({
182
+ const report = reportError !== void 0 ? reportError({
184
183
  phase: "spawn",
185
184
  state,
186
185
  event,
187
186
  cause
188
- }).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)));
189
190
  }));
190
191
  yield* Effect.forkScoped(effect).pipe(Effect.provideService(Scope.Scope, stateScope));
191
192
  }
@@ -258,11 +259,10 @@ const getIndex = (machine) => {
258
259
  * Find all transitions matching a state/event pair.
259
260
  * Returns empty array if no matches.
260
261
  *
261
- * Accepts both `Machine` and `BuiltMachine`.
262
262
  * O(1) lookup after first access (index is lazily built).
263
263
  */
264
- const findTransitions = (input, stateTag, eventTag) => {
265
- const index = getIndex(input instanceof BuiltMachine ? input._inner : input);
264
+ const findTransitions = (machine, stateTag, eventTag) => {
265
+ const index = getIndex(machine);
266
266
  const specific = index.transitions.get(stateTag)?.get(eventTag) ?? [];
267
267
  if (specific.length > 0) return specific;
268
268
  return index.transitions.get("*")?.get(eventTag) ?? [];
package/dist/machine.d.ts CHANGED
@@ -1,15 +1,16 @@
1
- import { EffectHandlers, EffectSlots, EffectsDef, EffectsSchema, GuardHandlers, GuardSlots, GuardsDef, GuardsSchema, MachineContext } from "./slot.js";
2
1
  import { DeferReplyResult, ReplyResult, TransitionResult } from "./internal/utils.js";
3
2
  import { BrandedEvent, BrandedState, ExtractReply, TaggedOrConstructor } from "./internal/brands.js";
4
3
  import { MachineEventSchema, MachineStateSchema, VariantsUnion } from "./schema.js";
5
4
  import { DuplicateActorError } from "./errors.js";
5
+ import { EffectHandlers, EffectSlots, EffectsDef, EffectsSchema, GuardHandlers, GuardSlots, GuardsDef, GuardsSchema, MachineContext } from "./slot.js";
6
+ import { Supervision } from "./supervision.js";
6
7
  import { findTransitions } from "./internal/transition.js";
7
8
  import { ActorRef, ActorSystem } from "./actor.js";
8
9
  import { Cause, Duration, Effect, Schema, Scope, ServiceMap } from "effect";
9
10
 
10
11
  //#region src/machine.d.ts
11
12
  declare namespace machine_d_exports {
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
+ export { BackgroundEffect, DeferReplyResult, HandlerContext, Machine, MachineRef, MakeConfig, ProvideHandlers, ReplyResult, SlotContext, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, deferReply, findTransitions, make, materializeMachine, replay, reply, spawn };
13
14
  }
14
15
  /**
15
16
  * Self reference for sending events back to the machine
@@ -22,7 +23,7 @@ interface MachineRef<Event> {
22
23
  readonly _tag: string;
23
24
  }, E2 extends {
24
25
  readonly _tag: string;
25
- }, R2>(id: string, machine: BuiltMachine<S2, E2, R2>) => Effect.Effect<ActorRef<S2, E2>, DuplicateActorError, R2>;
26
+ }, R2>(id: string, machine: Machine<S2, E2, R2, any, any, any, any>) => Effect.Effect<ActorRef<S2, E2>, DuplicateActorError, R2>;
26
27
  /**
27
28
  * Settle a deferred reply from a spawn handler.
28
29
  * Only usable when the transition handler returned `Machine.deferReply(state)`.
@@ -99,9 +100,6 @@ interface TimeoutConfig<State, Event> {
99
100
  /** Event to send when the timer fires. Static or derived from current state. */
100
101
  readonly event: Event | ((state: State) => Event);
101
102
  }
102
- type IsAny<T> = 0 extends 1 & T ? true : false;
103
- type IsUnknown<T> = unknown extends T ? ([T] extends [unknown] ? true : false) : false;
104
- type NormalizeR<T> = IsAny<T> extends true ? T : IsUnknown<T> extends true ? never : T;
105
103
  interface MakeConfig<SD extends Record<string, Schema.Struct.Fields>, ED extends Record<string, Schema.Struct.Fields>, S extends BrandedState, E extends BrandedEvent, GD extends GuardsDef, EFD extends EffectsDef> {
106
104
  readonly state: MachineStateSchema<SD> & {
107
105
  Type: S;
@@ -121,22 +119,14 @@ type HasEffectKeys<EFD extends EffectsDef> = [keyof EFD] extends [never] ? false
121
119
  type SlotContext<State, Event> = MachineContext<State, Event, MachineRef<Event>>;
122
120
  /** Combined handlers for build() - guards and effects only */
123
121
  type ProvideHandlers<State, Event, GD extends GuardsDef, EFD extends EffectsDef, R> = (HasGuardKeys<GD> extends true ? GuardHandlers<GD, SlotContext<State, Event>, R> : object) & (HasEffectKeys<EFD> extends true ? EffectHandlers<EFD, SlotContext<State, Event>, R> : object);
124
- /** Whether the machine has any guard or effect slots */
125
- type HasSlots<GD extends GuardsDef, EFD extends EffectsDef> = HasGuardKeys<GD> extends true ? true : HasEffectKeys<EFD>;
126
122
  /**
127
- * A finalized machine ready for spawning.
123
+ * Bind slot handlers to a machine, returning a fresh copy with handlers installed.
124
+ * If no handlers provided and machine has no slots, returns the machine as-is.
125
+ * Validates that all required slots are provided and no extra slots are given.
128
126
  *
129
- * Created by calling `.build()` on a `Machine`. This is the only type
130
- * accepted by `Machine.spawn` and `ActorSystem.spawn` (regular overload).
131
- * Testing utilities (`simulate`, `createTestHarness`, etc.) still accept `Machine`.
127
+ * @internal used by spawn, replay, simulate, test harness, entity-machine
132
128
  */
133
- declare class BuiltMachine<State, Event, R = never> {
134
- /** @internal */
135
- readonly _inner: Machine<State, Event, R, any, any, any, any>;
136
- /** @internal */
137
- constructor(machine: Machine<State, Event, R, any, any, any, any>);
138
- get initial(): State;
139
- }
129
+ declare const materializeMachine: <S, E, R, GD extends GuardsDef, EFD extends EffectsDef>(machine: Machine<S, E, R, any, any, GD, EFD>, handlers?: Record<string, any>) => Machine<S, E, never, any, any, GD, EFD>;
140
130
  /**
141
131
  * Machine definition with fluent builder API.
142
132
  *
@@ -312,13 +302,6 @@ declare class Machine<State, Event, R = never, _SD extends Record<string, Schema
312
302
  */
313
303
  postpone<NS extends VariantsUnion<_SD> & BrandedState>(state: TaggedOrConstructor<NS>, events: TaggedOrConstructor<VariantsUnion<_ED> & BrandedEvent> | ReadonlyArray<TaggedOrConstructor<VariantsUnion<_ED> & BrandedEvent>>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
314
304
  final<NS extends VariantsUnion<_SD> & BrandedState>(state: TaggedOrConstructor<NS>): Machine<State, Event, R, _SD, _ED, GD, EFD>;
315
- /**
316
- * Finalize the machine. Returns a `BuiltMachine` — the only type accepted by `Machine.spawn`.
317
- *
318
- * - Machines with slots: pass implementations as the first argument.
319
- * - Machines without slots: call with no arguments.
320
- */
321
- build<R2 = never>(...args: HasSlots<GD, EFD> extends true ? [handlers: ProvideHandlers<State, Event, GD, EFD, R2>] : [handlers?: ProvideHandlers<State, Event, GD, EFD, R2>]): BuiltMachine<State, Event, R | NormalizeR<R2>>;
322
305
  static make<SD extends Record<string, Schema.Struct.Fields>, ED extends Record<string, Schema.Struct.Fields>, S extends BrandedState, E extends BrandedEvent, GD extends GuardsDef = Record<string, never>, EFD extends EffectsDef = Record<string, never>>(config: MakeConfig<SD, ED, S, E, GD, EFD>): Machine<S, E, never, SD, ED, GD, EFD>;
323
306
  }
324
307
  declare class TransitionScope<State, Event, R, _SD extends Record<string, Schema.Struct.Fields>, _ED extends Record<string, Schema.Struct.Fields>, GD extends GuardsDef, EFD extends EffectsDef, SelectedState extends VariantsUnion<_SD> & BrandedState> {
@@ -330,34 +313,72 @@ declare class TransitionScope<State, Event, R, _SD extends Record<string, Schema
330
313
  }
331
314
  declare const make: typeof Machine.make;
332
315
  /**
333
- * Spawn an actor from a built machine.
316
+ * Spawn an actor directly without ActorSystem ceremony.
317
+ * Accepts a `Machine` directly. For slotful machines, pass `{ slots }` in options.
318
+ *
319
+ * **Single actor, no registry.** Caller manages lifetime via `actor.stop`.
320
+ * If a `Scope` exists in context, cleanup attaches automatically on scope close.
334
321
  *
335
- * Options:
336
- * - `id` custom actor ID (default: random)
337
- * - `hydrate` — restore from a previously-saved state snapshot.
338
- * The actor starts in the hydrated state and re-runs spawn effects
339
- * for that state (timers, scoped resources, etc.). Transition history
340
- * is not replayed — only the current state's entry effects run.
322
+ * For registry, lookup by ID, persistence, or multi-actor coordination,
323
+ * use `ActorSystemService` / `system.spawn` instead.
341
324
  *
342
- * Persistence is composed in userland by observing `actor.changes`
343
- * and saving snapshots to your own storage.
325
+ * @example
326
+ * ```ts
327
+ * // Fire-and-forget — caller manages lifetime
328
+ * const actor = yield* Machine.spawn(machine.build());
329
+ * yield* actor.send(Event.Start);
330
+ * yield* actor.awaitFinal;
331
+ * yield* actor.stop;
332
+ *
333
+ * // Scope-aware — auto-cleans up on scope close
334
+ * yield* Effect.scoped(Effect.gen(function* () {
335
+ * const actor = yield* Machine.spawn(machine.build());
336
+ * yield* actor.send(Event.Start);
337
+ * // actor.stop called automatically when scope closes
338
+ * }));
339
+ * ```
340
+ */
341
+ type AnyMachine<S, E, R> = Machine<S, E, R, any, any, any, any>;
342
+ /**
343
+ * Spawn an actor from a machine.
344
+ *
345
+ * For machines with slots, pass implementations via `{ slots: { ... } }`.
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * // No slots
350
+ * const actor = yield* Machine.spawn(machine);
351
+ *
352
+ * // With slots
353
+ * const actor = yield* Machine.spawn(machine, {
354
+ * slots: { canRetry: ({ max }, { state }) => state.attempts < max },
355
+ * });
356
+ *
357
+ * // With hydration
358
+ * const actor = yield* Machine.spawn(machine, { hydrate: savedState });
359
+ * ```
344
360
  */
345
361
  declare const spawn: <S extends {
346
362
  readonly _tag: string;
347
363
  }, E extends {
348
364
  readonly _tag: string;
349
- }, R>(machine: BuiltMachine<S, E, R>, idOrOptions?: string | {
365
+ }, R>(machine: AnyMachine<S, E, R>, options?: string | {
350
366
  id?: string;
351
367
  hydrate?: S;
368
+ slots?: Record<string, any>;
369
+ supervision?: Supervision.Policy;
352
370
  }) => Effect.Effect<ActorRef<S, E>, never, R>;
353
- declare const replay: <S extends {
354
- readonly _tag: string;
355
- }, E extends {
356
- readonly _tag: string;
357
- }, R>(machine: BuiltMachine<S, E, R>, events: ReadonlyArray<E>, options?: {
358
- from?: S;
359
- }) => Effect.Effect<S, never, R>;
371
+ declare const replay: {
372
+ <S extends {
373
+ readonly _tag: string;
374
+ }, E extends {
375
+ readonly _tag: string;
376
+ }, R>(machine: AnyMachine<S, E, R>, events: ReadonlyArray<E>, options?: {
377
+ from?: S;
378
+ slots?: Record<string, any>;
379
+ }): Effect.Effect<S, never, R>;
380
+ };
360
381
  declare const reply: <State, Reply>(state: State, reply: Reply) => ReplyResult<State, Reply>;
361
382
  declare const deferReply: <State>(state: State) => DeferReplyResult<State>;
362
383
  //#endregion
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 };
384
+ export { BackgroundEffect, type DeferReplyResult, HandlerContext, Machine, MachineRef, MakeConfig, ProvideHandlers, type ReplyResult, SlotContext, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, deferReply, findTransitions, machine_d_exports, make, materializeMachine, replay, reply, spawn };