effect-machine 0.1.0 → 0.2.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.
@@ -1,15 +1,36 @@
1
1
  // @effect-diagnostics missingEffectContext:off
2
2
  // @effect-diagnostics anyUnknownInErrorContext:off
3
3
 
4
- import { Clock, Effect, Fiber, Option, Queue, Ref, SubscriptionRef } from "effect";
4
+ import {
5
+ Clock,
6
+ Cause,
7
+ Effect,
8
+ Exit,
9
+ Fiber,
10
+ Option,
11
+ Queue,
12
+ Ref,
13
+ Schedule,
14
+ Scope,
15
+ SubscriptionRef,
16
+ } from "effect";
5
17
 
6
18
  import type { ActorRef, Listeners } from "../actor.js";
7
19
  import { buildActorRefCore, notifyListeners } from "../actor.js";
8
20
  import type { MachineRef, Machine } from "../machine.js";
9
21
  import type { Inspector } from "../inspection.js";
10
22
  import { Inspector as InspectorTag } from "../inspection.js";
11
- import { resolveTransition, runTransitionHandler } from "../internal/transition.js";
23
+ import {
24
+ processEventCore,
25
+ resolveTransition,
26
+ runSpawnEffects,
27
+ runTransitionHandler,
28
+ } from "../internal/transition.js";
29
+ import type { ProcessEventError } from "../internal/transition.js";
12
30
  import type { GuardsDef, EffectsDef } from "../slot.js";
31
+ import { UnprovidedSlotsError } from "../errors.js";
32
+ import { INTERNAL_INIT_EVENT } from "../internal/utils.js";
33
+ import { emitWithTimestamp } from "../internal/inspection.js";
13
34
 
14
35
  import type {
15
36
  ActorMetadata,
@@ -28,6 +49,7 @@ import type { PersistentMachine } from "./persistent-machine.js";
28
49
  export interface PersistentActorRef<
29
50
  S extends { readonly _tag: string },
30
51
  E extends { readonly _tag: string },
52
+ R = never,
31
53
  > extends ActorRef<S, E> {
32
54
  /**
33
55
  * Force an immediate snapshot save
@@ -43,7 +65,7 @@ export interface PersistentActorRef<
43
65
  * Replay events to restore actor to a specific version.
44
66
  * Note: This only computes state; does not re-run transition effects.
45
67
  */
46
- readonly replayTo: (version: number) => Effect.Effect<void, PersistenceError>;
68
+ readonly replayTo: (version: number) => Effect.Effect<void, PersistenceError, R>;
47
69
  }
48
70
 
49
71
  /** Get current time in milliseconds using Effect Clock */
@@ -54,7 +76,7 @@ const now = Clock.currentTimeMillis;
54
76
  * Supports async handlers - used for initial restore.
55
77
  * @internal
56
78
  */
57
- const replayEvents = <
79
+ const replayEvents = Effect.fn("effect-machine.persistentActor.replayEvents")(function* <
58
80
  S extends { readonly _tag: string },
59
81
  E extends { readonly _tag: string },
60
82
  R,
@@ -66,42 +88,7 @@ const replayEvents = <
66
88
  events: ReadonlyArray<PersistedEvent<E>>,
67
89
  self: MachineRef<E>,
68
90
  stopVersion?: number,
69
- ): Effect.Effect<{ state: S; version: number }, never, R> =>
70
- Effect.gen(function* () {
71
- let state = startState;
72
- let version = 0;
73
-
74
- for (const persistedEvent of events) {
75
- if (stopVersion !== undefined && persistedEvent.version > stopVersion) break;
76
-
77
- const transition = resolveTransition(machine, state, persistedEvent.event);
78
- if (transition !== undefined) {
79
- state = yield* runTransitionHandler(machine, transition, state, persistedEvent.event, self);
80
- }
81
- version = persistedEvent.version;
82
- }
83
-
84
- return { state, version };
85
- });
86
-
87
- /**
88
- * Replay events synchronously - for replayTo which doesn't have R in scope.
89
- * Only supports synchronous handlers; async handlers are skipped.
90
- * @internal
91
- */
92
- const replayEventsSync = <
93
- S extends { readonly _tag: string },
94
- E extends { readonly _tag: string },
95
- R,
96
- GD extends GuardsDef = Record<string, never>,
97
- EFD extends EffectsDef = Record<string, never>,
98
- >(
99
- machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
100
- startState: S,
101
- events: ReadonlyArray<PersistedEvent<E>>,
102
- self: MachineRef<E>,
103
- stopVersion?: number,
104
- ): { state: S; version: number } => {
91
+ ) {
105
92
  let state = startState;
106
93
  let version = 0;
107
94
 
@@ -110,22 +97,13 @@ const replayEventsSync = <
110
97
 
111
98
  const transition = resolveTransition(machine, state, persistedEvent.event);
112
99
  if (transition !== undefined) {
113
- // Create handler context
114
- const ctx = { state, event: persistedEvent.event, self };
115
- const { guards, effects } = machine._createSlotAccessors(ctx);
116
- const handlerCtx = { state, event: persistedEvent.event, guards, effects };
117
- const result = transition.handler(handlerCtx);
118
-
119
- // Only apply sync results - skip async handlers
120
- if (typeof result === "object" && result !== null && "_tag" in result) {
121
- state = result as S;
122
- }
100
+ state = yield* runTransitionHandler(machine, transition, state, persistedEvent.event, self);
123
101
  }
124
102
  version = persistedEvent.version;
125
103
  }
126
104
 
127
105
  return { state, version };
128
- };
106
+ });
129
107
 
