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