effect-machine 0.1.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.
package/src/actor.ts ADDED
@@ -0,0 +1,942 @@
1
+ /**
2
+ * Actor system: spawning, lifecycle, and event processing.
3
+ *
4
+ * Combines:
5
+ * - ActorRef interface (running actor handle)
6
+ * - ActorSystem service (spawn/stop/get actors)
7
+ * - Actor creation and event loop
8
+ */
9
+ import {
10
+ Clock,
11
+ Context,
12
+ Effect,
13
+ Exit,
14
+ Fiber,
15
+ Layer,
16
+ MutableHashMap,
17
+ Option,
18
+ Queue,
19
+ Scope,
20
+ SubscriptionRef,
21
+ } from "effect";
22
+ import type { Stream } from "effect";
23
+
24
+ import type { Machine, MachineRef } from "./machine.js";
25
+ import type { InspectionEvent, Inspector } from "./inspection.js";
26
+ import { Inspector as InspectorTag } from "./inspection.js";
27
+ import { processEventCore, runSpawnEffects, resolveTransition } from "./internal/transition.js";
28
+ import type { ProcessEventHooks } from "./internal/transition.js";
29
+
30
+ // Re-export for external use (cluster, persistence)
31
+ export { resolveTransition, runSpawnEffects, processEventCore } from "./internal/transition.js";
32
+ export type { ProcessEventHooks, ProcessEventResult } from "./internal/transition.js";
33
+ import type { GuardsDef, EffectsDef } from "./slot.js";
34
+ import { DuplicateActorError } from "./errors.js";
35
+ import { INTERNAL_INIT_EVENT } from "./internal/utils.js";
36
+ import type {
37
+ ActorMetadata,
38
+ PersistenceError,
39
+ RestoreResult,
40
+ VersionConflictError,
41
+ } from "./persistence/adapter.js";
42
+ import {
43
+ PersistenceAdapterTag,
44
+ PersistenceError as PersistenceErrorClass,
45
+ } from "./persistence/adapter.js";
46
+ import type { PersistentMachine } from "./persistence/persistent-machine.js";
47
+ import { isPersistentMachine } from "./persistence/persistent-machine.js";
48
+ import type { PersistentActorRef } from "./persistence/persistent-actor.js";
49
+ import { createPersistentActor, restorePersistentActor } from "./persistence/persistent-actor.js";
50
+
51
+ // ============================================================================
52
+ // ActorRef Interface
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Reference to a running actor.
57
+ */
58
+ export interface ActorRef<State extends { readonly _tag: string }, Event> {
59
+ /**
60
+ * Unique identifier for this actor
61
+ */
62
+ readonly id: string;
63
+
64
+ /**
65
+ * Send an event to the actor
66
+ */
67
+ readonly send: (event: Event) => Effect.Effect<void>;
68
+
69
+ /**
70
+ * Observable state of the actor
71
+ */
72
+ readonly state: SubscriptionRef.SubscriptionRef<State>;
73
+
74
+ /**
75
+ * Stop the actor gracefully
76
+ */
77
+ readonly stop: Effect.Effect<void>;
78
+
79
+ /**
80
+ * Get current state snapshot (Effect)
81
+ */
82
+ readonly snapshot: Effect.Effect<State>;
83
+
84
+ /**
85
+ * Get current state snapshot (sync)
86
+ */
87
+ readonly snapshotSync: () => State;
88
+
89
+ /**
90
+ * Check if current state matches tag (Effect)
91
+ */
92
+ readonly matches: (tag: State["_tag"]) => Effect.Effect<boolean>;
93
+
94
+ /**
95
+ * Check if current state matches tag (sync)
96
+ */
97
+ readonly matchesSync: (tag: State["_tag"]) => boolean;
98
+
99
+ /**
100
+ * Check if event can be handled in current state (Effect)
101
+ */
102
+ readonly can: (event: Event) => Effect.Effect<boolean>;
103
+
104
+ /**
105
+ * Check if event can be handled in current state (sync)
106
+ */
107
+ readonly canSync: (event: Event) => boolean;
108
+
109
+ /**
110
+ * Stream of state changes
111
+ */
112
+ readonly changes: Stream.Stream<State>;
113
+
114
+ /**
115
+ * Subscribe to state changes (sync callback)
116
+ * Returns unsubscribe function
117
+ */
118
+ readonly subscribe: (fn: (state: State) => void) => () => void;
119
+ }
120
+
121
+ // ============================================================================
122
+ // ActorSystem Interface
123
+ // ============================================================================
124
+
125
+ /** Base type for stored actors (internal) */
126
+ type AnyState = { readonly _tag: string };
127
+
128
+ /**
129
+ * Actor system for managing actor lifecycles
130
+ */
131
+ export interface ActorSystem {
132
+ /**
133
+ * Spawn a new actor with the given machine.
134
+ *
135
+ * For regular machines, returns ActorRef.
136
+ * For persistent machines (created with Machine.persist), returns PersistentActorRef.
137
+ *
138
+ * Note: All effect slots must be provided via `Machine.provide` before spawning.
139
+ * Attempting to spawn a machine with unprovided effect slots will fail.
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * // Regular machine (effects provided)
144
+ * const machine = Machine.provide(baseMachine, { fetchData: ... })
145
+ * const actor = yield* system.spawn("my-actor", machine);
146
+ *
147
+ * // Persistent machine (auto-detected)
148
+ * const persistentActor = yield* system.spawn("my-actor", persistentMachine);
149
+ * persistentActor.persist; // available
150
+ * persistentActor.version; // available
151
+ * ```
152
+ */
153
+ readonly spawn: {
154
+ // Regular machine overload
155
+ <
156
+ S extends { readonly _tag: string },
157
+ E extends { readonly _tag: string },
158
+ R,
159
+ GD extends GuardsDef = Record<string, never>,
160
+ EFD extends EffectsDef = Record<string, never>,
161
+ >(
162
+ id: string,
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
164
+ machine: Machine<S, E, R, any, any, GD, EFD>,
165
+ ): Effect.Effect<ActorRef<S, E>, DuplicateActorError, R | Scope.Scope>;
166
+
167
+ // Persistent machine overload
168
+ <S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
169
+ id: string,
170
+ machine: PersistentMachine<S, E, R>,
171
+ ): Effect.Effect<
172
+ PersistentActorRef<S, E>,
173
+ PersistenceError | VersionConflictError | DuplicateActorError,
174
+ R | Scope.Scope | PersistenceAdapterTag
175
+ >;
176
+ };
177
+
178
+ /**
179
+ * Restore an actor from persistence.
180
+ * Returns None if no persisted state exists for the given ID.
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * const maybeActor = yield* system.restore("order-1", persistentMachine);
185
+ * if (Option.isSome(maybeActor)) {
186
+ * const actor = maybeActor.value;
187
+ * const state = yield* actor.snapshot;
188
+ * console.log(`Restored to state: ${state._tag}`);
189
+ * }
190
+ * ```
191
+ */
192
+ readonly restore: <S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
193
+ id: string,
194
+ machine: PersistentMachine<S, E, R>,
195
+ ) => Effect.Effect<
196
+ Option.Option<PersistentActorRef<S, E>>,
197
+ PersistenceError | DuplicateActorError,
198
+ R | Scope.Scope | PersistenceAdapterTag
199
+ >;
200
+
201
+ /**
202
+ * Get an existing actor by ID
203
+ */
204
+ readonly get: (id: string) => Effect.Effect<Option.Option<ActorRef<AnyState, unknown>>>;
205
+
206
+ /**
207
+ * Stop an actor by ID
208
+ */
209
+ readonly stop: (id: string) => Effect.Effect<boolean>;
210
+
211
+ /**
212
+ * List all persisted actor metadata.
213
+ * Returns empty array if adapter doesn't support registry.
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * const actors = yield* system.listPersisted();
218
+ * for (const meta of actors) {
219
+ * console.log(`${meta.id}: ${meta.stateTag} (v${meta.version})`);
220
+ * }
221
+ * ```
222
+ */
223
+ readonly listPersisted: () => Effect.Effect<
224
+ ReadonlyArray<ActorMetadata>,
225
+ PersistenceError,
226
+ PersistenceAdapterTag
227
+ >;
228
+
229
+ /**
230
+ * Restore multiple actors by ID.
231
+ * Returns both successfully restored actors and failures.
232
+ *
233
+ * @example
234
+ * ```ts
235
+ * const result = yield* system.restoreMany(["order-1", "order-2"], orderMachine);
236
+ * console.log(`Restored: ${result.restored.length}, Failed: ${result.failed.length}`);
237
+ * ```
238
+ */
239
+ readonly restoreMany: <
240
+ S extends { readonly _tag: string },
241
+ E extends { readonly _tag: string },
242
+ R,
243
+ >(
244
+ ids: ReadonlyArray<string>,
245
+ machine: PersistentMachine<S, E, R>,
246
+ ) => Effect.Effect<RestoreResult<S, E>, never, R | Scope.Scope | PersistenceAdapterTag>;
247
+
248
+ /**
249
+ * Restore all persisted actors for a machine type.
250
+ * Uses adapter registry if available, otherwise returns empty result.
251
+ *
252
+ * @example
253
+ * ```ts
254
+ * const result = yield* system.restoreAll(orderMachine, {
255
+ * filter: (meta) => meta.stateTag !== "Done"
256
+ * });
257
+ * console.log(`Restored ${result.restored.length} active orders`);
258
+ * ```
259
+ */
260
+ readonly restoreAll: <
261
+ S extends { readonly _tag: string },
262
+ E extends { readonly _tag: string },
263
+ R,
264
+ >(
265
+ machine: PersistentMachine<S, E, R>,
266
+ options?: { filter?: (meta: ActorMetadata) => boolean },
267
+ ) => Effect.Effect<
268
+ RestoreResult<S, E>,
269
+ PersistenceError,
270
+ R | Scope.Scope | PersistenceAdapterTag
271
+ >;
272
+ }
273
+
274
+ /**
275
+ * ActorSystem service tag
276
+ */
277
+ export const ActorSystem = Context.GenericTag<ActorSystem>("@effect/machine/ActorSystem");
278
+
279
+ // ============================================================================
280
+ // Actor Core Helpers
281
+ // ============================================================================
282
+
283
+ /** Listener set for sync subscriptions */
284
+ export type Listeners<S> = Set<(state: S) => void>;
285
+
286
+ /**
287
+ * Notify all listeners of state change.
288
+ */
289
+ export const notifyListeners = <S>(listeners: Listeners<S>, state: S): void => {
290
+ for (const listener of listeners) {
291
+ listener(state);
292
+ }
293
+ };
294
+
295
+ /**
296
+ * Build core ActorRef methods shared between regular and persistent actors.
297
+ */
298
+ export const buildActorRefCore = <
299
+ S extends { readonly _tag: string },
300
+ E extends { readonly _tag: string },
301
+ R,
302
+ GD extends GuardsDef,
303
+ EFD extends EffectsDef,
304
+ >(
305
+ id: string,
306
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
307
+ machine: Machine<S, E, R, any, any, GD, EFD>,
308
+ stateRef: SubscriptionRef.SubscriptionRef<S>,
309
+ eventQueue: Queue.Queue<E>,
310
+ listeners: Listeners<S>,
311
+ stop: Effect.Effect<void>,
312
+ ): ActorRef<S, E> =>
313
+ ({
314
+ id,
315
+ send: (event) => Queue.offer(eventQueue, event),
316
+ state: stateRef,
317
+ stop,
318
+ snapshot: SubscriptionRef.get(stateRef),
319
+ snapshotSync: () => Effect.runSync(SubscriptionRef.get(stateRef)),
320
+ matches: (tag) => Effect.map(SubscriptionRef.get(stateRef), (s) => s._tag === tag),
321
+ matchesSync: (tag) => Effect.runSync(SubscriptionRef.get(stateRef))._tag === tag,
322
+ can: (event) =>
323
+ Effect.map(
324
+ SubscriptionRef.get(stateRef),
325
+ (s) => resolveTransition(machine, s, event) !== undefined,
326
+ ),
327
+ canSync: (event) => {
328
+ const state = Effect.runSync(SubscriptionRef.get(stateRef));
329
+ return resolveTransition(machine, state, event) !== undefined;
330
+ },
331
+ changes: stateRef.changes,
332
+ subscribe: (fn) => {
333
+ listeners.add(fn);
334
+ return () => {
335
+ listeners.delete(fn);
336
+ };
337
+ },
338
+ });
339
+
340
+ // ============================================================================
341
+ // Inspection Helpers
342
+ // ============================================================================
343
+
344
+ /** Emit an inspection event with timestamp from Clock */
345
+ const emitWithTimestamp = <S, E>(
346
+ inspector: Inspector<S, E> | undefined,
347
+ makeEvent: (timestamp: number) => InspectionEvent<S, E>,
348
+ ): Effect.Effect<void> =>
349
+ inspector === undefined
350
+ ? Effect.void
351
+ : Effect.flatMap(Clock.currentTimeMillis, (timestamp) =>
352
+ Effect.sync(() => inspector.onInspect(makeEvent(timestamp))),
353
+ );
354
+
355
+ // ============================================================================
356
+ // Actor Creation and Event Loop
357
+ // ============================================================================
358
+
359
+ /**
360
+ * Create and start an actor for a machine
361
+ */
362
+ export const createActor = <
363
+ S extends { readonly _tag: string },
364
+ E extends { readonly _tag: string },
365
+ R,
366
+ GD extends GuardsDef,
367
+ EFD extends EffectsDef,
368
+ >(
369
+ id: string,
370
+ machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
371
+ ): Effect.Effect<ActorRef<S, E>, never, R> =>
372
+ Effect.withSpan("effect-machine.actor.spawn", {
373
+ attributes: { "effect_machine.actor.id": id },
374
+ })(
375
+ Effect.gen(function* () {
376
+ // Get optional inspector from context
377
+ const inspectorValue = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as
378
+ | Inspector<S, E>
379
+ | undefined;
380
+
381
+ // Create self reference for sending events
382
+ const eventQueue = yield* Queue.unbounded<E>();
383
+ const self: MachineRef<E> = {
384
+ send: (event) => Queue.offer(eventQueue, event),
385
+ };
386
+
387
+ // Annotate span with initial state
388
+ yield* Effect.annotateCurrentSpan("effect_machine.actor.initial_state", machine.initial._tag);
389
+
390
+ // Emit spawn event
391
+ yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
392
+ type: "@machine.spawn",
393
+ actorId: id,
394
+ initialState: machine.initial,
395
+ timestamp,
396
+ }));
397
+
398
+ // Initialize state
399
+ const stateRef = yield* SubscriptionRef.make(machine.initial);
400
+ const listeners: Listeners<S> = new Set();
401
+
402
+ // Fork background effects (run for entire machine lifetime)
403
+ const backgroundFibers: Fiber.Fiber<void, never>[] = [];
404
+ const initEvent = { _tag: INTERNAL_INIT_EVENT } as E;
405
+ const { effects: effectSlots } = machine._createSlotAccessors({
406
+ state: machine.initial,
407
+ event: initEvent,
408
+ self,
409
+ });
410
+
411
+ for (const bg of machine.backgroundEffects) {
412
+ const fiber = yield* Effect.fork(
413
+ bg.handler({ state: machine.initial, event: initEvent, self, effects: effectSlots }),
414
+ );
415
+ backgroundFibers.push(fiber);
416
+ }
417
+
418
+ // Create state scope for initial state's spawn effects
419
+ const stateScopeRef: { current: Scope.CloseableScope } = {
420
+ current: yield* Scope.make(),
421
+ };
422
+
423
+ // Run initial spawn effects
424
+ yield* runSpawnEffectsWithInspection(
425
+ machine,
426
+ machine.initial,
427
+ initEvent,
428
+ self,
429
+ stateScopeRef.current,
430
+ id,
431
+ inspectorValue,
432
+ );
433
+
434
+ // Check if initial state (after always) is final
435
+ if (machine.finalStates.has(machine.initial._tag)) {
436
+ // Close state scope and interrupt background effects
437
+ yield* Scope.close(stateScopeRef.current, Exit.void);
438
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
439
+ yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
440
+ type: "@machine.stop",
441
+ actorId: id,
442
+ finalState: machine.initial,
443
+ timestamp,
444
+ }));
445
+ return buildActorRefCore(
446
+ id,
447
+ machine,
448
+ stateRef,
449
+ eventQueue,
450
+ listeners,
451
+ Queue.shutdown(eventQueue).pipe(Effect.asVoid),
452
+ );
453
+ }
454
+
455
+ // Start the event loop
456
+ const loopFiber = yield* Effect.fork(
457
+ eventLoop(
458
+ machine,
459
+ stateRef,
460
+ eventQueue,
461
+ self,
462
+ listeners,
463
+ backgroundFibers,
464
+ stateScopeRef,
465
+ id,
466
+ inspectorValue,
467
+ ),
468
+ );
469
+
470
+ return buildActorRefCore(
471
+ id,
472
+ machine,
473
+ stateRef,
474
+ eventQueue,
475
+ listeners,
476
+ Effect.gen(function* () {
477
+ const finalState = yield* SubscriptionRef.get(stateRef);
478
+ yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
479
+ type: "@machine.stop",
480
+ actorId: id,
481
+ finalState,
482
+ timestamp,
483
+ }));
484
+ yield* Queue.shutdown(eventQueue);
485
+ yield* Fiber.interrupt(loopFiber);
486
+ // Close state scope (interrupts spawn fibers)
487
+ yield* Scope.close(stateScopeRef.current, Exit.void);
488
+ // Interrupt background effects (in parallel)
489
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
490
+ }).pipe(Effect.asVoid),
491
+ );
492
+ }),
493
+ );
494
+
495
+ /**
496
+ * Main event loop for the actor
497
+ */
498
+ const eventLoop = <
499
+ S extends { readonly _tag: string },
500
+ E extends { readonly _tag: string },
501
+ R,
502
+ GD extends GuardsDef,
503
+ EFD extends EffectsDef,
504
+ >(
505
+ machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
506
+ stateRef: SubscriptionRef.SubscriptionRef<S>,
507
+ eventQueue: Queue.Queue<E>,
508
+ self: MachineRef<E>,
509
+ listeners: Listeners<S>,
510
+ backgroundFibers: Fiber.Fiber<void, never>[],
511
+ stateScopeRef: { current: Scope.CloseableScope },
512
+ actorId: string,
513
+ inspector?: Inspector<S, E>,
514
+ ): Effect.Effect<void, never, R> =>
515
+ Effect.gen(function* () {
516
+ while (true) {
517
+ // Block waiting for next event - will fail with QueueShutdown when queue is shut down
518
+ const event = yield* Queue.take(eventQueue);
519
+
520
+ const currentState = yield* SubscriptionRef.get(stateRef);
521
+
522
+ // Process event in a span
523
+ const shouldStop = yield* Effect.withSpan("effect-machine.event.process", {
524
+ attributes: {
525
+ "effect_machine.actor.id": actorId,
526
+ "effect_machine.state.current": currentState._tag,
527
+ "effect_machine.event.type": event._tag,
528
+ },
529
+ })(
530
+ processEvent(
531
+ machine,
532
+ currentState,
533
+ event,
534
+ stateRef,
535
+ self,
536
+ listeners,
537
+ stateScopeRef,
538
+ actorId,
539
+ inspector,
540
+ ),
541
+ );
542
+
543
+ if (shouldStop) {
544
+ // Close state scope and interrupt background effects when reaching final state
545
+ yield* Scope.close(stateScopeRef.current, Exit.void);
546
+ yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
547
+ return;
548
+ }
549
+ }
550
+ });
551
+
552
+ /**
553
+ * Process a single event, returning true if the actor should stop.
554
+ * Wraps processEventCore with actor-specific concerns (inspection, listeners, state ref).
555
+ */
556
+ const processEvent = <
557
+ S extends { readonly _tag: string },
558
+ E extends { readonly _tag: string },
559
+ R,
560
+ GD extends GuardsDef,
561
+ EFD extends EffectsDef,
562
+ >(
563
+ machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
564
+ currentState: S,
565
+ event: E,
566
+ stateRef: SubscriptionRef.SubscriptionRef<S>,
567
+ self: MachineRef<E>,
568
+ listeners: Listeners<S>,
569
+ stateScopeRef: { current: Scope.CloseableScope },
570
+ actorId: string,
571
+ inspector?: Inspector<S, E>,
572
+ ): Effect.Effect<boolean, never, R> =>
573
+ Effect.gen(function* () {
574
+ // Emit event received
575
+ yield* emitWithTimestamp(inspector, (timestamp) => ({
576
+ type: "@machine.event",
577
+ actorId,
578
+ state: currentState,
579
+ event,
580
+ timestamp,
581
+ }));
582
+
583
+ // Build inspection hooks for processEventCore
584
+ const hooks: ProcessEventHooks<S, E> | undefined =
585
+ inspector === undefined
586
+ ? undefined
587
+ : {
588
+ onSpawnEffect: (state) =>
589
+ emitWithTimestamp(inspector, (timestamp) => ({
590
+ type: "@machine.effect",
591
+ actorId,
592
+ effectType: "spawn",
593
+ state,
594
+ timestamp,
595
+ })),
596
+ onTransition: (from, to, ev) =>
597
+ emitWithTimestamp(inspector, (timestamp) => ({
598
+ type: "@machine.transition",
599
+ actorId,
600
+ fromState: from,
601
+ toState: to,
602
+ event: ev,
603
+ timestamp,
604
+ })),
605
+ };
606
+
607
+ // Process event using shared core
608
+ const result = yield* processEventCore(
609
+ machine,
610
+ currentState,
611
+ event,
612
+ self,
613
+ stateScopeRef,
614
+ hooks,
615
+ );
616
+
617
+ if (!result.transitioned) {
618
+ yield* Effect.annotateCurrentSpan("effect_machine.transition.matched", false);
619
+ return false;
620
+ }
621
+
622
+ yield* Effect.annotateCurrentSpan("effect_machine.transition.matched", true);
623
+
624
+ // Update state ref and notify listeners
625
+ yield* SubscriptionRef.set(stateRef, result.newState);
626
+ notifyListeners(listeners, result.newState);
627
+
628
+ if (result.lifecycleRan) {
629
+ yield* Effect.annotateCurrentSpan("effect_machine.state.from", result.previousState._tag);
630
+ yield* Effect.annotateCurrentSpan("effect_machine.state.to", result.newState._tag);
631
+
632
+ // Transition inspection event emitted via hooks in processEventCore
633
+
634
+ // Check if new state is final
635
+ if (result.isFinal) {
636
+ yield* emitWithTimestamp(inspector, (timestamp) => ({
637
+ type: "@machine.stop",
638
+ actorId,
639
+ finalState: result.newState,
640
+ timestamp,
641
+ }));
642
+ return true;
643
+ }
644
+ }
645
+
646
+ return false;
647
+ });
648
+
649
+ /**
650
+ * Run spawn effects with actor-specific inspection and tracing.
651
+ * Wraps the core runSpawnEffects with inspection events and spans.
652
+ * @internal
653
+ */
654
+ const runSpawnEffectsWithInspection = <
655
+ S extends { readonly _tag: string },
656
+ E extends { readonly _tag: string },
657
+ R,
658
+ GD extends GuardsDef,
659
+ EFD extends EffectsDef,
660
+ >(
661
+ machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
662
+ state: S,
663
+ event: E,
664
+ self: MachineRef<E>,
665
+ stateScope: Scope.CloseableScope,
666
+ actorId: string,
667
+ inspector?: Inspector<S, E>,
668
+ ): Effect.Effect<void, never, R> =>
669
+ Effect.gen(function* () {
670
+ // Emit inspection event before running effects
671
+ yield* emitWithTimestamp(inspector, (timestamp) => ({
672
+ type: "@machine.effect",
673
+ actorId,
674
+ effectType: "spawn",
675
+ state,
676
+ timestamp,
677
+ }));
678
+
679
+ // Use shared core
680
+ yield* runSpawnEffects(machine, state, event, self, stateScope);
681
+ });
682
+
683
+ // ============================================================================
684
+ // ActorSystem Implementation
685
+ // ============================================================================
686
+
687
+ /**
688
+ * Internal implementation
689
+ */
690
+ const make = Effect.sync(() => {
691
+ // MutableHashMap for O(1) spawn/stop/get operations
692
+ const actors = MutableHashMap.empty<string, ActorRef<AnyState, unknown>>();
693
+
694
+ /** Check for duplicate ID, register actor, add cleanup finalizer */
695
+ const registerActor = <T extends { stop: Effect.Effect<void> }>(
696
+ id: string,
697
+ actor: T,
698
+ ): Effect.Effect<T, DuplicateActorError, Scope.Scope> =>
699
+ Effect.gen(function* () {
700
+ // Check if actor already exists
701
+ if (MutableHashMap.has(actors, id)) {
702
+ return yield* new DuplicateActorError({ actorId: id });
703
+ }
704
+
705
+ // Register it - O(1)
706
+ MutableHashMap.set(actors, id, actor as unknown as ActorRef<AnyState, unknown>);
707
+
708
+ // Register cleanup on scope finalization
709
+ yield* Effect.addFinalizer(() =>
710
+ Effect.gen(function* () {
711
+ yield* actor.stop;
712
+ MutableHashMap.remove(actors, id);
713
+ }),
714
+ );
715
+
716
+ return actor;
717
+ });
718
+
719
+ const spawnRegular = <
720
+ S extends { readonly _tag: string },
721
+ E extends { readonly _tag: string },
722
+ R,
723
+ GD extends GuardsDef = Record<string, never>,
724
+ EFD extends EffectsDef = Record<string, never>,
725
+ >(
726
+ id: string,
727
+ machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
728
+ ): Effect.Effect<ActorRef<S, E>, DuplicateActorError, R | Scope.Scope> =>
729
+ Effect.gen(function* () {
730
+ // Create and register the actor
731
+ const actor = yield* createActor(id, machine);
732
+ return yield* registerActor(id, actor);
733
+ });
734
+
735
+ const spawnPersistent = <
736
+ S extends { readonly _tag: string },
737
+ E extends { readonly _tag: string },
738
+ R,
739
+ >(
740
+ id: string,
741
+ persistentMachine: PersistentMachine<S, E, R>,
742
+ ): Effect.Effect<
743
+ PersistentActorRef<S, E>,
744
+ PersistenceError | VersionConflictError | DuplicateActorError,
745
+ R | Scope.Scope | PersistenceAdapterTag
746
+ > =>
747
+ Effect.gen(function* () {
748
+ const adapter = yield* PersistenceAdapterTag;
749
+
750
+ // Try to load existing snapshot
751
+ const maybeSnapshot = yield* adapter.loadSnapshot(
752
+ id,
753
+ persistentMachine.persistence.stateSchema,
754
+ );
755
+
756
+ // Load events after snapshot (if any)
757
+ const events = Option.isSome(maybeSnapshot)
758
+ ? yield* adapter.loadEvents(
759
+ id,
760
+ persistentMachine.persistence.eventSchema,
761
+ maybeSnapshot.value.version,
762
+ )
763
+ : [];
764
+
765
+ // Create and register the persistent actor
766
+ const actor = yield* createPersistentActor(id, persistentMachine, maybeSnapshot, events);
767
+ return yield* registerActor(id, actor);
768
+ });
769
+
770
+ // Type-safe overloaded spawn implementation
771
+ function spawn<
772
+ S extends { readonly _tag: string },
773
+ E extends { readonly _tag: string },
774
+ R,
775
+ GD extends GuardsDef = Record<string, never>,
776
+ EFD extends EffectsDef = Record<string, never>,
777
+ >(
778
+ id: string,
779
+ machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
780
+ ): Effect.Effect<ActorRef<S, E>, DuplicateActorError, R | Scope.Scope>;
781
+ function spawn<S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
782
+ id: string,
783
+ machine: PersistentMachine<S, E, R>,
784
+ ): Effect.Effect<
785
+ PersistentActorRef<S, E>,
786
+ PersistenceError | VersionConflictError | DuplicateActorError,
787
+ R | Scope.Scope | PersistenceAdapterTag
788
+ >;
789
+ function spawn<
790
+ S extends { readonly _tag: string },
791
+ E extends { readonly _tag: string },
792
+ R,
793
+ GD extends GuardsDef = Record<string, never>,
794
+ EFD extends EffectsDef = Record<string, never>,
795
+ >(
796
+ id: string,
797
+ machine:
798
+ | Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>
799
+ | PersistentMachine<S, E, R>,
800
+ ):
801
+ | Effect.Effect<ActorRef<S, E>, DuplicateActorError, R | Scope.Scope>
802
+ | Effect.Effect<
803
+ PersistentActorRef<S, E>,
804
+ PersistenceError | VersionConflictError | DuplicateActorError,
805
+ R | Scope.Scope | PersistenceAdapterTag
806
+ > {
807
+ if (isPersistentMachine(machine)) {
808
+ // TypeScript can't narrow union with invariant generic params
809
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
810
+ return spawnPersistent(id, machine as PersistentMachine<S, E, R>);
811
+ }
812
+ return spawnRegular(
813
+ id,
814
+ machine as Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
815
+ );
816
+ }
817
+
818
+ const restore = <S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
819
+ id: string,
820
+ persistentMachine: PersistentMachine<S, E, R>,
821
+ ): Effect.Effect<
822
+ Option.Option<PersistentActorRef<S, E>>,
823
+ PersistenceError | DuplicateActorError,
824
+ R | Scope.Scope | PersistenceAdapterTag
825
+ > =>
826
+ Effect.gen(function* () {
827
+ // Try to restore from persistence
828
+ const maybeActor = yield* restorePersistentActor(id, persistentMachine);
829
+
830
+ if (Option.isSome(maybeActor)) {
831
+ yield* registerActor(id, maybeActor.value);
832
+ }
833
+
834
+ return maybeActor;
835
+ });
836
+
837
+ const get = (id: string): Effect.Effect<Option.Option<ActorRef<AnyState, unknown>>> =>
838
+ Effect.sync(() => MutableHashMap.get(actors, id));
839
+
840
+ const stop = (id: string): Effect.Effect<boolean> =>
841
+ Effect.gen(function* () {
842
+ const maybeActor = MutableHashMap.get(actors, id);
843
+ if (Option.isNone(maybeActor)) {
844
+ return false;
845
+ }
846
+
847
+ yield* maybeActor.value.stop;
848
+ MutableHashMap.remove(actors, id);
849
+ return true;
850
+ });
851
+
852
+ const listPersisted = (): Effect.Effect<
853
+ ReadonlyArray<ActorMetadata>,
854
+ PersistenceError,
855
+ PersistenceAdapterTag
856
+ > =>
857
+ Effect.gen(function* () {
858
+ const adapter = yield* PersistenceAdapterTag;
859
+ if (adapter.listActors === undefined) {
860
+ return [];
861
+ }
862
+ return yield* adapter.listActors();
863
+ });
864
+
865
+ const restoreMany = <S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
866
+ ids: ReadonlyArray<string>,
867
+ persistentMachine: PersistentMachine<S, E, R>,
868
+ ): Effect.Effect<RestoreResult<S, E>, never, R | Scope.Scope | PersistenceAdapterTag> =>
869
+ Effect.gen(function* () {
870
+ const restored: PersistentActorRef<S, E>[] = [];
871
+ const failed: { id: string; error: PersistenceError | DuplicateActorError }[] = [];
872
+
873
+ for (const id of ids) {
874
+ // Skip if already running
875
+ if (MutableHashMap.has(actors, id)) {
876
+ continue;
877
+ }
878
+
879
+ const result = yield* Effect.either(restore(id, persistentMachine));
880
+ if (result._tag === "Left") {
881
+ failed.push({ id, error: result.left });
882
+ } else if (Option.isSome(result.right)) {
883
+ restored.push(result.right.value);
884
+ } else {
885
+ // No persisted state for this ID
886
+ failed.push({
887
+ id,
888
+ error: new PersistenceErrorClass({
889
+ operation: "restore",
890
+ actorId: id,
891
+ message: "No persisted state found",
892
+ }),
893
+ });
894
+ }
895
+ }
896
+
897
+ return { restored, failed };
898
+ });
899
+
900
+ const restoreAll = <S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
901
+ persistentMachine: PersistentMachine<S, E, R>,
902
+ options?: { filter?: (meta: ActorMetadata) => boolean },
903
+ ): Effect.Effect<
904
+ RestoreResult<S, E>,
905
+ PersistenceError,
906
+ R | Scope.Scope | PersistenceAdapterTag
907
+ > =>
908
+ Effect.gen(function* () {
909
+ const adapter = yield* PersistenceAdapterTag;
910
+ if (adapter.listActors === undefined) {
911
+ return { restored: [], failed: [] };
912
+ }
913
+
914
+ // Require explicit machineType to prevent cross-machine restores
915
+ const machineType = persistentMachine.persistence.machineType;
916
+ if (machineType === undefined) {
917
+ return yield* new PersistenceErrorClass({
918
+ operation: "restoreAll",
919
+ actorId: "*",
920
+ message: "restoreAll requires explicit machineType in persistence config",
921
+ });
922
+ }
923
+
924
+ const allMetadata = yield* adapter.listActors();
925
+
926
+ // Filter by machineType and optional user filter
927
+ let filtered = allMetadata.filter((meta) => meta.machineType === machineType);
928
+ if (options?.filter !== undefined) {
929
+ filtered = filtered.filter(options.filter);
930
+ }
931
+
932
+ const ids = filtered.map((meta) => meta.id);
933
+ return yield* restoreMany(ids, persistentMachine);
934
+ });
935
+
936
+ return ActorSystem.of({ spawn, restore, get, stop, listPersisted, restoreMany, restoreAll });
937
+ });
938
+
939
+ /**
940
+ * Default ActorSystem layer
941
+ */
942
+ export const Default = Layer.effect(ActorSystem, make);