130
108
  /**
131
109
  * Build PersistentActorRef with all methods
@@ -142,10 +120,11 @@ const buildPersistentActorRef = <
142
120
  stateRef: SubscriptionRef.SubscriptionRef<S>,
143
121
  versionRef: Ref.Ref<number>,
144
122
  eventQueue: Queue.Queue<E>,
123
+ stoppedRef: Ref.Ref<boolean>,
145
124
  listeners: Listeners<S>,
146
125
  stop: Effect.Effect<void>,
147
126
  adapter: PersistenceAdapter,
148
- ): PersistentActorRef<S, E> => {
127
+ ): PersistentActorRef<S, E, R> => {
149
128
  const { machine, persistence } = persistentMachine;
150
129
  const typedMachine = machine as unknown as Machine<
151
130
  S,
@@ -157,55 +136,89 @@ const buildPersistentActorRef = <
157
136
  EFD
158
137
  >;
159
138
 
160
- const persist: Effect.Effect<void, PersistenceError | VersionConflictError> = Effect.gen(
161
- function* () {
162
- const state = yield* SubscriptionRef.get(stateRef);
163
- const version = yield* Ref.get(versionRef);
164
- const timestamp = yield* now;
165
- const snapshot: Snapshot<S> = {
166
- state,
167
- version,
168
- timestamp,
169
- };
170
- yield* adapter.saveSnapshot(id, snapshot, persistence.stateSchema);
171
- },
139
+ const persist = Effect.gen(function* () {
140
+ const state = yield* SubscriptionRef.get(stateRef);
141
+ const version = yield* Ref.get(versionRef);
142
+ const timestamp = yield* now;
143
+ const snapshot: Snapshot<S> = {
144
+ state,
145
+ version,
146
+ timestamp,
147
+ };
148
+ yield* adapter.saveSnapshot(id, snapshot, persistence.stateSchema);
149
+ }).pipe(Effect.withSpan("effect-machine.persistentActor.persist"));
150
+
151
+ const version = Ref.get(versionRef).pipe(
152
+ Effect.withSpan("effect-machine.persistentActor.version"),
172
153
  );
173
154
 
174
155
  // Replay only computes state - doesn't run spawn effects
175
- // Uses sync replay since we don't have R context here
176
- const replayTo = (targetVersion: number): Effect.Effect<void, PersistenceError> =>
177
- Effect.gen(function* () {
178
- const currentVersion = yield* Ref.get(versionRef);
179
- if (targetVersion <= currentVersion) {
180
- const maybeSnapshot = yield* adapter.loadSnapshot(id, persistence.stateSchema);
181
- if (Option.isSome(maybeSnapshot)) {
182
- const snapshot = maybeSnapshot.value;
183
- if (snapshot.version <= targetVersion) {
184
- const events = yield* adapter.loadEvents(id, persistence.eventSchema, snapshot.version);
185
- const dummySelf: MachineRef<E> = { send: () => Effect.void };
186
-
187
- const result = replayEventsSync(
188
- typedMachine,
189
- snapshot.state,
190
- events,
191
- dummySelf,
192
- targetVersion,
193
- );
194
-
195
- yield* SubscriptionRef.set(stateRef, result.state);
196
- yield* Ref.set(versionRef, result.version);
197
- notifyListeners(listeners, result.state);
198
- }
156
+ const replayTo = Effect.fn("effect-machine.persistentActor.replayTo")(function* (
157
+ targetVersion: number,
158
+ ) {
159
+ const currentVersion = yield* Ref.get(versionRef);
160
+ if (targetVersion <= currentVersion) {
161
+ const maybeSnapshot = yield* adapter.loadSnapshot(id, persistence.stateSchema);
162
+ if (Option.isSome(maybeSnapshot)) {
163
+ const snapshot = maybeSnapshot.value;
164
+ if (snapshot.version <= targetVersion) {
165
+ const events = yield* adapter.loadEvents(id, persistence.eventSchema, snapshot.version);
166
+ const dummySelf: MachineRef<E> = {
167
+ send: Effect.fn("effect-machine.persistentActor.replay.send")(
168
+ (_event: E) => Effect.void,
169
+ ),
170
+ };
171
+
172
+ const result = yield* replayEvents(
173
+ typedMachine,
174
+ snapshot.state,
175
+ events,
176
+ dummySelf,
177
+ targetVersion,
178
+ );
179
+
180
+ yield* SubscriptionRef.set(stateRef, result.state);
181
+ yield* Ref.set(versionRef, result.version);
182
+ notifyListeners(listeners, result.state);
183
+ }
184
+ } else {
185
+ // No snapshot - replay from initial state if events exist
186
+ const events = yield* adapter.loadEvents(id, persistence.eventSchema);
187
+ if (events.length > 0) {
188
+ const dummySelf: MachineRef<E> = {
189
+ send: Effect.fn("effect-machine.persistentActor.replay.send")(
190
+ (_event: E) => Effect.void,
191
+ ),
192
+ };
193
+ const result = yield* replayEvents(
194
+ typedMachine,
195
+ typedMachine.initial,
196
+ events,
197
+ dummySelf,
198
+ targetVersion,
199
+ );
200
+ yield* SubscriptionRef.set(stateRef, result.state);
201
+ yield* Ref.set(versionRef, result.version);
202
+ notifyListeners(listeners, result.state);
199
203
  }
200
204
  }
201
- });
205
+ }
206
+ });
202
207
 
203
- const core = buildActorRefCore(id, typedMachine, stateRef, eventQueue, listeners, stop);
208
+ const core = buildActorRefCore(
209
+ id,
210
+ typedMachine,
211
+ stateRef,
212
+ eventQueue,
213
+ stoppedRef,
214
+ listeners,
215
+ stop,
216
+ );
204
217
 
205
218
  return {
206
219
  ...core,
207
220
  persist,
208
- version: Ref.get(versionRef),
221
+ version,
209
222
  replayTo,
210
223
  };
211
224
  };
@@ -214,7 +227,7 @@ const buildPersistentActorRef = <
214
227
  * Create a persistent actor from a PersistentMachine.
215
228
  * Restores from existing snapshot if available, otherwise starts fresh.
216
229
  */
