effect-machine 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,13 +25,13 @@ npm install effect-machine effect
25
25
 
26
26
  ```ts
27
27
  import { Effect, Schema } from "effect";
28
- import { Machine, State, Event, Slot } from "effect-machine";
28
+ import { Machine, State, Event, Slot, type BuiltMachine } from "effect-machine";
29
29
 
30
30
  // Define state schema - states ARE schemas
31
31
  const OrderState = State({
32
32
  Pending: { orderId: Schema.String },
33
33
  Processing: { orderId: Schema.String },
34
- Shipped: { trackingId: Schema.String },
34
+ Shipped: { orderId: Schema.String, trackingId: Schema.String },
35
35
  Cancelled: {},
36
36
  });
37
37
 
@@ -54,36 +54,36 @@ const orderMachine = Machine.make({
54
54
  effects: OrderEffects,
55
55
  initial: OrderState.Pending({ orderId: "order-1" }),
56
56
  })
57
- .on(OrderState.Pending, OrderEvent.Process, ({ state }) =>
58
- OrderState.Processing({ orderId: state.orderId }),
57
+ .on(OrderState.Pending, OrderEvent.Process, ({ state }) => OrderState.Processing.derive(state))
58
+ .on(OrderState.Processing, OrderEvent.Ship, ({ state, event }) =>
59
+ OrderState.Shipped.derive(state, { trackingId: event.trackingId }),
59
60
  )
60
- .on(OrderState.Processing, OrderEvent.Ship, ({ event }) =>
61
- OrderState.Shipped({ trackingId: event.trackingId }),
62
- )
63
- .on(OrderState.Pending, OrderEvent.Cancel, () => OrderState.Cancelled)
64
- .on(OrderState.Processing, OrderEvent.Cancel, () => OrderState.Cancelled)
61
+ // Cancel from any state
62
+ .onAny(OrderEvent.Cancel, () => OrderState.Cancelled)
65
63
  // Effect runs when entering Processing, cancelled on exit
66
64
  .spawn(OrderState.Processing, ({ effects, state }) =>
67
65
  effects.notifyWarehouse({ orderId: state.orderId }),
68
66
  )
69
- .provide({
70
- notifyWarehouse: ({ orderId }) => Effect.log(`Warehouse notified: ${orderId}`),
71
- })
72
67
  .final(OrderState.Shipped)
73
- .final(OrderState.Cancelled);
68
+ .final(OrderState.Cancelled)
69
+ .build({
70
+ notifyWarehouse: ({ orderId }) => Effect.log(`Warehouse notified: ${orderId}`),
71
+ });
74
72
 
75
- // Run as actor (simple)
73
+ // Run as actor (simple — no scope required)
76
74
  const program = Effect.gen(function* () {
77
75
  const actor = yield* Machine.spawn(orderMachine);
78
76
 
79
77
  yield* actor.send(OrderEvent.Process);
80
78
  yield* actor.send(OrderEvent.Ship({ trackingId: "TRACK-123" }));
81
79
 
82
- const state = yield* actor.snapshot;
83
- console.log(state); // Shipped { trackingId: "TRACK-123" }
80
+ const state = yield* actor.waitFor(OrderState.Shipped);
81
+ console.log(state); // Shipped { orderId: "order-1", trackingId: "TRACK-123" }
82
+
83
+ yield* actor.stop;
84
84
  });
85
85
 
86
- Effect.runPromise(Effect.scoped(program));
86
+ Effect.runPromise(program);
87
87
  ```
88
88
 
89
89
  ## Core Concepts
@@ -102,6 +102,34 @@ MyState.Idle; // Value (no parens)
102
102
  MyState.Loading({ url: "/api" }); // Constructor
