effect-machine 0.19.0 → 0.21.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.
@@ -2,7 +2,6 @@ import { INTERNAL_INIT_EVENT, isEffect } from "./utils.js";
2
2
  import { ActorStoppedError, NoReplyError } from "../errors.js";
3
3
  import { makeEventAdvancement } from "./event-advancement.js";
4
4
  import { processEventCoreImmediate, runSpawnEffects, shouldPostpone } from "./transition.js";
5
- import { ActorExit } from "../supervision.js";
6
5
  import { ActorSystem } from "../actor.js";
7
6
  import { Cause, Deferred, Effect, Exit, Fiber, Queue, Ref, Schema, Scope, SubscriptionRef } from "effect";
8
7
  //#region src/internal/runtime.ts
@@ -18,12 +17,24 @@ import { Cause, Deferred, Effect, Exit, Fiber, Queue, Ref, Schema, Scope, Subscr
18
17
  * - Reply settlement (call/ask Deferreds)
19
18
  * - Reply schema validation
20
19
  * - Lifecycle hooks for actor-specific concerns (inspection, listeners, etc.)
21
- * - ActorExit with exit reason (Final/Stopped/Defect) via exitDeferred
20
+ * - RuntimeExit with exit reason (Final/Stopped/Defect) via exitDeferred
22
21
  *
23
22
  * Used by entity-machine and local actor (actor.ts delegates here).
24
23
  *
25
24
  * @internal
26
25
  */
26
+ const RuntimeExit = {
27
+ Final: (state) => ({
28
+ _tag: "Final",
29
+ state
30
+ }),
31
+ Stopped: { _tag: "Stopped" },
32
+ Defect: (cause, phase) => ({
33
+ _tag: "Defect",
34
+ cause,
35
+ phase
36
+ })
37
+ };
27
38
  /**
28
39
  * Create a runtime for a machine. Returns a handle for sending events
29
40
  * and querying state. The runtime owns:
@@ -41,6 +52,7 @@ import { Cause, Deferred, Effect, Exit, Fiber, Queue, Ref, Schema, Scope, Subscr
41
52
  */
42
53
  const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (machine, system, config) {
43
54
  const { actorId, hooks, lifecycle } = config;
55
+ const generation = config.generation ?? 0;
44
56
  const services = yield* Effect.context();
45
57
  const fork = Effect.runForkWith(services);
46
58
  const { stateRef, stoppedRef, eventQueue } = config.cellResources;
@@ -84,51 +96,78 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
84
96
  yield* Deferred.await(startDeferred);
85
97
  return;
86
98
  }
99
+ const initialSpawnDefectSignal = (cause) => Deferred.succeed(exitDeferred, RuntimeExit.Defect(cause, "initial-spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => {
100
+ const loopFiber = loopFiberRef.current;
101
+ if (loopFiber !== void 0) return Fiber.interrupt(loopFiber);
102
+ return Effect.void;
103
+ })), Effect.asVoid);
104
+ const initialState = yield* SubscriptionRef.get(stateRef);
105
+ const initialProcessing = processEventCoreImmediate(machine, initialState, initEvent, self, stateScopeRef, system, actorId, {
106
+ ...hooks,
107
+ onSpawnDefect: initialSpawnDefectSignal
108
+ }, generation);
109
+ let initialResult;
110
+ if (isEffect(initialProcessing)) initialResult = yield* initialProcessing.pipe(Effect.catchCause((cause) => Effect.gen(function* () {
111
+ yield* Ref.set(stoppedRef, true);
112
+ yield* Scope.close(stateScopeRef.current, Exit.void);
113
+ yield* Scope.close(actorScope, Exit.void);
114
+ yield* Deferred.succeed(exitDeferred, RuntimeExit.Defect(cause, "transition"));
115
+ return yield* Effect.failCause(cause);
116
+ })));
117
+ else initialResult = initialProcessing;
118
+ if (initialResult.transitioned) {
119
+ yield* SubscriptionRef.set(stateRef, initialResult.newState);
120
+ if (lifecycle?.onStateChange !== void 0) {
121
+ const stateChange = lifecycle.onStateChange(initialResult, initEvent);
122
+ if (isEffect(stateChange)) yield* stateChange;
123
+ }
124
+ if (lifecycle?.onProcessed !== void 0) {
125
+ const processed = lifecycle.onProcessed(initialResult, initEvent);
126
+ if (isEffect(processed)) yield* processed;
127
+ }
128
+ }
129
+ const stableInitialState = initialResult.newState;
87
130
  const backgroundFibers = [];
