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.
@@ -8,13 +8,6 @@ declare const DuplicateActorError_base: Schema.TaggedErrorClass<DuplicateActorEr
8
8
  }>;
9
9
  /** Attempted to spawn/restore actor with ID already in use */
10
10
  declare class DuplicateActorError extends DuplicateActorError_base {}
11
- declare const UnprovidedSlotsError_base: Schema.TaggedErrorClass<UnprovidedSlotsError, "UnprovidedSlotsError", {
12
- readonly _tag: Schema.tag<"UnprovidedSlotsError">;
13
- } & {
14
- slots: Schema.Array$<typeof Schema.String>;
15
- }>;
16
- /** Machine has unprovided effect slots */
17
- declare class UnprovidedSlotsError extends UnprovidedSlotsError_base {}
18
11
  declare const MissingSchemaError_base: Schema.TaggedErrorClass<MissingSchemaError, "MissingSchemaError", {
19
12
  readonly _tag: Schema.tag<"MissingSchemaError">;
20
13
  } & {
@@ -24,6 +17,8 @@ declare const MissingSchemaError_base: Schema.TaggedErrorClass<MissingSchemaErro
24
17
  declare class MissingSchemaError extends MissingSchemaError_base {}
25
18
  declare const InvalidSchemaError_base: Schema.TaggedErrorClass<InvalidSchemaError, "InvalidSchemaError", {
26
19
  readonly _tag: Schema.tag<"InvalidSchemaError">;
20
+ } & {
21
+ message: typeof Schema.String;
27
22
  }>;
28
23
  /** State/Event schema has no variants */
29
24
  declare class InvalidSchemaError extends InvalidSchemaError_base {}
@@ -88,4 +83,4 @@ declare const VersionConflictError_base: Schema.TaggedErrorClass<VersionConflict
88
83
  /** Optimistic locking failure — stored version doesn't match expected */
89
84
  declare class VersionConflictError extends VersionConflictError_base {}
90
85
  //#endregion
91
- export { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, ProvisionValidationError, SlotProvisionError, UnprovidedSlotsError, VersionConflictError };
86
+ export { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, ProvisionValidationError, SlotProvisionError, VersionConflictError };
package/v3/dist/errors.js CHANGED
@@ -12,12 +12,10 @@ import { Schema } from "effect";
12
12
  */
13
13
  /** Attempted to spawn/restore actor with ID already in use */
14
14
  var DuplicateActorError = class extends Schema.TaggedError()("DuplicateActorError", { actorId: Schema.String }) {};
15
- /** Machine has unprovided effect slots */
16
- var UnprovidedSlotsError = class extends Schema.TaggedError()("UnprovidedSlotsError", { slots: Schema.Array(Schema.String) }) {};
17
15
  /** Operation requires schemas attached to machine */
18
16
  var MissingSchemaError = class extends Schema.TaggedError()("MissingSchemaError", { operation: Schema.String }) {};
19
17
  /** State/Event schema has no variants */
20
- var InvalidSchemaError = class extends Schema.TaggedError()("InvalidSchemaError", {}) {};
18
+ var InvalidSchemaError = class extends Schema.TaggedError()("InvalidSchemaError", { message: Schema.String }) {};
21
19
  /** $match called with missing handler for tag */
22
20
  var MissingMatchHandlerError = class extends Schema.TaggedError()("MissingMatchHandlerError", { tag: Schema.String }) {};
23
21
  /** Slot handler not found at runtime (internal error) */
@@ -47,4 +45,4 @@ var VersionConflictError = class extends Schema.TaggedError()("VersionConflictEr
47
45
  actual: Schema.Number
48
46
  }) {};
49
47
  //#endregion
50
- export { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, ProvisionValidationError, SlotProvisionError, UnprovidedSlotsError, VersionConflictError };
48
+ export { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, ProvisionValidationError, SlotProvisionError, VersionConflictError };
@@ -1,10 +1,11 @@
1
- import { EffectHandlers, EffectSlot, EffectSlots, EffectsDef, EffectsSchema, GuardHandlers, GuardSlot, GuardSlots, GuardsDef, GuardsSchema, MachineContext, Slot } from "./slot.js";
1
+ import { ActorExit, CellPhase, DefectPhase, Supervision } from "./supervision.js";
2
2
  import { ReplyResult } from "./internal/utils.js";
3
3
  import { Event, MachineEventSchema, MachineStateSchema, ReplyFields, State } from "./schema.js";