103
103
  ```
104
104
 
105
+ ### State.derive()
106
+
107
+ Construct new states from existing ones — picks overlapping fields, applies overrides:
108
+
109
+ ```ts
110
+ // Same-state: preserve fields, override specific ones
111
+ .on(State.Active, Event.Update, ({ state, event }) =>
112
+ State.Active.derive(state, { count: event.count })
113
+ )
114
+
115
+ // Cross-state: picks only target fields from source
116
+ .on(State.Processing, Event.Ship, ({ state, event }) =>
117
+ State.Shipped.derive(state, { trackingId: event.trackingId })
118
+ )
119
+ ```
120
+
121
+ ### Multi-State Transitions
122
+
123
+ Handle the same event from multiple states:
124
+
125
+ ```ts
126
+ // Array of states — handler receives union type
127
+ .on([State.Draft, State.Review], Event.Cancel, () => State.Cancelled)
128
+
129
+ // Wildcard — fires from any state (specific .on() takes priority)
130
+ .onAny(Event.Cancel, () => State.Cancelled)
131
+ ```
132
+
105
133
  ### Guards and Effects as Slots
106
134
 
107
135
  Define parameterized guards and effects, provide implementations:
@@ -126,7 +154,7 @@ machine
126
154
  )
127
155
  // Fetch runs when entering Loading, auto-cancelled if state changes
128
156
  .spawn(MyState.Loading, ({ effects, state }) => effects.fetchData({ url: state.url }))
129
- .provide({
157
+ .build({
130
158
  canRetry: ({ max }, { state }) => state.attempts < max,
131
159
  fetchData: ({ url }, { self }) =>
132
160
  Effect.gen(function* () {
@@ -189,49 +217,62 @@ See the [primer](./primer/) for comprehensive documentation:
189
217
 
190
218
  ### Building
191
219
 
192
- | Method | Purpose |
193
- | ----------------------------------------- | ---------------------------- |
194
- | `Machine.make({ state, event, initial })` | Create machine |
195
- | `.on(State.X, Event.Y, handler)` | Add transition |
196
- | `.reenter(State.X, Event.Y, handler)` | Force re-entry on same state |
197
- | `.spawn(State.X, handler)` | State-scoped effect |
198
- | `.task(State.X, run, { onSuccess })` | State-scoped task |
199
- | `.background(handler)` | Machine-lifetime effect |
200
- | `.provide({ slot: impl })` | Provide implementations |
201
- | `.final(State.X)` | Mark final state |
202
- | `.persist(config)` | Enable persistence |
220
+ | Method | Purpose |
221
+ | ----------------------------------------- | ----------------------------------------------------------- |
222
+ | `Machine.make({ state, event, initial })` | Create machine |
223
+ | `.on(State.X, Event.Y, handler)` | Add transition |
224
+ | `.on([State.X, State.Y], Event.Z, h)` | Multi-state transition |
225
+ | `.onAny(Event.X, handler)` | Wildcard transition (any state) |
226
+ | `.reenter(State.X, Event.Y, handler)` | Force re-entry on same state |
227
+ | `.spawn(State.X, handler)` | State-scoped effect |
228
+ | `.task(State.X, run, { onSuccess })` | State-scoped task |
229
+ | `.background(handler)` | Machine-lifetime effect |
230
+ | `.final(State.X)` | Mark final state |
231
+ | `.build({ slot: impl })` | Provide implementations, returns `BuiltMachine` (terminal) |
232
+ | `.build()` | Finalize no-slot machine, returns `BuiltMachine` (terminal) |
233
+ | `.persist(config)` | Enable persistence |
234
+
235
+ ### State Constructors
236
+
237
+ | Method | Purpose |
238
+ | -------------------------------------- | ------------------------------ |
239
+ | `State.X.derive(source)` | Pick target fields from source |
240
+ | `State.X.derive(source, { field: v })` | Pick fields + apply overrides |
241
+ | `State.$is("X")(value)` | Type guard |
242
+ | `State.$match(value, { X: fn, ... })` | Pattern matching |
203
243
 
204
244
  ### Running
205
245
 
206
- | Method | Purpose |
207
- | ---------------------------- | ---------------------------------------- |
208
- | `Machine.spawn(machine)` | Spawn actor (simple, no registry) |
209
- | `Machine.spawn(machine, id)` | Spawn actor with custom ID |
210
- | `system.spawn(id, machine)` | Spawn via ActorSystem (registry/persist) |
246
+ | Method | Purpose |
247
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------- |
248
+ | `Machine.spawn(machine)` | Single actor, no registry. Caller manages lifetime via `actor.stop`. Auto-cleans up if `Scope` present. |
249
+ | `Machine.spawn(machine, id)` | Same as above with custom ID |
250
+ | `system.spawn(id, machine)` | Registry, lookup by ID, bulk ops, persistence. Cleans up on system teardown. |
211
251
 
212
252
  ### Testing
213
253
 
214
- | Function | Description |
215
- | ------------------------------------------ | -------------------------- |
216
- | `simulate(machine, events)` | Run events, get all states |
217
- | `createTestHarness(machine)` | Step-by-step testing |
218
- | `assertPath(machine, events, path)` | Assert exact path |
219
- | `assertReaches(machine, events, tag)` | Assert final state |
220
- | `assertNeverReaches(machine, events, tag)` | Assert state never visited |
254
+ | Function | Description |
255
+ | ------------------------------------------ | ---------------------------------------------------------------- |
256
+ | `simulate(machine, events)` | Run events, get all states (accepts `Machine` or `BuiltMachine`) |
257
+ | `createTestHarness(machine)` | Step-by-step testing (accepts `Machine` or `BuiltMachine`) |
258
+ | `assertPath(machine, events, path)` | Assert exact path |
259
+ | `assertReaches(machine, events, tag)` | Assert final state |
260
+ | `assertNeverReaches(machine, events, tag)` | Assert state never visited |
221
261
 
222
262
  ### Actor
223
263
 
224
- | Method | Description |
225
- | --------------------- | ----------------- |
226
- | `actor.send(event)` | Queue event |
227
- | `actor.snapshot` | Get current state |
228
- | `actor.matches(tag)` | Check state tag |
229
- | `actor.can(event)` | Can handle event? |
230
- | `actor.changes` | Stream of changes |
231
- | `actor.waitFor(fn)` | Wait for match |
232
- | `actor.awaitFinal` | Wait final state |
233
- | `actor.sendAndWait` | Send + wait |
234
- | `actor.subscribe(fn)` | Sync callback |
264
+ | Method | Description |
265
+ | -------------------------------- | ---------------------------------- |
266
+ | `actor.send(event)` | Queue event |
267
+ | `actor.sendSync(event)` | Fire-and-forget (sync, for UI) |
268
+ | `actor.snapshot` | Get current state |
269
+ | `actor.matches(tag)` | Check state tag |
270
+ | `actor.can(event)` | Can handle event? |
271
+ | `actor.changes` | Stream of changes |
272
+ | `actor.waitFor(State.X)` | Wait for state (constructor or fn) |
273
+ | `actor.awaitFinal` | Wait final state |
274
+ | `actor.sendAndWait(ev, State.X)` | Send + wait for state |
275
+ | `actor.subscribe(fn)` | Sync callback |
235
276
 
236
277
  ## License
237
278
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-machine",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cevr/effect-machine.git"
@@ -25,7 +25,7 @@
25
25
  "fmt:check": "oxfmt --check",
26
26
  "test": "bun test",
27
27
  "test:watch": "bun test --watch",
28
- "gate": "concurrently -n type,lint,fmt,test -c blue,yellow,magenta,green \"bun run typecheck\" \"bun run lint\" \"bun run fmt\" \"bun run test\"",
28
+ "gate": "concurrently -n type,lint,fmt,test -c blue,yellow,magenta,green \"bun run typecheck\" \"bun run lint:fix\" \"bun run fmt\" \"bun run test\"",
29
29
  "prepare": "lefthook install || true && effect-language-service patch",
30
30
  "version": "changeset version",
31
31
  "release": "changeset publish"
package/src/actor.ts CHANGED
@@ -6,9 +6,11 @@
6
6
  * - ActorSystem service (spawn/stop/get actors)
7
7
  * - Actor creation and event loop
8
8
  */
9
+ import type { Stream } from "effect";
9
10
  import {
10
11
  Cause,
11
12
  Context,
13
+ Deferred,
12
14
  Effect,
13
15
  Exit,
14
16
  Fiber,
@@ -17,12 +19,12 @@ import {
17
19
  Option,
18
20
  Queue,
19
21
  Ref,
22
+ Runtime,
20
23
  Scope,
21
- Stream,
22
24
  SubscriptionRef,
23
25
  } from "effect";
24
26
 
25
- import type { Machine, MachineRef } from "./machine.js";
27
+ import type { Machine, MachineRef, BuiltMachine } from "./machine.js";
26
28
  import type { Inspector } from "./inspection.js";
27
29
  import { Inspector as InspectorTag } from "./inspection.js";
28
30
  import { processEventCore, runSpawnEffects, resolveTransition } from "./internal/transition.js";
@@ -37,7 +39,7 @@ export type {
37
39
  ProcessEventResult,
38
40
  } from "./internal/transition.js";
39
41
  import type { GuardsDef, EffectsDef } from "./slot.js";
40
- import { DuplicateActorError, UnprovidedSlotsError } from "./errors.js";
42
+ import { DuplicateActorError } from "./errors.js";
41
43
  import { INTERNAL_INIT_EVENT } from "./internal/utils.js";
42
44
  import type {
43
45
  ActorMetadata,
@@ -118,9 +120,13 @@ export interface ActorRef<State extends { readonly _tag: string }, Event> {
118
120
  readonly changes: Stream.Stream<State>;
119
121
 
120
122
  /**
121
- * Wait for a state that matches predicate (includes current snapshot)
123
+ * Wait for a state that matches predicate or state variant (includes current snapshot).
124
+ * Accepts a predicate function or a state constructor/value (e.g. `State.Active`).
122
125
  */
123
- readonly waitFor: (predicate: (state: State) => boolean) => Effect.Effect<State>;
126
+ readonly waitFor: {
127
+ (predicate: (state: State) => boolean): Effect.Effect<State>;
128
+ (state: { readonly _tag: State["_tag"] }): Effect.Effect<State>;
129
+ };
124
130
 
125
131
  /**
126
132
  * Wait for a final state (includes current snapshot)
@@ -128,12 +134,21 @@ export interface ActorRef<State extends { readonly _tag: string }, Event> {
128
134
  readonly awaitFinal: Effect.Effect<State>;
129
135
 
130
136
  /**
131
- * Send event and wait for predicate or final state
137
+ * Send event and wait for predicate, state variant, or final state.
138
+ * Accepts a predicate function or a state constructor/value (e.g. `State.Active`).
139
+ */
140
+ readonly sendAndWait: {
141
+ (event: Event, predicate: (state: State) => boolean): Effect.Effect<State>;
142
+ (event: Event, state: { readonly _tag: State["_tag"] }): Effect.Effect<State>;
143
+ (event: Event): Effect.Effect<State>;
144
+ };
145
+
146
+ /**
147
+ * Send event synchronously (fire-and-forget).
148
+ * No-op on stopped actors. Use when you need to send from sync contexts
149
+ * (e.g. framework hooks, event handlers).
132
150
  */
133
- readonly sendAndWait: (
134
- event: Event,
135
- predicate?: (state: State) => boolean,
136
- ) => Effect.Effect<State>;
151
+ readonly sendSync: (event: Event) => void;
137
152
 
138
153
  /**
139
154
  * Subscribe to state changes (sync callback)
@@ -159,14 +174,13 @@ export interface ActorSystem {
159
174
  * For regular machines, returns ActorRef.
160
175
  * For persistent machines (created with Machine.persist), returns PersistentActorRef.
161
176
  *
162
- * Note: All effect slots must be provided via `Machine.provide` before spawning.
163
- * Attempting to spawn a machine with unprovided effect slots will fail.
177
+ * All effect slots must be provided via `.build()` before spawning.
164
178
  *
165
179
  * @example
166
180
  * ```ts