88
131
  for (const bg of machine._backgroundEffectEntries()) {
89
132
  const fiber = yield* bg.handler({
90
133
  actorId,
91
- state: machine.initial,
134
+ generation,
135
+ state: stableInitialState,
92
136
  event: initEvent,
93
137
  self,
94
138
  system
95
139
  }).pipe(Effect.forkIn(actorScope));
96
140
  backgroundFibers.push(fiber);
97
141
  }
98
- if (lifecycle?.onInitialSpawnEffects !== void 0) yield* lifecycle.onInitialSpawnEffects(machine.initial);
99
- const initialSpawnDefectSignal = (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => {
100
- const loopFiber = loopFiberRef.current;
101
- if (loopFiber !== void 0) return Fiber.interrupt(loopFiber);
102
- return Effect.void;
103
- })), Effect.asVoid);
104
- yield* runSpawnEffects(machine, machine.initial, initEvent, self, stateScopeRef.current, system, actorId, hooks?.onError, initialSpawnDefectSignal).pipe(Effect.catchCause((cause) => Effect.gen(function* () {
142
+ if (!initialResult.lifecycleRan && lifecycle?.onInitialSpawnEffects !== void 0) yield* lifecycle.onInitialSpawnEffects(stableInitialState);
143
+ if (!initialResult.lifecycleRan) yield* runSpawnEffects(machine, stableInitialState, initEvent, self, stateScopeRef.current, system, actorId, hooks?.onError, initialSpawnDefectSignal, generation).pipe(Effect.catchCause((cause) => Effect.gen(function* () {
105
144
  yield* Ref.set(stoppedRef, true);
106
145
  yield* Scope.close(stateScopeRef.current, Exit.void);
107
146
  yield* Scope.close(actorScope, Exit.void);
108
- yield* Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn"));
147
+ yield* Deferred.succeed(exitDeferred, RuntimeExit.Defect(cause, "initial-spawn"));
109
148
  return yield* Effect.failCause(cause);
110
149
  })));
111
- if (machine._isFinal(machine.initial._tag)) {
112
- if (lifecycle?.onFinal !== void 0) yield* lifecycle.onFinal(machine.initial);
150
+ if (machine._isFinal(stableInitialState._tag)) {
151
+ if (lifecycle?.onFinal !== void 0) yield* lifecycle.onFinal(stableInitialState);
113
152
  yield* Ref.set(stoppedRef, true);
114
153
  yield* Scope.close(stateScopeRef.current, Exit.void);
115
154
  yield* Scope.close(actorScope, Exit.void);
116
- yield* setExit(ActorExit.Final(machine.initial));
155
+ yield* setExit(RuntimeExit.Final(stableInitialState));
117
156
  yield* Deferred.succeed(startDeferred, void 0);
118
157
  return;
119
158
  }
120
159
  const augmentedHooks = {
121
160
  ...hooks,
122
- onSpawnDefect: (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => {
161
+ onSpawnDefect: (cause) => Deferred.succeed(exitDeferred, RuntimeExit.Defect(cause, "spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => {
123
162
  const loopFiber = loopFiberRef.current;
124
163
  if (loopFiber !== void 0) return Fiber.interrupt(loopFiber);
125
164
  return Effect.void;
126
165
  })), Effect.asVoid)
127
166
  };
128
- const loopFiber = yield* runtimeEventLoop(machine, stateRef, eventQueue, pendingRequests, stoppedRef, self, stateScopeRef, actorId, system, exitDeferred, augmentedHooks, deferredReplyRef, lifecycle, fork).pipe(Effect.provide(services), Effect.forkDetach);
167
+ const loopFiber = yield* runtimeEventLoop(machine, stateRef, eventQueue, pendingRequests, stoppedRef, self, stateScopeRef, actorId, generation, system, exitDeferred, augmentedHooks, deferredReplyRef, lifecycle, fork).pipe(Effect.provide(services), Effect.forkDetach);
129
168
  loopFiberRef.current = loopFiber;
130
169
  if (backgroundFibers.length > 0) yield* Effect.raceAll(backgroundFibers.map((fiber) => Fiber.await(fiber).pipe(Effect.flatMap((exit) => {
131
- 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)));
170
+ if (exit._tag === "Failure" && !Cause.hasInterruptsOnly(exit.cause)) return setExit(RuntimeExit.Defect(exit.cause, "background")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Fiber.interrupt(loopFiber)));
132
171
  return Effect.never;
133
172
  })))).pipe(Effect.forkIn(actorScope));
134
173
  yield* Effect.forkDetach(Effect.gen(function* () {
@@ -137,7 +176,7 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
137
176
  else yield* Scope.close(actorScope, loopExit);
138
177
  }));
139
178
  yield* Deferred.succeed(startDeferred, void 0);
140
- }).pipe(Effect.catchCause((cause) => Deferred.failCause(startDeferred, cause).pipe(Effect.andThen(Effect.failCause(cause)))));
179
+ }).pipe(Effect.catchCause((cause) => Ref.set(stoppedRef, true).pipe(Effect.andThen(Scope.close(stateScopeRef.current, Exit.void)), Effect.andThen(Scope.close(actorScope, Exit.void)), Effect.andThen(setExit(RuntimeExit.Defect(cause, "transition"))), Effect.andThen(Deferred.failCause(startDeferred, cause)), Effect.andThen(Effect.failCause(cause)))));
141
180
  const stop = Effect.gen(function* () {
142
181
  if (yield* Ref.get(stoppedRef)) return;
143
182
  if (lifecycle?.onShutdown !== void 0) yield* lifecycle.onShutdown();
@@ -147,7 +186,7 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
147
186
  if (loopFiber !== void 0) yield* Fiber.interrupt(loopFiber);
148
187
  yield* Scope.close(stateScopeRef.current, Exit.void);
149
188
  yield* Scope.close(actorScope, Exit.void);
150
- yield* setExit(ActorExit.Stopped);
189
+ yield* setExit(RuntimeExit.Stopped);
151
190
  }).pipe(Effect.asVoid);
