effect-machine 0.11.0 → 0.13.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.
Files changed (68) hide show
  1. package/README.md +128 -324
  2. package/dist/actor.d.ts +52 -31
  3. package/dist/actor.js +218 -283
  4. package/dist/cluster/adapters/in-memory.d.ts +28 -0
  5. package/dist/cluster/adapters/in-memory.js +79 -0
  6. package/dist/cluster/entity-actor-ref.d.ts +56 -0
  7. package/dist/cluster/entity-actor-ref.js +33 -0
  8. package/dist/cluster/entity-machine.d.ts +31 -49
  9. package/dist/cluster/entity-machine.js +178 -52
  10. package/dist/cluster/index.d.ts +5 -2
  11. package/dist/cluster/index.js +4 -1
  12. package/dist/cluster/persistence.d.ts +49 -0
  13. package/dist/cluster/persistence.js +18 -0
  14. package/dist/cluster/to-entity.d.ts +9 -3
  15. package/dist/cluster/to-entity.js +16 -4
  16. package/dist/errors.d.ts +25 -17
  17. package/dist/errors.js +10 -5
  18. package/dist/index.d.ts +6 -4
  19. package/dist/index.js +4 -3
  20. package/dist/internal/brands.d.ts +14 -1
  21. package/dist/internal/runtime.d.ts +142 -0
  22. package/dist/internal/runtime.js +357 -0
  23. package/dist/internal/transition.d.ts +10 -4
  24. package/dist/internal/transition.js +24 -12
  25. package/dist/internal/utils.d.ts +42 -6
  26. package/dist/internal/utils.js +27 -1
  27. package/dist/machine.d.ts +89 -55
  28. package/dist/machine.js +80 -68
  29. package/dist/schema.d.ts +35 -34
  30. package/dist/schema.js +33 -4
  31. package/dist/supervision.d.ts +97 -0
  32. package/dist/supervision.js +42 -0
  33. package/dist/testing.d.ts +17 -8
  34. package/dist/testing.js +22 -23
  35. package/package.json +7 -7
  36. package/v3/dist/actor.d.ts +54 -37
  37. package/v3/dist/actor.js +209 -277
  38. package/v3/dist/cluster/adapters/in-memory.d.ts +15 -0
  39. package/v3/dist/cluster/adapters/in-memory.js +62 -0
  40. package/v3/dist/cluster/entity-actor-ref.d.ts +49 -0
  41. package/v3/dist/cluster/entity-actor-ref.js +19 -0
  42. package/v3/dist/cluster/entity-machine.d.ts +34 -49
  43. package/v3/dist/cluster/entity-machine.js +134 -50
  44. package/v3/dist/cluster/index.d.ts +5 -2
  45. package/v3/dist/cluster/index.js +4 -1
  46. package/v3/dist/cluster/persistence.d.ts +48 -0
  47. package/v3/dist/cluster/persistence.js +14 -0
  48. package/v3/dist/cluster/to-entity.d.ts +5 -2
  49. package/v3/dist/cluster/to-entity.js +12 -4
  50. package/v3/dist/errors.d.ts +18 -8
  51. package/v3/dist/errors.js +9 -4
  52. package/v3/dist/index.d.ts +6 -4
  53. package/v3/dist/index.js +3 -2
  54. package/v3/dist/internal/brands.d.ts +15 -1
  55. package/v3/dist/internal/runtime.d.ts +142 -0
  56. package/v3/dist/internal/runtime.js +335 -0
  57. package/v3/dist/internal/transition.d.ts +10 -4
  58. package/v3/dist/internal/transition.js +23 -11
  59. package/v3/dist/internal/utils.d.ts +42 -6
  60. package/v3/dist/internal/utils.js +27 -1
  61. package/v3/dist/machine.d.ts +35 -47
  62. package/v3/dist/machine.js +62 -64
  63. package/v3/dist/schema.d.ts +35 -34
  64. package/v3/dist/schema.js +29 -3
  65. package/v3/dist/supervision.d.ts +97 -0
  66. package/v3/dist/supervision.js +42 -0
  67. package/v3/dist/testing.d.ts +18 -9
  68. package/v3/dist/testing.js +21 -22
