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.
@@ -0,0 +1,601 @@
1
+ // @effect-diagnostics missingEffectContext:off
2
+ // @effect-diagnostics anyUnknownInErrorContext:off
3
+
4
+ import { Clock, Effect, Fiber, Option, Queue, Ref, SubscriptionRef } from "effect";
5
+
6
+ import type { ActorRef, Listeners } from "../actor.js";
7
+ import { buildActorRefCore, notifyListeners } from "../actor.js";
8
+ import type { MachineRef, Machine } from "../machine.js";
9
+ import type { Inspector } from "../inspection.js";
10
+ import { Inspector as InspectorTag } from "../inspection.js";
11
+ import { resolveTransition, runTransitionHandler } from "../internal/transition.js";
12
+ import type { GuardsDef, EffectsDef } from "../slot.js";
13
+
14
+ import type {
15
+ ActorMetadata,
16
+ PersistedEvent,
17
+ PersistenceAdapter,
18
+ PersistenceError,
19
+ Snapshot,
20
+ VersionConflictError,
21
+ } from "./adapter.js";
22
+ import { PersistenceAdapterTag } from "./adapter.js";
23
+ import type { PersistentMachine } from "./persistent-machine.js";
24
+
25
+ /**
26
+ * Extended ActorRef with persistence capabilities
27
+ */
28
+ export interface PersistentActorRef<
29
+ S extends { readonly _tag: string },
30
+ E extends { readonly _tag: string },
31
+ > extends ActorRef<S, E> {
32
+ /**
33
+ * Force an immediate snapshot save
34
+ */
35
+ readonly persist: Effect.Effect<void, PersistenceError | VersionConflictError>;
36
+
37
+ /**
38
+ * Get the current persistence version
39
+ */
40
+ readonly version: Effect.Effect<number>;
41
+
42
+ /**
43
+ * Replay events to restore actor to a specific version.
44
+ * Note: This only computes state; does not re-run transition effects.
45
+ */
46
+ readonly replayTo: (version: number) => Effect.Effect<void, PersistenceError>;
47
+ }
48
+
49
+ /** Get current time in milliseconds using Effect Clock */
50
+ const now = Clock.currentTimeMillis;
51
+
52
+ /**
53
+ * Replay persisted events to compute state.
54
+ * Supports async handlers - used for initial restore.
55
+ * @internal
56
+ */
57
+ const replayEvents = <
58
+ S extends { readonly _tag: string },
59
+ E extends { readonly _tag: string },
60
+ R,
61
+ GD extends GuardsDef = Record<string, never>,
62
+ EFD extends EffectsDef = Record<string, never>,
63
+ >(
64
+ machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
65
+ startState: S,
66
+ events: ReadonlyArray<PersistedEvent<E>>,
67
+ self: MachineRef<E>,
68
+ 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 } => {
105
+ let state = startState;
106
+ let version = 0;
107
+
108
+ for (const persistedEvent of events) {
109
+ if (stopVersion !== undefined && persistedEvent.version > stopVersion) break;
110
+
111
+ const transition = resolveTransition(machine, state, persistedEvent.event);
112
+ 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
+ }
123
+ }
124
+ version = persistedEvent.version;
125
+ }
126
+
127
+ return { state, version };
128
+ };
129
+
130
+ /**
131
+ * Build PersistentActorRef with all methods
132
+ */
133
+ const buildPersistentActorRef = <
134
+ S extends { readonly _tag: string },
135
+ E extends { readonly _tag: string },
136
+ R,
137
+ GD extends GuardsDef = Record<string, never>,
138
+ EFD extends EffectsDef = Record<string, never>,
139
+ >(
140
+ id: string,
141
+ persistentMachine: PersistentMachine<S, E, R>,
142
+ stateRef: SubscriptionRef.SubscriptionRef<S>,
143
+ versionRef: Ref.Ref<number>,
144
+ eventQueue: Queue.Queue<E>,
145
+ listeners: Listeners<S>,
146
+ stop: Effect.Effect<void>,
147
+ adapter: PersistenceAdapter,
148
+ ): PersistentActorRef<S, E> => {
149
+ const { machine, persistence } = persistentMachine;
150
+ const typedMachine = machine as unknown as Machine<
151
+ S,
152
+ E,
153
+ R,
154
+ Record<string, never>,
155
+ Record<string, never>,
156
+ GD,
157
+ EFD
158
+ >;
159
+
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
+ },
172
+ );
173
+
174
+ // 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
+ }
199
+ }
200
+ }
201
+ });
202
+
203
+ const core = buildActorRefCore(id, typedMachine, stateRef, eventQueue, listeners, stop);
204
+
205
+ return {
206
+ ...core,
207
+ persist,
208
+ version: Ref.get(versionRef),
209
+ replayTo,
210
+ };
211
+ };
212
+
213
+ /**
214
+ * Create a persistent actor from a PersistentMachine.
215
+ * Restores from existing snapshot if available, otherwise starts fresh.
216
+ */
217
+ export const createPersistentActor = <
218
+ S extends { readonly _tag: string },
219
+ E extends { readonly _tag: string },
220
+ R,
221
+ GD extends GuardsDef = Record<string, never>,
222
+ EFD extends EffectsDef = Record<string, never>,
223
+ >(
224
+ id: string,
225
+ persistentMachine: PersistentMachine<S, E, R>,
226
+ initialSnapshot: Option.Option<Snapshot<S>>,
227
+ 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
+ };
255
+
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
+ }
275
+
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;
294
+ }
295
+
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
+ }
306
+
307
+ // Save initial metadata
308
+ yield* saveMetadata(
309
+ id,
310
+ resolvedInitial,
311
+ initialVersion,
312
+ createdAt,
313
+ persistentMachine.persistence,
314
+ adapter,
315
+ );
316
+
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
+ }
339
+
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
+ );
355
+
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
+ }),
380
+ );
381
+
382
+ /**
383
+ * Main event loop for persistent actor
384
+ */
385
+ const persistentEventLoop = <
386
+ S extends { readonly _tag: string },
387
+ E extends { readonly _tag: string },
388
+ R,
389
+ GD extends GuardsDef = Record<string, never>,
390
+ EFD extends EffectsDef = Record<string, never>,
391
+ >(
392
+ id: string,
393
+ persistentMachine: PersistentMachine<S, E, R>,
394
+ stateRef: SubscriptionRef.SubscriptionRef<S>,
395
+ versionRef: Ref.Ref<number>,
396
+ eventQueue: Queue.Queue<E>,
397
+ self: MachineRef<E>,
398
+ listeners: Listeners<S>,
399
+ adapter: PersistenceAdapter,
400
+ createdAt: number,
401
+ 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
+ }
437
+
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,
449
+ };
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
+
457
+ // Compute new state using shared handler utility
458
+ const newState = yield* runTransitionHandler(
459
+ typedMachine,
460
+ transition,
461
+ currentState,
462
+ event,
463
+ self,
464
+ );
465
+
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
+ }
481
+
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
+ }
503
+ }
504
+ });
505
+
506
+ /**
507
+ * Save a snapshot after state transition.
508
+ * Called inline in event loop to avoid race conditions.
509
+ */
510
+ const saveSnapshot = <S extends { readonly _tag: string }, E extends { readonly _tag: string }>(
511
+ id: string,
512
+ state: S,
513
+ version: number,
514
+ persistence: PersistentMachine<S, E, never>["persistence"],
515
+ 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
+ });
530
+
531
+ /**
532
+ * Save or update actor metadata if adapter supports registry.
533
+ * Called on spawn and state transitions.
534
+ */
535
+ const saveMetadata = <S extends { readonly _tag: string }, E extends { readonly _tag: string }>(
536
+ id: string,
537
+ state: S,
538
+ version: number,
539
+ createdAt: number,
540
+ persistence: PersistentMachine<S, E, never>["persistence"],
541
+ adapter: PersistenceAdapter,
542
+ ): Effect.Effect<void> => {
543
+ const save = adapter.saveMetadata;
544
+ if (save === undefined) {
545
+ return Effect.void;
546
+ }
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
+ };
562
+
563
+ /**
564
+ * Restore an actor from persistence.
565
+ * Returns None if no persisted state exists.
566
+ */
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* () {
580
+ const adapter = yield* PersistenceAdapterTag;
581
+ const { persistence } = persistentMachine;
582
+
583
+ // Try to load snapshot
584
+ const maybeSnapshot = yield* adapter.loadSnapshot(id, persistence.stateSchema);
585
+
586
+ if (Option.isNone(maybeSnapshot)) {
587
+ return Option.none();
588
+ }
589
+
590
+ // Load events after snapshot
591
+ const events = yield* adapter.loadEvents(
592
+ id,
593
+ persistence.eventSchema,
594
+ maybeSnapshot.value.version,
595
+ );
596
+
597
+ // Create actor with restored state
598
+ const actor = yield* createPersistentActor(id, persistentMachine, maybeSnapshot, events);
599
+
600
+ return Option.some(actor);
601
+ });