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/README.md +221 -0
- package/package.json +63 -0
- package/src/actor.ts +942 -0
- package/src/cluster/entity-machine.ts +202 -0
- package/src/cluster/index.ts +43 -0
- package/src/cluster/to-entity.ts +99 -0
- package/src/errors.ts +64 -0
- package/src/index.ts +102 -0
- package/src/inspection.ts +132 -0
- package/src/internal/brands.ts +51 -0
- package/src/internal/transition.ts +427 -0
- package/src/internal/utils.ts +80 -0
- package/src/machine.ts +685 -0
- package/src/persistence/adapter.ts +169 -0
- package/src/persistence/adapters/in-memory.ts +275 -0
- package/src/persistence/index.ts +24 -0
- package/src/persistence/persistent-actor.ts +601 -0
- package/src/persistence/persistent-machine.ts +131 -0
- package/src/schema.ts +316 -0
- package/src/slot.ts +281 -0
- package/src/testing.ts +282 -0
- package/tsconfig.json +68 -0
|
@@ -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
|
+
});
|