@@ -0,0 +1,62 @@
1
+ import { VersionConflictError } from "../../errors.js";
2
+ import { PersistenceAdapter } from "../persistence.js";
3
+ import { Effect, Layer, Option, Ref } from "effect";
4
+ //#region src/cluster/adapters/in-memory.ts
5
+ /**
6
+ * In-memory persistence adapter for testing and development (v3).
7
+ *
8
+ * @module
9
+ */
10
+ const makeKey = (key) => `${key.entityType}/${key.entityId}`;
11
+ const getOrCreate = (store, key) => {
12
+ let entry = store.get(key);
13
+ if (entry === void 0) {
14
+ entry = {
15
+ snapshot: void 0,
16
+ events: []
17
+ };
18
+ store.set(key, entry);
19
+ }
20
+ return entry;
21
+ };
22
+ const makeInMemoryPersistenceAdapter = Effect.gen(function* () {
23
+ const store = /* @__PURE__ */ new Map();
24
+ const storeRef = yield* Ref.make(store);
25
+ const adapter = {
26
+ saveSnapshot: (key, snapshot) => Effect.gen(function* () {
27
+ const entry = getOrCreate(yield* Ref.get(storeRef), makeKey(key));
28
+ if (entry.snapshot !== void 0 && snapshot.version < entry.snapshot.version) return yield* new VersionConflictError({
29
+ expected: snapshot.version,
30
+ actual: entry.snapshot.version
31
+ });
32
+ entry.snapshot = snapshot;
33
+ }),
34
+ loadSnapshot: (key) => Effect.gen(function* () {
35
+ const entry = (yield* Ref.get(storeRef)).get(makeKey(key));
36
+ return Option.fromNullable(entry?.snapshot);
37
+ }),
38
+ appendEvents: (key, events, expectedVersion) => Effect.gen(function* () {
39
+ const entry = getOrCreate(yield* Ref.get(storeRef), makeKey(key));
40
+ const lastEvent = entry.events[entry.events.length - 1];
41
+ const currentVersion = lastEvent !== void 0 ? lastEvent.version : 0;
42
+ if (currentVersion !== expectedVersion) return yield* new VersionConflictError({
43
+ expected: expectedVersion,
44
+ actual: currentVersion
45
+ });
46
+ for (const event of events) entry.events.push(event);
47
+ }),
48
+ loadEvents: (key, afterVersion) => Effect.gen(function* () {
49
+ const entry = (yield* Ref.get(storeRef)).get(makeKey(key));
50
+ if (entry === void 0) return [];
51
+ if (afterVersion === void 0) return entry.events;
52
+ return entry.events.filter((e) => e.version > afterVersion);
53
+ })
54
+ };
55
+ return {
56
+ adapter,
57
+ storeRef,
58
+ layer: Layer.succeed(PersistenceAdapter, adapter)
59
+ };
60
+ });
61
+ //#endregion
62
+ export { makeInMemoryPersistenceAdapter };
@@ -0,0 +1,49 @@
1
+ import { ExtractReply, ReplyTypeBrand } from "../internal/brands.js";
2
+ import { NoReplyError } from "../errors.js";
3
+ import { Effect } from "effect";
4
+ import { RpcClient } from "effect/unstable/rpc";
5
+
6
+ //#region src/cluster/entity-actor-ref.d.ts
7
+ /**
8
+ * Typed client wrapper for remote entity machines.
9
+ *
10
+ * Unlike local `ActorRef`, this communicates over cluster RPCs.
11
+ * Only operations that make sense over the network are exposed.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * const ref = yield* EntityActorRef.make(OrderEntity, OrderEntityLayer, "order-123")
16
+ * yield* ref.send(OrderEvent.Ship({ trackingId: "abc" }))
17
+ * const state = yield* ref.snapshot
18
+ * ```
19
+ */
20
+ interface EntityActorRef<State extends {
21
+ readonly _tag: string;
22
+ }, Event extends {
23
+ readonly _tag: string;
24
+ }> {
25
+ readonly entityId: string;
26
+ /** Send event (fire-and-forget). Returns new state after processing. */
27
+ readonly send: (event: Event) => Effect.Effect<State>;
28
+ /** Send event and get typed domain reply (via Event.reply() schema). */
29
+ readonly ask: <E extends Event & ReplyTypeBrand<unknown>>(event: E) => Effect.Effect<ExtractReply<E>, NoReplyError>;
30
+ /** Get current state. */
31
+ readonly snapshot: Effect.Effect<State>;
32
+ }
33
+ /**
34
+ * Create an EntityActorRef from a test client factory and entity ID.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * const makeClient = yield* Entity.makeTestClient(entity, entityLayer)
39
+ * const ref = yield* makeEntityActorRef(makeClient, "order-123")
40
+ * yield* ref.send(OrderEvent.Process)
41
+ * ```
42
+ */
43
+ declare const makeEntityActorRef: <State extends {
44
+ readonly _tag: string;
45
+ }, Event extends {
46
+ readonly _tag: string;
47
+ }>(client: RpcClient.RpcClient<any>, entityId: string) => EntityActorRef<State, Event>;
48
+ //#endregion
49
+ export { EntityActorRef, makeEntityActorRef };
@@ -0,0 +1,19 @@
1
+ //#region src/cluster/entity-actor-ref.ts
2
+ /**
3
+ * Create an EntityActorRef from a test client factory and entity ID.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * const makeClient = yield* Entity.makeTestClient(entity, entityLayer)
8
+ * const ref = yield* makeEntityActorRef(makeClient, "order-123")
9
+ * yield* ref.send(OrderEvent.Process)
10
+ * ```
11
+ */
12
+ const makeEntityActorRef = (client, entityId) => ({
13
+ entityId,
14
+ send: (event) => client.Send({ event }),
15
+ ask: ((event) => client.Ask({ event })),
16
+ snapshot: client.GetState()
17
+ });
18
+ //#endregion
19
+ export { makeEntityActorRef };
@@ -1,7 +1,7 @@
1
- import { EffectsDef, GuardsDef } from "../slot.js";
2
1
  import { ProcessEventHooks } from "../internal/transition.js";