152
191
  if (config.skipFinalizer !== true) yield* Effect.addFinalizer(() => stop);
153
192
  return {
@@ -233,7 +272,7 @@ const settlePendingRequests = (pendingRequests, actorId) => Effect.gen(function*
233
272
  for (const settle of pendingRequests) yield* settle(error);
234
273
  pendingRequests.clear();
235
274
  });
236
- const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* (machine, stateRef, eventQueue, pendingRequests, stoppedRef, self, stateScopeRef, actorId, system, exitDeferred, hooks, deferredReplyRef, lifecycle, fork) {
275
+ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* (machine, stateRef, eventQueue, pendingRequests, stoppedRef, self, stateScopeRef, actorId, generation, system, exitDeferred, hooks, deferredReplyRef, lifecycle, fork) {
237
276
  const forkEffect = fork ?? Effect.runFork;
238
277
  /** Set the exit deferred exactly once. */
239
278
  const setExit = (exit) => Deferred.succeed(exitDeferred, exit).pipe(Effect.asVoid);
@@ -248,7 +287,8 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
248
287
  hasReply: false,
249
288
  deferReply: false,
250
289
  reply: void 0,
251
- postponed: true
290
+ postponed: true,
291
+ transitions: []
252
292
  };
253
293
  let input = queued;
254
294
  if (queued._tag === "call") {
@@ -276,7 +316,7 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
276
316
  const processQueued = (currentState, queued) => Effect.gen(function* () {
277
317
  const event = queued.event;
278
318
  if (lifecycle?.onEvent !== void 0) yield* lifecycle.onEvent(currentState, event);
279
- const processing = processEventCoreImmediate(machine, currentState, event, self, stateScopeRef, system, actorId, hooks);
319
+ const processing = processEventCoreImmediate(machine, currentState, event, self, stateScopeRef, system, actorId, hooks, generation);
280
320
  let result;
281
321
  if (isEffect(processing)) result = yield* processing;
282
322
  else result = processing;
@@ -364,7 +404,8 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
364
404
  hasReply: false,
365
405
  deferReply: false,
366
406
  reply: void 0,
367
- postponed: false
407
+ postponed: false,
408
+ transitions: []
368
409
  }));
369
410
  }
370
411
  yield* Scope.close(stateScopeRef.current, Exit.void);
@@ -373,7 +414,7 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
373
414
  while (true) {
374
415
  const queued = yield* Queue.take(eventQueue);
375
416
  if (queued._tag === "drain") {
376
- yield* shutdown(ActorExit.Stopped);
417
+ yield* shutdown(RuntimeExit.Stopped);
377
418
  yield* Deferred.succeed(queued.done, void 0);
378
419
  return;
379
420
  }
@@ -382,7 +423,7 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
382
423
  if (queued._tag === "sendWait") forkEffect(Deferred.failCause(queued.done, cause));
383
424
  else if (queued._tag === "ask") forkEffect(Deferred.die(queued.reply, cause));
384
425
  else if (queued._tag === "call") forkEffect(Deferred.failCause(queued.reply, cause));
385
- return shutdown(ActorExit.Defect(cause, "transition")).pipe(Effect.andThen(Effect.failCause(cause)));
426
+ return shutdown(RuntimeExit.Defect(cause, "transition")).pipe(Effect.andThen(Effect.failCause(cause)));
386
427
  }));