4
- import { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, ProvisionValidationError, SlotProvisionError, UnprovidedSlotsError } from "./errors.js";
4
+ import { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, ProvisionValidationError, SlotProvisionError, VersionConflictError } from "./errors.js";
5
+ import { EffectHandlers, EffectSlot, EffectSlots, EffectsDef, EffectsSchema, GuardHandlers, GuardSlot, GuardSlots, GuardsDef, GuardsSchema, MachineContext, Slot } from "./slot.js";
5
6
  import { ProcessEventResult } from "./internal/transition.js";
6
- import { BackgroundEffect, BuiltMachine, HandlerContext, Machine, MachineRef, MakeConfig, ProvideHandlers, SpawnEffect, StateHandlerContext, TaskOptions, Transition, machine_d_exports } from "./machine.js";
7
+ import { BackgroundEffect, HandlerContext, Machine, MachineRef, MakeConfig, ProvideHandlers, SlotContext, SpawnEffect, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, machine_d_exports } from "./machine.js";
7
8
  import { ActorRef, ActorRefSync, ActorSystem, Default, SystemEvent, SystemEventListener, TransitionInfo } from "./actor.js";
8
9
  import { SimulationResult, TestHarness, TestHarnessOptions, assertNeverReaches, assertPath, assertReaches, createTestHarness, simulate } from "./testing.js";
9
10
  import { AnyInspectionEvent, EffectEvent, ErrorEvent, EventReceivedEvent, InspectionEvent, Inspector, InspectorHandler, SpawnEvent, StopEvent, TaskEvent, TracingInspectorOptions, TransitionEvent, collectingInspector, combineInspectors, consoleInspector, makeInspector, makeInspectorEffect, tracingInspector } from "./inspection.js";
10
- export { type ActorRef, type ActorRefSync, ActorStoppedError, type ActorSystem, Default as ActorSystemDefault, ActorSystem as ActorSystemService, type AnyInspectionEvent, AssertionError, type BackgroundEffect, type BuiltMachine, DuplicateActorError, type EffectEvent, type EffectSlots, type EffectsDef, type EffectsSchema, type ErrorEvent, Event, type EventReceivedEvent, type GuardHandlers, type GuardSlot, type GuardSlots, type GuardsDef, type GuardsSchema, type HandlerContext, type InspectionEvent, type Inspector, type InspectorHandler, Inspector as InspectorService, InvalidSchemaError, machine_d_exports as Machine, type MachineContext, type MachineEventSchema, type MachineRef, type MachineStateSchema, type Machine as MachineType, type MakeConfig, MissingMatchHandlerError, MissingSchemaError, NoReplyError, type ProcessEventResult, type ProvideHandlers, ProvisionValidationError, type ReplyFields, type ReplyResult, type SimulationResult, Slot, type EffectHandlers as SlotEffectHandlers, type EffectSlot as SlotEffectSlot, SlotProvisionError, type SpawnEffect, type SpawnEvent, State, type StateHandlerContext, type StopEvent, type SystemEvent, type SystemEventListener, type TaskEvent, type TaskOptions, type TestHarness, type TestHarnessOptions, type TracingInspectorOptions, type Transition, type TransitionEvent, type TransitionInfo, UnprovidedSlotsError, assertNeverReaches, assertPath, assertReaches, collectingInspector, combineInspectors, consoleInspector, createTestHarness, makeInspector, makeInspectorEffect, simulate, tracingInspector };
11
+ export { ActorExit, type ActorRef, type ActorRefSync, ActorStoppedError, type ActorSystem, Default as ActorSystemDefault, ActorSystem as ActorSystemService, type AnyInspectionEvent, AssertionError, type BackgroundEffect, type CellPhase, type DefectPhase, DuplicateActorError, type EffectEvent, type EffectSlots, type EffectsDef, type EffectsSchema, type ErrorEvent, Event, type EventReceivedEvent, type GuardHandlers, type GuardSlot, type GuardSlots, type GuardsDef, type GuardsSchema, type HandlerContext, type InspectionEvent, type Inspector, type InspectorHandler, Inspector as InspectorService, InvalidSchemaError, machine_d_exports as Machine, type MachineContext, type MachineEventSchema, type MachineRef, type MachineStateSchema, type Machine as MachineType, type MakeConfig, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, type ProcessEventResult, type ProvideHandlers, ProvisionValidationError, type ReplyFields, type ReplyResult, type SimulationResult, Slot, type SlotContext, type EffectHandlers as SlotEffectHandlers, type EffectSlot as SlotEffectSlot, SlotProvisionError, type SpawnEffect, type SpawnEvent, State, type StateHandlerContext, type StopEvent, Supervision, type SystemEvent, type SystemEventListener, type TaskEvent, type TaskOptions, type TestHarness, type TestHarnessOptions, type TimeoutConfig, type TracingInspectorOptions, type Transition, type TransitionEvent, type TransitionInfo, VersionConflictError, assertNeverReaches, assertPath, assertReaches, collectingInspector, combineInspectors, consoleInspector, createTestHarness, makeInspector, makeInspectorEffect, simulate, tracingInspector };
package/v3/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, ProvisionValidationError, SlotProvisionError, VersionConflictError } from "./errors.js";
1
2
  import { Inspector, collectingInspector, combineInspectors, consoleInspector, makeInspector, makeInspectorEffect, tracingInspector } from "./inspection.js";