217
- export const createPersistentActor = <
230
+ export const createPersistentActor = Effect.fn("effect-machine.persistentActor.spawn")(function* <
218
231
  S extends { readonly _tag: string },
219
232
  E extends { readonly _tag: string },
220
233
  R,
@@ -225,164 +238,232 @@ export const createPersistentActor = <
225
238
  persistentMachine: PersistentMachine<S, E, R>,
226
239
  initialSnapshot: Option.Option<Snapshot<S>>,
227
240
  initialEvents: ReadonlyArray<PersistedEvent<E>>,
228
- ): Effect.Effect<PersistentActorRef<S, E>, PersistenceError, R | PersistenceAdapterTag> =>
229
- Effect.withSpan("effect-machine.persistent-actor.spawn", {
230
- attributes: { "effect_machine.actor.id": id },
231
- })(
232
- Effect.gen(function* () {
233
- const adapter = yield* PersistenceAdapterTag;
234
- const { machine } = persistentMachine;
235
- const typedMachine = machine as unknown as Machine<
236
- S,
237
- E,
238
- R,
239
- Record<string, never>,
240
- Record<string, never>,
241
- GD,
242
- EFD
243
- >;
244
-
245
- // Get optional inspector from context
246
- const inspectorOption = yield* Effect.serviceOption(InspectorTag);
247
- const inspector =
248
- inspectorOption._tag === "Some" ? (inspectorOption.value as Inspector<S, E>) : undefined;
249
-
250
- // Create self reference for sending events
251
- const eventQueue = yield* Queue.unbounded<E>();
252
- const self: MachineRef<E> = {
253
- send: (event) => Queue.offer(eventQueue, event),
254
- };
241
+ ) {
242
+ yield* Effect.annotateCurrentSpan("effect_machine.actor.id", id);
243
+ const adapter = yield* PersistenceAdapterTag;
244
+ const { machine, persistence } = persistentMachine;
245
+ const typedMachine = machine as unknown as Machine<
246
+ S,
247
+ E,
248
+ R,
249
+ Record<string, never>,
250
+ Record<string, never>,
251
+ GD,
252
+ EFD
253
+ >;
255
254
 
256
- // Determine initial state and version
257
- let resolvedInitial: S;
258
- let initialVersion: number;
259
-
260
- if (Option.isSome(initialSnapshot)) {
261
- // Restore from snapshot + replay events
262
- const result = yield* replayEvents(
263
- typedMachine,
264
- initialSnapshot.value.state,
265
- initialEvents,
266
- self,
267
- );
268
- resolvedInitial = result.state;
269
- initialVersion = initialEvents.length > 0 ? result.version : initialSnapshot.value.version;
270
- } else {
271
- // Fresh start
272
- resolvedInitial = typedMachine.initial;
273
- initialVersion = 0;
274
- }
255
+ const missing = typedMachine._missingSlots();
256
+ if (missing.length > 0) {
257
+ return yield* new UnprovidedSlotsError({ slots: missing });
258
+ }
275
259
 
276
- // Initialize state refs
277
- const stateRef = yield* SubscriptionRef.make(resolvedInitial);
278
- const versionRef = yield* Ref.make(initialVersion);
279
- const listeners: Listeners<S> = new Set();
280
-
281
- // Track creation time for metadata - prefer existing metadata if restoring
282
- let createdAt: number;
283
- if (Option.isSome(initialSnapshot)) {
284
- // Restoring - try to get original createdAt from metadata
285
- const existingMeta =
286
- adapter.loadMetadata !== undefined
287
- ? yield* adapter.loadMetadata(id)
288
- : Option.none<ActorMetadata>();
289
- createdAt = Option.isSome(existingMeta)
290
- ? existingMeta.value.createdAt
291
- : initialSnapshot.value.timestamp; // fallback to snapshot time
292
- } else {
293
- createdAt = yield* now;
260
+ // Get optional inspector from context
261
+ const inspector = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as
262
+ | Inspector<S, E>
263
+ | undefined;
264
+
265
+ // Create self reference for sending events
266
+ const eventQueue = yield* Queue.unbounded<E>();
267
+ const stoppedRef = yield* Ref.make(false);
268
+ const self: MachineRef<E> = {
269
+ send: Effect.fn("effect-machine.persistentActor.self.send")(function* (event: E) {
270
+ const stopped = yield* Ref.get(stoppedRef);
271
+ if (stopped) {
272
+ return;
294
273
  }
274
+ yield* Queue.offer(eventQueue, event);
275
+ }),
276
+ };
295
277
 
296
- // Emit spawn event
297
- if (inspector !== undefined) {
298
- const timestamp = yield* now;
299
- inspector.onInspect({
300
- type: "@machine.spawn",
301
- actorId: id,
302
- initialState: resolvedInitial,
303
- timestamp,
304
- });
305
- }
278
+ // Determine initial state and version
279
+ let resolvedInitial: S;
280
+ let initialVersion: number;
281
+
282
+ if (Option.isSome(initialSnapshot)) {
283
+ // Restore from snapshot + replay events
284
+ const result = yield* replayEvents(
285
+ typedMachine,
286
+ initialSnapshot.value.state,
287
+ initialEvents,
288
+ self,
289
+ );
290
+ resolvedInitial = result.state;
291
+ initialVersion = initialEvents.length > 0 ? result.version : initialSnapshot.value.version;
292
+ } else if (initialEvents.length > 0) {
293
+ // Restore from events only
294
+ const result = yield* replayEvents(typedMachine, typedMachine.initial, initialEvents, self);
295
+ resolvedInitial = result.state;
296
+ initialVersion = result.version;
297
+ } else {
298
+ // Fresh start
299
+ resolvedInitial = typedMachine.initial;
300
+ initialVersion = 0;
301
+ }
306
302
 
307
- // Save initial metadata
308
- yield* saveMetadata(
309
- id,
310
- resolvedInitial,
311
- initialVersion,
312
- createdAt,
313
- persistentMachine.persistence,
314
- adapter,
315
- );
303
+ yield* Effect.annotateCurrentSpan("effect_machine.actor.initial_state", resolvedInitial._tag);
304
+
305
+ // Initialize state refs
306
+ const stateRef = yield* SubscriptionRef.make(resolvedInitial);
307
+ const versionRef = yield* Ref.make(initialVersion);
308
+ const listeners: Listeners<S> = new Set();
309
+
310
+ // Track creation time for metadata - prefer existing metadata if restoring
311
+ let createdAt: number;
312
+ if (Option.isSome(initialSnapshot)) {
313
+ // Restoring - try to get original createdAt from metadata
314
+ const existingMeta =
315
+ adapter.loadMetadata !== undefined
316
+ ? yield* adapter.loadMetadata(id)
317
+ : Option.none<ActorMetadata>();
318
+ createdAt = Option.isSome(existingMeta)
319
+ ? existingMeta.value.createdAt
320
+ : initialSnapshot.value.timestamp; // fallback to snapshot time
321
+ } else {
322
+ createdAt = yield* now;
323
+ }
316
324
 
317
- // Check if initial state is final
318
- if (typedMachine.finalStates.has(resolvedInitial._tag)) {
319
- if (inspector !== undefined) {
320
- const timestamp = yield* now;
321
- inspector.onInspect({
322
- type: "@machine.stop",
323
- actorId: id,
324
- finalState: resolvedInitial,
325
- timestamp,
326
- });
327
- }
328
- return buildPersistentActorRef(
329
- id,
330
- persistentMachine,
331
- stateRef,
332
- versionRef,
333
- eventQueue,
334
- listeners,
335
- Queue.shutdown(eventQueue).pipe(Effect.asVoid),
336
- adapter,
337
- );
338
- }
325
+ // Emit spawn event
326
+ yield* emitWithTimestamp(inspector, (timestamp) => ({
327
+ type: "@machine.spawn",
328
+ actorId: id,
329
+ initialState: resolvedInitial,
330
+ timestamp,
331
+ }));
332
+
333
+ const snapshotEnabledRef = yield* Ref.make(true);
334
+ const persistenceQueue = yield* Queue.unbounded<Effect.Effect<void, never>>();
335
+ const persistenceFiber = yield* Effect.fork(persistenceWorker(persistenceQueue));
336
+
337
+ // Save initial metadata
338
+ yield* Queue.offer(
339
+ persistenceQueue,
340
+ saveMetadata(id, resolvedInitial, initialVersion, createdAt, persistence, adapter),
341
+ );
339
342
 
340
- // Start the persistent event loop
341
- const loopFiber = yield* Effect.fork(
342
- persistentEventLoop(
343
- id,
344
- persistentMachine,
345
- stateRef,
346
- versionRef,
347
- eventQueue,
348
- self,
349
- listeners,
350
- adapter,
351
- createdAt,
352
- inspector,
353
- ),
354
- );
343
+ // Snapshot scheduler
344
+ const snapshotQueue = yield* Queue.unbounded<{ state: S; version: number }>();
345
+ const snapshotFiber = yield* Effect.fork(
346
+ snapshotWorker(id, persistence, adapter, snapshotQueue, snapshotEnabledRef),
347
+ );
355
348
 
356
- return buildPersistentActorRef(
357
- id,
358
- persistentMachine,
359
- stateRef,
360
- versionRef,
361
- eventQueue,
362
- listeners,
363
- Effect.gen(function* () {
364
- const finalState = yield* SubscriptionRef.get(stateRef);
365
- if (inspector !== undefined) {
366
- const timestamp = yield* now;
367
- inspector.onInspect({
368
- type: "@machine.stop",
369
- actorId: id,
370
- finalState,
371
- timestamp,
372
- });
373
- }
374
- yield* Queue.shutdown(eventQueue);
375
- yield* Fiber.interrupt(loopFiber);
376
- }).pipe(Effect.asVoid),
377
- adapter,
378
- );
379
- }),
349
+ // Fork background effects (run for entire machine lifetime)
350
+ const backgroundFibers: Fiber.Fiber<void, never>[] = [];
351
+ const initEvent = { _tag: INTERNAL_INIT_EVENT } as E;
352
+ const initCtx = { state: resolvedInitial, event: initEvent, self };
353
+ const { effects: effectSlots } = typedMachine._slots;
354
+
355
+ for (const bg of typedMachine.backgroundEffects) {
356
+ const fiber = yield* Effect.fork(
357
+ bg
358
+ .handler({ state: resolvedInitial, event: initEvent, self, effects: effectSlots })
359
+ .pipe(Effect.provideService(typedMachine.Context, initCtx)),
360
+ );
361
+ backgroundFibers.push(fiber);
362
+ }
363
+
364
+ // Create state scope for spawn effects
365
+ const stateScopeRef: { current: Scope.CloseableScope } = {
366
+ current: yield* Scope.make(),
367
+ };
368
+
369
+ // Run initial spawn effects
370
+ yield* runSpawnEffectsWithInspection(
371
+ typedMachine,
372
+ resolvedInitial,
373
+ initEvent,
374
+ self,
375
+ stateScopeRef.current,
376
+ id,
377
+ inspector,
378
+ );
379
+
380
+ // Check if initial state is final
381
+ if (typedMachine.finalStates.has(resolvedInitial._tag)) {
382
+ yield* Scope.close(stateScopeRef.current, Exit.void);
383
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
384
+ yield* Fiber.interrupt(snapshotFiber);
385
+ yield* Fiber.interrupt(persistenceFiber);
386
+ yield* Ref.set(stoppedRef, true);
387
+ yield* emitWithTimestamp(inspector, (timestamp) => ({
388
+ type: "@machine.stop",
389
+ actorId: id,
390
+ finalState: resolvedInitial,
391
+ timestamp,
392
+ }));
393
+ const stop = Ref.set(stoppedRef, true).pipe(
394
+ Effect.withSpan("effect-machine.persistentActor.stop"),
395
+ Effect.asVoid,
396
+ );
397
+ return buildPersistentActorRef(
398
+ id,
399
+ persistentMachine,
400
+ stateRef,
401
+ versionRef,
402
+ eventQueue,
403
+ stoppedRef,
404
+ listeners,
405
+ stop,
406
+ adapter,
407
+ );
408
+ }
409
+
410
+ // Start the persistent event loop
411
+ const loopFiber = yield* Effect.fork(
412
+ persistentEventLoop(
413
+ id,
414
+ persistentMachine,
415
+ stateRef,
416
+ versionRef,
417
+ eventQueue,
418
+ stoppedRef,
419
+ self,
420
+ listeners,
421
+ adapter,
422
+ createdAt,
423
+ stateScopeRef,
424
+ backgroundFibers,
425
+ snapshotQueue,
426
+ snapshotEnabledRef,
427
+ persistenceQueue,
428
+ snapshotFiber,
429
+ persistenceFiber,
430
+ inspector,
431
+ ),
432
+ );
433
+
434
+ const stop = Effect.gen(function* () {
435
+ const finalState = yield* SubscriptionRef.get(stateRef);
436
+ yield* emitWithTimestamp(inspector, (timestamp) => ({
437
+ type: "@machine.stop",
438
+ actorId: id,
439
+ finalState,
440
+ timestamp,
441
+ }));
442
+ yield* Ref.set(stoppedRef, true);
443
+ yield* Fiber.interrupt(loopFiber);
444
+ yield* Scope.close(stateScopeRef.current, Exit.void);
445
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
446
+ yield* Fiber.interrupt(snapshotFiber);
447
+ yield* Fiber.interrupt(persistenceFiber);
448
+ }).pipe(Effect.withSpan("effect-machine.persistentActor.stop"), Effect.asVoid);
449
+
450
+ return buildPersistentActorRef(
451
+ id,
452
+ persistentMachine,
453
+ stateRef,
454
+ versionRef,
455
+ eventQueue,
456
+ stoppedRef,
457
+ listeners,
458
+ stop,
459
+ adapter,
380
460
  );
461
+ });
381
462
 
