effect-machine 0.1.0 → 0.2.1

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.
@@ -41,6 +41,8 @@ export interface EntityMachineOptions<S, E> {
41
41
  * Effect.log(`Transition: ${from._tag} -> ${to._tag}`),
42
42
  * onSpawnEffect: (state) =>
43
43
  * Effect.log(`Running spawn effects for ${state._tag}`),
44
+ * onError: ({ phase, state }) =>
45
+ * Effect.log(`Defect in ${phase} at ${state._tag}`),
44
46
  * },
45
47
  * })
46
48
  * ```
@@ -52,7 +54,7 @@ export interface EntityMachineOptions<S, E> {
52
54
  * Process a single event through the machine using shared core.
53
55
  * Returns the new state after processing.
54
56
  */
55
- const processEvent = <
57
+ const processEvent = Effect.fn("effect-machine.cluster.processEvent")(function* <
56
58
  S extends { readonly _tag: string },
57
59
  E extends { readonly _tag: string },
58
60
  R,
@@ -65,27 +67,19 @@ const processEvent = <
65
67
  self: MachineRef<E>,
66
68
  stateScopeRef: { current: Scope.CloseableScope },
67
69
  hooks?: ProcessEventHooks<S, E>,
68
- ): Effect.Effect<S, never, R> =>
69
- Effect.gen(function* () {
70
- const currentState = yield* Ref.get(stateRef);
71
-
72
- // Process event using shared core
73
- const result = yield* processEventCore(
74
- machine,
75
- currentState,
76
- event,
77
- self,
78
- stateScopeRef,
79
- hooks,
80
- );
81
-
82
- // Update state ref if transition occurred
83
- if (result.transitioned) {
84
- yield* Ref.set(stateRef, result.newState);
85
- }
86
-
87
- return result.newState;
88
- });
70
+ ) {
71
+ const currentState = yield* Ref.get(stateRef);
72
+
73
+ // Process event using shared core
74
+ const result = yield* processEventCore(machine, currentState, event, self, stateScopeRef, hooks);
75
+
76
+ // Update state ref if transition occurred
77
+ if (result.transitioned) {
78
+ yield* Ref.set(stateRef, result.newState);
79
+ }
80
+
81
+ return result.newState;
82
+ });
89
83
 
90
84
  /**
91
85
  * Create an Entity layer that wires a machine to handle RPC calls.
@@ -137,66 +131,71 @@ export const EntityMachine = {
137
131
  machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
138
132
  options?: EntityMachineOptions<S, E>,
139
133
  ): Layer.Layer<never, never, R> => {
140
- return entity.toLayer(
141
- Effect.gen(function* () {
142
- // Get entity ID from context if available
143
- const entityId = yield* Effect.serviceOption(Entity.CurrentAddress).pipe(
144
- Effect.map((opt) => (opt._tag === "Some" ? opt.value.entityId : "")),
145
- );
146
-
147
- // Initialize state - use provided initializer or machine's initial state
148
- const initialState =
149
- options?.initializeState !== undefined
150
- ? options.initializeState(entityId)
151
- : machine.initial;
152
-
153
- // Create self reference for sending events back to machine
154
- const internalQueue = yield* Queue.unbounded<E>();
155
- const self: MachineRef<E> = {
156
- send: (event) => Queue.offer(internalQueue, event),
157
- };
158
-
159
- // Create state ref
160
- const stateRef = yield* Ref.make<S>(initialState);
161
-
162
- // Create state scope for spawn effects
163
- const stateScopeRef: { current: Scope.CloseableScope } = {
164
- current: yield* Scope.make(),
165
- };
166
-
167
- // Use $init event for initial lifecycle
168
- const initEvent = { _tag: "$init" } as E;
169
-
170
- // Run initial spawn effects
171
- yield* runSpawnEffects(machine, initialState, initEvent, self, stateScopeRef.current);
172
-
173
- // Process internal events in background
174
- yield* Effect.forkScoped(
175
- Effect.forever(
176
- Effect.gen(function* () {
177
- const event = yield* Queue.take(internalQueue);
178
- yield* processEvent(machine, stateRef, event, self, stateScopeRef, options?.hooks);
179
- }),
134
+ const layer = Effect.fn("effect-machine.cluster.layer")(function* () {
135
+ // Get entity ID from context if available
136
+ const entityId = yield* Effect.serviceOption(Entity.CurrentAddress).pipe(
137
+ Effect.map((opt) => (opt._tag === "Some" ? opt.value.entityId : "")),
138
+ );
139
+
140
+ // Initialize state - use provided initializer or machine's initial state
141
+ const initialState =
142
+ options?.initializeState !== undefined
143
+ ? options.initializeState(entityId)
144
+ : machine.initial;
145
+
146
+ // Create self reference for sending events back to machine
147
+ const internalQueue = yield* Queue.unbounded<E>();
148
+ const self: MachineRef<E> = {
149
+ send: Effect.fn("effect-machine.cluster.self.send")(function* (event: E) {
150
+ yield* Queue.offer(internalQueue, event);
151
+ }),
152
+ };
153
+
154
+ // Create state ref
155
+ const stateRef = yield* Ref.make<S>(initialState);
156
+
157
+ // Create state scope for spawn effects
158
+ const stateScopeRef: { current: Scope.CloseableScope } = {
159
+ current: yield* Scope.make(),
160
+ };
161
+
162
+ // Use $init event for initial lifecycle
163
+ const initEvent = { _tag: "$init" } as E;
164
+
165
+ // Run initial spawn effects
166
+ yield* runSpawnEffects(
167
+ machine,
168
+ initialState,
169
+ initEvent,
170
+ self,
171
+ stateScopeRef.current,
172
+ options?.hooks?.onError,
173
+ );
174
+
175
+ // Process internal events in background
176
+ const runInternalEvent = Effect.fn("effect-machine.cluster.internalEvent")(function* () {
177
+ const event = yield* Queue.take(internalQueue);
178
+ yield* processEvent(machine, stateRef, event, self, stateScopeRef, options?.hooks);
179
+ });
180
+ yield* Effect.forkScoped(Effect.forever(runInternalEvent()));
181
+
182
+ // Return handlers matching the Entity's RPC protocol
183
+ // The actual types are inferred from the entity definition
184
+ return entity.of({
185
+ Send: (envelope: { payload: { event: E } }) =>
186
+ processEvent(
187
+ machine,
188
+ stateRef,
189
+ envelope.payload.event,
190
+ self,
191
+ stateScopeRef,
192
+ options?.hooks,
180
193
  ),
181
- );
182
-
183
- // Return handlers matching the Entity's RPC protocol
184
- // The actual types are inferred from the entity definition
185
- return entity.of({
186
- Send: (envelope: { payload: { event: E } }) =>
187
- processEvent(
188
- machine,
189
- stateRef,
190
- envelope.payload.event,
191
- self,
192
- stateScopeRef,
193
- options?.hooks,
194
- ),
195
-
196
- GetState: () => Ref.get(stateRef),
197
- // Entity.of expects handlers matching Rpcs type param - dynamic construction requires cast
198
- } as unknown as Parameters<typeof entity.of>[0]);
199
- }),
200
- ) as unknown as Layer.Layer<never, never, R>;
194
+
195
+ GetState: () => Ref.get(stateRef),
196
+ // Entity.of expects handlers matching Rpcs type param - dynamic construction requires cast
197
+ } as unknown as Parameters<typeof entity.of>[0]);
198
+ });
199
+ return entity.toLayer(layer()) as unknown as Layer.Layer<never, never, R>;
201
200
  },
202
201
  };
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ export type { SimulationResult, TestHarness, TestHarnessOptions } from "./testin
64
64
  // Inspection
65
65
  export type {
66
66
  EffectEvent,
67
+ ErrorEvent,
67
68
  EventReceivedEvent,
68
69
  InspectionEvent,
69
70
  Inspector,
package/src/inspection.ts CHANGED
@@ -48,6 +48,19 @@ export interface EffectEvent<S> {
48
48
  readonly timestamp: number;
49
49
  }
50
50
 
51
+ /**
52
+ * Event emitted when a transition handler or spawn effect fails with a defect
53
+ */
54
+ export interface ErrorEvent<S, E> {
55
+ readonly type: "@machine.error";
56
+ readonly actorId: string;
57
+ readonly phase: "transition" | "spawn";
58
+ readonly state: S;
59
+ readonly event: E;
60
+ readonly error: string;
61
+ readonly timestamp: number;
62
+ }
63
+
51
64
  /**
52
65
  * Event emitted when an actor stops
53
66
  */
@@ -66,6 +79,7 @@ export type InspectionEvent<S, E> =
66
79
  | EventReceivedEvent<S, E>
67
80
  | TransitionEvent<S, E>
68
81
  | EffectEvent<S>
82
+ | ErrorEvent<S, E>
69
83
  | StopEvent<S>;
70
84
 
71
85
  // ============================================================================
@@ -119,6 +133,9 @@ export const consoleInspector = <
119
133
  case "@machine.effect":
120
134
  console.log(prefix, event.effectType, "effect in", event.state._tag);
121
135
  break;
136
+ case "@machine.error":
137
+ console.log(prefix, "error in", event.phase, event.state._tag, "-", event.error);
138
+ break;
122
139
  case "@machine.stop":
123
140
  console.log(prefix, "stopped in", event.finalState._tag);
124
141
  break;
@@ -0,0 +1,18 @@
1
+ import { Clock, Effect } from "effect";
2
+
3
+ import type { InspectionEvent, Inspector } from "../inspection.js";
4
+
5
+ /**
6
+ * Emit an inspection event with timestamp from Clock.
7
+ * @internal
8
+ */
9
+ export const emitWithTimestamp = Effect.fn("effect-machine.emitWithTimestamp")(function* <S, E>(
10
+ inspector: Inspector<S, E> | undefined,
11
+ makeEvent: (timestamp: number) => InspectionEvent<S, E>,
12
+ ) {
13
+ if (inspector === undefined) {
14
+ return;
15
+ }
16
+ const timestamp = yield* Clock.currentTimeMillis;
17
+ yield* Effect.try(() => inspector.onInspect(makeEvent(timestamp))).pipe(Effect.ignore);
18
+ });
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @internal
10
10
  */
11
- import { Effect, Exit, Scope } from "effect";
11
+ import { Cause, Effect, Exit, Scope } from "effect";
12
12
 
13
13
  import type { Machine, MachineRef, Transition, SpawnEffect, HandlerContext } from "../machine.js";
14
14
  import type { GuardsDef, EffectsDef, MachineContext } from "../slot.js";
@@ -40,7 +40,7 @@ export interface TransitionExecutionResult<S> {
40
40
  *
41
41
  * @internal
42
42
  */
43
- export const runTransitionHandler = <
43
+ export const runTransitionHandler = Effect.fn("effect-machine.runTransitionHandler")(function* <
44
44
  S extends { readonly _tag: string },
45
45
  E extends { readonly _tag: string },
46
46
  R,
@@ -52,20 +52,19 @@ export const runTransitionHandler = <
52
52
  state: S,
53
53
  event: E,
54
54
  self: MachineRef<E>,
55
- ): Effect.Effect<S, never, R> =>
56
- Effect.gen(function* () {
57
- const ctx: MachineContext<S, E, MachineRef<E>> = { state, event, self };
58
- const { guards, effects } = machine._createSlotAccessors(ctx);
55
+ ) {
56
+ const ctx: MachineContext<S, E, MachineRef<E>> = { state, event, self };
57
+ const { guards, effects } = machine._slots;
59
58
 
60
- const handlerCtx: HandlerContext<S, E, GD, EFD> = { state, event, guards, effects };
61
- const result = transition.handler(handlerCtx);
59
+ const handlerCtx: HandlerContext<S, E, GD, EFD> = { state, event, guards, effects };
60
+ const result = transition.handler(handlerCtx);
62
61
 
63
- return isEffect(result)
64
- ? yield* (result as Effect.Effect<S, never, R>).pipe(
65
- Effect.provideService(machine.Context, ctx),
66
- )
67
- : result;
68
- });
62
+ return isEffect(result)
63
+ ? yield* (result as Effect.Effect<S, never, R>).pipe(
64
+ Effect.provideService(machine.Context, ctx),
65
+ )
66
+ : result;
67
+ });
69
68
 
70
69
  /**
71
70
  * Execute a transition for a given state and event.
@@ -78,7 +77,7 @@ export const runTransitionHandler = <
78
77
  *
79
78
  * @internal
80
79
  */
81
- export const executeTransition = <
80
+ export const executeTransition = Effect.fn("effect-machine.executeTransition")(function* <
82
81
  S extends { readonly _tag: string },
83
82
  E extends { readonly _tag: string },
84
83
  R,
@@ -89,26 +88,25 @@ export const executeTransition = <
89
88
  currentState: S,
90
89
  event: E,
91
90
  self: MachineRef<E>,
92
- ): Effect.Effect<TransitionExecutionResult<S>, never, R> =>
93
- Effect.gen(function* () {
94
- const transition = resolveTransition(machine, currentState, event);
95
-
96
- if (transition === undefined) {
97
- return {
98
- newState: currentState,
99
- transitioned: false,
100
- reenter: false,
101
- };
102
- }
103
-
104
- const newState = yield* runTransitionHandler(machine, transition, currentState, event, self);
91
+ ) {
92
+ const transition = resolveTransition(machine, currentState, event);
105
93
 
94
+ if (transition === undefined) {
106
95
  return {
107
- newState,
108
- transitioned: true,
109
- reenter: transition.reenter === true,
96
+ newState: currentState,
97
+ transitioned: false,
98
+ reenter: false,
110
99
  };
111
- });
100
+ }
101
+
102
+ const newState = yield* runTransitionHandler(machine, transition, currentState, event, self);
103
+
104
+ return {
105
+ newState,
106
+ transitioned: true,
107
+ reenter: transition.reenter === true,
108
+ };
109
+ });
112
110
 
113
111
  // ============================================================================
114
112
  // Event Processing Core (shared by actor and entity-machine)
@@ -122,6 +120,18 @@ export interface ProcessEventHooks<S, E> {
122
120
  readonly onSpawnEffect?: (state: S) => Effect.Effect<void>;
123
121
  /** Called after transition completes */
124
122
  readonly onTransition?: (from: S, to: S, event: E) => Effect.Effect<void>;
123
+ /** Called when a transition handler or spawn effect fails with a defect */
124
+ readonly onError?: (info: ProcessEventError<S, E>) => Effect.Effect<void>;
125
+ }
126
+
127
+ /**
128
+ * Error info for inspection hooks.
129
+ */
130
+ export interface ProcessEventError<S, E> {
131
+ readonly phase: "transition" | "spawn";
132
+ readonly state: S;
133
+ readonly event: E;
134
+ readonly cause: Cause.Cause<unknown>;
125
135
  }
126
136
 
127
137
  /**
@@ -152,7 +162,7 @@ export interface ProcessEventResult<S> {
152
162
  *
153
163
  * @internal
154
164
  */
155
- export const processEventCore = <
165
+ export const processEventCore = Effect.fn("effect-machine.processEventCore")(function* <
156
166
  S extends { readonly _tag: string },
157
167
  E extends { readonly _tag: string },
158
168
  R,
@@ -165,62 +175,84 @@ export const processEventCore = <
165
175
  self: MachineRef<E>,
166
176
  stateScopeRef: { current: Scope.CloseableScope },
167
177
  hooks?: ProcessEventHooks<S, E>,
168
- ): Effect.Effect<ProcessEventResult<S>, never, R> =>
169
- Effect.gen(function* () {
170
- // Execute transition
171
- const result = yield* executeTransition(machine, currentState, event, self);
172
-
173
- if (!result.transitioned) {
174
- return {
175
- newState: currentState,
176
- previousState: currentState,
177
- transitioned: false,
178
- lifecycleRan: false,
179
- isFinal: false,
180
- };
181
- }
182
-
183
- const newState = result.newState;
184
- const stateTagChanged = newState._tag !== currentState._tag;
185
- const runLifecycle = stateTagChanged || result.reenter;
178
+ ) {
179
+ // Execute transition (defect-aware)
180
+ const result = yield* executeTransition(machine, currentState, event, self).pipe(
181
+ Effect.catchAllCause((cause) => {
182
+ if (Cause.isInterruptedOnly(cause)) {
183
+ return Effect.interrupt;
184
+ }
185
+ const onError = hooks?.onError;
186
+ if (onError === undefined) {
187
+ return Effect.failCause(cause).pipe(Effect.orDie);
188
+ }
189
+ return onError({
190
+ phase: "transition",
191
+ state: currentState,
192
+ event,
193
+ cause,
194
+ }).pipe(Effect.zipRight(Effect.failCause(cause).pipe(Effect.orDie)));
195
+ }),
196
+ );
197
+
198
+ if (!result.transitioned) {
199
+ return {
200
+ newState: currentState,
201
+ previousState: currentState,
202
+ transitioned: false,
203
+ lifecycleRan: false,
204
+ isFinal: false,
205
+ };
206
+ }
186
207
 
187
- if (runLifecycle) {
188
- // Close old state scope (interrupts spawn fibers)
189
- yield* Scope.close(stateScopeRef.current, Exit.void);
208
+ const newState = result.newState;
209
+ const stateTagChanged = newState._tag !== currentState._tag;
210
+ const runLifecycle = stateTagChanged || result.reenter;
190
211
 
191
- // Create new state scope
192
- stateScopeRef.current = yield* Scope.make();
212
+ if (runLifecycle) {
213
+ // Close old state scope (interrupts spawn fibers)
214
+ yield* Scope.close(stateScopeRef.current, Exit.void);
193
215
 
194
- // Hook: transition complete (before spawn effects)
195
- if (hooks?.onTransition !== undefined) {
196
- yield* hooks.onTransition(currentState, newState, event);
197
- }
216
+ // Create new state scope
217
+ stateScopeRef.current = yield* Scope.make();
198
218
 
199
- // Hook: about to run spawn effects
200
- if (hooks?.onSpawnEffect !== undefined) {
201
- yield* hooks.onSpawnEffect(newState);
202
- }
219
+ // Hook: transition complete (before spawn effects)
220
+ if (hooks?.onTransition !== undefined) {
221
+ yield* hooks.onTransition(currentState, newState, event);
222
+ }
203
223
 
204
- // Run spawn effects for new state
205
- const enterEvent = { _tag: INTERNAL_ENTER_EVENT } as E;
206
- yield* runSpawnEffects(machine, newState, enterEvent, self, stateScopeRef.current);
224
+ // Hook: about to run spawn effects
225
+ if (hooks?.onSpawnEffect !== undefined) {
226
+ yield* hooks.onSpawnEffect(newState);
207
227
  }
208
228
 
209
- return {
229
+ // Run spawn effects for new state
230
+ const enterEvent = { _tag: INTERNAL_ENTER_EVENT } as E;
231
+ yield* runSpawnEffects(
232
+ machine,
210
233
  newState,
211
- previousState: currentState,
212
- transitioned: true,
213
- lifecycleRan: runLifecycle,
214
- isFinal: machine.finalStates.has(newState._tag),
215
- };
216
- });
234
+ enterEvent,
235
+ self,
236
+ stateScopeRef.current,
237
+ hooks?.onError,
238
+ );
239
+ }
240
+
241
+ return {
242
+ newState,
243
+ previousState: currentState,
244
+ transitioned: true,
245
+ lifecycleRan: runLifecycle,
246
+ isFinal: machine.finalStates.has(newState._tag),
247
+ };
248
+ });
217
249
 
218
250
  /**
219
251
  * Run spawn effects for a state (forked into state scope, auto-cancelled on state exit).
220
252
  *
221
253
  * @internal
222
254
  */
223
- export const runSpawnEffects = <
255
+ export const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(function* <
224
256
  S extends { readonly _tag: string },
225
257
  E extends { readonly _tag: string },
226
258
  R,
@@ -232,25 +264,42 @@ export const runSpawnEffects = <
232
264
  event: E,
233
265
  self: MachineRef<E>,
234
266
  stateScope: Scope.CloseableScope,
235
- ): Effect.Effect<void, never, R> =>
236
- Effect.gen(function* () {
237
- const spawnEffects = findSpawnEffects(machine, state._tag);
238
- const ctx: MachineContext<S, E, MachineRef<E>> = { state, event, self };
239
- const { effects: effectSlots } = machine._createSlotAccessors(ctx);
240
-
241
- for (const spawnEffect of spawnEffects) {
242
- // Fork the spawn effect into the state scope - interrupted when scope closes
243
- yield* Effect.forkScoped(
244
- (
245
- spawnEffect.handler({ state, event, self, effects: effectSlots }) as Effect.Effect<
246
- void,
247
- never,
248
- R
249
- >
250
- ).pipe(Effect.provideService(machine.Context, ctx)),
251
- ).pipe(Effect.provideService(Scope.Scope, stateScope));
252
- }
253
- });
267
+ onError?: (info: ProcessEventError<S, E>) => Effect.Effect<void>,
268
+ ) {
269
+ const spawnEffects = findSpawnEffects(machine, state._tag);
270
+ const ctx: MachineContext<S, E, MachineRef<E>> = { state, event, self };
271
+ const { effects: effectSlots } = machine._slots;
272
+ const reportError = onError;
273
+
274
+ for (const spawnEffect of spawnEffects) {
275
+ // Fork the spawn effect into the state scope - interrupted when scope closes
276
+ const effect = (
277
+ spawnEffect.handler({ state, event, self, effects: effectSlots }) as Effect.Effect<
278
+ void,
279
+ never,
280
+ R
281
+ >
282
+ ).pipe(
283
+ Effect.provideService(machine.Context, ctx),
284
+ Effect.catchAllCause((cause) => {
285
+ if (Cause.isInterruptedOnly(cause)) {
286
+ return Effect.interrupt;
287
+ }
288
+ if (reportError === undefined) {
289
+ return Effect.failCause(cause).pipe(Effect.orDie);
290
+ }
291
+ return reportError({
292
+ phase: "spawn",
293
+ state,
294
+ event,
295
+ cause,
296
+ }).pipe(Effect.zipRight(Effect.failCause(cause).pipe(Effect.orDie)));
297
+ }),
298
+ );
299
+
300
+ yield* Effect.forkScoped(effect).pipe(Effect.provideService(Scope.Scope, stateScope));
301
+ }
302
+ });
254
303
 
255
304
  /**
256
305
  * Resolve which transition should fire for a given state and event.
@@ -300,6 +349,13 @@ interface MachineIndex<S, E, GD extends GuardsDef, EFD extends EffectsDef, R> {
300
349
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
301
350
  const indexCache = new WeakMap<object, MachineIndex<any, any, any, any, any>>();
302
351
 
352
+ /**
353
+ * Invalidate cached index for a machine (call after mutation).
354
+ */
355
+ export const invalidateIndex = (machine: object): void => {
356
+ indexCache.delete(machine);
357
+ };
358
+
303
359
  /**
304
360
  * Build transition index from machine definition.
305
361
  * O(n) where n = number of transitions.