2
- import { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, ProvisionValidationError, SlotProvisionError, UnprovidedSlotsError } from "./errors.js";
3
3
  import { Slot } from "./slot.js";
4
4
  import { machine_exports } from "./machine.js";
5
+ import { ActorExit, Supervision } from "./supervision.js";
5
6
  import { ActorSystem, Default } from "./actor.js";
6
7
  import { Event, State } from "./schema.js";
7
8
  import { assertNeverReaches, assertPath, assertReaches, createTestHarness, simulate } from "./testing.js";
8
- export { ActorStoppedError, Default as ActorSystemDefault, ActorSystem as ActorSystemService, AssertionError, DuplicateActorError, Event, Inspector as InspectorService, InvalidSchemaError, machine_exports as Machine, MissingMatchHandlerError, MissingSchemaError, NoReplyError, ProvisionValidationError, Slot, SlotProvisionError, State, UnprovidedSlotsError, assertNeverReaches, assertPath, assertReaches, collectingInspector, combineInspectors, consoleInspector, createTestHarness, makeInspector, makeInspectorEffect, simulate, tracingInspector };
9
+ export { ActorExit, ActorStoppedError, Default as ActorSystemDefault, ActorSystem as ActorSystemService, AssertionError, DuplicateActorError, Event, Inspector as InspectorService, InvalidSchemaError, machine_exports as Machine, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, ProvisionValidationError, Slot, SlotProvisionError, State, Supervision, VersionConflictError, assertNeverReaches, assertPath, assertReaches, collectingInspector, combineInspectors, consoleInspector, createTestHarness, makeInspector, makeInspectorEffect, simulate, tracingInspector };
@@ -1,9 +1,10 @@
1
- import { EffectsDef, GuardsDef, MachineContext } from "../slot.js";
1
+ import { ActorExit } from "../supervision.js";
2
2
  import { NoReplyError } from "../errors.js";
3
- import { ProcessEventHooks } from "./transition.js";
3
+ import { EffectsDef, GuardsDef, MachineContext } from "../slot.js";
4
+ import { ProcessEventHooks, ProcessEventResult } from "./transition.js";
4
5
  import { Machine, MachineRef } from "../machine.js";
5
6
  import { ActorSystem } from "../actor.js";
6
- import { Deferred, Effect, Queue, Scope } from "effect";
7
+ import { Deferred, Effect, Queue, Ref, Scope, SubscriptionRef } from "effect";
7
8
 
8
9
  //#region src/internal/runtime.d.ts
9
10
  /** @internal */
@@ -14,11 +15,30 @@ type RuntimeQueuedEvent<E> = {
14
15
  readonly _tag: "sendWait";
15
16
  readonly event: E;
16
17
  readonly done: Deferred.Deferred<void>;
18
+ } | {
19
+ readonly _tag: "call";
20
+ readonly event: E;
21
+ readonly reply: Deferred.Deferred<ProcessEventResult<{
22
+ readonly _tag: string;
23
+ }>, unknown>;
17
24
  } | {
18
25
  readonly _tag: "ask";
19
26
  readonly event: E;
20
27
  readonly reply: Deferred.Deferred<unknown, NoReplyError>;
28
+ } | {
29
+ readonly _tag: "drain";
30
+ readonly done: Deferred.Deferred<void, never>;
21
31
  };
32
+ /**
33
+ * Resources owned by the actor cell (stable across generations).
34
+ * When provided, createRuntime uses these instead of allocating its own.
35
+ * @internal
36
+ */
37
+ interface RuntimeCellResources<S, E> {
38
+ readonly stateRef: SubscriptionRef.SubscriptionRef<S>;
39
+ readonly eventQueue: Queue.Queue<RuntimeQueuedEvent<E>>;
40
+ readonly stoppedRef: Ref.Ref<boolean>;
41
+ }
22
42
  /** @internal */