382
463
  /**
383
464
  * Main event loop for persistent actor
384
465
  */
385
- const persistentEventLoop = <
466
+ const persistentEventLoop = Effect.fn("effect-machine.persistentActor.eventLoop")(function* <
386
467
  S extends { readonly _tag: string },
387
468
  E extends { readonly _tag: string },
388
469
  R,
@@ -394,208 +475,323 @@ const persistentEventLoop = <
394
475
  stateRef: SubscriptionRef.SubscriptionRef<S>,
395
476
  versionRef: Ref.Ref<number>,
396
477
  eventQueue: Queue.Queue<E>,
478
+ stoppedRef: Ref.Ref<boolean>,
397
479
  self: MachineRef<E>,
398
480
  listeners: Listeners<S>,
399
481
  adapter: PersistenceAdapter,
400
482
  createdAt: number,
483
+ stateScopeRef: { current: Scope.CloseableScope },
484
+ backgroundFibers: ReadonlyArray<Fiber.Fiber<void, never>>,
485
+ snapshotQueue: Queue.Queue<{ state: S; version: number }>,
486
+ snapshotEnabledRef: Ref.Ref<boolean>,
487
+ persistenceQueue: Queue.Queue<Effect.Effect<void, never>>,
488
+ snapshotFiber: Fiber.Fiber<void, never>,
489
+ persistenceFiber: Fiber.Fiber<void, never>,
401
490
  inspector?: Inspector<S, E>,
402
- ): Effect.Effect<void, never, R> =>
403
- Effect.gen(function* () {
404
- const { machine, persistence } = persistentMachine;
405
- const typedMachine = machine as unknown as Machine<
406
- S,
407
- E,
408
- R,
409
- Record<string, never>,
410
- Record<string, never>,
411
- GD,
412
- EFD
413
- >;
414
-
415
- while (true) {
416
- const event = yield* Queue.take(eventQueue);
417
- const currentState = yield* SubscriptionRef.get(stateRef);
418
- const currentVersion = yield* Ref.get(versionRef);
419
-
420
- // Emit event received
421
- if (inspector !== undefined) {
422
- const timestamp = yield* now;
423
- inspector.onInspect({
424
- type: "@machine.event",
425
- actorId: id,
426
- state: currentState,
427
- event,
428
- timestamp,
429
- });
430
- }
431
-
432
- // Find matching transition
433
- const transition = resolveTransition(typedMachine, currentState, event);
434
- if (transition === undefined) {
435
- continue;
436
- }
491
+ ) {
492
+ const { machine, persistence } = persistentMachine;
493
+ const typedMachine = machine as unknown as Machine<
494
+ S,
495
+ E,
496
+ R,
497
+ Record<string, never>,
498
+ Record<string, never>,
499
+ GD,
500
+ EFD
501
+ >;
437
502
 
438
- // Increment version
439
- const newVersion = currentVersion + 1;
440
- yield* Ref.set(versionRef, newVersion);
441
-
442
- // Journal event if enabled
443
- if (persistence.journalEvents) {
444
- const timestamp = yield* now;
445
- const persistedEvent: PersistedEvent<E> = {
446
- event,
447
- version: newVersion,
448
- timestamp,
503
+ const hooks =
504
+ inspector === undefined
505
+ ? undefined
506
+ : {
507
+ onSpawnEffect: (state: S) =>
508
+ emitWithTimestamp(inspector, (timestamp) => ({
509
+ type: "@machine.effect",
510
+ actorId: id,
511
+ effectType: "spawn",
512
+ state,
513
+ timestamp,
514
+ })),
515
+ onTransition: (from: S, to: S, ev: E) =>
516
+ emitWithTimestamp(inspector, (timestamp) => ({
517
+ type: "@machine.transition",
518
+ actorId: id,
519
+ fromState: from,
520
+ toState: to,
521
+ event: ev,
522
+ timestamp,
523
+ })),
524
+ onError: (info: ProcessEventError<S, E>) =>
525
+ emitWithTimestamp(inspector, (timestamp) => ({
526
+ type: "@machine.error",
527
+ actorId: id,
528
+ phase: info.phase,
529
+ state: info.state,
530
+ event: info.event,
531
+ error: Cause.pretty(info.cause),
532
+ timestamp,
533
+ })),
449
534
  };
450
- yield* adapter
451
- .appendEvent(id, persistedEvent, persistence.eventSchema)
452
- .pipe(
453
- Effect.catchAll((e) => Effect.logWarning(`Failed to journal event for actor ${id}`, e)),
454
- );
455
- }
456
535
 
457
- // Compute new state using shared handler utility
458
- const newState = yield* runTransitionHandler(
459
- typedMachine,
460
- transition,
461
- currentState,
536
+ while (true) {
537
+ const event = yield* Queue.take(eventQueue);
538
+ const currentState = yield* SubscriptionRef.get(stateRef);
539
+ const currentVersion = yield* Ref.get(versionRef);
540
+
541
+ // Emit event received
542
+ yield* emitWithTimestamp(inspector, (timestamp) => ({
543
+ type: "@machine.event",
544
+ actorId: id,
545
+ state: currentState,
546
+ event,
547
+ timestamp,
548
+ }));
549
+
550
+ const result = yield* processEventCore(
551
+ typedMachine,
552
+ currentState,
553
+ event,
554
+ self,
555
+ stateScopeRef,
556
+ hooks,
557
+ );
558
+
559
+ if (!result.transitioned) {
560
+ continue;
561
+ }
562
+
563
+ // Increment version
564
+ const newVersion = currentVersion + 1;
565
+ yield* Ref.set(versionRef, newVersion);
566
+
567
+ // Update state and notify listeners
568
+ yield* SubscriptionRef.set(stateRef, result.newState);
569
+ notifyListeners(listeners, result.newState);
570
+
571
+ // Journal event if enabled (async)
572
+ if (persistence.journalEvents) {
573
+ const timestamp = yield* now;
574
+ const persistedEvent: PersistedEvent<E> = {
462
575
  event,
463
- self,
576
+ version: newVersion,
577
+ timestamp,
578
+ };
579
+ const journalTask = adapter.appendEvent(id, persistedEvent, persistence.eventSchema).pipe(
580
+ Effect.catchAll((e) => Effect.logWarning(`Failed to journal event for actor ${id}`, e)),
581
+ Effect.asVoid,
464
582
  );
583
+ yield* Queue.offer(persistenceQueue, journalTask);
584
+ }
465
585
 
466
- // Determine if we should run lifecycle (state change or reenter)
467
- const stateTagChanged = newState._tag !== currentState._tag;
468
- const runLifecycle = stateTagChanged || transition.reenter === true;
469
-
470
- if (runLifecycle && inspector !== undefined) {
471
- const timestamp = yield* now;
472
- inspector.onInspect({
473
- type: "@machine.transition",
474
- actorId: id,
475
- fromState: currentState,
476
- toState: newState,
477
- event,
478
- timestamp,
479
- });
480
- }
586
+ // Save metadata (async)
587
+ yield* Queue.offer(
588
+ persistenceQueue,
589
+ saveMetadata(id, result.newState, newVersion, createdAt, persistence, adapter),
590
+ );
481
591
 
482
- // Update state and notify listeners
483
- yield* SubscriptionRef.set(stateRef, newState);
484
- notifyListeners(listeners, newState);
485
-
486
- // Save snapshot and metadata (consolidated - same for both branches)
487
- yield* saveSnapshot(id, newState, newVersion, persistence, adapter);
488
- yield* saveMetadata(id, newState, newVersion, createdAt, persistence, adapter);
489
-
490
- // Check if final state reached
491
- if (runLifecycle && typedMachine.finalStates.has(newState._tag)) {
492
- if (inspector !== undefined) {
493
- const timestamp = yield* now;
494
- inspector.onInspect({
495
- type: "@machine.stop",
496
- actorId: id,
497
- finalState: newState,
498
- timestamp,
499
- });
500
- }
501
- return;
502
- }
592
+ // Schedule snapshot (non-blocking)
593
+ if (yield* Ref.get(snapshotEnabledRef)) {
594
+ yield* Queue.offer(snapshotQueue, { state: result.newState, version: newVersion });
503
595
  }
504
- });
596
+
597
+ // Check if final state reached
598
+ if (result.lifecycleRan && result.isFinal) {
599
+ yield* emitWithTimestamp(inspector, (timestamp) => ({
600
+ type: "@machine.stop",
601
+ actorId: id,
602
+ finalState: result.newState,
603
+ timestamp,
604
+ }));
605
+ yield* Ref.set(stoppedRef, true);
606
+ yield* Scope.close(stateScopeRef.current, Exit.void);
607
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
608
+ yield* Fiber.interrupt(snapshotFiber);
609
+ yield* Fiber.interrupt(persistenceFiber);
610
+ return;
611
+ }
612
+ }
613
+ });
614
+
615
+ /**
616
+ * Run spawn effects with inspection and tracing.
617
+ * @internal
618
+ */
619
+ const runSpawnEffectsWithInspection = Effect.fn("effect-machine.persistentActor.spawnEffects")(
620
+ function* <
621
+ S extends { readonly _tag: string },
622
+ E extends { readonly _tag: string },
623
+ R,
624
+ GD extends GuardsDef,
625
+ EFD extends EffectsDef,
626
+ >(
627
+ machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
628
+ state: S,
629
+ event: E,
630
+ self: MachineRef<E>,
631
+ stateScope: Scope.CloseableScope,
632
+ actorId: string,
633
+ inspector?: Inspector<S, E>,
634
+ ) {
635
+ yield* emitWithTimestamp(inspector, (timestamp) => ({
636
+ type: "@machine.effect",
637
+ actorId,
638
+ effectType: "spawn",
639
+ state,
640
+ timestamp,
641
+ }));
642
+
643
+ const onError =
644
+ inspector === undefined
645
+ ? undefined
646
+ : (info: ProcessEventError<S, E>) =>
647
+ emitWithTimestamp(inspector, (timestamp) => ({
648
+ type: "@machine.error",
649
+ actorId,
650
+ phase: info.phase,
651
+ state: info.state,
652
+ event: info.event,
653
+ error: Cause.pretty(info.cause),
654
+ timestamp,
655
+ }));
656
+
657
+ yield* runSpawnEffects(machine, state, event, self, stateScope, onError);
658
+ },
659
+ );
660
+
661
+ /**
662
+ * Persistence worker (journaling + metadata).
663
+ */
664
+ const persistenceWorker = Effect.fn("effect-machine.persistentActor.persistenceWorker")(function* (
665
+ queue: Queue.Queue<Effect.Effect<void, never>>,
666
+ ) {
667
+ while (true) {
668
+ const task = yield* Queue.take(queue);
669
+ yield* task;
670
+ }
671
+ });
672
+
673
+ /**
674
+ * Snapshot scheduler worker (runs in background).
675
+ */
676
+ const snapshotWorker = Effect.fn("effect-machine.persistentActor.snapshotWorker")(function* <
677
+ S extends { readonly _tag: string },
678
+ E extends { readonly _tag: string },
679
+ >(
680
+ id: string,
681
+ persistence: PersistentMachine<S, E, never>["persistence"],
682
+ adapter: PersistenceAdapter,
683
+ queue: Queue.Queue<{ state: S; version: number }>,
684
+ enabledRef: Ref.Ref<boolean>,
685
+ ) {
686
+ const driver = yield* Schedule.driver(persistence.snapshotSchedule);
687
+
688
+ while (true) {
689
+ const { state, version } = yield* Queue.take(queue);
690
+ if (!(yield* Ref.get(enabledRef))) {
691
+ continue;
692
+ }
693
+ const shouldSnapshot = yield* driver.next(state).pipe(
694
+ Effect.match({
695
+ onFailure: () => false,
696
+ onSuccess: () => true,
697
+ }),
698
+ );
699
+ if (!shouldSnapshot) {
700
+ yield* Ref.set(enabledRef, false);
701
+ continue;
702
+ }
703
+
704
+ yield* saveSnapshot(id, state, version, persistence, adapter);
705
+ }
706
+ });
505
707
 
506
708
  /**
507
709
  * Save a snapshot after state transition.
508
- * Called inline in event loop to avoid race conditions.
710
+ * Called by snapshot scheduler.
509
711
  */
510
- const saveSnapshot = <S extends { readonly _tag: string }, E extends { readonly _tag: string }>(
712
+ const saveSnapshot = Effect.fn("effect-machine.persistentActor.saveSnapshot")(function* <
713
+ S extends { readonly _tag: string },
714
+ E extends { readonly _tag: string },
715
+ >(
511
716
  id: string,
512
717
  state: S,
513
718
  version: number,
514
719
  persistence: PersistentMachine<S, E, never>["persistence"],
515
720
  adapter: PersistenceAdapter,
516
- ): Effect.Effect<void> =>
517
- Effect.gen(function* () {
518
- const timestamp = yield* now;
519
- const snapshot: Snapshot<S> = {
520
- state,
521
- version,
522
- timestamp,
523
- };
524
- yield* adapter
525
- .saveSnapshot(id, snapshot, persistence.stateSchema)
526
- .pipe(
527
- Effect.catchAll((e) => Effect.logWarning(`Failed to save snapshot for actor ${id}`, e)),
528
- );
529
- });
721
+ ) {
722
+ const timestamp = yield* now;
723
+ const snapshot: Snapshot<S> = {
724
+ state,
725
+ version,
726
+ timestamp,
727
+ };
728
+ yield* adapter
729
+ .saveSnapshot(id, snapshot, persistence.stateSchema)
730
+ .pipe(Effect.catchAll((e) => Effect.logWarning(`Failed to save snapshot for actor ${id}`, e)));
731
+ });
530
732
 
531
733
  /**
532
734
  * Save or update actor metadata if adapter supports registry.
533
735
  * Called on spawn and state transitions.
534
736
  */
535
- const saveMetadata = <S extends { readonly _tag: string }, E extends { readonly _tag: string }>(
737
+ const saveMetadata = Effect.fn("effect-machine.persistentActor.saveMetadata")(function* <
738
+ S extends { readonly _tag: string },
739
+ E extends { readonly _tag: string },
740
+ >(
536
741
  id: string,
537
742
  state: S,
538
743
  version: number,
539
744
  createdAt: number,
540
745
  persistence: PersistentMachine<S, E, never>["persistence"],
541
746
  adapter: PersistenceAdapter,
542
- ): Effect.Effect<void> => {
747
+ ) {
543
748
  const save = adapter.saveMetadata;
544
749
  if (save === undefined) {
545
- return Effect.void;
750
+ return;
546
751
  }
547
- return Effect.gen(function* () {
548
- const lastActivityAt = yield* now;
549
- const metadata: ActorMetadata = {
550
- id,
551
- machineType: persistence.machineType ?? "unknown",
552
- createdAt,
553
- lastActivityAt,
554
- version,
555
- stateTag: state._tag,
556
- };
557
- yield* save(metadata).pipe(
558
- Effect.catchAll((e) => Effect.logWarning(`Failed to save metadata for actor ${id}`, e)),
559
- );
560
- });
561
- };
752
+ const lastActivityAt = yield* now;
753
+ const metadata: ActorMetadata = {
754
+ id,
755
+ machineType: persistence.machineType ?? "unknown",
756
+ createdAt,
757
+ lastActivityAt,
758
+ version,
759
+ stateTag: state._tag,
760
+ };
761
+ yield* save(metadata).pipe(
762
+ Effect.catchAll((e) => Effect.logWarning(`Failed to save metadata for actor ${id}`, e)),
763
+ );
764
+ });
562
765
 
563
766
  /**
564
767
  * Restore an actor from persistence.
565
768
  * Returns None if no persisted state exists.
566
769
  */
567
- export const restorePersistentActor = <
568
- S extends { readonly _tag: string },
569
- E extends { readonly _tag: string },
570
- R,
571
- >(
572
- id: string,
573
- persistentMachine: PersistentMachine<S, E, R>,
574
- ): Effect.Effect<
575
- Option.Option<PersistentActorRef<S, E>>,
576
- PersistenceError,
577
- R | PersistenceAdapterTag
578
- > =>
579
- Effect.gen(function* () {
770
+ export const restorePersistentActor = Effect.fn("effect-machine.persistentActor.restore")(
771
+ function* <S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
772
+ id: string,
773
+ persistentMachine: PersistentMachine<S, E, R>,
774
+ ) {
580
775
  const adapter = yield* PersistenceAdapterTag;
581
776
  const { persistence } = persistentMachine;
582
777
 
583
778
  // Try to load snapshot
584
779
  const maybeSnapshot = yield* adapter.loadSnapshot(id, persistence.stateSchema);
585
780
 
586
- if (Option.isNone(maybeSnapshot)) {
587
- return Option.none();
588
- }
589
-
590
- // Load events after snapshot
781
+ // Load events (after snapshot if present)
591
782
  const events = yield* adapter.loadEvents(
592
783
  id,
593
784
  persistence.eventSchema,
594
- maybeSnapshot.value.version,
785
+ Option.isSome(maybeSnapshot) ? maybeSnapshot.value.version : undefined,
595
786
  );
596
787
 
788
+ if (Option.isNone(maybeSnapshot) && events.length === 0) {
789
+ return Option.none();
790
+ }
791
+
597
792
  // Create actor with restored state
598
793
  const actor = yield* createPersistentActor(id, persistentMachine, maybeSnapshot, events);
599
794
 
600
795
  return Option.some(actor);
601
- });
796
+ },
797
+ );