effect-machine 0.19.0 → 0.20.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.
@@ -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 };