23
43
  interface RuntimeHandle<S, E> {
24
44
  /** Enqueue a fire-and-forget event */
@@ -29,30 +49,87 @@ interface RuntimeHandle<S, E> {
29
49
  readonly ask: (event: E) => Effect.Effect<unknown, NoReplyError>;
30
50
  /** Get current state */
31
51
  readonly getState: Effect.Effect<S>;
52
+ /** SubscriptionRef for state observation */
53
+ readonly stateRef: SubscriptionRef.SubscriptionRef<S>;
32
54
  /** Whether the runtime has stopped (final state reached) */
33
55
  readonly isStopped: Effect.Effect<boolean>;
34
56
  /** Stop the runtime (interrupt event loop, clean up) */
35
57
  readonly stop: Effect.Effect<void>;
58
+ /** @internal — raw event queue for direct enqueue (actor.ts uses this for pendingReplies tracking) */
59
+ readonly _queue: Queue.Queue<RuntimeQueuedEvent<E>>;
60
+ /** @internal — stopped ref for direct access */
61
+ readonly _stoppedRef: Ref.Ref<boolean>;
62
+ /**
63
+ * Exit deferred — set exactly once with the exit reason when the runtime stops.
64
+ * Final state → ActorExit.Final, explicit stop → ActorExit.Stopped, defect → ActorExit.Defect.
65
+ */
66
+ readonly exitDeferred: Deferred.Deferred<ActorExit<S>, never>;
67
+ /**
68
+ * Actor scope — owns background fibers for this generation.
69
+ * Closing this scope interrupts all background fibers.
70
+ */
71
+ readonly actorScope: Scope.CloseableScope;
72
+ }
73
+ /** @internal */
74
+ interface RuntimeLifecycleHooks<S, E> {
75
+ /** Before processEventCore — actor emits @machine.event inspection */
76
+ readonly onEvent?: (state: S, event: E) => Effect.Effect<void>;
77
+ /** After SubscriptionRef.set on transition — actor notifies listeners, annotates spans */
78
+ readonly onStateChange?: (result: ProcessEventResult<S>, event: E) => Effect.Effect<void>;
79
+ /** After reply settlement when transition occurred — actor publishes to transitionsPubSub */
80
+ readonly onProcessed?: (result: ProcessEventResult<S>, event: E) => Effect.Effect<void>;
81
+ /** When final state detected in event loop — actor emits @machine.stop */
82
+ readonly onFinal?: (state: S) => Effect.Effect<void>;
83
+ /** Before stop resource cleanup — actor emits @machine.stop, settles pending replies */
84
+ readonly onShutdown?: () => Effect.Effect<void>;
85
+ /** Before initial spawn effects — actor emits @machine.effect inspection */
86
+ readonly onInitialSpawnEffects?: (state: S) => Effect.Effect<void>;
36
87
  }
37
88
  /** @internal */
38
89
  interface RuntimeConfig<S, E> {
39
90
  readonly actorId: string;
40
91
  readonly hooks?: ProcessEventHooks<S, E>;
92
+ /**
93
+ * Cell-owned resources. When provided, the runtime uses the cell's stateRef,
94
+ * eventQueue, and stoppedRef instead of creating its own.
95
+ * Used by actor.ts for supervision (cell owns stable resources across generations).
96
+ */
97
+ readonly cellResources?: RuntimeCellResources<S, E>;
41
98
  /**
42
99
  * Custom queue factory. Default: `Queue.unbounded()`.
43
100
  * Use `Queue.sliding(n)` or `Queue.dropping(n)` for bounded queues.
101
+ * Ignored when cellResources is provided.
44
102
  */
45
103
  readonly queueFactory?: Effect.Effect<Queue.Queue<RuntimeQueuedEvent<E>>>;
104
+ /** Lifecycle callbacks for actor-specific concerns */
105
+ readonly lifecycle?: RuntimeLifecycleHooks<S, E>;
106
+ /** Wrap each processQueued invocation — actor uses for span annotations */
107
+ readonly wrapProcess?: (state: S, event: E, inner: Effect.Effect<ProcessQueuedResult<S>>) => Effect.Effect<ProcessQueuedResult<S>>;
108
+ /** Called after self.spawn succeeds — actor tracks children */
109
+ readonly onChildSpawned?: (childId: string, child: unknown) => Effect.Effect<void>;
110
+ /** Skip registering stop as scope finalizer — actor manages its own lifecycle */
111
+ readonly skipFinalizer?: boolean;
112
+ /** Prefix for child actor IDs in self.spawn. Entity-machine uses `${actorId}/`. Default: no prefix. */
113
+ readonly childIdPrefix?: string;
114
+ }
115
+ /** @internal */
116
+ interface ProcessQueuedResult<S> {
117
+ readonly shouldStop: boolean;
118
+ readonly stateChanged: boolean;
119
+ readonly result: ProcessEventResult<S>;
46
120
  }
47
121
  /**
48
122
  * Create a runtime for a machine. Returns a handle for sending events
49
123
  * and querying state. The runtime owns:
50
- * - Single event queue (all events serialized)
51
124
  * - Event loop fiber
52
125
  * - Postpone buffer
53
126
  * - Background effects
54
127
  * - State scope (spawn effects)
55
128
  * - Final state detection
129
+ * - Exit reason via exitDeferred
130
+ *
131
+ * Resources (stateRef, eventQueue, stoppedRef) are either cell-provided
132
+ * or allocated fresh by the runtime.
56
133
  *
57
134
  * @internal
58
135
  */
@@ -62,4 +139,4 @@ declare const createRuntime: <S extends {
62
139
  readonly _tag: string;
63
140
  }, R, GD extends GuardsDef, EFD extends EffectsDef>(machine: Machine<S, E, R, any, any, GD, EFD>, system: ActorSystem, config: RuntimeConfig<S, E>) => Effect.Effect<RuntimeHandle<S, E>, never, Scope.Scope | Exclude<R, MachineContext<S, E, MachineRef<E>>> | Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>>;
64
141
  //#endregion
65
- export { RuntimeConfig, RuntimeHandle, RuntimeQueuedEvent, createRuntime };
142
+ export { ProcessQueuedResult, RuntimeCellResources, RuntimeConfig, RuntimeHandle, RuntimeLifecycleHooks, RuntimeQueuedEvent, createRuntime };
@@ -1,8 +1,9 @@
1
1
  import { INTERNAL_INIT_EVENT } from "./utils.js";
2
2
  import { NoReplyError } from "../errors.js";
3
3
  import { processEventCore, runSpawnEffects, shouldPostpone } from "./transition.js";
4
+ import { ActorExit } from "../supervision.js";
4
5
  import { ActorSystem } from "../actor.js";
5
- import { Deferred, Effect, Exit, Fiber, Queue, Ref, Schema, Scope } from "effect";
6
+ import { Cause, Deferred, Effect, Exit, Fiber, 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,45 +11,53 @@ import { Deferred, Effect, Exit, Fiber, Queue, Ref, Schema, Scope } from "effect
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
31
  * - Background effects
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* Ref.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 stateRef = config.cellResources?.stateRef ?? (yield* SubscriptionRef.make(machine.initial));
44
+ const stoppedRef = config.cellResources?.stoppedRef ?? (yield* Ref.make(false));
45
+ const eventQueue = config.cellResources?.eventQueue ?? (yield* config.queueFactory ?? Queue.unbounded());
46
+ const exitDeferred = yield* Deferred.make();
47
+ const actorScope = yield* Scope.make();
42
48
  const selfSend = Effect.fn("effect-machine.runtime.self.send")(function* (event) {
43
49
  if (!(yield* Ref.get(stoppedRef))) yield* Queue.offer(eventQueue, {
44
50
  _tag: "send",
45
51
  event
46
52
  });
47
53
  });
54
+ const childPrefix = config.childIdPrefix ?? "";
55
+ const defaultSpawn = (childId, childMachine) => system.spawn(`${childPrefix}${childId}`, childMachine).pipe(Effect.provideService(ActorSystem, system));
56
+ const onChildSpawned = config.onChildSpawned;
48
57
  const self = {
49
58
  send: selfSend,
50
59
  cast: selfSend,
51
- spawn: (childId, childMachine) => system.spawn(childId, childMachine).pipe(Effect.provideService(ActorSystem, system))
60
+ spawn: onChildSpawned !== void 0 ? (childId, childMachine) => defaultSpawn(childId, childMachine).pipe(Effect.tap((child) => onChildSpawned(childId, child))) : defaultSpawn
52
61
  };
53
62
  const stateScopeRef = { current: yield* Scope.make() };
54
63
  const backgroundFibers = [];
@@ -72,24 +81,52 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
72
81
  }).pipe(Effect.provideService(machine.Context, ctx)));