387
428
  let stopped;
388
429
  if (advancement === void 0) {
@@ -392,7 +433,7 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
392
433
  } else stopped = (yield* catchEventDefect(advancement.advance(eventQueued))).stopped;
393
434
  if (stopped) {
394
435
  const finalState = yield* SubscriptionRef.get(stateRef);
395
- yield* shutdown(ActorExit.Final(finalState));
436
+ yield* shutdown(RuntimeExit.Final(finalState));
396
437
  return;
397
438
  }
398
439
  }
@@ -1,10 +1,35 @@
1
1
  import { Machine } from "../machine.js";
2
2
  import { Transition } from "./machine-definition.js";
3
3
  //#region src/internal/transition.d.ts
4
+ interface ExecutedStep<S, E> {
5
+ readonly previousState: S;
6
+ readonly newState: S;
7
+ readonly event: E;
8
+ readonly transition: Transition<S, E, never>;
9
+ }
10
+ interface ExecutedTransition<S, E = unknown> {
11
+ readonly newState: S;
12
+ readonly transitioned: boolean;
13
+ readonly reenter: boolean;
14
+ readonly hasReply: boolean;
15
+ readonly deferReply: boolean;
16
+ readonly reply: unknown;
17
+ readonly transition?: Transition<S, E, never>;
18
+ readonly steps: ReadonlyArray<ExecutedStep<S, E>>;
19
+ }
20
+ declare const executeTransitionImmediate: <S extends {
21
+ readonly _tag: string;
22
+ }, E extends {
23
+ readonly _tag: string;
24
+ }, R>(machine: Machine<S, E, R, any, any, any, any>, currentState: S, event: E, hooks?: ProcessEventHooks<S, E>) => ExecutedTransition<S, E> | Effect.Effect<ExecutedTransition<S, E>>;
4
25
  /**
5
26
  * Optional hooks for event processing inspection/tracing.
6
27
  */
