effect-machine 0.6.0 → 0.7.1
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/dist/actor.d.ts +2 -2
- package/dist/actor.js +13 -15
- package/dist/cluster/entity-machine.d.ts +2 -2
- package/dist/cluster/entity-machine.js +1 -1
- package/dist/cluster/to-entity.d.ts +5 -5
- package/dist/cluster/to-entity.js +2 -2
- package/dist/errors.d.ts +25 -40
- package/dist/errors.js +10 -10
- package/dist/inspection.d.ts +3 -3
- package/dist/inspection.js +2 -2
- package/dist/internal/brands.d.ts +3 -6
- package/dist/internal/inspection.js +5 -1
- package/dist/internal/transition.d.ts +2 -2
- package/dist/internal/transition.js +6 -6
- package/dist/internal/utils.js +5 -1
- package/dist/machine.d.ts +5 -5
- package/dist/machine.js +9 -5
- package/dist/persistence/adapter.d.ts +18 -21
- package/dist/persistence/adapter.js +4 -4
- package/dist/persistence/adapters/in-memory.js +4 -4
- package/dist/persistence/persistent-actor.js +9 -9
- package/dist/persistence/persistent-machine.d.ts +3 -3
- package/dist/schema.d.ts +4 -4
- package/dist/schema.js +2 -2
- package/dist/slot.d.ts +3 -3
- package/dist/slot.js +2 -2
- package/dist-v3/_virtual/_rolldown/runtime.js +18 -0
- package/dist-v3/actor.d.ts +291 -0
- package/dist-v3/actor.js +459 -0
- package/dist-v3/cluster/entity-machine.d.ts +90 -0
- package/dist-v3/cluster/entity-machine.js +80 -0
- package/dist-v3/cluster/index.d.ts +3 -0
- package/dist-v3/cluster/index.js +4 -0
- package/dist-v3/cluster/to-entity.d.ts +61 -0
- package/dist-v3/cluster/to-entity.js +53 -0
- package/dist-v3/errors.d.ts +27 -0
- package/dist-v3/errors.js +38 -0
- package/dist-v3/index.d.ts +13 -0
- package/dist-v3/index.js +14 -0
- package/dist-v3/inspection.d.ts +125 -0
- package/dist-v3/inspection.js +50 -0
- package/dist-v3/internal/brands.d.ts +40 -0
- package/dist-v3/internal/brands.js +0 -0
- package/dist-v3/internal/inspection.d.ts +11 -0
- package/dist-v3/internal/inspection.js +15 -0
- package/dist-v3/internal/transition.d.ts +160 -0
- package/dist-v3/internal/transition.js +238 -0
- package/dist-v3/internal/utils.d.ts +60 -0
- package/dist-v3/internal/utils.js +51 -0
- package/dist-v3/machine.d.ts +278 -0
- package/dist-v3/machine.js +317 -0
- package/dist-v3/persistence/adapter.d.ts +125 -0
- package/dist-v3/persistence/adapter.js +27 -0
- package/dist-v3/persistence/adapters/in-memory.d.ts +32 -0
- package/dist-v3/persistence/adapters/in-memory.js +176 -0
- package/dist-v3/persistence/index.d.ts +5 -0
- package/dist-v3/persistence/index.js +6 -0
- package/dist-v3/persistence/persistent-actor.d.ts +49 -0
- package/dist-v3/persistence/persistent-actor.js +367 -0
- package/dist-v3/persistence/persistent-machine.d.ts +105 -0
- package/dist-v3/persistence/persistent-machine.js +24 -0
- package/dist-v3/schema.d.ts +141 -0
- package/dist-v3/schema.js +165 -0
- package/dist-v3/slot.d.ts +130 -0
- package/dist-v3/slot.js +99 -0
- package/dist-v3/testing.d.ts +136 -0
- package/dist-v3/testing.js +138 -0
- package/package.json +29 -21
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PersistentMachine } from "./persistent-machine.js";
|
|
2
|
+
import { EffectsDef, GuardsDef } from "../slot.js";
|
|
3
|
+
import { PersistedEvent, PersistenceError, Snapshot, VersionConflictError } from "./adapter.js";
|
|
4
|
+
import { ActorRef } from "../actor.js";
|
|
5
|
+
import { Effect, Option } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src-v3/persistence/persistent-actor.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* Extended ActorRef with persistence capabilities
|
|
10
|
+
*/
|
|
11
|
+
interface PersistentActorRef<S extends {
|
|
12
|
+
readonly _tag: string;
|
|
13
|
+
}, E extends {
|
|
14
|
+
readonly _tag: string;
|
|
15
|
+
}, R = never> extends ActorRef<S, E> {
|
|
16
|
+
/**
|
|
17
|
+
* Force an immediate snapshot save
|
|
18
|
+
*/
|
|
19
|
+
readonly persist: Effect.Effect<void, PersistenceError | VersionConflictError>;
|
|
20
|
+
/**
|
|
21
|
+
* Get the current persistence version
|
|
22
|
+
*/
|
|
23
|
+
readonly version: Effect.Effect<number>;
|
|
24
|
+
/**
|
|
25
|
+
* Replay events to restore actor to a specific version.
|
|
26
|
+
* Note: This only computes state; does not re-run transition effects.
|
|
27
|
+
*/
|
|
28
|
+
readonly replayTo: (version: number) => Effect.Effect<void, PersistenceError, R>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create a persistent actor from a PersistentMachine.
|
|
32
|
+
* Restores from existing snapshot if available, otherwise starts fresh.
|
|
33
|
+
*/
|
|
34
|
+
declare const createPersistentActor: <S extends {
|
|
35
|
+
readonly _tag: string;
|
|
36
|
+
}, E extends {
|
|
37
|
+
readonly _tag: string;
|
|
38
|
+
}, R, GD extends GuardsDef = Record<string, never>, EFD extends EffectsDef = Record<string, never>>(id: string, persistentMachine: PersistentMachine<S, E, R>, initialSnapshot: Option.Option<Snapshot<S>>, initialEvents: readonly PersistedEvent<E>[]) => Effect.Effect<PersistentActorRef<S, E, R>, unknown, unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* Restore an actor from persistence.
|
|
41
|
+
* Returns None if no persisted state exists.
|
|
42
|
+
*/
|
|
43
|
+
declare const restorePersistentActor: <S extends {
|
|
44
|
+
readonly _tag: string;
|
|
45
|
+
}, E extends {
|
|
46
|
+
readonly _tag: string;
|
|
47
|
+
}, R>(id: string, persistentMachine: PersistentMachine<S, E, R>) => Effect.Effect<Option.None<PersistentActorRef<S, E, R>> | Option.Some<PersistentActorRef<S, E, R>>, unknown, unknown>;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { PersistentActorRef, createPersistentActor, restorePersistentActor };
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { Inspector } from "../inspection.js";
|
|
2
|
+
import { INTERNAL_INIT_EVENT, stubSystem } from "../internal/utils.js";
|
|
3
|
+
import { processEventCore, resolveTransition, runSpawnEffects, runTransitionHandler } from "../internal/transition.js";
|
|
4
|
+
import { emitWithTimestamp } from "../internal/inspection.js";
|
|
5
|
+
import { PersistenceAdapterTag } from "./adapter.js";
|
|
6
|
+
import { ActorSystem, buildActorRefCore, notifyListeners } from "../actor.js";
|
|
7
|
+
import { Cause, Clock, Effect, Exit, Fiber, Option, Queue, Ref, Schedule, Scope, SubscriptionRef } from "effect";
|
|
8
|
+
|
|
9
|
+
//#region src-v3/persistence/persistent-actor.ts
|
|
10
|
+
/** Get current time in milliseconds using Effect Clock */
|
|
11
|
+
const now = Clock.currentTimeMillis;
|
|
12
|
+
/**
|
|
13
|
+
* Replay persisted events to compute state.
|
|
14
|
+
* Supports async handlers - used for initial restore.
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
const replayEvents = Effect.fn("effect-machine.persistentActor.replayEvents")(function* (machine, startState, events, self, stopVersion) {
|
|
18
|
+
let state = startState;
|
|
19
|
+
let version = 0;
|
|
20
|
+
for (const persistedEvent of events) {
|
|
21
|
+
if (stopVersion !== void 0 && persistedEvent.version > stopVersion) break;
|
|
22
|
+
const transition = resolveTransition(machine, state, persistedEvent.event);
|
|
23
|
+
if (transition !== void 0) state = yield* runTransitionHandler(machine, transition, state, persistedEvent.event, self, stubSystem);
|
|
24
|
+
version = persistedEvent.version;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
state,
|
|
28
|
+
version
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Build PersistentActorRef with all methods
|
|
33
|
+
*/
|
|
34
|
+
const buildPersistentActorRef = (id, persistentMachine, stateRef, versionRef, eventQueue, stoppedRef, listeners, stop, adapter, system, childrenMap) => {
|
|
35
|
+
const { machine, persistence } = persistentMachine;
|
|
36
|
+
const typedMachine = machine;
|
|
37
|
+
const persist = Effect.gen(function* () {
|
|
38
|
+
const snapshot = {
|
|
39
|
+
state: yield* SubscriptionRef.get(stateRef),
|
|
40
|
+
version: yield* Ref.get(versionRef),
|
|
41
|
+
timestamp: yield* now
|
|
42
|
+
};
|
|
43
|
+
yield* adapter.saveSnapshot(id, snapshot, persistence.stateSchema);
|
|
44
|
+
}).pipe(Effect.withSpan("effect-machine.persistentActor.persist"));
|
|
45
|
+
const version = Ref.get(versionRef).pipe(Effect.withSpan("effect-machine.persistentActor.version"));
|
|
46
|
+
const replayTo = Effect.fn("effect-machine.persistentActor.replayTo")(function* (targetVersion) {
|
|
47
|
+
if (targetVersion <= (yield* Ref.get(versionRef))) {
|
|
48
|
+
const dummySelf = {
|
|
49
|
+
send: Effect.fn("effect-machine.persistentActor.replay.send")((_event) => Effect.void),
|
|
50
|
+
spawn: () => Effect.die("spawn not supported in replay")
|
|
51
|
+
};
|
|
52
|
+
const maybeSnapshot = yield* adapter.loadSnapshot(id, persistence.stateSchema);
|
|
53
|
+
if (Option.isSome(maybeSnapshot)) {
|
|
54
|
+
const snapshot = maybeSnapshot.value;
|
|
55
|
+
if (snapshot.version <= targetVersion) {
|
|
56
|
+
const events = yield* adapter.loadEvents(id, persistence.eventSchema, snapshot.version);
|
|
57
|
+
const result = yield* replayEvents(typedMachine, snapshot.state, events, dummySelf, targetVersion);
|
|
58
|
+
yield* SubscriptionRef.set(stateRef, result.state);
|
|
59
|
+
yield* Ref.set(versionRef, result.version);
|
|
60
|
+
notifyListeners(listeners, result.state);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
const events = yield* adapter.loadEvents(id, persistence.eventSchema);
|
|
64
|
+
if (events.length > 0) {
|
|
65
|
+
const result = yield* replayEvents(typedMachine, typedMachine.initial, events, dummySelf, targetVersion);
|
|
66
|
+
yield* SubscriptionRef.set(stateRef, result.state);
|
|
67
|
+
yield* Ref.set(versionRef, result.version);
|
|
68
|
+
notifyListeners(listeners, result.state);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
...buildActorRefCore(id, typedMachine, stateRef, eventQueue, stoppedRef, listeners, stop, system, childrenMap),
|
|
75
|
+
persist,
|
|
76
|
+
version,
|
|
77
|
+
replayTo
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Create a persistent actor from a PersistentMachine.
|
|
82
|
+
* Restores from existing snapshot if available, otherwise starts fresh.
|
|
83
|
+
*/
|
|
84
|
+
const createPersistentActor = Effect.fn("effect-machine.persistentActor.spawn")(function* (id, persistentMachine, initialSnapshot, initialEvents) {
|
|
85
|
+
yield* Effect.annotateCurrentSpan("effect_machine.actor.id", id);
|
|
86
|
+
const adapter = yield* PersistenceAdapterTag;
|
|
87
|
+
const { machine, persistence } = persistentMachine;
|
|
88
|
+
const typedMachine = machine;
|
|
89
|
+
const existingSystem = yield* Effect.serviceOption(ActorSystem);
|
|
90
|
+
if (Option.isNone(existingSystem)) return yield* Effect.die("PersistentActor requires ActorSystem in context");
|
|
91
|
+
const system = existingSystem.value;
|
|
92
|
+
const inspector = Option.getOrUndefined(yield* Effect.serviceOption(Inspector));
|
|
93
|
+
const eventQueue = yield* Queue.unbounded();
|
|
94
|
+
const stoppedRef = yield* Ref.make(false);
|
|
95
|
+
const childrenMap = /* @__PURE__ */ new Map();
|
|
96
|
+
const self = {
|
|
97
|
+
send: Effect.fn("effect-machine.persistentActor.self.send")(function* (event) {
|
|
98
|
+
if (yield* Ref.get(stoppedRef)) return;
|
|
99
|
+
yield* Queue.offer(eventQueue, event);
|
|
100
|
+
}),
|
|
101
|
+
spawn: (childId, childMachine) => Effect.gen(function* () {
|
|
102
|
+
const child = yield* system.spawn(childId, childMachine).pipe(Effect.provideService(ActorSystem, system));
|
|
103
|
+
childrenMap.set(childId, child);
|
|
104
|
+
const maybeScope = yield* Effect.serviceOption(Scope.Scope);
|
|
105
|
+
if (Option.isSome(maybeScope)) yield* Scope.addFinalizer(maybeScope.value, Effect.sync(() => {
|
|
106
|
+
childrenMap.delete(childId);
|
|
107
|
+
}));
|
|
108
|
+
return child;
|
|
109
|
+
})
|
|
110
|
+
};
|
|
111
|
+
let resolvedInitial;
|
|
112
|
+
let initialVersion;
|
|
113
|
+
if (Option.isSome(initialSnapshot)) {
|
|
114
|
+
const result = yield* replayEvents(typedMachine, initialSnapshot.value.state, initialEvents, self);
|
|
115
|
+
resolvedInitial = result.state;
|
|
116
|
+
initialVersion = initialEvents.length > 0 ? result.version : initialSnapshot.value.version;
|
|
117
|
+
} else if (initialEvents.length > 0) {
|
|
118
|
+
const result = yield* replayEvents(typedMachine, typedMachine.initial, initialEvents, self);
|
|
119
|
+
resolvedInitial = result.state;
|
|
120
|
+
initialVersion = result.version;
|
|
121
|
+
} else {
|
|
122
|
+
resolvedInitial = typedMachine.initial;
|
|
123
|
+
initialVersion = 0;
|
|
124
|
+
}
|
|
125
|
+
yield* Effect.annotateCurrentSpan("effect_machine.actor.initial_state", resolvedInitial._tag);
|
|
126
|
+
const stateRef = yield* SubscriptionRef.make(resolvedInitial);
|
|
127
|
+
const versionRef = yield* Ref.make(initialVersion);
|
|
128
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
129
|
+
let createdAt;
|
|
130
|
+
if (Option.isSome(initialSnapshot)) {
|
|
131
|
+
const existingMeta = adapter.loadMetadata !== void 0 ? yield* adapter.loadMetadata(id) : Option.none();
|
|
132
|
+
createdAt = Option.isSome(existingMeta) ? existingMeta.value.createdAt : initialSnapshot.value.timestamp;
|
|
133
|
+
} else createdAt = yield* now;
|
|
134
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
135
|
+
type: "@machine.spawn",
|
|
136
|
+
actorId: id,
|
|
137
|
+
initialState: resolvedInitial,
|
|
138
|
+
timestamp
|
|
139
|
+
}));
|
|
140
|
+
const snapshotEnabledRef = yield* Ref.make(true);
|
|
141
|
+
const persistenceQueue = yield* Queue.unbounded();
|
|
142
|
+
const persistenceFiber = yield* Effect.forkDaemon(persistenceWorker(persistenceQueue));
|
|
143
|
+
yield* Queue.offer(persistenceQueue, saveMetadata(id, resolvedInitial, initialVersion, createdAt, persistence, adapter));
|
|
144
|
+
const snapshotQueue = yield* Queue.unbounded();
|
|
145
|
+
const snapshotFiber = yield* Effect.forkDaemon(snapshotWorker(id, persistence, adapter, snapshotQueue, snapshotEnabledRef));
|
|
146
|
+
const backgroundFibers = [];
|
|
147
|
+
const initEvent = { _tag: INTERNAL_INIT_EVENT };
|
|
148
|
+
const initCtx = {
|
|
149
|
+
state: resolvedInitial,
|
|
150
|
+
event: initEvent,
|
|
151
|
+
self,
|
|
152
|
+
system
|
|
153
|
+
};
|
|
154
|
+
const { effects: effectSlots } = typedMachine._slots;
|
|
155
|
+
for (const bg of typedMachine.backgroundEffects) {
|
|
156
|
+
const fiber = yield* Effect.forkDaemon(bg.handler({
|
|
157
|
+
state: resolvedInitial,
|
|
158
|
+
event: initEvent,
|
|
159
|
+
self,
|
|
160
|
+
effects: effectSlots,
|
|
161
|
+
system
|
|
162
|
+
}).pipe(Effect.provideService(typedMachine.Context, initCtx)));
|
|
163
|
+
backgroundFibers.push(fiber);
|
|
164
|
+
}
|
|
165
|
+
const stateScopeRef = { current: yield* Scope.make() };
|
|
166
|
+
yield* runSpawnEffectsWithInspection(typedMachine, resolvedInitial, initEvent, self, stateScopeRef.current, id, inspector, system);
|
|
167
|
+
if (typedMachine.finalStates.has(resolvedInitial._tag)) {
|
|
168
|
+
yield* Scope.close(stateScopeRef.current, Exit.void);
|
|
169
|
+
yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
|
|
170
|
+
yield* Fiber.interrupt(snapshotFiber);
|
|
171
|
+
yield* Fiber.interrupt(persistenceFiber);
|
|
172
|
+
yield* Ref.set(stoppedRef, true);
|
|
173
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
174
|
+
type: "@machine.stop",
|
|
175
|
+
actorId: id,
|
|
176
|
+
finalState: resolvedInitial,
|
|
177
|
+
timestamp
|
|
178
|
+
}));
|
|
179
|
+
return buildPersistentActorRef(id, persistentMachine, stateRef, versionRef, eventQueue, stoppedRef, listeners, Ref.set(stoppedRef, true).pipe(Effect.withSpan("effect-machine.persistentActor.stop"), Effect.asVoid), adapter, system, childrenMap);
|
|
180
|
+
}
|
|
181
|
+
const loopFiber = yield* Effect.forkDaemon(persistentEventLoop(id, persistentMachine, stateRef, versionRef, eventQueue, stoppedRef, self, listeners, adapter, createdAt, stateScopeRef, backgroundFibers, snapshotQueue, snapshotEnabledRef, persistenceQueue, snapshotFiber, persistenceFiber, inspector, system));
|
|
182
|
+
return buildPersistentActorRef(id, persistentMachine, stateRef, versionRef, eventQueue, stoppedRef, listeners, Effect.gen(function* () {
|
|
183
|
+
const finalState = yield* SubscriptionRef.get(stateRef);
|
|
184
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
185
|
+
type: "@machine.stop",
|
|
186
|
+
actorId: id,
|
|
187
|
+
finalState,
|
|
188
|
+
timestamp
|
|
189
|
+
}));
|
|
190
|
+
yield* Ref.set(stoppedRef, true);
|
|
191
|
+
yield* Fiber.interrupt(loopFiber);
|
|
192
|
+
yield* Scope.close(stateScopeRef.current, Exit.void);
|
|
193
|
+
yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
|
|
194
|
+
yield* Fiber.interrupt(snapshotFiber);
|
|
195
|
+
yield* Fiber.interrupt(persistenceFiber);
|
|
196
|
+
}).pipe(Effect.withSpan("effect-machine.persistentActor.stop"), Effect.asVoid), adapter, system, childrenMap);
|
|
197
|
+
});
|
|
198
|
+
/**
|
|
199
|
+
* Main event loop for persistent actor
|
|
200
|
+
*/
|
|
201
|
+
const persistentEventLoop = Effect.fn("effect-machine.persistentActor.eventLoop")(function* (id, persistentMachine, stateRef, versionRef, eventQueue, stoppedRef, self, listeners, adapter, createdAt, stateScopeRef, backgroundFibers, snapshotQueue, snapshotEnabledRef, persistenceQueue, snapshotFiber, persistenceFiber, inspector, system) {
|
|
202
|
+
const { machine, persistence } = persistentMachine;
|
|
203
|
+
const typedMachine = machine;
|
|
204
|
+
const hooks = inspector === void 0 ? void 0 : {
|
|
205
|
+
onSpawnEffect: (state) => emitWithTimestamp(inspector, (timestamp) => ({
|
|
206
|
+
type: "@machine.effect",
|
|
207
|
+
actorId: id,
|
|
208
|
+
effectType: "spawn",
|
|
209
|
+
state,
|
|
210
|
+
timestamp
|
|
211
|
+
})),
|
|
212
|
+
onTransition: (from, to, ev) => emitWithTimestamp(inspector, (timestamp) => ({
|
|
213
|
+
type: "@machine.transition",
|
|
214
|
+
actorId: id,
|
|
215
|
+
fromState: from,
|
|
216
|
+
toState: to,
|
|
217
|
+
event: ev,
|
|
218
|
+
timestamp
|
|
219
|
+
})),
|
|
220
|
+
onError: (info) => emitWithTimestamp(inspector, (timestamp) => ({
|
|
221
|
+
type: "@machine.error",
|
|
222
|
+
actorId: id,
|
|
223
|
+
phase: info.phase,
|
|
224
|
+
state: info.state,
|
|
225
|
+
event: info.event,
|
|
226
|
+
error: Cause.pretty(info.cause),
|
|
227
|
+
timestamp
|
|
228
|
+
}))
|
|
229
|
+
};
|
|
230
|
+
while (true) {
|
|
231
|
+
const event = yield* Queue.take(eventQueue);
|
|
232
|
+
const currentState = yield* SubscriptionRef.get(stateRef);
|
|
233
|
+
const currentVersion = yield* Ref.get(versionRef);
|
|
234
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
235
|
+
type: "@machine.event",
|
|
236
|
+
actorId: id,
|
|
237
|
+
state: currentState,
|
|
238
|
+
event,
|
|
239
|
+
timestamp
|
|
240
|
+
}));
|
|
241
|
+
const result = yield* processEventCore(typedMachine, currentState, event, self, stateScopeRef, system, hooks);
|
|
242
|
+
if (!result.transitioned) continue;
|
|
243
|
+
const newVersion = currentVersion + 1;
|
|
244
|
+
yield* Ref.set(versionRef, newVersion);
|
|
245
|
+
yield* SubscriptionRef.set(stateRef, result.newState);
|
|
246
|
+
notifyListeners(listeners, result.newState);
|
|
247
|
+
if (persistence.journalEvents) {
|
|
248
|
+
const persistedEvent = {
|
|
249
|
+
event,
|
|
250
|
+
version: newVersion,
|
|
251
|
+
timestamp: yield* now
|
|
252
|
+
};
|
|
253
|
+
const journalTask = adapter.appendEvent(id, persistedEvent, persistence.eventSchema).pipe(Effect.catchAll((e) => Effect.logWarning(`Failed to journal event for actor ${id}`, e)), Effect.asVoid);
|
|
254
|
+
yield* Queue.offer(persistenceQueue, journalTask);
|
|
255
|
+
}
|
|
256
|
+
yield* Queue.offer(persistenceQueue, saveMetadata(id, result.newState, newVersion, createdAt, persistence, adapter));
|
|
257
|
+
if (yield* Ref.get(snapshotEnabledRef)) yield* Queue.offer(snapshotQueue, {
|
|
258
|
+
state: result.newState,
|
|
259
|
+
version: newVersion
|
|
260
|
+
});
|
|
261
|
+
if (result.lifecycleRan && result.isFinal) {
|
|
262
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
263
|
+
type: "@machine.stop",
|
|
264
|
+
actorId: id,
|
|
265
|
+
finalState: result.newState,
|
|
266
|
+
timestamp
|
|
267
|
+
}));
|
|
268
|
+
yield* Ref.set(stoppedRef, true);
|
|
269
|
+
yield* Scope.close(stateScopeRef.current, Exit.void);
|
|
270
|
+
yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
|
|
271
|
+
yield* Fiber.interrupt(snapshotFiber);
|
|
272
|
+
yield* Fiber.interrupt(persistenceFiber);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
/**
|
|
278
|
+
* Run spawn effects with inspection and tracing.
|
|
279
|
+
* @internal
|
|
280
|
+
*/
|
|
281
|
+
const runSpawnEffectsWithInspection = Effect.fn("effect-machine.persistentActor.spawnEffects")(function* (machine, state, event, self, stateScope, actorId, inspector, system) {
|
|
282
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
283
|
+
type: "@machine.effect",
|
|
284
|
+
actorId,
|
|
285
|
+
effectType: "spawn",
|
|
286
|
+
state,
|
|
287
|
+
timestamp
|
|
288
|
+
}));
|
|
289
|
+
yield* runSpawnEffects(machine, state, event, self, stateScope, system, inspector === void 0 ? void 0 : (info) => emitWithTimestamp(inspector, (timestamp) => ({
|
|
290
|
+
type: "@machine.error",
|
|
291
|
+
actorId,
|
|
292
|
+
phase: info.phase,
|
|
293
|
+
state: info.state,
|
|
294
|
+
event: info.event,
|
|
295
|
+
error: Cause.pretty(info.cause),
|
|
296
|
+
timestamp
|
|
297
|
+
})));
|
|
298
|
+
});
|
|
299
|
+
/**
|
|
300
|
+
* Persistence worker (journaling + metadata).
|
|
301
|
+
*/
|
|
302
|
+
const persistenceWorker = Effect.fn("effect-machine.persistentActor.persistenceWorker")(function* (queue) {
|
|
303
|
+
while (true) yield* yield* Queue.take(queue);
|
|
304
|
+
});
|
|
305
|
+
/**
|
|
306
|
+
* Snapshot scheduler worker (runs in background).
|
|
307
|
+
*/
|
|
308
|
+
const snapshotWorker = Effect.fn("effect-machine.persistentActor.snapshotWorker")(function* (id, persistence, adapter, queue, enabledRef) {
|
|
309
|
+
const driver = yield* Schedule.driver(persistence.snapshotSchedule);
|
|
310
|
+
while (true) {
|
|
311
|
+
const { state, version } = yield* Queue.take(queue);
|
|
312
|
+
if (!(yield* Ref.get(enabledRef))) continue;
|
|
313
|
+
if (!(yield* driver.next(state).pipe(Effect.match({
|
|
314
|
+
onFailure: () => false,
|
|
315
|
+
onSuccess: () => true
|
|
316
|
+
})))) {
|
|
317
|
+
yield* Ref.set(enabledRef, false);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
yield* saveSnapshot(id, state, version, persistence, adapter);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
/**
|
|
324
|
+
* Save a snapshot after state transition.
|
|
325
|
+
* Called by snapshot scheduler.
|
|
326
|
+
*/
|
|
327
|
+
const saveSnapshot = Effect.fn("effect-machine.persistentActor.saveSnapshot")(function* (id, state, version, persistence, adapter) {
|
|
328
|
+
const snapshot = {
|
|
329
|
+
state,
|
|
330
|
+
version,
|
|
331
|
+
timestamp: yield* now
|
|
332
|
+
};
|
|
333
|
+
yield* adapter.saveSnapshot(id, snapshot, persistence.stateSchema).pipe(Effect.catchAll((e) => Effect.logWarning(`Failed to save snapshot for actor ${id}`, e)));
|
|
334
|
+
});
|
|
335
|
+
/**
|
|
336
|
+
* Save or update actor metadata if adapter supports registry.
|
|
337
|
+
* Called on spawn and state transitions.
|
|
338
|
+
*/
|
|
339
|
+
const saveMetadata = Effect.fn("effect-machine.persistentActor.saveMetadata")(function* (id, state, version, createdAt, persistence, adapter) {
|
|
340
|
+
const save = adapter.saveMetadata;
|
|
341
|
+
if (save === void 0) return;
|
|
342
|
+
const lastActivityAt = yield* now;
|
|
343
|
+
yield* save({
|
|
344
|
+
id,
|
|
345
|
+
machineType: persistence.machineType ?? "unknown",
|
|
346
|
+
createdAt,
|
|
347
|
+
lastActivityAt,
|
|
348
|
+
version,
|
|
349
|
+
stateTag: state._tag
|
|
350
|
+
}).pipe(Effect.catchAll((e) => Effect.logWarning(`Failed to save metadata for actor ${id}`, e)));
|
|
351
|
+
});
|
|
352
|
+
/**
|
|
353
|
+
* Restore an actor from persistence.
|
|
354
|
+
* Returns None if no persisted state exists.
|
|
355
|
+
*/
|
|
356
|
+
const restorePersistentActor = Effect.fn("effect-machine.persistentActor.restore")(function* (id, persistentMachine) {
|
|
357
|
+
const adapter = yield* PersistenceAdapterTag;
|
|
358
|
+
const { persistence } = persistentMachine;
|
|
359
|
+
const maybeSnapshot = yield* adapter.loadSnapshot(id, persistence.stateSchema);
|
|
360
|
+
const events = yield* adapter.loadEvents(id, persistence.eventSchema, Option.isSome(maybeSnapshot) ? maybeSnapshot.value.version : void 0);
|
|
361
|
+
if (Option.isNone(maybeSnapshot) && events.length === 0) return Option.none();
|
|
362
|
+
const actor = yield* createPersistentActor(id, persistentMachine, maybeSnapshot, events);
|
|
363
|
+
return Option.some(actor);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
//#endregion
|
|
367
|
+
export { createPersistentActor, restorePersistentActor };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { EventBrand, StateBrand } from "../internal/brands.js";
|
|
2
|
+
import { Machine } from "../machine.js";
|
|
3
|
+
import { Schedule, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src-v3/persistence/persistent-machine.d.ts
|
|
6
|
+
type BrandedState = {
|
|
7
|
+
readonly _tag: string;
|
|
8
|
+
} & StateBrand;
|
|
9
|
+
type BrandedEvent = {
|
|
10
|
+
readonly _tag: string;
|
|
11
|
+
} & EventBrand;
|
|
12
|
+
/**
|
|
13
|
+
* Configuration for persistence behavior (after resolution).
|
|
14
|
+
* Schemas are required at runtime - the persist function ensures this.
|
|
15
|
+
*
|
|
16
|
+
* Note: Schema types S and E should match the structural shape of the machine's
|
|
17
|
+
* state and event types (without brands). The schemas don't know about brands.
|
|
18
|
+
*/
|
|
19
|
+
interface PersistenceConfig<S, E, SSI = unknown, ESI = unknown> {
|
|
20
|
+
/**
|
|
21
|
+
* Schedule controlling when snapshots are taken.
|
|
22
|
+
* Input is the new state after each transition.
|
|
23
|
+
*
|
|
24
|
+
* Examples:
|
|
25
|
+
* - Schedule.forever — snapshot every transition
|
|
26
|
+
* - Schedule.spaced("5 seconds") — debounced snapshots
|
|
27
|
+
* - Schedule.recurs(100) — every N transitions
|
|
28
|
+
*/
|
|
29
|
+
readonly snapshotSchedule: Schedule.Schedule<unknown, S>;
|
|
30
|
+
/**
|
|
31
|
+
* Whether to journal events for replay capability.
|
|
32
|
+
* When true, all events are appended to the event log.
|
|
33
|
+
*/
|
|
34
|
+
readonly journalEvents: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Schema for serializing/deserializing state.
|
|
37
|
+
* Always present at runtime (resolved from config or machine).
|
|
38
|
+
*/
|
|
39
|
+
readonly stateSchema: Schema.Schema<S, SSI, never>;
|
|
40
|
+
/**
|
|
41
|
+
* Schema for serializing/deserializing events.
|
|
42
|
+
* Always present at runtime (resolved from config or machine).
|
|
43
|
+
*/
|
|
44
|
+
readonly eventSchema: Schema.Schema<E, ESI, never>;
|
|
45
|
+
/**
|
|
46
|
+
* User-provided identifier for the machine type.
|
|
47
|
+
* Used for filtering actors in restoreAll.
|
|
48
|
+
* Optional — defaults to "unknown" if not provided.
|
|
49
|
+
*/
|
|
50
|
+
readonly machineType?: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Machine with persistence configuration attached.
|
|
54
|
+
* Spawn auto-detects this and returns PersistentActorRef.
|
|
55
|
+
*/
|
|
56
|
+
interface PersistentMachine<S extends {
|
|
57
|
+
readonly _tag: string;
|
|
58
|
+
}, E extends {
|
|
59
|
+
readonly _tag: string;
|
|
60
|
+
}, R = never> {
|
|
61
|
+
readonly _tag: "PersistentMachine";
|
|
62
|
+
readonly machine: Machine<S, E, R>;
|
|
63
|
+
readonly persistence: PersistenceConfig<S, E>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Type guard to check if a value is a PersistentMachine
|
|
67
|
+
*/
|
|
68
|
+
declare const isPersistentMachine: (value: unknown) => value is PersistentMachine<{
|
|
69
|
+
readonly _tag: string;
|
|
70
|
+
}, {
|
|
71
|
+
readonly _tag: string;
|
|
72
|
+
}, unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* Attach persistence configuration to a machine.
|
|
75
|
+
*
|
|
76
|
+
* Schemas are read from the machine - must use `Machine.make({ state, event, initial })`.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* const orderMachine = Machine.make({
|
|
81
|
+
* state: OrderState,
|
|
82
|
+
* event: OrderEvent,
|
|
83
|
+
* initial: OrderState.Idle(),
|
|
84
|
+
* }).pipe(
|
|
85
|
+
* Machine.on(OrderState.Idle, OrderEvent.Submit, ({ event }) =>
|
|
86
|
+
* OrderState.Pending({ orderId: event.orderId })
|
|
87
|
+
* ),
|
|
88
|
+
* Machine.final(OrderState.Paid),
|
|
89
|
+
* Machine.persist({
|
|
90
|
+
* snapshotSchedule: Schedule.forever,
|
|
91
|
+
* journalEvents: true,
|
|
92
|
+
* }),
|
|
93
|
+
* );
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
interface WithPersistenceConfig {
|
|
97
|
+
readonly snapshotSchedule: Schedule.Schedule<unknown, {
|
|
98
|
+
readonly _tag: string;
|
|
99
|
+
}>;
|
|
100
|
+
readonly journalEvents: boolean;
|
|
101
|
+
readonly machineType?: string;
|
|
102
|
+
}
|
|
103
|
+
declare const persist: (config: WithPersistenceConfig) => <S extends BrandedState, E extends BrandedEvent, R>(machine: Machine<S, E, R>) => PersistentMachine<S, E, R>;
|
|
104
|
+
//#endregion
|
|
105
|
+
export { PersistenceConfig, PersistentMachine, WithPersistenceConfig, isPersistentMachine, persist };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { MissingSchemaError } from "../errors.js";
|
|
2
|
+
|
|
3
|
+
//#region src-v3/persistence/persistent-machine.ts
|
|
4
|
+
/**
|
|
5
|
+
* Type guard to check if a value is a PersistentMachine
|
|
6
|
+
*/
|
|
7
|
+
const isPersistentMachine = (value) => typeof value === "object" && value !== null && "_tag" in value && value._tag === "PersistentMachine";
|
|
8
|
+
const persist = (config) => (machine) => {
|
|
9
|
+
const stateSchema = machine.stateSchema;
|
|
10
|
+
const eventSchema = machine.eventSchema;
|
|
11
|
+
if (stateSchema === void 0 || eventSchema === void 0) throw new MissingSchemaError({ operation: "persist" });
|
|
12
|
+
return {
|
|
13
|
+
_tag: "PersistentMachine",
|
|
14
|
+
machine,
|
|
15
|
+
persistence: {
|
|
16
|
+
...config,
|
|
17
|
+
stateSchema,
|
|
18
|
+
eventSchema
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
export { isPersistentMachine, persist };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { FullEventBrand, FullStateBrand } from "./internal/brands.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src-v3/schema.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Extract the TypeScript type from a TaggedStruct schema
|
|
7
|
+
*/
|
|
8
|
+
type TaggedStructType<Tag extends string, Fields extends Schema.Struct.Fields> = Schema.Schema.Type<Schema.TaggedStruct<Tag, Fields>>;
|
|
9
|
+
/**
|
|
10
|
+
* Build variant schemas type from definition
|
|
11
|
+
*/
|
|
12
|
+
type VariantSchemas<D extends Record<string, Schema.Struct.Fields>> = { readonly [K in keyof D & string]: Schema.TaggedStruct<K, D[K]> };
|
|
13
|
+
/**
|
|
14
|
+
* Build union type from variant schemas.
|
|
15
|
+
* Used for constraining fluent method type params.
|
|
16
|
+
*/
|
|
17
|
+
type VariantsUnion<D extends Record<string, Schema.Struct.Fields>> = { [K in keyof D & string]: TaggedStructType<K, D[K]> }[keyof D & string];
|
|
18
|
+
/**
|
|
19
|
+
* Check if fields are empty (no required properties)
|
|
20
|
+
*/
|
|
21
|
+
type IsEmptyFields<Fields extends Schema.Struct.Fields> = keyof Fields extends never ? true : false;
|
|
22
|
+
/**
|
|
23
|
+
* Constructor functions for each variant.
|
|
24
|
+
* Empty structs: plain values with `_tag`: `State.Idle`
|
|
25
|
+
* Non-empty structs require args: `State.Loading({ url })`
|
|
26
|
+
*
|
|
27
|
+
* Each variant also has a `derive` method for constructing from a source object.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Constructor functions for each variant.
|
|
31
|
+
* Empty structs: plain values with `_tag`: `State.Idle`
|
|
32
|
+
* Non-empty structs require args: `State.Loading({ url })`
|
|
33
|
+
*
|
|
34
|
+
* Each variant also has a `derive` method for constructing from a source object.
|
|
35
|
+
* The source type uses `object` to accept branded state types without index signature issues.
|
|
36
|
+
*/
|
|
37
|
+
type VariantConstructors<D extends Record<string, Schema.Struct.Fields>, Brand> = { readonly [K in keyof D & string]: IsEmptyFields<D[K]> extends true ? TaggedStructType<K, D[K]> & Brand & {
|
|
38
|
+
readonly derive: (source: object) => TaggedStructType<K, D[K]> & Brand;
|
|
39
|
+
} : ((args: Schema.Struct.Constructor<D[K]>) => TaggedStructType<K, D[K]> & Brand) & {
|
|
40
|
+
readonly derive: (source: object, partial?: Partial<Schema.Struct.Constructor<D[K]>>) => TaggedStructType<K, D[K]> & Brand;
|
|
41
|
+
readonly _tag: K;
|
|
42
|
+
} };
|
|
43
|
+
/**
|
|
44
|
+
* Pattern matching cases type
|
|
45
|
+
*/
|
|
46
|
+
type MatchCases<D extends Record<string, Schema.Struct.Fields>, R> = { readonly [K in keyof D & string]: (value: TaggedStructType<K, D[K]>) => R };
|
|
47
|
+
/**
|
|
48
|
+
* Base schema interface with pattern matching helpers
|
|
49
|
+
*/
|
|
50
|
+
interface MachineSchemaBase<D extends Record<string, Schema.Struct.Fields>, Brand> {
|
|
51
|
+
/**
|
|
52
|
+
* Raw definition record for introspection
|
|
53
|
+
*/
|
|
54
|
+
readonly _definition: D;
|
|
55
|
+
/**
|
|
56
|
+
* Per-variant schemas for fine-grained operations
|
|
57
|
+
*/
|
|
58
|
+
readonly variants: VariantSchemas<D>;
|
|
59
|
+
/**
|
|
60
|
+
* Type guard: `OrderState.$is("Pending")(value)`
|
|
61
|
+
*/
|
|
62
|
+
readonly $is: <Tag extends keyof D & string>(tag: Tag) => (u: unknown) => u is TaggedStructType<Tag, D[Tag]> & Brand;
|
|
63
|
+
/**
|
|
64
|
+
* Pattern matching (curried and uncurried)
|
|
65
|
+
*/
|
|
66
|
+
readonly $match: {
|
|
67
|
+
<R>(cases: MatchCases<D, R>): (value: VariantsUnion<D> & Brand) => R;
|
|
68
|
+
<R>(value: VariantsUnion<D> & Brand, cases: MatchCases<D, R>): R;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Schema-first state definition that provides:
|
|
73
|
+
* - Schema for encode/decode/validate
|
|
74
|
+
* - Variant constructors: `OrderState.Pending({ orderId: "x" })`
|
|
75
|
+
* - Pattern matching: `$is`, `$match`
|
|
76
|
+
* - Type inference: `typeof OrderState.Type`
|
|
77
|
+
*
|
|
78
|
+
* The D type parameter captures the definition, creating a unique brand
|
|
79
|
+
* per distinct schema definition shape.
|
|
80
|
+
*/
|
|
81
|
+
type MachineStateSchema<D extends Record<string, Schema.Struct.Fields>> = Schema.Schema<VariantsUnion<D> & FullStateBrand<D>, VariantsUnion<D>, never> & MachineSchemaBase<D, FullStateBrand<D>> & VariantConstructors<D, FullStateBrand<D>>;
|
|
82
|
+
/**
|
|
83
|
+
* Schema-first event definition (same structure as state, different brand)
|
|
84
|
+
*
|
|
85
|
+
* The D type parameter captures the definition, creating a unique brand
|
|
86
|
+
* per distinct schema definition shape.
|
|
87
|
+
*/
|
|
88
|
+
type MachineEventSchema<D extends Record<string, Schema.Struct.Fields>> = Schema.Schema<VariantsUnion<D> & FullEventBrand<D>, VariantsUnion<D>, never> & MachineSchemaBase<D, FullEventBrand<D>> & VariantConstructors<D, FullEventBrand<D>>;
|
|
89
|
+
/**
|
|
90
|
+
* Create a schema-first State definition.
|
|
91
|
+
*
|
|
92
|
+
* The schema's definition type D creates a unique brand, preventing
|
|
93
|
+
* accidental use of constructors from different state schemas
|
|
94
|
+
* (unless they have identical definitions).
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* const OrderState = MachineSchema.State({
|
|
99
|
+
* Pending: { orderId: Schema.String },
|
|
100
|
+
* Shipped: { trackingId: Schema.String },
|
|
101
|
+
* })
|
|
102
|
+
*
|
|
103
|
+
* type OrderState = typeof OrderState.Type
|
|
104
|
+
*
|
|
105
|
+
* // Construct
|
|
106
|
+
* const s = OrderState.Pending({ orderId: "123" })
|
|
107
|
+
*
|
|
108
|
+
* // Pattern match
|
|
109
|
+
* OrderState.$match(s, {
|
|
110
|
+
* Pending: (v) => v.orderId,
|
|
111
|
+
* Shipped: (v) => v.trackingId,
|
|
112
|
+
* })
|
|
113
|
+
*
|
|
114
|
+
* // Validate
|
|
115
|
+
* Schema.decodeUnknownSync(OrderState)(rawJson)
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
declare const State: <const D extends Record<string, Schema.Struct.Fields>>(definition: D) => MachineStateSchema<D>;
|
|
119
|
+
/**
|
|
120
|
+
* Create a schema-first Event definition.
|
|
121
|
+
*
|
|
122
|
+
* The schema's definition type D creates a unique brand, preventing
|
|
123
|
+
* accidental use of constructors from different event schemas
|
|
124
|
+
* (unless they have identical definitions).
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* const OrderEvent = MachineSchema.Event({
|
|
129
|
+
* Ship: { trackingId: Schema.String },
|
|
130
|
+
* Cancel: {},
|
|
131
|
+
* })
|
|
132
|
+
*
|
|
133
|
+
* type OrderEvent = typeof OrderEvent.Type
|
|
134
|
+
*
|
|
135
|
+
* // Construct
|
|
136
|
+
* const e = OrderEvent.Ship({ trackingId: "abc" })
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
declare const Event: <const D extends Record<string, Schema.Struct.Fields>>(definition: D) => MachineEventSchema<D>;
|
|
140
|
+
//#endregion
|
|
141
|
+
export { Event, MachineEventSchema, MachineStateSchema, State, VariantsUnion };
|