73
82
  backgroundFibers.push(fiber);
74
83
  }
75
- yield* runSpawnEffects(machine, machine.initial, initEvent, self, stateScopeRef.current, system, actorId, hooks?.onError);
84
+ if (lifecycle?.onInitialSpawnEffects !== void 0) yield* lifecycle.onInitialSpawnEffects(machine.initial);
85
+ const loopFiberRef = { current: void 0 };
86
+ const initialSpawnDefectSignal = (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn")).pipe(Effect.zipRight(Ref.set(stoppedRef, true)), Effect.zipRight(Effect.suspend(() => loopFiberRef.current !== void 0 ? Fiber.interrupt(loopFiberRef.current) : Effect.void)), Effect.asVoid);
87
+ yield* runSpawnEffects(machine, machine.initial, initEvent, self, stateScopeRef.current, system, actorId, hooks?.onError, initialSpawnDefectSignal).pipe(Effect.catchAllCause((cause) => {
88
+ return Effect.gen(function* () {
89
+ yield* Ref.set(stoppedRef, true);
90
+ yield* Scope.close(stateScopeRef.current, Exit.void);
91
+ yield* Scope.close(actorScope, Exit.void);
92
+ yield* Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn"));
93
+ return yield* Effect.failCause(cause);
94
+ });
95
+ }));
96
+ /** Set the exit deferred exactly once. */
97
+ const setExit = (exit) => Deferred.succeed(exitDeferred, exit).pipe(Effect.asVoid);
76
98
  if (machine.finalStates.has(machine.initial._tag)) {
99
+ if (lifecycle?.onFinal !== void 0) yield* lifecycle.onFinal(machine.initial);
77
100
  yield* Ref.set(stoppedRef, true);
78
101
  yield* Scope.close(stateScopeRef.current, Exit.void);
102
+ yield* Scope.close(actorScope, Exit.void);
79
103
  yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
80
- return makeHandle(stateRef, stoppedRef, eventQueue, machine);
104
+ yield* setExit(ActorExit.Final(machine.initial));
105
+ return makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope);
81
106
  }