7
28
  interface ProcessEventHooks<S, E> {
29
+ /** Called after each guard candidate is evaluated. */
30
+ readonly onGuard?: (evaluation: GuardEvaluation<S, E>) => Effect.Effect<void>;
31
+ /** Called before an accepted transition handler runs. */
32
+ readonly onOperation?: (operation: TransitionOperation<S, E>) => Effect.Effect<void>;
8
33
  /** Called before running spawn effects */
9
34
  readonly onSpawnEffect?: (state: S) => Effect.Effect<void>;
10
35
  /** Called after transition completes */
@@ -14,6 +39,17 @@ interface ProcessEventHooks<S, E> {
14
39
  /** Called when a forked spawn fiber defects — signals the runtime to set exitDeferred */
15
40
  readonly onSpawnDefect?: (cause: Cause.Cause<unknown>) => Effect.Effect<void>;
16
41
  }
42
+ interface GuardEvaluation<S, E> {
43
+ readonly guard: string;
44
+ readonly state: S;
45
+ readonly event: E;
46
+ readonly result: boolean;
47
+ }
48
+ interface TransitionOperation<S, E> {
49
+ readonly operation: string;
50
+ readonly state: S;
51
+ readonly event: E;
52
+ }
17
53
  /**
18
54
  * Error info for inspection hooks.
19
55
  */
@@ -26,7 +62,7 @@ interface ProcessEventError<S, E> {
26
62
  /**
27
63
  * Result of processing an event through the machine.
28
64
  */
29
- interface ProcessEventResult<S> {
65
+ interface ProcessEventResult<S, E = unknown> {
30
66
  /** New state after processing */
31
67
  readonly newState: S;
32
68
  /** Previous state before processing */
@@ -45,6 +81,12 @@ interface ProcessEventResult<S> {
45
81
  readonly reply?: unknown;
46
82
  /** Whether the event was postponed (buffered for retry after next state change) */
47
83
  readonly postponed: boolean;
84
+ /** Each accepted edge in the stable macrostep. */
85
+ readonly transitions: ReadonlyArray<{
86
+ readonly previousState: S;
87
+ readonly newState: S;
88
+ readonly event: E;
89
+ }>;
48
90
  }
49
91
  /**
50
92
  * Resolve which transition should fire for a given state and event.
@@ -54,6 +96,12 @@ declare const resolveTransition: <S extends {
54
96
  readonly _tag: string;
55
97
  }, E extends {
56
98
  readonly _tag: string;
57
- }, R>(machine: Machine<S, E, R, any, any>, currentState: S, event: E) => Transition<S, E, never> | undefined;
99
+ }, R>(machine: Machine<S, E, R, any, any, any, any>, currentState: S, event: E) => Transition<S, E, never> | undefined;
100
+ /** Resolve a transition with pure or Effect guards. */
101
+ declare const resolveTransitionEffect: <S extends {
102
+ readonly _tag: string;
103
+ }, E extends {
104
+ readonly _tag: string;
105
+ }, R>(machine: Machine<S, E, R, any, any, any, any>, currentState: S, event: E) => Effect.Effect<Transition<S, E, never> | undefined>;
58
106
  //#endregion
59
- export { ProcessEventError, ProcessEventHooks, ProcessEventResult, resolveTransition };
107
+ export { GuardEvaluation, ProcessEventError, ProcessEventHooks, ProcessEventResult, TransitionOperation, executeTransitionImmediate, resolveTransition, resolveTransitionEffect };
@@ -11,14 +11,21 @@ import { Cause, Effect, Exit, Scope } from "effect";
11
11
  *
12
12
  * @internal
13
13
  */
14
- const completeTransition = (transition, resolved) => {
14
+ const completeTransition = (currentState, event, transition, resolved) => {
15
15
  if (isReplyResult(resolved)) return {
16
16
  newState: resolved.state,
17
17
  transitioned: true,
18
18
  reenter: transition.reenter === true,
19
19
  hasReply: true,
20
20
  deferReply: false,
21
- reply: resolved.reply
21
+ reply: resolved.reply,
22
+ transition,
23
+ steps: [{
24
+ previousState: currentState,
25
+ newState: resolved.state,
26
+ event,
27
+ transition
28
+ }]
22
29
  };
23
30
  if (isDeferReplyResult(resolved)) return {
24
31
  newState: resolved.state,
@@ -26,7 +33,14 @@ const completeTransition = (transition, resolved) => {
26
33
  reenter: transition.reenter === true,
27
34
  hasReply: false,
28
35
  deferReply: true,
29
- reply: void 0
36
+ reply: void 0,
37
+ transition,
38
+ steps: [{
39
+ previousState: currentState,
40
+ newState: resolved.state,
41
+ event,
42
+ transition
43
+ }]
30
44
  };
31
45
  return {
32
46
  newState: resolved,
@@ -34,7 +48,14 @@ const completeTransition = (transition, resolved) => {
34
48
  reenter: transition.reenter === true,
35
49
  hasReply: false,
36
50
  deferReply: false,
37
- reply: void 0
51
+ reply: void 0,
52
+ transition,
53
+ steps: [{
54
+ previousState: currentState,
55
+ newState: resolved,
56
+ event,
57
+ transition
58
+ }]
38
59
  };
39
60
  };
40
61
  /**
@@ -50,8 +71,10 @@ const completeTransition = (transition, resolved) => {
50
71
  */
51
72
  const executeTransition = (machine, currentState, event) => Effect.suspend(() => {
52
73
  const result = executeTransitionImmediate(machine, currentState, event);
53
- if (isEffect(result)) return result;
54
- return Effect.succeed(result);
74
+ let first;
75
+ if (isEffect(result)) first = result;
76
+ else first = Effect.succeed(result);
77
+ return first.pipe(Effect.flatMap((initialResult) => stabilizeTransition(machine, currentState, event, initialResult)));
55
78
  });
56
79
  /**
57
80
  * Execute a transition without adding an Effect boundary for a synchronous handler.
@@ -59,29 +82,98 @@ const executeTransition = (machine, currentState, event) => Effect.suspend(() =>
59
82
  *
60
83
  * @internal
61
84
  */
62
- const executeTransitionImmediate = (machine, currentState, event) => {
63
- const transition = resolveTransition(machine, currentState, event);
64
- if (transition === void 0) return {
65
- newState: currentState,
66
- transitioned: false,
67
- reenter: false,
68
- hasReply: false,
69
- deferReply: false,
70
- reply: void 0
71
- };
72
- const handlerCtx = {
73
- state: currentState,
74
- event
75
- };
76
- let raw;
85
+ const executeTransitionCandidatesImmediate = (currentState, event, candidates, hooks) => {
86
+ let resolution;
77
87
  try {
78
- raw = transition.handler(handlerCtx);
88
+ resolution = evaluateTransitions(candidates, currentState, event);
79
89
  } catch (defect) {
80
90
  return Effect.die(defect);
81
91
  }
82
- if (isEffect(raw)) return raw.pipe(Effect.map((resolved) => completeTransition(transition, resolved)));
83
- return completeTransition(transition, raw);
92
+ const executeResolved = (resolved) => {
93
+ const transition = resolved.transition;
94
+ if (transition === void 0) {
95
+ const unhandled = {
96
+ newState: currentState,
97
+ transitioned: false,
98
+ reenter: false,
99
+ hasReply: false,
100
+ deferReply: false,
101
+ reply: void 0,
102
+ transition: void 0,
103
+ steps: []
104
+ };
105
+ if (hooks?.onGuard === void 0 || resolved.evaluations.length === 0) return unhandled;
106
+ return Effect.forEach(resolved.evaluations, hooks.onGuard, { discard: true }).pipe(Effect.as(unhandled));
107
+ }
108
+ const runHandler = () => {
109
+ const handlerCtx = {
110
+ state: currentState,
111
+ event
112
+ };
113
+ let raw;
114
+ try {
115
+ raw = transition.handler(handlerCtx);
116
+ } catch (defect) {
117
+ return Effect.die(defect);
118
+ }
119
+ if (isEffect(raw)) return raw.pipe(Effect.map((value) => completeTransition(currentState, event, transition, value)));
120
+ return completeTransition(currentState, event, transition, raw);
121
+ };
122
+ const runInspectedHandler = () => {
123
+ if (hooks?.onOperation === void 0) return runHandler();
124
+ return hooks.onOperation({
125
+ operation: transition.handler.name || "<inline>",
126
+ state: currentState,
127
+ event
128
+ }).pipe(Effect.andThen(Effect.suspend(() => {
129
+ const result = runHandler();
130
+ if (isEffect(result)) return result;
131
+ return Effect.succeed(result);
132
+ })));
133
+ };
134
+ if (hooks?.onGuard === void 0 || resolved.evaluations.length === 0) return runInspectedHandler();
135
+ return Effect.forEach(resolved.evaluations, hooks.onGuard, { discard: true }).pipe(Effect.andThen(Effect.suspend(() => {
136
+ const result = runInspectedHandler();
137
+ if (isEffect(result)) return result;
138
+ return Effect.succeed(result);
139
+ })));
140
+ };
141
+ if (!isEffect(resolution)) return executeResolved(resolution);
142
+ return resolution.pipe(Effect.flatMap((resolved) => {
143
+ const result = executeResolved(resolved);
144
+ if (isEffect(result)) return result;
145
+ return Effect.succeed(result);
146
+ }));
84
147
  };
148
+ const executeTransitionImmediate = (machine, currentState, event, hooks) => executeTransitionCandidatesImmediate(currentState, event, machine._findTransitions(currentState._tag, event._tag), hooks);
149
+ const stabilizeTransition = (machine, initialState, event, result, hooks) => Effect.gen(function* () {
150
+ const steps = [...result.steps];
151
+ let stableState = result.newState;
152
+ let lifecycleRequired = result.reenter || stableState._tag !== initialState._tag;
153
+ let depth = 0;
154
+ while (true) {
155
+ const candidates = machine._findImmediateTransitions(stableState._tag);
156
+ if (candidates.length === 0) break;
157
+ depth += 1;
158
+ if (depth > 100) return yield* Effect.die(/* @__PURE__ */ new Error("Immediate transition limit exceeded. Check for an eventless loop."));
159
+ const immediate = executeTransitionCandidatesImmediate(stableState, event, candidates, hooks);
160
+ let next;
161
+ if (isEffect(immediate)) next = yield* immediate;
162
+ else next = immediate;
163
+ if (!next.transitioned) break;
164
+ if (next.reenter || next.newState._tag !== stableState._tag) lifecycleRequired = true;
165
+ steps.push(...next.steps);
166
+ stableState = next.newState;
167
+ }
168
+ return {
169
+ ...result,
170
+ newState: stableState,
171
+ transitioned: steps.length > 0,
172
+ reenter: lifecycleRequired,
173
+ transition: steps.at(-1)?.transition,
174
+ steps
175
+ };
176
+ });
85
177
  /**
86
178
  * Check if an event should be postponed in the current state.
87
179
  * @internal
@@ -99,12 +191,12 @@ const shouldPostpone = (machine, stateTag, eventTag) => machine._shouldPostpone(
99
191
  *
100
192
  * @internal
101
193
  */
102
- const processEventCore = (machine, currentState, event, self, stateScopeRef, system, actorId, hooks) => Effect.suspend(() => {
103
- const processed = processEventCoreImmediate(machine, currentState, event, self, stateScopeRef, system, actorId, hooks);
194
+ const processEventCore = (machine, currentState, event, self, stateScopeRef, system, actorId, hooks, generation = 0) => Effect.suspend(() => {
195
+ const processed = processEventCoreImmediate(machine, currentState, event, self, stateScopeRef, system, actorId, hooks, generation);
104
196
  if (isEffect(processed)) return processed;
105
197
  return Effect.succeed(processed);
106
198
  });
107
- const completeProcessedEvent = (machine, currentState, event, result, self, stateScopeRef, system, actorId, hooks) => {
199
+ const completeProcessedEvent = (machine, currentState, event, result, self, stateScopeRef, system, actorId, hooks, generation = 0) => {
108
200
  if (!result.transitioned) return {
109
201
  newState: currentState,
110
202
  previousState: currentState,
@@ -114,7 +206,8 @@ const completeProcessedEvent = (machine, currentState, event, result, self, stat
114
206
  hasReply: false,
115
207
  deferReply: false,
116
208
  reply: void 0,
117
- postponed: false
209
+ postponed: false,
210
+ transitions: []
118
211
  };
119
212
  const newState = result.newState;
120
213
  const runLifecycle = newState._tag !== currentState._tag || result.reenter;
@@ -127,22 +220,59 @@ const completeProcessedEvent = (machine, currentState, event, result, self, stat
127
220
  hasReply: result.hasReply,
128
221
  deferReply: result.deferReply,
129
222
  reply: result.reply,
130
- postponed: false
223
+ postponed: false,
224
+ transitions: result.steps
131
225
  };
132
- if (!runLifecycle) return processed;
226
+ const observeTransitions = hooks?.onTransition;
227
+ if (!runLifecycle) {
228
+ if (observeTransitions === void 0 || result.steps.length === 0) return processed;
229
+ return Effect.forEach(result.steps, (step) => observeTransitions(step.previousState, step.newState, step.event), { discard: true }).pipe(Effect.as(processed));
230
+ }
133
231
  return Effect.gen(function* () {
134
232
  yield* Scope.close(stateScopeRef.current, Exit.void);
135
233
  stateScopeRef.current = yield* Scope.make();
136
- if (hooks?.onTransition !== void 0) yield* hooks.onTransition(currentState, newState, event);
234
+ if (observeTransitions !== void 0) yield* Effect.forEach(result.steps, (step) => observeTransitions(step.previousState, step.newState, step.event), { discard: true });
137
235
  if (hooks?.onSpawnEffect !== void 0) yield* hooks.onSpawnEffect(newState);
138
- yield* runSpawnEffects(machine, newState, { _tag: INTERNAL_ENTER_EVENT }, self, stateScopeRef.current, system, actorId, hooks?.onError, hooks?.onSpawnDefect);
236
+ yield* runSpawnEffects(machine, newState, { _tag: INTERNAL_ENTER_EVENT }, self, stateScopeRef.current, system, actorId, hooks?.onError, hooks?.onSpawnDefect, generation);
139
237
  return processed;
140
238
  });
141
239
  };
142
240
  /** @internal */
143
- const processEventCoreImmediate = (machine, currentState, event, self, stateScopeRef, system, actorId, hooks) => {
144
- const execution = executeTransitionImmediate(machine, currentState, event);
145
- const complete = (result) => completeProcessedEvent(machine, currentState, event, result, self, stateScopeRef, system, actorId, hooks);
241
+ const processEventCoreImmediate = (machine, currentState, event, self, stateScopeRef, system, actorId, hooks, generation = 0) => {
242
+ const execution = executeTransitionImmediate(machine, currentState, event, hooks);
243
+ const complete = (result) => {
244
+ if (machine._findImmediateTransitions(result.newState._tag).length === 0) return completeProcessedEvent(machine, currentState, event, result, self, stateScopeRef, system, actorId, hooks, generation);
245
+ return Effect.gen(function* () {
246
+ const steps = [...result.steps];
247
+ let stableState = result.newState;
248
+ let lifecycleRequired = result.reenter || stableState._tag !== currentState._tag;
249
+ let depth = 0;
250
+ while (true) {
251
+ const candidates = machine._findImmediateTransitions(stableState._tag);
252
+ if (candidates.length === 0) break;
253
+ depth += 1;
254
+ if (depth > 100) return yield* Effect.die(/* @__PURE__ */ new Error("Immediate transition limit exceeded. Check for an eventless loop."));
255
+ const immediate = executeTransitionCandidatesImmediate(stableState, event, candidates, hooks);
256
+ let next;
257
+ if (isEffect(immediate)) next = yield* immediate;
258
+ else next = immediate;
259
+ if (!next.transitioned) break;
260
+ if (next.reenter || next.newState._tag !== stableState._tag) lifecycleRequired = true;
261
+ steps.push(...next.steps);
262
+ stableState = next.newState;
263
+ }
264
+ const processed = completeProcessedEvent(machine, currentState, event, {
265
+ ...result,
266
+ newState: stableState,
267
+ transitioned: steps.length > 0,
268
+ reenter: lifecycleRequired,
269
+ transition: steps.at(-1)?.transition,
270
+ steps
271
+ }, self, stateScopeRef, system, actorId, hooks, generation);
272
+ if (isEffect(processed)) return yield* processed;
273
+ return processed;
274
+ });
275
+ };
146
276
  if (!isEffect(execution)) return complete(execution);
147
277
  return execution.pipe(Effect.catchCause((cause) => {
148
278
  if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt;
@@ -165,13 +295,14 @@ const processEventCoreImmediate = (machine, currentState, event, self, stateScop
165
295
  *
166
296
  * @internal
167
297
  */
168
- const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* (machine, state, event, self, stateScope, system, actorId, onError, onSpawnDefect) {
298
+ const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* (machine, state, event, self, stateScope, system, actorId, onError, onSpawnDefect, generation = 0) {
169
299
  const spawnEffects = machine._findSpawnEffects(state._tag);
170
300
  const reportError = onError;
171
301
  const defectSignal = onSpawnDefect;
172
302
  for (const spawnEffect of spawnEffects) {
173
303
  const effect = spawnEffect.handler({
174
304
  actorId,
305
+ generation,
175
306
  state,
176
307
  event,
177
308
  self,
@@ -197,7 +328,54 @@ const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* (m
197
328
  * Uses indexed O(1) lookup. First matching transition wins.
198
329
  */
199
330
  const resolveTransition = (machine, currentState, event) => {
200
- return machine._findTransitions(currentState._tag, event._tag)[0];
331
+ const resolution = evaluateTransitions(machine._findTransitions(currentState._tag, event._tag), currentState, event);
332
+ if (isEffect(resolution)) return Effect.runSync(Effect.die("Effect guards require actor.can(event). actor.client.canSync(event) is synchronous."));
333
+ return resolution.transition;
334
+ };
335
+ /** Resolve a transition with pure or Effect guards. */
336
+ const resolveTransitionEffect = (machine, currentState, event) => {
337
+ const resolution = evaluateTransitions(machine._findTransitions(currentState._tag, event._tag), currentState, event);
338
+ if (isEffect(resolution)) return resolution.pipe(Effect.map((value) => value.transition));
339
+ return Effect.succeed(resolution.transition);
340
+ };
341
+ const evaluateTransitions = (candidates, currentState, event) => {
342
+ const ctx = {
343
+ state: currentState,
344
+ event
345
+ };
346
+ const loop = (index, evaluations) => {
347
+ const candidate = candidates[index];
348
+ if (candidate === void 0) return {
349
+ transition: void 0,
350
+ evaluations
351
+ };
352
+ if (candidate.guard === void 0) return {
353
+ transition: candidate,
354
+ evaluations
355
+ };
356
+ const guardName = candidate.guard.name || "<inline>";
357
+ const result = candidate.guard(ctx);
358
+ const continueWith = (passed) => {
359
+ const nextEvaluations = [...evaluations, {
360
+ guard: guardName,
361
+ state: currentState,
362
+ event,
363
+ result: passed
364
+ }];
365
+ if (passed) return {
366
+ transition: candidate,
367
+ evaluations: nextEvaluations
368
+ };
369
+ return loop(index + 1, nextEvaluations);
370
+ };
371
+ if (!isEffect(result)) return continueWith(result);
372
+ return result.pipe(Effect.flatMap((passed) => {
373
+ const next = continueWith(passed);
374
+ if (isEffect(next)) return next;
375
+ return Effect.succeed(next);
376
+ }));
377
+ };
378
+ return loop(0, []);
201
379
  };
202
380
  //#endregion
203
- export { executeTransition, executeTransitionImmediate, processEventCore, processEventCoreImmediate, resolveTransition, runSpawnEffects, shouldPostpone };
381
+ export { executeTransition, executeTransitionImmediate, processEventCore, processEventCoreImmediate, resolveTransition, resolveTransitionEffect, runSpawnEffects, shouldPostpone };
@@ -64,6 +64,7 @@ const isEffect = Effect.isEffect;
64
64
  const stubSystem = {
65
65
  spawn: () => Effect.die("spawn not supported in stub system"),
66
66
  get: () => Effect.die("get not supported in stub system"),
67
+ watch: () => Stream.die("watch not supported in stub system"),
67
68
  stop: () => Effect.die("stop not supported in stub system"),
68
69
  events: Stream.empty,
69
70
  get actors() {