3
2
  import { Machine } from "../machine.js";
4
- import { Layer } from "effect";
3
+ import { EntityPersistenceConfig } from "./persistence.js";
4
+ import { Duration, Layer, Schedule } from "effect";
5
5
  import { Entity } from "@effect/cluster";
6
6
  import { Rpc } from "@effect/rpc";
7
7
 
@@ -13,77 +13,62 @@ interface EntityMachineOptions<S, E> {
13
13
  /**
14
14
  * Initialize state from entity ID.
15
15
  * Called once when entity is first activated.
16
- *
17
- * @example
18
- * ```ts
19
- * EntityMachine.layer(OrderEntity, orderMachine, {
20
- * initializeState: (entityId) => OrderState.Pending({ orderId: entityId }),
21
- * })
22
- * ```
23
16
  */
24
17
  readonly initializeState?: (entityId: string) => S;
25
18
  /**
26
19
  * Optional hooks for inspection/tracing.
27
- * Called at specific points during event processing.
28
- *
29
- * @example
30
- * ```ts
31
- * EntityMachine.layer(OrderEntity, orderMachine, {
32
- * hooks: {
33
- * onTransition: (from, to, event) =>
34
- * Effect.log(`Transition: ${from._tag} -> ${to._tag}`),
35
- * onSpawnEffect: (state) =>
36
- * Effect.log(`Running spawn effects for ${state._tag}`),
37
- * onError: ({ phase, state }) =>
38
- * Effect.log(`Defect in ${phase} at ${state._tag}`),
39
- * },
40
- * })
41
- * ```
42
20
  */
43
21
  readonly hooks?: ProcessEventHooks<S, E>;
22
+ /**
23
+ * Maximum idle time before entity deactivation.
24
+ * Forwarded to Entity.toLayer.
25
+ */
26
+ readonly maxIdleTime?: Duration.DurationInput;
27
+ /**
28
+ * Concurrency for handler execution.
29
+ * Forwarded to Entity.toLayer.
30
+ */
31
+ readonly concurrency?: number | "unbounded";
32
+ /**
33
+ * Mailbox capacity. Default: "unbounded".
34
+ * Forwarded to Entity.toLayer.
35
+ */
36
+ readonly mailboxCapacity?: number | "unbounded";
37
+ /**
38
+ * Disable fatal defects (defects won't crash the entity activation).
39
+ * Forwarded to Entity.toLayer.
40
+ */
41
+ readonly disableFatalDefects?: boolean;
42
+ /**
43
+ * Retry policy for defects (schedule for restarting after defect).
44
+ * Forwarded to Entity.toLayer.
45
+ */
46
+ readonly defectRetryPolicy?: Schedule.Schedule<any, unknown>;
47
+ /**
48
+ * Persistence configuration. When set, requires PersistenceAdapter in R.
49
+ */
50
+ readonly persistence?: EntityPersistenceConfig;
44
51
  }
45
52
  /**
46
53
  * Create an Entity layer that wires a machine to handle RPC calls.
47
54
  *
48
- * The layer:
49
- * - Maintains state via Ref per entity instance
50
- * - Resolves transitions using the indexed lookup
51
- * - Evaluates guards in registration order
52
- * - Runs lifecycle effects (onEnter/spawn)
53
- * - Processes internal events from spawn effects
55
+ * v3: Uses `Entity.toLayer` with handler objects backed by the runtime kernel.
54
56
  *
55
57
  * @example
56
58
  * ```ts
57
- * const OrderEntity = toEntity(orderMachine, {
58
- * type: "Order",
59
- * stateSchema: OrderState,
60
- * eventSchema: OrderEvent,
61
- * })
59
+ * const OrderEntity = toEntity(orderMachine, { type: "Order" })
62
60
  *
63
61
  * const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, {
64
62
  * initializeState: (entityId) => OrderState.Pending({ orderId: entityId }),
65
63
  * })
66
- *
67
- * // Use in cluster
68
- * const program = Effect.gen(function* () {
69
- * const client = yield* ShardingClient.client(OrderEntity)
70
- * yield* client.Send("order-123", { event: OrderEvent.Ship({ trackingId: "abc" }) })
71
- * })
72
64
  * ```
73
65
  */