82
- const loopFiber = yield* Effect.forkDaemon(runtimeEventLoop(machine, stateRef, eventQueue, stoppedRef, self, backgroundFibers, stateScopeRef, actorId, system, hooks));
107
+ const augmentedHooks = {
108
+ ...hooks,
109
+ onSpawnDefect: (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "spawn")).pipe(Effect.zipRight(Ref.set(stoppedRef, true)), Effect.zipRight(Effect.suspend(() => loopFiberRef.current !== void 0 ? Fiber.interrupt(loopFiberRef.current) : Effect.void)), Effect.asVoid)
110
+ };
111
+ const loopFiber = yield* Effect.forkDaemon(runtimeEventLoop(machine, stateRef, eventQueue, stoppedRef, self, stateScopeRef, actorId, system, exitDeferred, augmentedHooks, lifecycle, config.wrapProcess));
112
+ loopFiberRef.current = loopFiber;
113
+ if (backgroundFibers.length > 0) yield* Effect.forkDaemon(Effect.raceAll(backgroundFibers.map((fiber) => Fiber.await(fiber).pipe(Effect.flatMap((exit) => {
114
+ if (exit._tag === "Failure" && !Cause.isInterruptedOnly(exit.cause)) return setExit(ActorExit.Defect(exit.cause, "background")).pipe(Effect.zipRight(Ref.set(stoppedRef, true)), Effect.zipRight(Fiber.interrupt(loopFiber)));
115
+ return Effect.never;
116
+ })))).pipe(Effect.catchAllCause(() => Effect.void)));
83
117
  const stop = Effect.gen(function* () {
84
118
  if (yield* Ref.get(stoppedRef)) return;
119
+ if (lifecycle?.onShutdown !== void 0) yield* lifecycle.onShutdown();
85
120
  yield* Ref.set(stoppedRef, true);
86
121
  yield* Fiber.interrupt(loopFiber);
87
122
  yield* Scope.close(stateScopeRef.current, Exit.void);
123
+ yield* Scope.close(actorScope, Exit.void);
88
124
  yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
125
+ yield* setExit(ActorExit.Stopped);
89
126
  }).pipe(Effect.asVoid);
90
- yield* Effect.addFinalizer(() => stop);
127
+ if (config.skipFinalizer !== true) yield* Effect.addFinalizer(() => stop);
91
128
  return {
92
- ...makeHandle(stateRef, stoppedRef, eventQueue, machine),
129
+ ...makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope),
93
130
  stop
94
131
  };
95
132
  });
@@ -97,7 +134,7 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
97
134
  * Build the runtime handle (send/ask/getState/isStopped).
98
135
  * Shared between initial-final and normal paths.
99
136
  */
