effect-machine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,169 @@
1
+ import { Context, Schema } from "effect";
2
+ import type { Effect, Option } from "effect";
3
+
4
+ import type { PersistentActorRef } from "./persistent-actor.js";
5
+ import type { DuplicateActorError } from "../errors.js";
6
+
7
+ /**
8
+ * Metadata for a persisted actor.
9
+ * Used for discovery and filtering during bulk restore.
10
+ */
11
+ export interface ActorMetadata {
12
+ readonly id: string;
13
+ /** User-provided identifier for the machine type */
14
+ readonly machineType: string;
15
+ readonly createdAt: number;
16
+ readonly lastActivityAt: number;
17
+ readonly version: number;
18
+ /** Current state _tag value */
19
+ readonly stateTag: string;
20
+ }
21
+
22
+ /**
23
+ * Result of a bulk restore operation.
24
+ * Contains both successfully restored actors and failures.
25
+ */
26
+ export interface RestoreResult<
27
+ S extends { readonly _tag: string },
28
+ E extends { readonly _tag: string },
29
+ > {
30
+ readonly restored: ReadonlyArray<PersistentActorRef<S, E>>;
31
+ readonly failed: ReadonlyArray<RestoreFailure>;
32
+ }
33
+
34
+ /**
35
+ * A single restore failure with actor ID and error details.
36
+ */
37
+ export interface RestoreFailure {
38
+ readonly id: string;
39
+ readonly error: PersistenceError | DuplicateActorError;
40
+ }
41
+
42
+ /**
43
+ * Snapshot of actor state at a point in time
44
+ */
45
+ export interface Snapshot<S> {
46
+ readonly state: S;
47
+ readonly version: number;
48
+ readonly timestamp: number;
49
+ }
50
+
51
+ /**
52
+ * Persisted event with metadata
53
+ */
54
+ export interface PersistedEvent<E> {
55
+ readonly event: E;
56
+ readonly version: number;
57
+ readonly timestamp: number;
58
+ }
59
+
60
+ /**
61
+ * Adapter for persisting actor state and events.
62
+ *
63
+ * Implementations handle serialization and storage of snapshots and event journals.
64
+ * Schema parameters ensure type-safe serialization/deserialization.
65
+ * Schemas must have no context requirements (use Schema<S, SI, never>).
66
+ */
67
+ export interface PersistenceAdapter {
68
+ /**
69
+ * Save a snapshot of actor state.
70
+ * Implementations should use optimistic locking — fail if version mismatch.
71
+ */
72
+ readonly saveSnapshot: <S, SI>(
73
+ id: string,
74
+ snapshot: Snapshot<S>,
75
+ schema: Schema.Schema<S, SI, never>,
76
+ ) => Effect.Effect<void, PersistenceError | VersionConflictError>;
77
+
78
+ /**
79
+ * Load the latest snapshot for an actor.
80
+ * Returns None if no snapshot exists.
81
+ */
82
+ readonly loadSnapshot: <S, SI>(
83
+ id: string,
84
+ schema: Schema.Schema<S, SI, never>,
85
+ ) => Effect.Effect<Option.Option<Snapshot<S>>, PersistenceError>;
86
+
87
+ /**
88
+ * Append an event to the actor's event journal.
89
+ */
90
+ readonly appendEvent: <E, EI>(
91
+ id: string,
92
+ event: PersistedEvent<E>,
93
+ schema: Schema.Schema<E, EI, never>,
94
+ ) => Effect.Effect<void, PersistenceError>;
95
+
96
+ /**
97
+ * Load events from the journal, optionally after a specific version.
98
+ */
99
+ readonly loadEvents: <E, EI>(
100
+ id: string,
101
+ schema: Schema.Schema<E, EI, never>,
102
+ afterVersion?: number,
103
+ ) => Effect.Effect<ReadonlyArray<PersistedEvent<E>>, PersistenceError>;
104
+
105
+ /**
106
+ * Delete all persisted data for an actor (snapshot + events).
107
+ */
108
+ readonly deleteActor: (id: string) => Effect.Effect<void, PersistenceError>;
109
+
110
+ // --- Optional registry methods for actor discovery ---
111
+
112
+ /**
113
+ * List all persisted actor metadata.
114
+ * Optional — adapters without registry support can omit this.
115
+ */
116
+ readonly listActors?: () => Effect.Effect<ReadonlyArray<ActorMetadata>, PersistenceError>;
117
+
118
+ /**
119
+ * Save or update actor metadata.
120
+ * Called on spawn and state transitions.
121
+ * Optional — adapters without registry support can omit this.
122
+ */
123
+ readonly saveMetadata?: (metadata: ActorMetadata) => Effect.Effect<void, PersistenceError>;
124
+
125
+ /**
126
+ * Delete actor metadata.
127
+ * Called when actor is deleted.
128
+ * Optional — adapters without registry support can omit this.
129
+ */
130
+ readonly deleteMetadata?: (id: string) => Effect.Effect<void, PersistenceError>;
131
+
132
+ /**
133
+ * Load metadata for a specific actor by ID.
134
+ * Returns None if no metadata exists.
135
+ * Optional — adapters without registry support can omit this.
136
+ */
137
+ readonly loadMetadata?: (
138
+ id: string,
139
+ ) => Effect.Effect<Option.Option<ActorMetadata>, PersistenceError>;
140
+ }
141
+
142
+ /**
143
+ * Error type for persistence operations
144
+ */
145
+ export class PersistenceError extends Schema.TaggedError<PersistenceError>()("PersistenceError", {
146
+ operation: Schema.String,
147
+ actorId: Schema.String,
148
+ cause: Schema.optional(Schema.Unknown),
149
+ message: Schema.optional(Schema.String),
150
+ }) {}
151
+
152
+ /**
153
+ * Version conflict error — snapshot version doesn't match expected
154
+ */
155
+ export class VersionConflictError extends Schema.TaggedError<VersionConflictError>()(
156
+ "VersionConflictError",
157
+ {
158
+ actorId: Schema.String,
159
+ expectedVersion: Schema.Number,
160
+ actualVersion: Schema.Number,
161
+ },
162
+ ) {}
163
+
164
+ /**
165
+ * PersistenceAdapter service tag
166
+ */
167
+ export class PersistenceAdapterTag extends Context.Tag(
168
+ "effect-machine/persistence/adapter/PersistenceAdapterTag",
169
+ )<PersistenceAdapterTag, PersistenceAdapter>() {}
@@ -0,0 +1,275 @@
1
+ import { Effect, Layer, Option, Ref, Schema } from "effect";
2
+
3
+ import type { ActorMetadata, PersistedEvent, PersistenceAdapter, Snapshot } from "../adapter.js";
4
+ import { PersistenceAdapterTag, PersistenceError, VersionConflictError } from "../adapter.js";
5
+
6
+ /**
7
+ * In-memory storage for a single actor
8
+ */
9
+ interface ActorStorage {
10
+ snapshot: Option.Option<{
11
+ readonly data: unknown;
12
+ readonly version: number;
13
+ readonly timestamp: number;
14
+ }>;
15
+ events: Array<{ readonly data: unknown; readonly version: number; readonly timestamp: number }>;
16
+ }
17
+
18
+ /**
19
+ * Create an in-memory persistence adapter.
20
+ * Useful for testing and development.
21
+ */
22
+ const make = Effect.gen(function* () {
23
+ const storage = yield* Ref.make(new Map<string, ActorStorage>());
24
+ const registry = yield* Ref.make(new Map<string, ActorMetadata>());
25
+
26
+ const getOrCreateStorage = (id: string): Effect.Effect<ActorStorage> =>
27
+ Ref.modify(storage, (map) => {
28
+ const existing = map.get(id);
29
+ if (existing !== undefined) {
30
+ return [existing, map];
31
+ }
32
+ const newStorage: ActorStorage = {
33
+ snapshot: Option.none(),
34
+ events: [],
35
+ };
36
+ const newMap = new Map(map);
37
+ newMap.set(id, newStorage);
38
+ return [newStorage, newMap];
39
+ });
40
+
41
+ const updateStorage = (
42
+ id: string,
43
+ update: (storage: ActorStorage) => ActorStorage,
44
+ ): Effect.Effect<void> =>
45
+ Ref.update(storage, (map) => {
46
+ const existing = map.get(id);
47
+ if (existing === undefined) {
48
+ return map;
49
+ }
50
+ const newMap = new Map(map);
51
+ newMap.set(id, update(existing));
52
+ return newMap;
53
+ });
54
+
55
+ const adapter: PersistenceAdapter = {
56
+ saveSnapshot: <S, SI>(
57
+ id: string,
58
+ snapshot: Snapshot<S>,
59
+ schema: Schema.Schema<S, SI, never>,
60
+ ): Effect.Effect<void, PersistenceError | VersionConflictError> =>
61
+ Effect.gen(function* () {
62
+ const actorStorage = yield* getOrCreateStorage(id);
63
+
64
+ // Optimistic locking: check version
65
+ // Reject only if trying to save an older version (strict <)
66
+ // Same-version saves are idempotent (allow retries/multiple callers)
67
+ if (Option.isSome(actorStorage.snapshot)) {
68
+ const existingVersion = actorStorage.snapshot.value.version;
69
+ if (snapshot.version < existingVersion) {
70
+ return yield* new VersionConflictError({
71
+ actorId: id,
72
+ expectedVersion: existingVersion,
73
+ actualVersion: snapshot.version,
74
+ });
75
+ }
76
+ }
77
+
78
+ // Encode state using schema
79
+ const encoded = yield* Schema.encode(schema)(snapshot.state).pipe(
80
+ Effect.mapError(
81
+ (cause) =>
82
+ new PersistenceError({
83
+ operation: "saveSnapshot",
84
+ actorId: id,
85
+ cause,
86
+ message: "Failed to encode state",
87
+ }),
88
+ ),
89
+ );
90
+
91
+ yield* updateStorage(id, (s) => ({
92
+ ...s,
93
+ snapshot: Option.some({
94
+ data: encoded,
95
+ version: snapshot.version,
96
+ timestamp: snapshot.timestamp,
97
+ }),
98
+ }));
99
+ }),
100
+
101
+ loadSnapshot: <S, SI>(
102
+ id: string,
103
+ schema: Schema.Schema<S, SI, never>,
104
+ ): Effect.Effect<Option.Option<Snapshot<S>>, PersistenceError> =>
105
+ Effect.gen(function* () {
106
+ const actorStorage = yield* getOrCreateStorage(id);
107
+
108
+ if (Option.isNone(actorStorage.snapshot)) {
109
+ return Option.none();
110
+ }
111
+
112
+ const stored = actorStorage.snapshot.value;
113
+
114
+ // Decode state using schema
115
+ const decoded = yield* Schema.decode(schema)(stored.data as SI).pipe(
116
+ Effect.mapError(
117
+ (cause) =>
118
+ new PersistenceError({
119
+ operation: "loadSnapshot",
120
+ actorId: id,
121
+ cause,
122
+ message: "Failed to decode state",
123
+ }),
124
+ ),
125
+ );
126
+
127
+ return Option.some({
128
+ state: decoded,
129
+ version: stored.version,
130
+ timestamp: stored.timestamp,
131
+ });
132
+ }),
133
+
134
+ appendEvent: <E, EI>(
135
+ id: string,
136
+ event: PersistedEvent<E>,
137
+ schema: Schema.Schema<E, EI, never>,
138
+ ): Effect.Effect<void, PersistenceError> =>
139
+ Effect.gen(function* () {
140
+ yield* getOrCreateStorage(id);
141
+
142
+ // Encode event using schema
143
+ const encoded = yield* Schema.encode(schema)(event.event).pipe(
144
+ Effect.mapError(
145
+ (cause) =>
146
+ new PersistenceError({
147
+ operation: "appendEvent",
148
+ actorId: id,
149
+ cause,
150
+ message: "Failed to encode event",
151
+ }),
152
+ ),
153
+ );
154
+
155
+ yield* updateStorage(id, (s) => ({
156
+ ...s,
157
+ events: [
158
+ ...s.events,
159
+ {
160
+ data: encoded,
161
+ version: event.version,
162
+ timestamp: event.timestamp,
163
+ },
164
+ ],
165
+ }));
166
+ }),
167
+
168
+ loadEvents: <E, EI>(
169
+ id: string,
170
+ schema: Schema.Schema<E, EI, never>,
171
+ afterVersion?: number,
172
+ ): Effect.Effect<ReadonlyArray<PersistedEvent<E>>, PersistenceError> =>
173
+ Effect.gen(function* () {
174
+ const actorStorage = yield* getOrCreateStorage(id);
175
+
176
+ // Single pass - skip filtered events inline instead of creating intermediate array
177
+ const decoded: PersistedEvent<E>[] = [];
178
+ for (const stored of actorStorage.events) {
179
+ if (afterVersion !== undefined && stored.version <= afterVersion) continue;
180
+
181
+ const event = yield* Schema.decode(schema)(stored.data as EI).pipe(
182
+ Effect.mapError(
183
+ (cause) =>
184
+ new PersistenceError({
185
+ operation: "loadEvents",
186
+ actorId: id,
187
+ cause,
188
+ message: "Failed to decode event",
189
+ }),
190
+ ),
191
+ );
192
+ decoded.push({
193
+ event,
194
+ version: stored.version,
195
+ timestamp: stored.timestamp,
196
+ });
197
+ }
198
+
199
+ return decoded;
200
+ }),
201
+
202
+ deleteActor: (id: string): Effect.Effect<void, PersistenceError> =>
203
+ Effect.gen(function* () {
204
+ yield* Ref.update(storage, (map) => {
205
+ const newMap = new Map(map);
206
+ newMap.delete(id);
207
+ return newMap;
208
+ });
209
+ // Also delete metadata
210
+ yield* Ref.update(registry, (map) => {
211
+ const newMap = new Map(map);
212
+ newMap.delete(id);
213
+ return newMap;
214
+ });
215
+ }),
216
+
217
+ // Registry methods for actor discovery
218
+
219
+ listActors: (): Effect.Effect<ReadonlyArray<ActorMetadata>, PersistenceError> =>
220
+ Effect.map(Ref.get(registry), (map) => Array.from(map.values())),
221
+
222
+ saveMetadata: (metadata: ActorMetadata): Effect.Effect<void, PersistenceError> =>
223
+ Ref.update(registry, (map) => {
224
+ const newMap = new Map(map);
225
+ newMap.set(metadata.id, metadata);
226
+ return newMap;
227
+ }),
228
+
229
+ deleteMetadata: (id: string): Effect.Effect<void, PersistenceError> =>
230
+ Ref.update(registry, (map) => {
231
+ const newMap = new Map(map);
232
+ newMap.delete(id);
233
+ return newMap;
234
+ }),
235
+
236
+ loadMetadata: (id: string): Effect.Effect<Option.Option<ActorMetadata>, PersistenceError> =>
237
+ Effect.map(Ref.get(registry), (map) => {
238
+ const meta = map.get(id);
239
+ return meta !== undefined ? Option.some(meta) : Option.none();
240
+ }),
241
+ };
242
+
243
+ return adapter;
244
+ });
245
+
246
+ /**
247
+ * Create an in-memory persistence adapter effect.
248
+ * Returns the adapter directly for custom layer composition.
249
+ */
250
+ export const makeInMemoryPersistenceAdapter = make;
251
+
252
+ /**
253
+ * In-memory persistence adapter layer.
254
+ * Data is not persisted across process restarts.
255
+ *
256
+ * NOTE: Each `Effect.provide(InMemoryPersistenceAdapter)` creates a NEW adapter
257
+ * with empty storage. For tests that need persistent storage across multiple
258
+ * runPromise calls, use `makeInMemoryPersistenceAdapter` with a shared scope.
259
+ *
260
+ * @example
261
+ * ```ts
262
+ * const program = Effect.gen(function* () {
263
+ * const system = yield* ActorSystemService;
264
+ * const actor = yield* system.spawn("my-actor", persistentMachine);
265
+ * // ...
266
+ * }).pipe(
267
+ * Effect.provide(InMemoryPersistenceAdapter),
268
+ * Effect.provide(ActorSystemDefault),
269
+ * );
270
+ * ```
271
+ */
272
+ export const InMemoryPersistenceAdapter: Layer.Layer<PersistenceAdapterTag> = Layer.effect(
273
+ PersistenceAdapterTag,
274
+ make,
275
+ );
@@ -0,0 +1,24 @@
1
+ // Core types
2
+ export type {
3
+ ActorMetadata,
4
+ PersistedEvent,
5
+ PersistenceAdapter,
6
+ RestoreFailure,
7
+ RestoreResult,
8
+ Snapshot,
9
+ } from "./adapter.js";
10
+ export { PersistenceAdapterTag, PersistenceError, VersionConflictError } from "./adapter.js";
11
+
12
+ // Persistent machine
13
+ export type { PersistenceConfig, PersistentMachine } from "./persistent-machine.js";
14
+ export { isPersistentMachine, persist } from "./persistent-machine.js";
15
+
16
+ // Persistent actor
17
+ export type { PersistentActorRef } from "./persistent-actor.js";
18
+ export { createPersistentActor, restorePersistentActor } from "./persistent-actor.js";
19
+
20
+ // Adapters
21
+ export {
22
+ InMemoryPersistenceAdapter,
23
+ makeInMemoryPersistenceAdapter,
24
+ } from "./adapters/in-memory.js";