167
- * // Regular machine (effects provided)
168
- * const machine = Machine.provide(baseMachine, { fetchData: ... })
169
- * const actor = yield* system.spawn("my-actor", machine);
181
+ * // Regular machine (built)
182
+ * const built = machine.build({ fetchData: ... })
183
+ * const actor = yield* system.spawn("my-actor", built);
170
184
  *
171
185
  * // Persistent machine (auto-detected)
172
186
  * const persistentActor = yield* system.spawn("my-actor", persistentMachine);
@@ -175,18 +189,11 @@ export interface ActorSystem {
175
189
  * ```
176
190
  */
177
191
  readonly spawn: {
178
- // Regular machine overload
179
- <
180
- S extends { readonly _tag: string },
181
- E extends { readonly _tag: string },
182
- R,
183
- GD extends GuardsDef = Record<string, never>,
184
- EFD extends EffectsDef = Record<string, never>,
185
- >(
192
+ // Regular machine overload (BuiltMachine)
193
+ <S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
186
194
  id: string,
187
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
188
- machine: Machine<S, E, R, any, any, GD, EFD>,
189
- ): Effect.Effect<ActorRef<S, E>, DuplicateActorError | UnprovidedSlotsError, R | Scope.Scope>;
195
+ machine: BuiltMachine<S, E, R>,
196
+ ): Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>;
190
197
 
191
198
  // Persistent machine overload
192
199
  <S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
@@ -194,8 +201,8 @@ export interface ActorSystem {
194
201
  machine: PersistentMachine<S, E, R>,
195
202
  ): Effect.Effect<
196
203
  PersistentActorRef<S, E, R>,
197
- PersistenceError | VersionConflictError | DuplicateActorError | UnprovidedSlotsError,
198
- R | Scope.Scope | PersistenceAdapterTag
204
+ PersistenceError | VersionConflictError | DuplicateActorError,
205
+ R | PersistenceAdapterTag
199
206
  >;
200
207
  };
201
208
 
@@ -218,8 +225,8 @@ export interface ActorSystem {
218
225
  machine: PersistentMachine<S, E, R>,
219
226
  ) => Effect.Effect<
220
227
  Option.Option<PersistentActorRef<S, E, R>>,
221
- PersistenceError | DuplicateActorError | UnprovidedSlotsError,
222
- R | Scope.Scope | PersistenceAdapterTag
228
+ PersistenceError | DuplicateActorError,
229
+ R | PersistenceAdapterTag
223
230
  >;
224
231
 
225
232
  /**
@@ -267,7 +274,7 @@ export interface ActorSystem {
267
274
  >(
268
275
  ids: ReadonlyArray<string>,
269
276
  machine: PersistentMachine<S, E, R>,
270
- ) => Effect.Effect<RestoreResult<S, E, R>, never, R | Scope.Scope | PersistenceAdapterTag>;
277
+ ) => Effect.Effect<RestoreResult<S, E, R>, never, R | PersistenceAdapterTag>;
271
278
 
272
279
  /**
273
280
  * Restore all persisted actors for a machine type.
@@ -288,11 +295,7 @@ export interface ActorSystem {
288
295
  >(
289
296
  machine: PersistentMachine<S, E, R>,
290
297
  options?: { filter?: (meta: ActorMetadata) => boolean },
291
- ) => Effect.Effect<
292
- RestoreResult<S, E, R>,
293
- PersistenceError,
294
- R | Scope.Scope | PersistenceAdapterTag
295
- >;
298
+ ) => Effect.Effect<RestoreResult<S, E, R>, PersistenceError, R | PersistenceAdapterTag>;
296
299
  }
297
300
 
298
301
  /**
@@ -362,16 +365,41 @@ export const buildActorRefCore = <
362
365
  });
363
366
 
364
367
  const waitFor = Effect.fn("effect-machine.actor.waitFor")(function* (
365
- predicate: (state: S) => boolean,
368
+ predicateOrState: ((state: S) => boolean) | { readonly _tag: S["_tag"] },
366
369
  ) {
367
- // Use stateRef.changes directly — it emits the current value as its first
368
- // element inside the SubscriptionRef semaphore, so this is atomic and
369
- // race-free (no gap between "check current" and "subscribe").
370
- const result = yield* stateRef.changes.pipe(Stream.filter(predicate), Stream.runHead);
371
- if (Option.isSome(result)) return result.value;
372
- // Unreachable: changes always emits at least the current value.
373
- // Fallback to snapshot for type completeness.
374
- return yield* SubscriptionRef.get(stateRef);
370
+ const predicate =
371
+ typeof predicateOrState === "function" && !("_tag" in predicateOrState)
372
+ ? predicateOrState
373
+ : (s: S) => s._tag === (predicateOrState as { readonly _tag: string })._tag;
374
+
375
+ // Check current state first SubscriptionRef.get acquires/releases
376
+ // the semaphore quickly (read-only), no deadlock risk.
377
+ const current = yield* SubscriptionRef.get(stateRef);
378
+ if (predicate(current)) return current;
379
+
380
+ // Use sync listener + Deferred to avoid holding the SubscriptionRef
381
+ // semaphore for the duration of a stream (which causes deadlock when
382
+ // send triggers SubscriptionRef.set concurrently).
383
+ const done = yield* Deferred.make<S>();
384
+ const rt = yield* Effect.runtime<never>();
385
+ const runFork = Runtime.runFork(rt);
386
+ const listener = (state: S) => {
387
+ if (predicate(state)) {
388
+ runFork(Deferred.succeed(done, state));
389
+ }
390
+ };
391
+ listeners.add(listener);
392
+
393
+ // Re-check after subscribing to close the race window
394
+ const afterSubscribe = yield* SubscriptionRef.get(stateRef);
395
+ if (predicate(afterSubscribe)) {
396
+ listeners.delete(listener);
397
+ return afterSubscribe;
398
+ }
399
+
400
+ const result = yield* Deferred.await(done);
401
+ listeners.delete(listener);
402
+ return result;
375
403
  });
376
404
 
377
405
  const awaitFinal = waitFor((state) => machine.finalStates.has(state._tag)).pipe(
@@ -380,11 +408,11 @@ export const buildActorRefCore = <
380
408
 
381
409
  const sendAndWait = Effect.fn("effect-machine.actor.sendAndWait")(function* (
382
410
  event: E,
383
- predicate?: (state: S) => boolean,
411
+ predicateOrState?: ((state: S) => boolean) | { readonly _tag: S["_tag"] },
384
412
  ) {
385
413
  yield* send(event);
386
- if (predicate !== undefined) {
387
- return yield* waitFor(predicate);
414
+ if (predicateOrState !== undefined) {
415
+ return yield* waitFor(predicateOrState);
388
416
  }
389
417
  return yield* awaitFinal;
390
418
  });
@@ -407,6 +435,12 @@ export const buildActorRefCore = <
407
435
  waitFor,
408
436
  awaitFinal,
409
437
  sendAndWait,
438
+ sendSync: (event) => {
439
+ const stopped = Effect.runSync(Ref.get(stoppedRef));
440
+ if (!stopped) {
441
+ Effect.runSync(Queue.offer(eventQueue, event));
442
+ }
443
+ },
410
444
  subscribe: (fn) => {
411
445
  listeners.add(fn);
412
446
  return () => {
@@ -432,11 +466,6 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
432
466
  >(id: string, machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>) {
433
467
  yield* Effect.annotateCurrentSpan("effect_machine.actor.id", id);
434
468
 
435
- const missing = machine._missingSlots();
436
- if (missing.length > 0) {
437
- return yield* new UnprovidedSlotsError({ slots: missing });
438
- }
439
-
440
469
  // Get optional inspector from context
441
470
  const inspectorValue = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as
442
471
  | Inspector<S, E>
@@ -477,7 +506,7 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
477
506
  const { effects: effectSlots } = machine._slots;
478
507
 
479
508
  for (const bg of machine.backgroundEffects) {
480
- const fiber = yield* Effect.fork(
509
+ const fiber = yield* Effect.forkDaemon(
481
510
  bg
482
511
  .handler({ state: machine.initial, event: initEvent, self, effects: effectSlots })
483
512
  .pipe(Effect.provideService(machine.Context, ctx)),
@@ -520,10 +549,9 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
520
549
  return buildActorRefCore(id, machine, stateRef, eventQueue, stoppedRef, listeners, stop);
521
550
  }
522
551
 
523
- // Start the event loop — use forkScoped so the event loop fiber's lifetime
524
- // is tied to the provided Scope, not the calling fiber. This prevents the
525
- // event loop from being interrupted when a transient caller completes.
526
- const loopFiber = yield* Effect.forkScoped(
552
+ // Start the event loop — use forkDaemon so the event loop fiber's lifetime
553
+ // is detached from any parent scope/fiber. actor.stop handles cleanup.
554
+ const loopFiber = yield* Effect.forkDaemon(
527
555
  eventLoop(
528
556
  machine,
529
557
  stateRef,
@@ -774,7 +802,16 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
774
802
  const spawnGate = yield* Effect.makeSemaphore(1);
775
803
  const withSpawnGate = spawnGate.withPermits(1);
776
804
 
777
- /** Check for duplicate ID, register actor, add cleanup finalizer */
805
+ // Stop all actors on system teardown
806
+ yield* Effect.addFinalizer(() => {
807
+ const stops: Effect.Effect<void>[] = [];
808
+ MutableHashMap.forEach(actors, (actor) => {
809
+ stops.push(actor.stop);
810
+ });
811
+ return Effect.all(stops, { concurrency: "unbounded" }).pipe(Effect.asVoid);
812
+ });
813
+
814
+ /** Check for duplicate ID, register actor, attach scope cleanup if available */
778
815
  const registerActor = Effect.fn("effect-machine.actorSystem.register")(function* <
779
816
  T extends { stop: Effect.Effect<void> },
780
817
  >(id: string, actor: T) {
@@ -788,13 +825,17 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
788
825
  // Register it - O(1)
789
826
  MutableHashMap.set(actors, id, actor as unknown as ActorRef<AnyState, unknown>);
790
827
 
791
- // Register cleanup on scope finalization
792
- yield* Effect.addFinalizer(
793
- Effect.fn("effect-machine.actorSystem.register.finalizer")(function* () {
794
- yield* actor.stop;
795
- MutableHashMap.remove(actors, id);
796
- }),
797
- );
828
+ // If scope available, attach per-actor cleanup
829
+ const maybeScope = yield* Effect.serviceOption(Scope.Scope);
830
+ if (Option.isSome(maybeScope)) {
831
+ yield* Scope.addFinalizer(
832
+ maybeScope.value,
833
+ Effect.gen(function* () {
834
+ yield* actor.stop;
835
+ MutableHashMap.remove(actors, id);
836
+ }),
837
+ );
838
+ }
798
839
 
799
840
  return actor;
800
841
  });
@@ -803,14 +844,12 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
803
844
  S extends { readonly _tag: string },
804
845
  E extends { readonly _tag: string },
805
846
  R,
806
- GD extends GuardsDef = Record<string, never>,
807
- EFD extends EffectsDef = Record<string, never>,
808
- >(id: string, machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>) {
847
+ >(id: string, built: BuiltMachine<S, E, R>) {
809
848
  if (MutableHashMap.has(actors, id)) {
810
849
  return yield* new DuplicateActorError({ actorId: id });
811
850
  }
812
851
  // Create and register the actor
813
- const actor = yield* createActor(id, machine);
852
+ const actor = yield* createActor(id, built._inner);
814
853
  return yield* registerActor(id, actor);
815
854
  });
816
855
 
@@ -846,68 +885,44 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
846
885
  S extends { readonly _tag: string },
847
886
  E extends { readonly _tag: string },
848
887
  R,
849
- GD extends GuardsDef = Record<string, never>,
850
- EFD extends EffectsDef = Record<string, never>,
851
- >(
852
- id: string,
853
- machine:
854
- | Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>
855
- | PersistentMachine<S, E, R>,
856
- ) {
888
+ >(id: string, machine: BuiltMachine<S, E, R> | PersistentMachine<S, E, R>) {
857
889
  if (isPersistentMachine(machine)) {
858
890
  // TypeScript can't narrow union with invariant generic params
859
891
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
860
892
  return yield* spawnPersistent(id, machine as PersistentMachine<S, E, R>);
861
893
  }
862
- return yield* spawnRegular(
863
- id,
864
- machine as Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
865
- );
894
+ return yield* spawnRegular(id, machine as BuiltMachine<S, E, R>);
866
895
  });
867
896
 
868
897
  // Type-safe overloaded spawn implementation
869
- function spawn<
870
- S extends { readonly _tag: string },
871
- E extends { readonly _tag: string },
872
- R,
873
- GD extends GuardsDef = Record<string, never>,
874
- EFD extends EffectsDef = Record<string, never>,
875
- >(
898
+ function spawn<S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
876
899
  id: string,
877
- machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
878
- ): Effect.Effect<ActorRef<S, E>, DuplicateActorError | UnprovidedSlotsError, R | Scope.Scope>;
900
+ machine: BuiltMachine<S, E, R>,
901
+ ): Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>;
879
902
  function spawn<S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
880
903
  id: string,
881
904
  machine: PersistentMachine<S, E, R>,
882
905
  ): Effect.Effect<
883
906
  PersistentActorRef<S, E, R>,
884
- PersistenceError | VersionConflictError | DuplicateActorError | UnprovidedSlotsError,
885
- R | Scope.Scope | PersistenceAdapterTag
907
+ PersistenceError | VersionConflictError | DuplicateActorError,
908
+ R | PersistenceAdapterTag
886
909
  >;
887
- function spawn<
888
- S extends { readonly _tag: string },
889
- E extends { readonly _tag: string },
890
- R,
891
- GD extends GuardsDef = Record<string, never>,
892
- EFD extends EffectsDef = Record<string, never>,
893
- >(
910
+ function spawn<S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
894
911
  id: string,
895
- machine:
896
- | Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>
897
- | PersistentMachine<S, E, R>,
912
+ machine: BuiltMachine<S, E, R> | PersistentMachine<S, E, R>,
898
913
  ):
899
- | Effect.Effect<ActorRef<S, E>, DuplicateActorError | UnprovidedSlotsError, R | Scope.Scope>
914
+ | Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>
900
915
  | Effect.Effect<
901
916
  PersistentActorRef<S, E, R>,
902
- PersistenceError | VersionConflictError | DuplicateActorError | UnprovidedSlotsError,
903
- R | Scope.Scope | PersistenceAdapterTag
917
+ PersistenceError | VersionConflictError | DuplicateActorError,
918
+ R | PersistenceAdapterTag
904
919
  > {
905
920
  return withSpawnGate(spawnImpl(id, machine)) as
906
- | Effect.Effect<ActorRef<S, E>, DuplicateActorError | UnprovidedSlotsError, R | Scope.Scope>
921
+ | Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>
907
922
  | Effect.Effect<
908
923
  PersistentActorRef<S, E, R>,
909
- PersistenceError | VersionConflictError | DuplicateActorError | UnprovidedSlotsError,
910
- R | Scope.Scope | PersistenceAdapterTag
924
+ PersistenceError | VersionConflictError | DuplicateActorError,
925
+ R | PersistenceAdapterTag
911
926
  >;
912
927
  }
913
928
 
@@ -961,7 +976,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
961
976
  const restored: PersistentActorRef<S, E, R>[] = [];
962
977
  const failed: {
963
978
  id: string;
964
- error: PersistenceError | DuplicateActorError | UnprovidedSlotsError;
979
+ error: PersistenceError | DuplicateActorError;
965
980
  }[] = [];
966
981
 
967
982
  for (const id of ids) {
@@ -1032,4 +1047,4 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
1032
1047
  /**
1033
1048
  * Default ActorSystem layer
1034
1049
  */
1035
- export const Default = Layer.effect(ActorSystem, make());
1050
+ export const Default = Layer.scoped(ActorSystem, make());