100
- const makeHandle = (stateRef, stoppedRef, eventQueue, _machine) => ({
137
+ const makeHandle = (stateRef, stoppedRef, eventQueue, exitDeferred, actorScope) => ({
101
138
  send: (event) => Effect.gen(function* () {
102
139
  if (!(yield* Ref.get(stoppedRef))) yield* Queue.offer(eventQueue, {
103
140
  _tag: "send",
@@ -128,30 +165,67 @@ const makeHandle = (stateRef, stoppedRef, eventQueue, _machine) => ({
128
165
  });
129
166
  return yield* Deferred.await(reply);
130
167
  }),
131
- getState: Ref.get(stateRef),
168
+ getState: SubscriptionRef.get(stateRef),
169
+ stateRef,
132
170
  isStopped: Ref.get(stoppedRef),
133
- stop: Effect.void
171
+ stop: Effect.void,
172
+ _queue: eventQueue,
173
+ _stoppedRef: stoppedRef,
174
+ exitDeferred,
175
+ actorScope
134
176
  });
135
- const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* (machine, stateRef, eventQueue, stoppedRef, self, backgroundFibers, stateScopeRef, actorId, system, hooks) {
177
+ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* (machine, stateRef, eventQueue, stoppedRef, self, stateScopeRef, actorId, system, exitDeferred, hooks, lifecycle, wrapProcess) {
178
+ /** Set the exit deferred exactly once. */
179
+ const setExit = (exit) => Deferred.succeed(exitDeferred, exit).pipe(Effect.asVoid);
136
180
  const postponed = [];
137
181
  const hasPostponeRules = machine.postponeRules.length > 0;
138
182
  const processQueued = Effect.fn("effect-machine.runtime.processQueued")(function* (queued) {
139
183
  const event = queued.event;
140
- const currentState = yield* Ref.get(stateRef);
184
+ const currentState = yield* SubscriptionRef.get(stateRef);
141
185
  if (hasPostponeRules && shouldPostpone(machine, currentState._tag, event._tag)) {
186
+ if (queued._tag === "call") {
187
+ const postponedResult = {
188
+ newState: currentState,
189
+ previousState: currentState,
190
+ transitioned: false,
191
+ lifecycleRan: false,
192
+ isFinal: false,
193
+ hasReply: false,
194
+ deferReply: false,
195
+ reply: void 0,
196
+ postponed: true
197
+ };
198
+ yield* Deferred.succeed(queued.reply, postponedResult);
199
+ }
200
+ if (queued._tag === "sendWait") yield* Deferred.succeed(queued.done, void 0);
142
201
  postponed.push({
143
202
  _tag: "send",
144
203
  event
145
204
  });
146
- if (queued._tag === "sendWait") yield* Deferred.succeed(queued.done, void 0);
147
205
  return {
148
206
  shouldStop: false,
149
- stateChanged: false
207
+ stateChanged: false,
208
+ result: {
209
+ newState: currentState,
210
+ previousState: currentState,
211
+ transitioned: false,
212
+ lifecycleRan: false,
213
+ isFinal: false,
214
+ hasReply: false,
215
+ deferReply: false,
216
+ reply: void 0,
217
+ postponed: true
218
+ }
150
219
  };
151
220
  }
221
+ if (lifecycle?.onEvent !== void 0) yield* lifecycle.onEvent(currentState, event);
152
222
  const result = yield* processEventCore(machine, currentState, event, self, stateScopeRef, system, actorId, hooks);
153
- if (result.transitioned) yield* Ref.set(stateRef, result.newState);
223
+ if (result.transitioned) yield* SubscriptionRef.set(stateRef, result.newState);
224
+ if (lifecycle?.onStateChange !== void 0 && result.transitioned) yield* lifecycle.onStateChange(result, event);
154
225
  switch (queued._tag) {
226
+ case "call":
227
+ yield* Deferred.succeed(queued.reply, result);
228
+ break;
155
229
  case "sendWait":
156
230
  yield* Deferred.succeed(queued.done, void 0);
157
231
  break;
@@ -174,29 +248,62 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
174
248
  }));
175
249
  break;
176
250
  }
251
+ if (lifecycle?.onProcessed !== void 0 && result.transitioned) yield* lifecycle.onProcessed(result, event);
252
+ const shouldStop = result.isFinal && result.lifecycleRan;
253
+ if (shouldStop && lifecycle?.onFinal !== void 0) yield* lifecycle.onFinal(result.newState);
177
254
  return {
178
- shouldStop: result.isFinal && result.lifecycleRan,
179
- stateChanged: result.lifecycleRan
255
+ shouldStop,
256
+ stateChanged: result.lifecycleRan,
257
+ result
180
258
  };
181
259
  });
260
+ const shutdown = (exitReason) => Effect.gen(function* () {
261
+ yield* Ref.set(stoppedRef, true);
262
+ if (lifecycle?.onShutdown !== void 0) yield* lifecycle.onShutdown();
263
+ settlePostponed(postponed, actorId);
264
+ const remaining = yield* Queue.takeAll(eventQueue);
265
+ for (const entry of remaining) if (entry._tag === "sendWait") Effect.runFork(Deferred.succeed(entry.done, void 0));
266
+ else if (entry._tag === "ask") Effect.runFork(Deferred.fail(entry.reply, new NoReplyError({
267
+ actorId,
268
+ eventTag: entry.event._tag
269
+ })));
270
+ else if (entry._tag === "call") {
271
+ const currentState = yield* SubscriptionRef.get(stateRef);
272
+ Effect.runFork(Deferred.succeed(entry.reply, {
273
+ newState: currentState,
274
+ previousState: currentState,
275
+ transitioned: false,
276
+ lifecycleRan: false,
277
+ isFinal: machine.finalStates.has(currentState._tag),
278
+ hasReply: false,
279
+ deferReply: false,
280
+ reply: void 0,
281
+ postponed: false
282
+ }));
283
+ }
284
+ yield* Scope.close(stateScopeRef.current, Exit.void);
285
+ yield* setExit(exitReason);
286
+ });
182
287
  while (true) {
183
288
  const queued = yield* Queue.take(eventQueue);
184
- const { shouldStop, stateChanged } = yield* processQueued(queued).pipe(Effect.catchAllCause((cause) => {
289
+ if (queued._tag === "drain") {
290
+ yield* shutdown(ActorExit.Stopped);
291
+ yield* Deferred.succeed(queued.done, void 0);
292
+ return;
293
+ }
294
+ const eventQueued = queued;
295
+ const processInner = processQueued(eventQueued);
296
+ const { shouldStop, stateChanged } = yield* (wrapProcess !== void 0 ? Effect.gen(function* () {
297
+ return yield* wrapProcess(yield* SubscriptionRef.get(stateRef), eventQueued.event, processInner);
298
+ }) : processInner).pipe(Effect.catchAllCause((cause) => {
185
299
  if (queued._tag === "sendWait") Effect.runFork(Deferred.succeed(queued.done, void 0));
186
300
  else if (queued._tag === "ask") Effect.runFork(Deferred.die(queued.reply, cause));
187
- return Effect.failCause(cause);
301
+ else if (queued._tag === "call") Effect.runFork(Deferred.failCause(queued.reply, cause));
302
+ return shutdown(ActorExit.Defect(cause, "transition")).pipe(Effect.zipRight(Effect.failCause(cause)));
188
303
  }));
189
304
  if (shouldStop) {
190
- yield* Ref.set(stoppedRef, true);
191
- settlePostponed(postponed, actorId);
192
- const remaining = yield* Queue.takeAll(eventQueue);
193
- for (const entry of remaining) if (entry._tag === "sendWait") Effect.runFork(Deferred.succeed(entry.done, void 0));
194
- else if (entry._tag === "ask") Effect.runFork(Deferred.fail(entry.reply, new NoReplyError({
195
- actorId,
196
- eventTag: entry.event._tag
197
- })));
198
- yield* Scope.close(stateScopeRef.current, Exit.void);
199
- yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
305
+ const finalState = yield* SubscriptionRef.get(stateRef);
306
+ yield* shutdown(ActorExit.Final(finalState));
200
307
  return;
201
308
  }
202
309
  let drainTriggered = stateChanged;
@@ -206,16 +313,8 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
206
313
  for (const entry of drained) {
207
314
  const drain = yield* processQueued(entry);
208
315
  if (drain.shouldStop) {
209
- yield* Ref.set(stoppedRef, true);
210
- settlePostponed(postponed, actorId);
211
- const remaining2 = yield* Queue.takeAll(eventQueue);
212
- for (const r of remaining2) if (r._tag === "sendWait") Effect.runFork(Deferred.succeed(r.done, void 0));
213
- else if (r._tag === "ask") Effect.runFork(Deferred.fail(r.reply, new NoReplyError({
214
- actorId,
215
- eventTag: r.event._tag
216
- })));
217
- yield* Scope.close(stateScopeRef.current, Exit.void);
218
- yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
316
+ const finalState = yield* SubscriptionRef.get(stateRef);
317
+ yield* shutdown(ActorExit.Final(finalState));
219
318
  return;
220
319
  }
221
320
  if (drain.stateChanged) drainTriggered = true;
@@ -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.CloseableScope, 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.CloseableScope, 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>>(input: 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.catchAllCause((cause) => {
181
181
  if (Cause.isInterruptedOnly(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.zipRight(Effect.failCause(cause).pipe(Effect.orDie)));
187
+ }) : Effect.void;
188
+ const signal = defectSignal !== void 0 ? defectSignal(cause) : Effect.void;
189
+ return report.pipe(Effect.zipRight(signal), Effect.zipRight(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
264
  const findTransitions = (input, stateTag, eventTag) => {
265
- const index = getIndex(input instanceof BuiltMachine ? input._inner : input);
265
+ const index = getIndex(input);
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) ?? [];