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.
- package/README.md +24 -0
- package/dist/_virtual/_rolldown/runtime.js +18 -0
- package/dist/actor.d.ts +256 -0
- package/dist/actor.js +402 -0
- package/dist/cluster/entity-machine.d.ts +90 -0
- package/dist/cluster/entity-machine.js +80 -0
- package/dist/cluster/index.d.ts +3 -0
- package/dist/cluster/index.js +4 -0
- package/dist/cluster/to-entity.d.ts +64 -0
- package/dist/cluster/to-entity.js +53 -0
- package/dist/errors.d.ts +61 -0
- package/dist/errors.js +38 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/inspection.d.ts +125 -0
- package/dist/inspection.js +50 -0
- package/dist/internal/brands.d.ts +40 -0
- package/dist/internal/brands.js +0 -0
- package/dist/internal/inspection.d.ts +11 -0
- package/dist/internal/inspection.js +15 -0
- package/dist/internal/transition.d.ts +160 -0
- package/dist/internal/transition.js +238 -0
- package/dist/internal/utils.d.ts +60 -0
- package/dist/internal/utils.js +46 -0
- package/dist/machine.d.ts +278 -0
- package/dist/machine.js +317 -0
- package/{src/persistence/adapter.ts → dist/persistence/adapter.d.ts} +40 -72
- package/dist/persistence/adapter.js +27 -0
- package/dist/persistence/adapters/in-memory.d.ts +32 -0
- package/dist/persistence/adapters/in-memory.js +176 -0
- package/dist/persistence/index.d.ts +5 -0
- package/dist/persistence/index.js +6 -0
- package/dist/persistence/persistent-actor.d.ts +50 -0
- package/dist/persistence/persistent-actor.js +358 -0
- package/{src/persistence/persistent-machine.ts → dist/persistence/persistent-machine.d.ts} +28 -54
- package/dist/persistence/persistent-machine.js +24 -0
- package/dist/schema.d.ts +141 -0
- package/dist/schema.js +165 -0
- package/dist/slot.d.ts +130 -0
- package/dist/slot.js +99 -0
- package/dist/testing.d.ts +142 -0
- package/dist/testing.js +138 -0
- package/package.json +28 -14
- package/src/actor.ts +0 -1058
- package/src/cluster/entity-machine.ts +0 -201
- package/src/cluster/index.ts +0 -43
- package/src/cluster/to-entity.ts +0 -99
- package/src/errors.ts +0 -64
- package/src/index.ts +0 -105
- package/src/inspection.ts +0 -178
- package/src/internal/brands.ts +0 -51
- package/src/internal/inspection.ts +0 -18
- package/src/internal/transition.ts +0 -489
- package/src/internal/utils.ts +0 -80
- package/src/machine.ts +0 -836
- package/src/persistence/adapters/in-memory.ts +0 -294
- package/src/persistence/index.ts +0 -24
- package/src/persistence/persistent-actor.ts +0 -791
- package/src/schema.ts +0 -362
- package/src/slot.ts +0 -281
- package/src/testing.ts +0 -284
- package/tsconfig.json +0 -65
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { PersistenceAdapterTag, PersistenceError, VersionConflictError } from "../adapter.js";
|
|
2
|
+
import { Effect, Layer, Option, Ref, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/persistence/adapters/in-memory.ts
|
|
5
|
+
/**
|
|
6
|
+
* Create an in-memory persistence adapter.
|
|
7
|
+
* Useful for testing and development.
|
|
8
|
+
*/
|
|
9
|
+
const make = Effect.gen(function* () {
|
|
10
|
+
const storage = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
11
|
+
const registry = yield* Ref.make(/* @__PURE__ */ new Map());
|
|
12
|
+
const getOrCreateStorage = Effect.fn("effect-machine.persistence.inMemory.getOrCreateStorage")(function* (id) {
|
|
13
|
+
return yield* Ref.modify(storage, (map) => {
|
|
14
|
+
const existing = map.get(id);
|
|
15
|
+
if (existing !== void 0) return [existing, map];
|
|
16
|
+
const newStorage = {
|
|
17
|
+
snapshot: Option.none(),
|
|
18
|
+
events: []
|
|
19
|
+
};
|
|
20
|
+
const newMap = new Map(map);
|
|
21
|
+
newMap.set(id, newStorage);
|
|
22
|
+
return [newStorage, newMap];
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
const updateStorage = Effect.fn("effect-machine.persistence.inMemory.updateStorage")(function* (id, update) {
|
|
26
|
+
yield* Ref.update(storage, (map) => {
|
|
27
|
+
const existing = map.get(id);
|
|
28
|
+
if (existing === void 0) return map;
|
|
29
|
+
const newMap = new Map(map);
|
|
30
|
+
newMap.set(id, update(existing));
|
|
31
|
+
return newMap;
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
saveSnapshot: Effect.fn("effect-machine.persistence.inMemory.saveSnapshot")(function* (id, snapshot, schema) {
|
|
36
|
+
const actorStorage = yield* getOrCreateStorage(id);
|
|
37
|
+
if (Option.isSome(actorStorage.snapshot)) {
|
|
38
|
+
const existingVersion = actorStorage.snapshot.value.version;
|
|
39
|
+
if (snapshot.version < existingVersion) return yield* new VersionConflictError({
|
|
40
|
+
actorId: id,
|
|
41
|
+
expectedVersion: existingVersion,
|
|
42
|
+
actualVersion: snapshot.version
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const encoded = yield* Schema.encode(schema)(snapshot.state).pipe(Effect.mapError((cause) => new PersistenceError({
|
|
46
|
+
operation: "saveSnapshot",
|
|
47
|
+
actorId: id,
|
|
48
|
+
cause,
|
|
49
|
+
message: "Failed to encode state"
|
|
50
|
+
})));
|
|
51
|
+
yield* updateStorage(id, (s) => ({
|
|
52
|
+
...s,
|
|
53
|
+
snapshot: Option.some({
|
|
54
|
+
data: encoded,
|
|
55
|
+
version: snapshot.version,
|
|
56
|
+
timestamp: snapshot.timestamp
|
|
57
|
+
})
|
|
58
|
+
}));
|
|
59
|
+
}),
|
|
60
|
+
loadSnapshot: Effect.fn("effect-machine.persistence.inMemory.loadSnapshot")(function* (id, schema) {
|
|
61
|
+
const actorStorage = yield* getOrCreateStorage(id);
|
|
62
|
+
if (Option.isNone(actorStorage.snapshot)) return Option.none();
|
|
63
|
+
const stored = actorStorage.snapshot.value;
|
|
64
|
+
const decoded = yield* Schema.decode(schema)(stored.data).pipe(Effect.mapError((cause) => new PersistenceError({
|
|
65
|
+
operation: "loadSnapshot",
|
|
66
|
+
actorId: id,
|
|
67
|
+
cause,
|
|
68
|
+
message: "Failed to decode state"
|
|
69
|
+
})));
|
|
70
|
+
return Option.some({
|
|
71
|
+
state: decoded,
|
|
72
|
+
version: stored.version,
|
|
73
|
+
timestamp: stored.timestamp
|
|
74
|
+
});
|
|
75
|
+
}),
|
|
76
|
+
appendEvent: Effect.fn("effect-machine.persistence.inMemory.appendEvent")(function* (id, event, schema) {
|
|
77
|
+
yield* getOrCreateStorage(id);
|
|
78
|
+
const encoded = yield* Schema.encode(schema)(event.event).pipe(Effect.mapError((cause) => new PersistenceError({
|
|
79
|
+
operation: "appendEvent",
|
|
80
|
+
actorId: id,
|
|
81
|
+
cause,
|
|
82
|
+
message: "Failed to encode event"
|
|
83
|
+
})));
|
|
84
|
+
yield* updateStorage(id, (s) => ({
|
|
85
|
+
...s,
|
|
86
|
+
events: [...s.events, {
|
|
87
|
+
data: encoded,
|
|
88
|
+
version: event.version,
|
|
89
|
+
timestamp: event.timestamp
|
|
90
|
+
}]
|
|
91
|
+
}));
|
|
92
|
+
}),
|
|
93
|
+
loadEvents: Effect.fn("effect-machine.persistence.inMemory.loadEvents")(function* (id, schema, afterVersion) {
|
|
94
|
+
const actorStorage = yield* getOrCreateStorage(id);
|
|
95
|
+
const decoded = [];
|
|
96
|
+
for (const stored of actorStorage.events) {
|
|
97
|
+
if (afterVersion !== void 0 && stored.version <= afterVersion) continue;
|
|
98
|
+
const event = yield* Schema.decode(schema)(stored.data).pipe(Effect.mapError((cause) => new PersistenceError({
|
|
99
|
+
operation: "loadEvents",
|
|
100
|
+
actorId: id,
|
|
101
|
+
cause,
|
|
102
|
+
message: "Failed to decode event"
|
|
103
|
+
})));
|
|
104
|
+
decoded.push({
|
|
105
|
+
event,
|
|
106
|
+
version: stored.version,
|
|
107
|
+
timestamp: stored.timestamp
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return decoded;
|
|
111
|
+
}),
|
|
112
|
+
deleteActor: Effect.fn("effect-machine.persistence.inMemory.deleteActor")(function* (id) {
|
|
113
|
+
yield* Ref.update(storage, (map) => {
|
|
114
|
+
const newMap = new Map(map);
|
|
115
|
+
newMap.delete(id);
|
|
116
|
+
return newMap;
|
|
117
|
+
});
|
|
118
|
+
yield* Ref.update(registry, (map) => {
|
|
119
|
+
const newMap = new Map(map);
|
|
120
|
+
newMap.delete(id);
|
|
121
|
+
return newMap;
|
|
122
|
+
});
|
|
123
|
+
}),
|
|
124
|
+
listActors: Effect.fn("effect-machine.persistence.inMemory.listActors")(function* () {
|
|
125
|
+
const map = yield* Ref.get(registry);
|
|
126
|
+
return Array.from(map.values());
|
|
127
|
+
}),
|
|
128
|
+
saveMetadata: Effect.fn("effect-machine.persistence.inMemory.saveMetadata")(function* (metadata) {
|
|
129
|
+
yield* Ref.update(registry, (map) => {
|
|
130
|
+
const newMap = new Map(map);
|
|
131
|
+
newMap.set(metadata.id, metadata);
|
|
132
|
+
return newMap;
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
deleteMetadata: Effect.fn("effect-machine.persistence.inMemory.deleteMetadata")(function* (id) {
|
|
136
|
+
yield* Ref.update(registry, (map) => {
|
|
137
|
+
const newMap = new Map(map);
|
|
138
|
+
newMap.delete(id);
|
|
139
|
+
return newMap;
|
|
140
|
+
});
|
|
141
|
+
}),
|
|
142
|
+
loadMetadata: Effect.fn("effect-machine.persistence.inMemory.loadMetadata")(function* (id) {
|
|
143
|
+
const meta = (yield* Ref.get(registry)).get(id);
|
|
144
|
+
return meta !== void 0 ? Option.some(meta) : Option.none();
|
|
145
|
+
})
|
|
146
|
+
};
|
|
147
|
+
}).pipe(Effect.withSpan("effect-machine.persistence.inMemory.make"));
|
|
148
|
+
/**
|
|
149
|
+
* Create an in-memory persistence adapter effect.
|
|
150
|
+
* Returns the adapter directly for custom layer composition.
|
|
151
|
+
*/
|
|
152
|
+
const makeInMemoryPersistenceAdapter = make;
|
|
153
|
+
/**
|
|
154
|
+
* In-memory persistence adapter layer.
|
|
155
|
+
* Data is not persisted across process restarts.
|
|
156
|
+
*
|
|
157
|
+
* NOTE: Each `Effect.provide(InMemoryPersistenceAdapter)` creates a NEW adapter
|
|
158
|
+
* with empty storage. For tests that need persistent storage across multiple
|
|
159
|
+
* runPromise calls, use `makeInMemoryPersistenceAdapter` with a shared scope.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* const program = Effect.gen(function* () {
|
|
164
|
+
* const system = yield* ActorSystemService;
|
|
165
|
+
* const actor = yield* system.spawn("my-actor", persistentMachine);
|
|
166
|
+
* // ...
|
|
167
|
+
* }).pipe(
|
|
168
|
+
* Effect.provide(InMemoryPersistenceAdapter),
|
|
169
|
+
* Effect.provide(ActorSystemDefault),
|
|
170
|
+
* );
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
const InMemoryPersistenceAdapter = Layer.effect(PersistenceAdapterTag, make);
|
|
174
|
+
|
|
175
|
+
//#endregion
|
|
176
|
+
export { InMemoryPersistenceAdapter, makeInMemoryPersistenceAdapter };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { PersistenceConfig, PersistentMachine, isPersistentMachine, persist } from "./persistent-machine.js";
|
|
2
|
+
import { PersistentActorRef, createPersistentActor, restorePersistentActor } from "./persistent-actor.js";
|
|
3
|
+
import { ActorMetadata, PersistedEvent, PersistenceAdapter, PersistenceAdapterTag, PersistenceError, RestoreFailure, RestoreResult, Snapshot, VersionConflictError } from "./adapter.js";
|
|
4
|
+
import { InMemoryPersistenceAdapter, makeInMemoryPersistenceAdapter } from "./adapters/in-memory.js";
|
|
5
|
+
export { type ActorMetadata, InMemoryPersistenceAdapter, type PersistedEvent, type PersistenceAdapter, PersistenceAdapterTag, type PersistenceConfig, PersistenceError, type PersistentActorRef, type PersistentMachine, type RestoreFailure, type RestoreResult, type Snapshot, VersionConflictError, createPersistentActor, isPersistentMachine, makeInMemoryPersistenceAdapter, persist, restorePersistentActor };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { isPersistentMachine, persist } from "./persistent-machine.js";
|
|
2
|
+
import { PersistenceAdapterTag, PersistenceError, VersionConflictError } from "./adapter.js";
|
|
3
|
+
import { createPersistentActor, restorePersistentActor } from "./persistent-actor.js";
|
|
4
|
+
import { InMemoryPersistenceAdapter, makeInMemoryPersistenceAdapter } from "./adapters/in-memory.js";
|
|
5
|
+
|
|
6
|
+
export { InMemoryPersistenceAdapter, PersistenceAdapterTag, PersistenceError, VersionConflictError, createPersistentActor, isPersistentMachine, makeInMemoryPersistenceAdapter, persist, restorePersistentActor };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { EffectsDef, GuardsDef, MachineContext } from "../slot.js";
|
|
2
|
+
import { PersistentMachine } from "./persistent-machine.js";
|
|
3
|
+
import { PersistedEvent, PersistenceAdapterTag, PersistenceError, Snapshot, VersionConflictError } from "./adapter.js";
|
|
4
|
+
import { MachineRef } from "../machine.js";
|
|
5
|
+
import { ActorRef } from "../actor.js";
|
|
6
|
+
import { Effect, Option, Scope } from "effect";
|
|
7
|
+
|
|
8
|
+
//#region src/persistence/persistent-actor.d.ts
|
|
9
|
+
/**
|
|
10
|
+
* Extended ActorRef with persistence capabilities
|
|
11
|
+
*/
|
|
12
|
+
interface PersistentActorRef<S extends {
|
|
13
|
+
readonly _tag: string;
|
|
14
|
+
}, E extends {
|
|
15
|
+
readonly _tag: string;
|
|
16
|
+
}, R = never> extends ActorRef<S, E> {
|
|
17
|
+
/**
|
|
18
|
+
* Force an immediate snapshot save
|
|
19
|
+
*/
|
|
20
|
+
readonly persist: Effect.Effect<void, PersistenceError | VersionConflictError>;
|
|
21
|
+
/**
|
|
22
|
+
* Get the current persistence version
|
|
23
|
+
*/
|
|
24
|
+
readonly version: Effect.Effect<number>;
|
|
25
|
+
/**
|
|
26
|
+
* Replay events to restore actor to a specific version.
|
|
27
|
+
* Note: This only computes state; does not re-run transition effects.
|
|
28
|
+
*/
|
|
29
|
+
readonly replayTo: (version: number) => Effect.Effect<void, PersistenceError, R>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create a persistent actor from a PersistentMachine.
|
|
33
|
+
* Restores from existing snapshot if available, otherwise starts fresh.
|
|
34
|
+
*/
|
|
35
|
+
declare const createPersistentActor: <S extends {
|
|
36
|
+
readonly _tag: string;
|
|
37
|
+
}, E extends {
|
|
38
|
+
readonly _tag: string;
|
|
39
|
+
}, 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>, PersistenceError, PersistenceAdapterTag | Exclude<R, MachineContext<S, E, MachineRef<E>>> | Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>>;
|
|
40
|
+
/**
|
|
41
|
+
* Restore an actor from persistence.
|
|
42
|
+
* Returns None if no persisted state exists.
|
|
43
|
+
*/
|
|
44
|
+
declare const restorePersistentActor: <S extends {
|
|
45
|
+
readonly _tag: string;
|
|
46
|
+
}, E extends {
|
|
47
|
+
readonly _tag: string;
|
|
48
|
+
}, R>(id: string, persistentMachine: PersistentMachine<S, E, R>) => Effect.Effect<Option.None<PersistentActorRef<S, E, R>> | Option.Some<PersistentActorRef<S, E, R>>, PersistenceError, PersistenceAdapterTag | Exclude<R, MachineContext<S, E, MachineRef<E>>> | Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>>;
|
|
49
|
+
//#endregion
|
|
50
|
+
export { PersistentActorRef, createPersistentActor, restorePersistentActor };
|
|
@@ -0,0 +1,358 @@
|
|
|
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/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) => {
|
|
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),
|
|
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 self = {
|
|
96
|
+
send: Effect.fn("effect-machine.persistentActor.self.send")(function* (event) {
|
|
97
|
+
if (yield* Ref.get(stoppedRef)) return;
|
|
98
|
+
yield* Queue.offer(eventQueue, event);
|
|
99
|
+
}),
|
|
100
|
+
spawn: (childId, childMachine) => system.spawn(childId, childMachine).pipe(Effect.provideService(ActorSystem, system))
|
|
101
|
+
};
|
|
102
|
+
let resolvedInitial;
|
|
103
|
+
let initialVersion;
|
|
104
|
+
if (Option.isSome(initialSnapshot)) {
|
|
105
|
+
const result = yield* replayEvents(typedMachine, initialSnapshot.value.state, initialEvents, self);
|
|
106
|
+
resolvedInitial = result.state;
|
|
107
|
+
initialVersion = initialEvents.length > 0 ? result.version : initialSnapshot.value.version;
|
|
108
|
+
} else if (initialEvents.length > 0) {
|
|
109
|
+
const result = yield* replayEvents(typedMachine, typedMachine.initial, initialEvents, self);
|
|
110
|
+
resolvedInitial = result.state;
|
|
111
|
+
initialVersion = result.version;
|
|
112
|
+
} else {
|
|
113
|
+
resolvedInitial = typedMachine.initial;
|
|
114
|
+
initialVersion = 0;
|
|
115
|
+
}
|
|
116
|
+
yield* Effect.annotateCurrentSpan("effect_machine.actor.initial_state", resolvedInitial._tag);
|
|
117
|
+
const stateRef = yield* SubscriptionRef.make(resolvedInitial);
|
|
118
|
+
const versionRef = yield* Ref.make(initialVersion);
|
|
119
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
120
|
+
let createdAt;
|
|
121
|
+
if (Option.isSome(initialSnapshot)) {
|
|
122
|
+
const existingMeta = adapter.loadMetadata !== void 0 ? yield* adapter.loadMetadata(id) : Option.none();
|
|
123
|
+
createdAt = Option.isSome(existingMeta) ? existingMeta.value.createdAt : initialSnapshot.value.timestamp;
|
|
124
|
+
} else createdAt = yield* now;
|
|
125
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
126
|
+
type: "@machine.spawn",
|
|
127
|
+
actorId: id,
|
|
128
|
+
initialState: resolvedInitial,
|
|
129
|
+
timestamp
|
|
130
|
+
}));
|
|
131
|
+
const snapshotEnabledRef = yield* Ref.make(true);
|
|
132
|
+
const persistenceQueue = yield* Queue.unbounded();
|
|
133
|
+
const persistenceFiber = yield* Effect.forkDaemon(persistenceWorker(persistenceQueue));
|
|
134
|
+
yield* Queue.offer(persistenceQueue, saveMetadata(id, resolvedInitial, initialVersion, createdAt, persistence, adapter));
|
|
135
|
+
const snapshotQueue = yield* Queue.unbounded();
|
|
136
|
+
const snapshotFiber = yield* Effect.forkDaemon(snapshotWorker(id, persistence, adapter, snapshotQueue, snapshotEnabledRef));
|
|
137
|
+
const backgroundFibers = [];
|
|
138
|
+
const initEvent = { _tag: INTERNAL_INIT_EVENT };
|
|
139
|
+
const initCtx = {
|
|
140
|
+
state: resolvedInitial,
|
|
141
|
+
event: initEvent,
|
|
142
|
+
self,
|
|
143
|
+
system
|
|
144
|
+
};
|
|
145
|
+
const { effects: effectSlots } = typedMachine._slots;
|
|
146
|
+
for (const bg of typedMachine.backgroundEffects) {
|
|
147
|
+
const fiber = yield* Effect.forkDaemon(bg.handler({
|
|
148
|
+
state: resolvedInitial,
|
|
149
|
+
event: initEvent,
|
|
150
|
+
self,
|
|
151
|
+
effects: effectSlots,
|
|
152
|
+
system
|
|
153
|
+
}).pipe(Effect.provideService(typedMachine.Context, initCtx)));
|
|
154
|
+
backgroundFibers.push(fiber);
|
|
155
|
+
}
|
|
156
|
+
const stateScopeRef = { current: yield* Scope.make() };
|
|
157
|
+
yield* runSpawnEffectsWithInspection(typedMachine, resolvedInitial, initEvent, self, stateScopeRef.current, id, inspector, system);
|
|
158
|
+
if (typedMachine.finalStates.has(resolvedInitial._tag)) {
|
|
159
|
+
yield* Scope.close(stateScopeRef.current, Exit.void);
|
|
160
|
+
yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
|
|
161
|
+
yield* Fiber.interrupt(snapshotFiber);
|
|
162
|
+
yield* Fiber.interrupt(persistenceFiber);
|
|
163
|
+
yield* Ref.set(stoppedRef, true);
|
|
164
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
165
|
+
type: "@machine.stop",
|
|
166
|
+
actorId: id,
|
|
167
|
+
finalState: resolvedInitial,
|
|
168
|
+
timestamp
|
|
169
|
+
}));
|
|
170
|
+
return buildPersistentActorRef(id, persistentMachine, stateRef, versionRef, eventQueue, stoppedRef, listeners, Ref.set(stoppedRef, true).pipe(Effect.withSpan("effect-machine.persistentActor.stop"), Effect.asVoid), adapter, system);
|
|
171
|
+
}
|
|
172
|
+
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));
|
|
173
|
+
return buildPersistentActorRef(id, persistentMachine, stateRef, versionRef, eventQueue, stoppedRef, listeners, Effect.gen(function* () {
|
|
174
|
+
const finalState = yield* SubscriptionRef.get(stateRef);
|
|
175
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
176
|
+
type: "@machine.stop",
|
|
177
|
+
actorId: id,
|
|
178
|
+
finalState,
|
|
179
|
+
timestamp
|
|
180
|
+
}));
|
|
181
|
+
yield* Ref.set(stoppedRef, true);
|
|
182
|
+
yield* Fiber.interrupt(loopFiber);
|
|
183
|
+
yield* Scope.close(stateScopeRef.current, Exit.void);
|
|
184
|
+
yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
|
|
185
|
+
yield* Fiber.interrupt(snapshotFiber);
|
|
186
|
+
yield* Fiber.interrupt(persistenceFiber);
|
|
187
|
+
}).pipe(Effect.withSpan("effect-machine.persistentActor.stop"), Effect.asVoid), adapter, system);
|
|
188
|
+
});
|
|
189
|
+
/**
|
|
190
|
+
* Main event loop for persistent actor
|
|
191
|
+
*/
|
|
192
|
+
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) {
|
|
193
|
+
const { machine, persistence } = persistentMachine;
|
|
194
|
+
const typedMachine = machine;
|
|
195
|
+
const hooks = inspector === void 0 ? void 0 : {
|
|
196
|
+
onSpawnEffect: (state) => emitWithTimestamp(inspector, (timestamp) => ({
|
|
197
|
+
type: "@machine.effect",
|
|
198
|
+
actorId: id,
|
|
199
|
+
effectType: "spawn",
|
|
200
|
+
state,
|
|
201
|
+
timestamp
|
|
202
|
+
})),
|
|
203
|
+
onTransition: (from, to, ev) => emitWithTimestamp(inspector, (timestamp) => ({
|
|
204
|
+
type: "@machine.transition",
|
|
205
|
+
actorId: id,
|
|
206
|
+
fromState: from,
|
|
207
|
+
toState: to,
|
|
208
|
+
event: ev,
|
|
209
|
+
timestamp
|
|
210
|
+
})),
|
|
211
|
+
onError: (info) => emitWithTimestamp(inspector, (timestamp) => ({
|
|
212
|
+
type: "@machine.error",
|
|
213
|
+
actorId: id,
|
|
214
|
+
phase: info.phase,
|
|
215
|
+
state: info.state,
|
|
216
|
+
event: info.event,
|
|
217
|
+
error: Cause.pretty(info.cause),
|
|
218
|
+
timestamp
|
|
219
|
+
}))
|
|
220
|
+
};
|
|
221
|
+
while (true) {
|
|
222
|
+
const event = yield* Queue.take(eventQueue);
|
|
223
|
+
const currentState = yield* SubscriptionRef.get(stateRef);
|
|
224
|
+
const currentVersion = yield* Ref.get(versionRef);
|
|
225
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
226
|
+
type: "@machine.event",
|
|
227
|
+
actorId: id,
|
|
228
|
+
state: currentState,
|
|
229
|
+
event,
|
|
230
|
+
timestamp
|
|
231
|
+
}));
|
|
232
|
+
const result = yield* processEventCore(typedMachine, currentState, event, self, stateScopeRef, system, hooks);
|
|
233
|
+
if (!result.transitioned) continue;
|
|
234
|
+
const newVersion = currentVersion + 1;
|
|
235
|
+
yield* Ref.set(versionRef, newVersion);
|
|
236
|
+
yield* SubscriptionRef.set(stateRef, result.newState);
|
|
237
|
+
notifyListeners(listeners, result.newState);
|
|
238
|
+
if (persistence.journalEvents) {
|
|
239
|
+
const persistedEvent = {
|
|
240
|
+
event,
|
|
241
|
+
version: newVersion,
|
|
242
|
+
timestamp: yield* now
|
|
243
|
+
};
|
|
244
|
+
const journalTask = adapter.appendEvent(id, persistedEvent, persistence.eventSchema).pipe(Effect.catchAll((e) => Effect.logWarning(`Failed to journal event for actor ${id}`, e)), Effect.asVoid);
|
|
245
|
+
yield* Queue.offer(persistenceQueue, journalTask);
|
|
246
|
+
}
|
|
247
|
+
yield* Queue.offer(persistenceQueue, saveMetadata(id, result.newState, newVersion, createdAt, persistence, adapter));
|
|
248
|
+
if (yield* Ref.get(snapshotEnabledRef)) yield* Queue.offer(snapshotQueue, {
|
|
249
|
+
state: result.newState,
|
|
250
|
+
version: newVersion
|
|
251
|
+
});
|
|
252
|
+
if (result.lifecycleRan && result.isFinal) {
|
|
253
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
254
|
+
type: "@machine.stop",
|
|
255
|
+
actorId: id,
|
|
256
|
+
finalState: result.newState,
|
|
257
|
+
timestamp
|
|
258
|
+
}));
|
|
259
|
+
yield* Ref.set(stoppedRef, true);
|
|
260
|
+
yield* Scope.close(stateScopeRef.current, Exit.void);
|
|
261
|
+
yield* Effect.all(backgroundFibers.map(Fiber.interrupt), { concurrency: "unbounded" });
|
|
262
|
+
yield* Fiber.interrupt(snapshotFiber);
|
|
263
|
+
yield* Fiber.interrupt(persistenceFiber);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
/**
|
|
269
|
+
* Run spawn effects with inspection and tracing.
|
|
270
|
+
* @internal
|
|
271
|
+
*/
|
|
272
|
+
const runSpawnEffectsWithInspection = Effect.fn("effect-machine.persistentActor.spawnEffects")(function* (machine, state, event, self, stateScope, actorId, inspector, system) {
|
|
273
|
+
yield* emitWithTimestamp(inspector, (timestamp) => ({
|
|
274
|
+
type: "@machine.effect",
|
|
275
|
+
actorId,
|
|
276
|
+
effectType: "spawn",
|
|
277
|
+
state,
|
|
278
|
+
timestamp
|
|
279
|
+
}));
|
|
280
|
+
yield* runSpawnEffects(machine, state, event, self, stateScope, system, inspector === void 0 ? void 0 : (info) => emitWithTimestamp(inspector, (timestamp) => ({
|
|
281
|
+
type: "@machine.error",
|
|
282
|
+
actorId,
|
|
283
|
+
phase: info.phase,
|
|
284
|
+
state: info.state,
|
|
285
|
+
event: info.event,
|
|
286
|
+
error: Cause.pretty(info.cause),
|
|
287
|
+
timestamp
|
|
288
|
+
})));
|
|
289
|
+
});
|
|
290
|
+
/**
|
|
291
|
+
* Persistence worker (journaling + metadata).
|
|
292
|
+
*/
|
|
293
|
+
const persistenceWorker = Effect.fn("effect-machine.persistentActor.persistenceWorker")(function* (queue) {
|
|
294
|
+
while (true) yield* yield* Queue.take(queue);
|
|
295
|
+
});
|
|
296
|
+
/**
|
|
297
|
+
* Snapshot scheduler worker (runs in background).
|
|
298
|
+
*/
|
|
299
|
+
const snapshotWorker = Effect.fn("effect-machine.persistentActor.snapshotWorker")(function* (id, persistence, adapter, queue, enabledRef) {
|
|
300
|
+
const driver = yield* Schedule.driver(persistence.snapshotSchedule);
|
|
301
|
+
while (true) {
|
|
302
|
+
const { state, version } = yield* Queue.take(queue);
|
|
303
|
+
if (!(yield* Ref.get(enabledRef))) continue;
|
|
304
|
+
if (!(yield* driver.next(state).pipe(Effect.match({
|
|
305
|
+
onFailure: () => false,
|
|
306
|
+
onSuccess: () => true
|
|
307
|
+
})))) {
|
|
308
|
+
yield* Ref.set(enabledRef, false);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
yield* saveSnapshot(id, state, version, persistence, adapter);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
/**
|
|
315
|
+
* Save a snapshot after state transition.
|
|
316
|
+
* Called by snapshot scheduler.
|
|
317
|
+
*/
|
|
318
|
+
const saveSnapshot = Effect.fn("effect-machine.persistentActor.saveSnapshot")(function* (id, state, version, persistence, adapter) {
|
|
319
|
+
const snapshot = {
|
|
320
|
+
state,
|
|
321
|
+
version,
|
|
322
|
+
timestamp: yield* now
|
|
323
|
+
};
|
|
324
|
+
yield* adapter.saveSnapshot(id, snapshot, persistence.stateSchema).pipe(Effect.catchAll((e) => Effect.logWarning(`Failed to save snapshot for actor ${id}`, e)));
|
|
325
|
+
});
|
|
326
|
+
/**
|
|
327
|
+
* Save or update actor metadata if adapter supports registry.
|
|
328
|
+
* Called on spawn and state transitions.
|
|
329
|
+
*/
|
|
330
|
+
const saveMetadata = Effect.fn("effect-machine.persistentActor.saveMetadata")(function* (id, state, version, createdAt, persistence, adapter) {
|
|
331
|
+
const save = adapter.saveMetadata;
|
|
332
|
+
if (save === void 0) return;
|
|
333
|
+
const lastActivityAt = yield* now;
|
|
334
|
+
yield* save({
|
|
335
|
+
id,
|
|
336
|
+
machineType: persistence.machineType ?? "unknown",
|
|
337
|
+
createdAt,
|
|
338
|
+
lastActivityAt,
|
|
339
|
+
version,
|
|
340
|
+
stateTag: state._tag
|
|
341
|
+
}).pipe(Effect.catchAll((e) => Effect.logWarning(`Failed to save metadata for actor ${id}`, e)));
|
|
342
|
+
});
|
|
343
|
+
/**
|
|
344
|
+
* Restore an actor from persistence.
|
|
345
|
+
* Returns None if no persisted state exists.
|
|
346
|
+
*/
|
|
347
|
+
const restorePersistentActor = Effect.fn("effect-machine.persistentActor.restore")(function* (id, persistentMachine) {
|
|
348
|
+
const adapter = yield* PersistenceAdapterTag;
|
|
349
|
+
const { persistence } = persistentMachine;
|
|
350
|
+
const maybeSnapshot = yield* adapter.loadSnapshot(id, persistence.stateSchema);
|
|
351
|
+
const events = yield* adapter.loadEvents(id, persistence.eventSchema, Option.isSome(maybeSnapshot) ? maybeSnapshot.value.version : void 0);
|
|
352
|
+
if (Option.isNone(maybeSnapshot) && events.length === 0) return Option.none();
|
|
353
|
+
const actor = yield* createPersistentActor(id, persistentMachine, maybeSnapshot, events);
|
|
354
|
+
return Option.some(actor);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
//#endregion
|
|
358
|
+
export { createPersistentActor, restorePersistentActor };
|