74
66
  declare const EntityMachine: {
75
- /**
76
- * Create a layer that wires a machine to an Entity.
77
- *
78
- * @param entity - Entity created via toEntity()
79
- * @param machine - Machine with all effects provided
80
- * @param options - Optional configuration (state initializer, inspection hooks)
81
- */
82
67
  layer: <S extends {
83
68
  readonly _tag: string;
84
69
  }, E extends {
85
70
  readonly _tag: string;
86
- }, R, GD extends GuardsDef, EFD extends EffectsDef, EntityType extends string, Rpcs extends Rpc.Any>(entity: Entity.Entity<EntityType, Rpcs>, machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>, options?: EntityMachineOptions<S, E>) => Layer.Layer<never, never, R>;
71
+ }, R, EntityType extends string, Rpcs extends Rpc.Any>(entity: Entity.Entity<EntityType, Rpcs>, machine: Machine<S, E, R, any, any, any, any>, options?: EntityMachineOptions<S, E>) => Layer.Layer<never, never, R>;
87
72
  };
88
73
  //#endregion
89
74
  export { EntityMachine, EntityMachineOptions };
@@ -1,80 +1,164 @@
1
- import { processEventCore, runSpawnEffects } from "../internal/transition.js";
1
+ import { stubSystem } from "../internal/utils.js";
2
+ import { replay } from "../machine.js";
3
+ import { createRuntime } from "../internal/runtime.js";
2
4
  import { ActorSystem } from "../actor.js";
3
- import { Effect, Option, Queue, Ref, Scope } from "effect";
5
+ import { PersistenceAdapter } from "./persistence.js";
6
+ import { Effect, Option, Ref } from "effect";
4
7
  import { Entity } from "@effect/cluster";
5
8
  //#region src/cluster/entity-machine.ts
6
9
  /**
7
10
  * EntityMachine adapter - wires a machine to a cluster Entity layer.
8
11
  *
12
+ * Uses the runtime kernel for a single serialized event loop per entity.
13
+ * All events (external RPCs + internal self.send) go through the
14
+ * runtime kernel's single queue — no split-mailbox race.
15
+ *
16
+ * v3 uses `entity.toLayer` with handler objects (no `toLayerQueue`/`toLayerMailbox`).
17
+ * Supports opt-in persistence (snapshot or journal strategy).
18
+ *
9
19
  * @module
10
20
  */
11
21
  /**
12
- * Process a single event through the machine using shared core.
13
- * Returns the new state after processing.
14
- */
15
- const processEvent = Effect.fn("effect-machine.cluster.processEvent")(function* (machine, stateRef, event, self, stateScopeRef, system, hooks) {
16
- const result = yield* processEventCore(machine, yield* Ref.get(stateRef), event, self, stateScopeRef, system, "*", hooks);
17
- if (result.transitioned) yield* Ref.set(stateRef, result.newState);
18
- return result.newState;
19
- });
20
- /**
21
22
  * Create an Entity layer that wires a machine to handle RPC calls.
22
23
  *
23
- * The layer:
24
- * - Maintains state via Ref per entity instance
25
- * - Resolves transitions using the indexed lookup
26
- * - Evaluates guards in registration order
27
- * - Runs lifecycle effects (onEnter/spawn)
28
- * - Processes internal events from spawn effects
24
+ * v3: Uses `Entity.toLayer` with handler objects backed by the runtime kernel.
29
25
  *
30
26
  * @example
31
27
  * ```ts
32
- * const OrderEntity = toEntity(orderMachine, {
33
- * type: "Order",
34
- * stateSchema: OrderState,
35
- * eventSchema: OrderEvent,
36
- * })
28
+ * const OrderEntity = toEntity(orderMachine, { type: "Order" })
37
29
  *
38
30
  * const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, {
39
31
  * initializeState: (entityId) => OrderState.Pending({ orderId: entityId }),
40
32
  * })
41
- *
42
- * // Use in cluster
43
- * const program = Effect.gen(function* () {
44
- * const client = yield* ShardingClient.client(OrderEntity)
45
- * yield* client.Send("order-123", { event: OrderEvent.Ship({ trackingId: "abc" }) })
46
- * })
47
33
  * ```
48
34
  */
49
35
  const EntityMachine = { layer: (entity, machine, options) => {
50
- const layer = Effect.fn("effect-machine.cluster.layer")(function* () {
36
+ const persistence = options?.persistence;
37
+ const build = Effect.gen(function* () {
51
38
  const entityId = yield* Effect.serviceOption(Entity.CurrentAddress).pipe(Effect.map((opt) => opt._tag === "Some" ? opt.value.entityId : ""));
52
- const initialState = options?.initializeState !== void 0 ? options.initializeState(entityId) : machine.initial;
53
39
  const existingSystem = yield* Effect.serviceOption(ActorSystem);
54
- if (Option.isNone(existingSystem)) return yield* Effect.die("EntityMachine requires ActorSystem in context");
55
- const system = existingSystem.value;
56
- const internalQueue = yield* Queue.unbounded();
57
- const clusterSend = Effect.fn("effect-machine.cluster.self.send")(function* (event) {
58
- yield* Queue.offer(internalQueue, event);
40
+ const system = Option.isSome(existingSystem) ? existingSystem.value : stubSystem;
41
+ const persistCtx = yield* hydratePersistence(persistence, entity, entityId, machine, options?.initializeState);
42
+ const initialState = persistCtx.hydratedState ?? (options?.initializeState !== void 0 ? options.initializeState(entityId) : void 0);
43
+ const machineWithState = initialState !== void 0 ? Object.create(machine, { initial: {
44
+ value: initialState,
45
+ enumerable: true
46
+ } }) : machine;
47
+ const versionRef = yield* Ref.make(persistCtx.initialVersion);
48
+ const runtime = yield* createRuntime(machineWithState, system, {
49
+ actorId: entityId,
50
+ hooks: options?.hooks,
51
+ childIdPrefix: `${entityId}/`
59
52
  });
60
- const self = {
61
- send: clusterSend,
62
- cast: clusterSend,
63
- spawn: (childId, childMachine) => system.spawn(childId, childMachine).pipe(Effect.provideService(ActorSystem, system))
64
- };
65
- const stateRef = yield* Ref.make(initialState);
66
- const stateScopeRef = { current: yield* Scope.make() };
67
- yield* runSpawnEffects(machine, initialState, { _tag: "$init" }, self, stateScopeRef.current, system, entityId, options?.hooks?.onError);
68
- const runInternalEvent = Effect.fn("effect-machine.cluster.internalEvent")(function* () {
69
- yield* processEvent(machine, stateRef, yield* Queue.take(internalQueue), self, stateScopeRef, system, options?.hooks);
70
- });
71
- yield* Effect.forkScoped(Effect.forever(runInternalEvent()));
53
+ if (persistCtx.adapter !== void 0) {
54
+ const { adapter: pAdapter, key } = persistCtx;
55
+ yield* Effect.addFinalizer(() => Effect.gen(function* () {
56
+ const state = yield* runtime.getState;
57
+ const version = yield* Ref.get(versionRef);
58
+ yield* pAdapter.saveSnapshot(key, {
59
+ state,
60
+ version,
61
+ timestamp: Date.now()
62
+ });
63
+ }).pipe(Effect.catchAll(() => Effect.void)));
64
+ }
65
+ const hasPersistence = persistCtx.adapter !== void 0;
66
+ const journalCtx = hasPersistence && (persistence?.strategy ?? "snapshot") === "journal" ? {
67
+ adapter: persistCtx.adapter,
68
+ key: persistCtx.key
69
+ } : void 0;
72
70
  return entity.of({
73
- Send: (envelope) => processEvent(machine, stateRef, envelope.payload.event, self, stateScopeRef, system, options?.hooks),
74
- GetState: () => Ref.get(stateRef)
71
+ Send: (envelope) => Effect.gen(function* () {
72
+ yield* runtime.sendWait(envelope.payload.event);
73
+ if (journalCtx !== void 0) yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, envelope.payload.event);
74
+ else if (hasPersistence) yield* Ref.update(versionRef, (v) => v + 1);
75
+ return yield* runtime.getState;
76
+ }),
77
+ Ask: (envelope) => Effect.gen(function* () {
78
+ const reply = yield* runtime.ask(envelope.payload.event);
79
+ if (journalCtx !== void 0) yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, envelope.payload.event);
80
+ else if (hasPersistence) yield* Ref.update(versionRef, (v) => v + 1);
81
+ return reply;
82
+ }),
83
+ GetState: () => runtime.getState
75
84
  });
76
85
  });
77
- return entity.toLayer(layer());
86
+ const clusterOptions = {};
87
+ if (options?.maxIdleTime !== void 0) clusterOptions.maxIdleTime = options.maxIdleTime;
88
+ if (options?.concurrency !== void 0) clusterOptions.concurrency = options.concurrency;
89
+ if (options?.mailboxCapacity !== void 0) clusterOptions.mailboxCapacity = options.mailboxCapacity;
90
+ if (options?.disableFatalDefects !== void 0) clusterOptions.disableFatalDefects = options.disableFatalDefects;
91
+ if (options?.defectRetryPolicy !== void 0) clusterOptions.defectRetryPolicy = options.defectRetryPolicy;
92
+ return entity.toLayer(build.pipe(Effect.orDie), Object.keys(clusterOptions).length > 0 ? clusterOptions : void 0);
78
93
  } };
94
+ const noPersistence = {
95
+ adapter: void 0,
96
+ key: void 0,
97
+ hydratedState: void 0,
98
+ initialVersion: 0
99
+ };
100
+ /** Load snapshot/journal and compute hydrated state. */
101
+ const hydratePersistence = (persistence, entityDef, entityId, machine, initializeState) => Effect.gen(function* () {
102
+ if (persistence === void 0) return noPersistence;
103
+ const adapter = yield* PersistenceAdapter;
104
+ const key = {
105
+ entityType: persistence.machineType ?? entityDef.type,
106
+ entityId
107
+ };
108
+ const maybeSnapshot = yield* adapter.loadSnapshot(key);
109
+ if ((persistence.strategy ?? "snapshot") === "journal") {
110
+ const baseState = Option.isSome(maybeSnapshot) ? maybeSnapshot.value.state : initializeState !== void 0 ? initializeState(entityId) : machine.initial;
111
+ const snapshotVersion = Option.isSome(maybeSnapshot) ? maybeSnapshot.value.version : 0;
112
+ const events = yield* adapter.loadEvents(key, snapshotVersion);
113
+ if (events.length > 0) {
114
+ const hydratedState = yield* replay(machine, events.map((e) => e.event), { from: baseState });
115
+ const lastEvent = events[events.length - 1];
116
+ return {
117
+ adapter,
118
+ key,
119
+ hydratedState,
120
+ initialVersion: lastEvent !== void 0 ? lastEvent.version : snapshotVersion
121
+ };
122
+ }
123
+ return {
124
+ adapter,
125
+ key,
126
+ hydratedState: Option.isSome(maybeSnapshot) ? maybeSnapshot.value.state : void 0,
127
+ initialVersion: snapshotVersion
128
+ };
129
+ }
130
+ if (Option.isSome(maybeSnapshot)) return {
131
+ adapter,
132
+ key,
133
+ hydratedState: maybeSnapshot.value.state,
134
+ initialVersion: maybeSnapshot.value.version
135
+ };
136
+ return {
137
+ adapter,
138
+ key,
139
+ hydratedState: void 0,
140
+ initialVersion: 0
141
+ };
142
+ });
143
+ /**
144
+ * Append a single event to the journal, incrementing version.
145
+ *
146
+ * On failure: defects the entity activation. The cluster's defectRetryPolicy
147
+ * restarts the entity from the last consistent snapshot.
148
+ */
149
+ const persistEvent = (adapter, key, versionRef, event) => Effect.gen(function* () {
150
+ const expectedVersion = yield* Ref.get(versionRef);
151
+ const newVersion = expectedVersion + 1;
152
+ const persisted = {
153
+ event,
154
+ version: newVersion,
155
+ timestamp: Date.now()
156
+ };
157
+ yield* adapter.appendEvents(key, [persisted], expectedVersion);
158
+ yield* Ref.set(versionRef, newVersion);
159
+ }).pipe(Effect.tapError((error) => Effect.logWarning("Journal append failed, defecting entity", {
160
+ key,
161
+ error
162
+ })), Effect.orDie);
79
163
  //#endregion
80
164
  export { EntityMachine };
@@ -1,3 +1,6 @@
1
+ import { EntityPersistenceConfig, PersistedEvent, PersistenceAdapter, PersistenceKey, Snapshot } from "./persistence.js";
2
+ import { makeInMemoryPersistenceAdapter } from "./adapters/in-memory.js";
3
+ import { EntityActorRef, makeEntityActorRef } from "./entity-actor-ref.js";
1
4
  import { EntityMachine, EntityMachineOptions } from "./entity-machine.js";
2
- import { ToEntityOptions, toEntity } from "./to-entity.js";
3
- export { EntityMachine, type EntityMachineOptions, type ToEntityOptions, toEntity };
5
+ import { EntityRpcs, ToEntityOptions, toEntity } from "./to-entity.js";
6
+ export { type EntityActorRef, EntityMachine, type EntityMachineOptions, type EntityPersistenceConfig, type EntityRpcs, type PersistedEvent, PersistenceAdapter, type PersistenceAdapter as PersistenceAdapterInterface, type PersistenceKey, type Snapshot, type ToEntityOptions, makeEntityActorRef, makeInMemoryPersistenceAdapter, toEntity };
@@ -1,3 +1,6 @@
1
+ import { makeEntityActorRef } from "./entity-actor-ref.js";
2
+ import { PersistenceAdapter } from "./persistence.js";
1
3
  import { EntityMachine } from "./entity-machine.js";
2
4
  import { toEntity } from "./to-entity.js";
3
- export { EntityMachine, toEntity };
5
+ import { makeInMemoryPersistenceAdapter } from "./adapters/in-memory.js";
6
+ export { EntityMachine, PersistenceAdapter, makeEntityActorRef, makeInMemoryPersistenceAdapter, toEntity };
@@ -0,0 +1,48 @@
1
+ import { PersistenceError, VersionConflictError } from "../errors.js";
2
+ import { Context, Effect, Option, Schedule } from "effect";
3
+
4
+ //#region src/cluster/persistence.d.ts
5
+ /** Namespaced key preventing cross-type collisions (e.g. Order/123 vs User/123). */
6
+ interface PersistenceKey {
7
+ readonly entityType: string;
8
+ readonly entityId: string;
9
+ }
10
+ /** Stored state snapshot with version and timestamp. */
11
+ interface Snapshot<S> {
12
+ readonly state: S;
13
+ readonly version: number;
14
+ readonly timestamp: number;
15
+ }
16
+ /** Stored event with version and timestamp. */
17
+ interface PersistedEvent<E> {
18
+ readonly event: E;
19
+ readonly version: number;
20
+ readonly timestamp: number;
21
+ }
22
+ /** Persistence configuration for EntityMachineOptions. */
23
+ interface EntityPersistenceConfig {
24
+ /** Persistence strategy. Default: "snapshot". */
25
+ readonly strategy?: "snapshot" | "journal";
26
+ /**
27
+ * Schedule controlling snapshot frequency.
28
+ * Default: save on every state change.
29
+ */
30
+ readonly snapshotSchedule?: Schedule.Schedule<any, any>;
31
+ /** Override entityType in the persistence key (defaults to Entity.type). */
32
+ readonly machineType?: string;
33
+ }
34
+ /** Storage backend for entity state persistence. */
35
+ interface PersistenceAdapter {
36
+ /** Save a state snapshot. Fails with VersionConflictError if version is stale. */
37
+ readonly saveSnapshot: (key: PersistenceKey, snapshot: Snapshot<unknown>) => Effect.Effect<void, PersistenceError | VersionConflictError>;
38
+ /** Load the latest snapshot, or None if no snapshot exists. */
39
+ readonly loadSnapshot: (key: PersistenceKey) => Effect.Effect<Option.Option<Snapshot<unknown>>, PersistenceError>;
40
+ /** Append events to the journal. Fails with VersionConflictError if expectedVersion doesn't match. */
41
+ readonly appendEvents: (key: PersistenceKey, events: ReadonlyArray<PersistedEvent<unknown>>, expectedVersion: number) => Effect.Effect<void, PersistenceError | VersionConflictError>;
42
+ /** Load events from the journal, optionally after a given version. */
43
+ readonly loadEvents: (key: PersistenceKey, afterVersion?: number) => Effect.Effect<ReadonlyArray<PersistedEvent<unknown>>, PersistenceError>;
44
+ }
45
+ /** Service tag for PersistenceAdapter — resolve from context for shared infra. */
46
+ declare const PersistenceAdapter: Context.Tag<PersistenceAdapter, PersistenceAdapter>;
47
+ //#endregion
48
+ export { EntityPersistenceConfig, PersistedEvent, PersistenceAdapter, PersistenceKey, Snapshot };
@@ -0,0 +1,14 @@
1
+ import { Context } from "effect";
2
+ //#region src/cluster/persistence.ts
3
+ /**
4
+ * Entity persistence types and adapter interface (v3).
5
+ *
6
+ * Provides snapshot and event journal persistence for entity-machine
7
+ * state across deactivation/reactivation cycles.
8
+ *
9
+ * @module
10
+ */
11
+ /** Service tag for PersistenceAdapter — resolve from context for shared infra. */
12
+ const PersistenceAdapter = Context.GenericTag("@effect-machine/cluster/PersistenceAdapter");
13
+ //#endregion
14
+ export { PersistenceAdapter };
@@ -15,12 +15,15 @@ interface ToEntityOptions {
15
15
  /**
16
16
  * Default RPC protocol for entity machines.
17
17
  *
18
- * - `Send` - Send event to machine, returns new state
18
+ * - `Send` - Send event to machine (fire-and-forget), returns new state
19
+ * - `Ask` - Send event and get domain reply (typed via Event.reply() schemas)
19
20
  * - `GetState` - Get current state
20
21
  */
21
22
  type EntityRpcs<StateSchema extends Schema.Schema.Any, EventSchema extends Schema.Schema.Any> = readonly [Rpc.Rpc<"Send", Schema.Struct<{
22
23
  readonly event: EventSchema;
23
- }>, StateSchema, typeof Schema.Never, never>, Rpc.Rpc<"GetState", typeof Schema.Void, StateSchema, typeof Schema.Never, never>];
24
+ }>, StateSchema, typeof Schema.Never, never>, Rpc.Rpc<"Ask", Schema.Struct<{
25
+ readonly event: EventSchema;
26
+ }>, typeof Schema.Unknown, typeof Schema.Never, never>, Rpc.Rpc<"GetState", typeof Schema.Void, StateSchema, typeof Schema.Never, never>];
24
27
  /**
25
28
  * Generate an Entity definition from a machine.
26
29
  *
@@ -1,4 +1,5 @@
1
1
  import { MissingSchemaError } from "../errors.js";
2
+ import { Schema } from "effect";
2
3
  import { Entity } from "@effect/cluster";
3
4
  import { Rpc } from "@effect/rpc";
4
5
  //#region src/cluster/to-entity.ts
@@ -42,10 +43,17 @@ const toEntity = (machine, options) => {
42
43
  const stateSchema = machine.stateSchema;
43
44
  const eventSchema = machine.eventSchema;
44
45
  if (stateSchema === void 0 || eventSchema === void 0) throw new MissingSchemaError({ operation: "toEntity" });
45
- return Entity.make(options.type, [Rpc.make("Send", {
46
- payload: { event: eventSchema },
47
- success: stateSchema
48
- }), Rpc.make("GetState", { success: stateSchema })]);
46
+ return Entity.make(options.type, [
47
+ Rpc.make("Send", {
48
+ payload: { event: eventSchema },
49
+ success: stateSchema
50
+ }),
51
+ Rpc.make("Ask", {
52
+ payload: { event: eventSchema },
53
+ success: Schema.Unknown
54
+ }),
55
+ Rpc.make("GetState", { success: stateSchema })
56
+ ]);
49
57
  };
50
58
  //#endregion
51
59
  export { toEntity };
@@ -8,13 +8,6 @@ declare const DuplicateActorError_base: Schema.TaggedErrorClass<DuplicateActorEr
8
8
  }>;
9
9
  /** Attempted to spawn/restore actor with ID already in use */
10
10
  declare class DuplicateActorError extends DuplicateActorError_base {}
11
- declare const UnprovidedSlotsError_base: Schema.TaggedErrorClass<UnprovidedSlotsError, "UnprovidedSlotsError", {
12
- readonly _tag: Schema.tag<"UnprovidedSlotsError">;
13
- } & {
14
- slots: Schema.Array$<typeof Schema.String>;
15
- }>;
16
- /** Machine has unprovided effect slots */
17
- declare class UnprovidedSlotsError extends UnprovidedSlotsError_base {}
18
11
  declare const MissingSchemaError_base: Schema.TaggedErrorClass<MissingSchemaError, "MissingSchemaError", {
19
12
  readonly _tag: Schema.tag<"MissingSchemaError">;
20
13
  } & {
@@ -24,6 +17,8 @@ declare const MissingSchemaError_base: Schema.TaggedErrorClass<MissingSchemaErro
24
17
  declare class MissingSchemaError extends MissingSchemaError_base {}
25
18
  declare const InvalidSchemaError_base: Schema.TaggedErrorClass<InvalidSchemaError, "InvalidSchemaError", {
26
19
  readonly _tag: Schema.tag<"InvalidSchemaError">;
20
+ } & {
21
+ message: typeof Schema.String;
27
22
  }>;
28
23
  /** State/Event schema has no variants */
29
24
  declare class InvalidSchemaError extends InvalidSchemaError_base {}
@@ -72,5 +67,20 @@ declare const NoReplyError_base: Schema.TaggedErrorClass<NoReplyError, "NoReplyE
72
67
  }>;
73
68
  /** ask() was used but the transition handler did not call reply */
74
69
  declare class NoReplyError extends NoReplyError_base {}
70
+ declare const PersistenceError_base: Schema.TaggedErrorClass<PersistenceError, "PersistenceError", {
71
+ readonly _tag: Schema.tag<"PersistenceError">;
72
+ } & {
73
+ message: typeof Schema.String;
74
+ }>;
75
+ /** Persistence adapter operation failed */
76
+ declare class PersistenceError extends PersistenceError_base {}
77
+ declare const VersionConflictError_base: Schema.TaggedErrorClass<VersionConflictError, "VersionConflictError", {
78
+ readonly _tag: Schema.tag<"VersionConflictError">;
79
+ } & {
80
+ expected: typeof Schema.Number;
81
+ actual: typeof Schema.Number;
82
+ }>;
83
+ /** Optimistic locking failure — stored version doesn't match expected */
84
+ declare class VersionConflictError extends VersionConflictError_base {}
75
85
  //#endregion
76
- export { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, ProvisionValidationError, SlotProvisionError, UnprovidedSlotsError };
86
+ export { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaError, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, ProvisionValidationError, SlotProvisionError, VersionConflictError };