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.
- package/README.md +128 -324
- package/dist/actor.d.ts +52 -31
- package/dist/actor.js +218 -283
- package/dist/cluster/adapters/in-memory.d.ts +28 -0
- package/dist/cluster/adapters/in-memory.js +79 -0
- package/dist/cluster/entity-actor-ref.d.ts +56 -0
- package/dist/cluster/entity-actor-ref.js +33 -0
- package/dist/cluster/entity-machine.d.ts +31 -49
- package/dist/cluster/entity-machine.js +178 -52
- package/dist/cluster/index.d.ts +5 -2
- package/dist/cluster/index.js +4 -1
- package/dist/cluster/persistence.d.ts +49 -0
- package/dist/cluster/persistence.js +18 -0
- package/dist/cluster/to-entity.d.ts +9 -3
- package/dist/cluster/to-entity.js +16 -4
- package/dist/errors.d.ts +25 -17
- package/dist/errors.js +10 -5
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -3
- package/dist/internal/brands.d.ts +14 -1
- package/dist/internal/runtime.d.ts +142 -0
- package/dist/internal/runtime.js +357 -0
- package/dist/internal/transition.d.ts +10 -4
- package/dist/internal/transition.js +24 -12
- package/dist/internal/utils.d.ts +42 -6
- package/dist/internal/utils.js +27 -1
- package/dist/machine.d.ts +89 -55
- package/dist/machine.js +80 -68
- package/dist/schema.d.ts +35 -34
- package/dist/schema.js +33 -4
- package/dist/supervision.d.ts +97 -0
- package/dist/supervision.js +42 -0
- package/dist/testing.d.ts +17 -8
- package/dist/testing.js +22 -23
- package/package.json +7 -7
- package/v3/dist/actor.d.ts +54 -37
- package/v3/dist/actor.js +209 -277
- package/v3/dist/cluster/adapters/in-memory.d.ts +15 -0
- package/v3/dist/cluster/adapters/in-memory.js +62 -0
- package/v3/dist/cluster/entity-actor-ref.d.ts +49 -0
- package/v3/dist/cluster/entity-actor-ref.js +19 -0
- package/v3/dist/cluster/entity-machine.d.ts +34 -49
- package/v3/dist/cluster/entity-machine.js +134 -50
- package/v3/dist/cluster/index.d.ts +5 -2
- package/v3/dist/cluster/index.js +4 -1
- package/v3/dist/cluster/persistence.d.ts +48 -0
- package/v3/dist/cluster/persistence.js +14 -0
- package/v3/dist/cluster/to-entity.d.ts +5 -2
- package/v3/dist/cluster/to-entity.js +12 -4
- package/v3/dist/errors.d.ts +18 -8
- package/v3/dist/errors.js +9 -4
- package/v3/dist/index.d.ts +6 -4
- package/v3/dist/index.js +3 -2
- package/v3/dist/internal/brands.d.ts +15 -1
- package/v3/dist/internal/runtime.d.ts +142 -0
- package/v3/dist/internal/runtime.js +335 -0
- package/v3/dist/internal/transition.d.ts +10 -4
- package/v3/dist/internal/transition.js +23 -11
- package/v3/dist/internal/utils.d.ts +42 -6
- package/v3/dist/internal/utils.js +27 -1
- package/v3/dist/machine.d.ts +35 -47
- package/v3/dist/machine.js +62 -64
- package/v3/dist/schema.d.ts +35 -34
- package/v3/dist/schema.js +29 -3
- package/v3/dist/supervision.d.ts +97 -0
- package/v3/dist/supervision.js +42 -0
- package/v3/dist/testing.d.ts +18 -9
- package/v3/dist/testing.js +21 -22
|
@@ -0,0 +1,79 @@
|
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* Backed by a simple Map — state is lost on process exit.
|
|
9
|
+
* Supports both snapshot and journal strategies with proper
|
|
10
|
+
* version checking (CAS on appends, monotonic on snapshots).
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
const makeKey = (key) => `${key.entityType}/${key.entityId}`;
|
|
15
|
+
const getOrCreate = (store, key) => {
|
|
16
|
+
let entry = store.get(key);
|
|
17
|
+
if (entry === void 0) {
|
|
18
|
+
entry = {
|
|
19
|
+
snapshot: void 0,
|
|
20
|
+
events: []
|
|
21
|
+
};
|
|
22
|
+
store.set(key, entry);
|
|
23
|
+
}
|
|
24
|
+
return entry;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Create an in-memory persistence adapter.
|
|
28
|
+
*
|
|
29
|
+
* Returns a Layer providing `PersistenceAdapter` and a ref
|
|
30
|
+
* to the backing store for test assertions.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const { layer, storeRef } = yield* makeInMemoryPersistenceAdapter
|
|
35
|
+
* // Use layer to provide PersistenceAdapter
|
|
36
|
+
* // Inspect storeRef for test assertions
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
const makeInMemoryPersistenceAdapter = Effect.gen(function* () {
|
|
40
|
+
const store = /* @__PURE__ */ new Map();
|
|
41
|
+
const storeRef = yield* Ref.make(store);
|
|
42
|
+
const adapter = {
|
|
43
|
+
saveSnapshot: (key, snapshot) => Effect.gen(function* () {
|
|
44
|
+
const entry = getOrCreate(yield* Ref.get(storeRef), makeKey(key));
|
|
45
|
+
if (entry.snapshot !== void 0 && snapshot.version < entry.snapshot.version) return yield* new VersionConflictError({
|
|
46
|
+
expected: snapshot.version,
|
|
47
|
+
actual: entry.snapshot.version
|
|
48
|
+
});
|
|
49
|
+
entry.snapshot = snapshot;
|
|
50
|
+
}),
|
|
51
|
+
loadSnapshot: (key) => Effect.gen(function* () {
|
|
52
|
+
const entry = (yield* Ref.get(storeRef)).get(makeKey(key));
|
|
53
|
+
return Option.fromNullishOr(entry?.snapshot);
|
|
54
|
+
}),
|
|
55
|
+
appendEvents: (key, events, expectedVersion) => Effect.gen(function* () {
|
|
56
|
+
const entry = getOrCreate(yield* Ref.get(storeRef), makeKey(key));
|
|
57
|
+
const lastEvent = entry.events[entry.events.length - 1];
|
|
58
|
+
const currentVersion = lastEvent !== void 0 ? lastEvent.version : 0;
|
|
59
|
+
if (currentVersion !== expectedVersion) return yield* new VersionConflictError({
|
|
60
|
+
expected: expectedVersion,
|
|
61
|
+
actual: currentVersion
|
|
62
|
+
});
|
|
63
|
+
for (const event of events) entry.events.push(event);
|
|
64
|
+
}),
|
|
65
|
+
loadEvents: (key, afterVersion) => Effect.gen(function* () {
|
|
66
|
+
const entry = (yield* Ref.get(storeRef)).get(makeKey(key));
|
|
67
|
+
if (entry === void 0) return [];
|
|
68
|
+
if (afterVersion === void 0) return entry.events;
|
|
69
|
+
return entry.events.filter((e) => e.version > afterVersion);
|
|
70
|
+
})
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
adapter,
|
|
74
|
+
storeRef,
|
|
75
|
+
layer: Layer.succeed(PersistenceAdapter, adapter)
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
//#endregion
|
|
79
|
+
export { makeInMemoryPersistenceAdapter };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ExtractReply, ReplyTypeBrand } from "../internal/brands.js";
|
|
2
|
+
import { ActorStoppedError, NoReplyError } from "../errors.js";
|
|
3
|
+
import { EntityRpcs } from "./to-entity.js";
|
|
4
|
+
import { Effect, Stream } from "effect";
|
|
5
|
+
import { RpcClient } from "effect/unstable/rpc";
|
|
6
|
+
|
|
7
|
+
//#region src/cluster/entity-actor-ref.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* Typed client wrapper for remote entity machines.
|
|
10
|
+
*
|
|
11
|
+
* Unlike local `ActorRef`, this communicates over cluster RPCs.
|
|
12
|
+
* Only operations that make sense over the network are exposed.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const ref = makeEntityActorRef(client, "order-123")
|
|
17
|
+
* yield* ref.send(OrderEvent.Ship({ trackingId: "abc" }))
|
|
18
|
+
* const state = yield* ref.snapshot
|
|
19
|
+
* yield* ref.waitFor((s) => s._tag === "Shipped")
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
interface EntityActorRef<State extends {
|
|
23
|
+
readonly _tag: string;
|
|
24
|
+
}, Event extends {
|
|
25
|
+
readonly _tag: string;
|
|
26
|
+
}> {
|
|
27
|
+
readonly entityId: string;
|
|
28
|
+
/** Send event. Returns new state after processing. */
|
|
29
|
+
readonly send: (event: Event) => Effect.Effect<State>;
|
|
30
|
+
/** Send event and get typed domain reply (via Event.reply() schema). */
|
|
31
|
+
readonly ask: <E extends Event & ReplyTypeBrand<unknown>>(event: E) => Effect.Effect<ExtractReply<E>, NoReplyError>;
|
|
32
|
+
/** Get current state. */
|
|
33
|
+
readonly snapshot: Effect.Effect<State>;
|
|
34
|
+
/** Stream of state changes (via WatchState streaming RPC). */
|
|
35
|
+
readonly watch: Stream.Stream<State>;
|
|
36
|
+
/** Wait for a state matching the predicate. Snapshots first, then watches stream. */
|
|
37
|
+
readonly waitFor: (predicate: (state: State) => boolean) => Effect.Effect<State, ActorStoppedError>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create an EntityActorRef from a RPC client.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* const makeClient = yield* Entity.makeTestClient(entity, entityLayer)
|
|
45
|
+
* const client = yield* makeClient("order-123")
|
|
46
|
+
* const ref = makeEntityActorRef(client, "order-123")
|
|
47
|
+
* yield* ref.send(OrderEvent.Process)
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
declare const makeEntityActorRef: <State extends {
|
|
51
|
+
readonly _tag: string;
|
|
52
|
+
}, Event extends {
|
|
53
|
+
readonly _tag: string;
|
|
54
|
+
}, Rpcs extends EntityRpcs<any, any>[number]>(client: RpcClient.RpcClient<Rpcs>, entityId: string) => EntityActorRef<State, Event>;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { EntityActorRef, makeEntityActorRef };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ActorStoppedError } from "../errors.js";
|
|
2
|
+
import { Effect, Option, Stream } from "effect";
|
|
3
|
+
//#region src/cluster/entity-actor-ref.ts
|
|
4
|
+
/**
|
|
5
|
+
* Create an EntityActorRef from a RPC client.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* const makeClient = yield* Entity.makeTestClient(entity, entityLayer)
|
|
10
|
+
* const client = yield* makeClient("order-123")
|
|
11
|
+
* const ref = makeEntityActorRef(client, "order-123")
|
|
12
|
+
* yield* ref.send(OrderEvent.Process)
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
const makeEntityActorRef = (client, entityId) => {
|
|
16
|
+
const c = client;
|
|
17
|
+
return {
|
|
18
|
+
entityId,
|
|
19
|
+
send: (event) => c.Send({ event }),
|
|
20
|
+
ask: ((event) => c.Ask({ event })),
|
|
21
|
+
snapshot: c.GetState(),
|
|
22
|
+
watch: c.WatchState(),
|
|
23
|
+
waitFor: (predicate) => Effect.gen(function* () {
|
|
24
|
+
const current = yield* c.GetState();
|
|
25
|
+
if (predicate(current)) return current;
|
|
26
|
+
const result = yield* c.WatchState().pipe(Stream.filter(predicate), Stream.take(1), Stream.runHead);
|
|
27
|
+
if (Option.isSome(result)) return result.value;
|
|
28
|
+
return yield* new ActorStoppedError({ actorId: entityId });
|
|
29
|
+
})
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
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 {
|
|
3
|
+
import { EntityPersistenceConfig } from "./persistence.js";
|
|
4
|
+
import { Duration, Layer, Schedule } from "effect";
|
|
5
5
|
import { Entity } from "effect/unstable/cluster";
|
|
6
6
|
import { Rpc } from "effect/unstable/rpc";
|
|
7
7
|
|
|
@@ -13,77 +13,59 @@ 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.toLayerQueue.
|
|
25
|
+
*/
|
|
26
|
+
readonly maxIdleTime?: Duration.Input;
|
|
27
|
+
/**
|
|
28
|
+
* Mailbox capacity. Default: "unbounded".
|
|
29
|
+
* Forwarded to Entity.toLayerQueue.
|
|
30
|
+
*/
|
|
31
|
+
readonly mailboxCapacity?: number | "unbounded";
|
|
32
|
+
/**
|
|
33
|
+
* Disable fatal defects (defects won't crash the entity activation).
|
|
34
|
+
* Forwarded to Entity.toLayerQueue.
|
|
35
|
+
*/
|
|
36
|
+
readonly disableFatalDefects?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Retry policy for defects (schedule for restarting after defect).
|
|
39
|
+
* Forwarded to Entity.toLayerQueue.
|
|
40
|
+
*/
|
|
41
|
+
readonly defectRetryPolicy?: Schedule.Schedule<any, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Persistence configuration. When set, requires PersistenceAdapter in R.
|
|
44
|
+
*/
|
|
45
|
+
readonly persistence?: EntityPersistenceConfig;
|
|
44
46
|
}
|
|
45
47
|
/**
|
|
46
48
|
* Create an Entity layer that wires a machine to handle RPC calls.
|
|
47
49
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* - Evaluates guards in registration order
|
|
52
|
-
* - Runs lifecycle effects (onEnter/spawn)
|
|
53
|
-
* - Processes internal events from spawn effects
|
|
50
|
+
* Uses `Entity.toLayerQueue` for a single serialized mailbox per entity.
|
|
51
|
+
* The runtime kernel handles event processing, postpone, background effects,
|
|
52
|
+
* spawn effects, and final state detection.
|
|
54
53
|
*
|
|
55
54
|
* @example
|
|
56
55
|
* ```ts
|
|
57
|
-
* const OrderEntity = toEntity(orderMachine, {
|
|
58
|
-
* type: "Order",
|
|
59
|
-
* stateSchema: OrderState,
|
|
60
|
-
* eventSchema: OrderEvent,
|
|
61
|
-
* })
|
|
56
|
+
* const OrderEntity = toEntity(orderMachine, { type: "Order" })
|
|
62
57
|
*
|
|
63
58
|
* const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, {
|
|
64
59
|
* initializeState: (entityId) => OrderState.Pending({ orderId: entityId }),
|
|
65
60
|
* })
|
|
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
61
|
* ```
|
|
73
62
|
*/
|
|
74
63
|
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
64
|
layer: <S extends {
|
|
83
65
|
readonly _tag: string;
|
|
84
66
|
}, E extends {
|
|
85
67
|
readonly _tag: string;
|
|
86
|
-
}, R,
|
|
68
|
+
}, 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
69
|
};
|
|
88
70
|
//#endregion
|
|
89
71
|
export { EntityMachine, EntityMachineOptions };
|
|
@@ -1,80 +1,206 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ActorSystem } from "../actor.js";
|
|
3
|
-
import {
|
|
1
|
+
import { createRuntime } from "../internal/runtime.js";
|
|
2
|
+
import { ActorSystem, makeSystem } from "../actor.js";
|
|
3
|
+
import { replay } from "../machine.js";
|
|
4
|
+
import { PersistenceAdapter } from "./persistence.js";
|
|
5
|
+
import { Clock, Effect, Option, Queue, Ref, Stream, SubscriptionRef } from "effect";
|
|
4
6
|
import { Entity } from "effect/unstable/cluster";
|
|
5
7
|
//#region src/cluster/entity-machine.ts
|
|
6
8
|
/**
|
|
7
9
|
* EntityMachine adapter - wires a machine to a cluster Entity layer.
|
|
8
10
|
*
|
|
11
|
+
* Uses Entity.toLayerQueue for a single serialized mailbox per entity.
|
|
12
|
+
* All events (external RPCs + internal self.send) go through the
|
|
13
|
+
* runtime kernel's single queue — no split-mailbox race.
|
|
14
|
+
*
|
|
15
|
+
* Supports opt-in persistence (snapshot or journal strategy) for
|
|
16
|
+
* state survival across entity deactivation/reactivation cycles.
|
|
17
|
+
*
|
|
9
18
|
* @module
|
|
10
19
|
*/
|
|
11
20
|
/**
|
|
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
21
|
* Create an Entity layer that wires a machine to handle RPC calls.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* - Evaluates guards in registration order
|
|
27
|
-
* - Runs lifecycle effects (onEnter/spawn)
|
|
28
|
-
* - Processes internal events from spawn effects
|
|
23
|
+
* Uses `Entity.toLayerQueue` for a single serialized mailbox per entity.
|
|
24
|
+
* The runtime kernel handles event processing, postpone, background effects,
|
|
25
|
+
* spawn effects, and final state detection.
|
|
29
26
|
*
|
|
30
27
|
* @example
|
|
31
28
|
* ```ts
|
|
32
|
-
* const OrderEntity = toEntity(orderMachine, {
|
|
33
|
-
* type: "Order",
|
|
34
|
-
* stateSchema: OrderState,
|
|
35
|
-
* eventSchema: OrderEvent,
|
|
36
|
-
* })
|
|
29
|
+
* const OrderEntity = toEntity(orderMachine, { type: "Order" })
|
|
37
30
|
*
|
|
38
31
|
* const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, {
|
|
39
32
|
* initializeState: (entityId) => OrderState.Pending({ orderId: entityId }),
|
|
40
33
|
* })
|
|
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
34
|
* ```
|
|
48
35
|
*/
|
|
49
36
|
const EntityMachine = { layer: (entity, machine, options) => {
|
|
50
|
-
const
|
|
37
|
+
const persistence = options?.persistence;
|
|
38
|
+
const build = Effect.gen(function* () {
|
|
51
39
|
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
40
|
const existingSystem = yield* Effect.serviceOption(ActorSystem);
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
41
|
+
const system = Option.isSome(existingSystem) ? existingSystem.value : yield* makeSystem();
|
|
42
|
+
const persistCtx = yield* hydratePersistence(persistence, entity, entityId, machine, options?.initializeState);
|
|
43
|
+
const initialState = persistCtx.hydratedState ?? (options?.initializeState !== void 0 ? options.initializeState(entityId) : void 0);
|
|
44
|
+
const machineWithState = initialState !== void 0 ? Object.create(machine, { initial: {
|
|
45
|
+
value: initialState,
|
|
46
|
+
enumerable: true
|
|
47
|
+
} }) : machine;
|
|
48
|
+
const versionRef = yield* Ref.make(persistCtx.initialVersion);
|
|
49
|
+
const computedInitial = initialState ?? machine.initial;
|
|
50
|
+
const stateRef = yield* SubscriptionRef.make(computedInitial);
|
|
51
|
+
const stoppedRef = yield* Ref.make(false);
|
|
52
|
+
const eventQueue = yield* Queue.unbounded();
|
|
53
|
+
const runtime = yield* createRuntime(machineWithState, system, {
|
|
54
|
+
actorId: entityId,
|
|
55
|
+
hooks: options?.hooks,
|
|
56
|
+
childIdPrefix: `${entityId}/`,
|
|
57
|
+
cellResources: {
|
|
58
|
+
stateRef,
|
|
59
|
+
stoppedRef,
|
|
60
|
+
eventQueue
|
|
61
|
+
}
|
|
59
62
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
63
|
+
if (persistCtx.adapter !== void 0) {
|
|
64
|
+
const { adapter: pAdapter, key } = persistCtx;
|
|
65
|
+
const strategy = persistence?.strategy ?? "snapshot";
|
|
66
|
+
const schedule = persistence?.snapshotSchedule;
|
|
67
|
+
if (strategy === "snapshot") yield* SubscriptionRef.changes(runtime.stateRef).pipe(schedule !== void 0 ? Stream.schedule(schedule) : (s) => s, Stream.runForEach((state) => Effect.gen(function* () {
|
|
68
|
+
const version = yield* Ref.get(versionRef);
|
|
69
|
+
const now = yield* Clock.currentTimeMillis;
|
|
70
|
+
yield* pAdapter.saveSnapshot(key, {
|
|
71
|
+
state,
|
|
72
|
+
version,
|
|
73
|
+
timestamp: now
|
|
74
|
+
});
|
|
75
|
+
}).pipe(Effect.catch(() => Effect.void))), Effect.forkScoped);
|
|
76
|
+
yield* Effect.addFinalizer(() => Effect.gen(function* () {
|
|
77
|
+
const state = yield* SubscriptionRef.get(runtime.stateRef);
|
|
78
|
+
const version = yield* Ref.get(versionRef);
|
|
79
|
+
const now = yield* Clock.currentTimeMillis;
|
|
80
|
+
yield* pAdapter.saveSnapshot(key, {
|
|
81
|
+
state,
|
|
82
|
+
version,
|
|
83
|
+
timestamp: now
|
|
84
|
+
});
|
|
85
|
+
}).pipe(Effect.catch(() => Effect.void)));
|
|
86
|
+
}
|
|
87
|
+
return (mailbox, replier) => Effect.gen(function* () {
|
|
88
|
+
const hasPersistence = persistCtx.adapter !== void 0;
|
|
89
|
+
const journalCtx = hasPersistence && (persistence?.strategy ?? "snapshot") === "journal" ? {
|
|
90
|
+
adapter: persistCtx.adapter,
|
|
91
|
+
key: persistCtx.key
|
|
92
|
+
} : void 0;
|
|
93
|
+
while (true) {
|
|
94
|
+
const request = yield* Queue.take(mailbox);
|
|
95
|
+
switch (request.tag) {
|
|
96
|
+
case "Send": {
|
|
97
|
+
const event = request.payload.event;
|
|
98
|
+
yield* runtime.sendWait(event).pipe(Effect.orDie);
|
|
99
|
+
if (journalCtx !== void 0) yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, event);
|
|
100
|
+
else if (hasPersistence) yield* Ref.update(versionRef, (v) => v + 1);
|
|
101
|
+
const state = yield* runtime.getState;
|
|
102
|
+
yield* replier.succeed(request, state);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case "Ask": {
|
|
106
|
+
const event = request.payload.event;
|
|
107
|
+
const reply = yield* runtime.ask(event);
|
|
108
|
+
if (journalCtx !== void 0) yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, event);
|
|
109
|
+
else if (hasPersistence) yield* Ref.update(versionRef, (v) => v + 1);
|
|
110
|
+
yield* replier.succeed(request, reply);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case "GetState": {
|
|
114
|
+
const state = yield* runtime.getState;
|
|
115
|
+
yield* replier.succeed(request, state);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "WatchState":
|
|
119
|
+
yield* replier.succeed(request, SubscriptionRef.changes(runtime.stateRef));
|
|
120
|
+
break;
|
|
121
|
+
default: break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
75
124
|
});
|
|
76
125
|
});
|
|
77
|
-
|
|
126
|
+
const clusterOptions = {};
|
|
127
|
+
if (options?.maxIdleTime !== void 0) clusterOptions.maxIdleTime = options.maxIdleTime;
|
|
128
|
+
if (options?.mailboxCapacity !== void 0) clusterOptions.mailboxCapacity = options.mailboxCapacity;
|
|
129
|
+
if (options?.disableFatalDefects !== void 0) clusterOptions.disableFatalDefects = options.disableFatalDefects;
|
|
130
|
+
if (options?.defectRetryPolicy !== void 0) clusterOptions.defectRetryPolicy = options.defectRetryPolicy;
|
|
131
|
+
return entity.toLayerQueue(build.pipe(Effect.orDie), Object.keys(clusterOptions).length > 0 ? clusterOptions : void 0);
|
|
78
132
|
} };
|
|
133
|
+
const noPersistence = {
|
|
134
|
+
adapter: void 0,
|
|
135
|
+
key: void 0,
|
|
136
|
+
hydratedState: void 0,
|
|
137
|
+
initialVersion: 0
|
|
138
|
+
};
|
|
139
|
+
/** Load snapshot/journal and compute hydrated state. */
|
|
140
|
+
const hydratePersistence = (persistence, entityDef, entityId, machine, initializeState) => Effect.gen(function* () {
|
|
141
|
+
if (persistence === void 0) return noPersistence;
|
|
142
|
+
const adapter = yield* PersistenceAdapter;
|
|
143
|
+
const key = {
|
|
144
|
+
entityType: persistence.machineType ?? entityDef.type,
|
|
145
|
+
entityId
|
|
146
|
+
};
|
|
147
|
+
const maybeSnapshot = yield* adapter.loadSnapshot(key);
|
|
148
|
+
if ((persistence.strategy ?? "snapshot") === "journal") {
|
|
149
|
+
const baseState = Option.isSome(maybeSnapshot) ? maybeSnapshot.value.state : initializeState !== void 0 ? initializeState(entityId) : machine.initial;
|
|
150
|
+
const snapshotVersion = Option.isSome(maybeSnapshot) ? maybeSnapshot.value.version : 0;
|
|
151
|
+
const events = yield* adapter.loadEvents(key, snapshotVersion);
|
|
152
|
+
if (events.length > 0) {
|
|
153
|
+
const hydratedState = yield* replay(machine, events.map((e) => e.event), { from: baseState });
|
|
154
|
+
const lastEvent = events[events.length - 1];
|
|
155
|
+
return {
|
|
156
|
+
adapter,
|
|
157
|
+
key,
|
|
158
|
+
hydratedState,
|
|
159
|
+
initialVersion: lastEvent !== void 0 ? lastEvent.version : snapshotVersion
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
adapter,
|
|
164
|
+
key,
|
|
165
|
+
hydratedState: Option.isSome(maybeSnapshot) ? maybeSnapshot.value.state : void 0,
|
|
166
|
+
initialVersion: snapshotVersion
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (Option.isSome(maybeSnapshot)) return {
|
|
170
|
+
adapter,
|
|
171
|
+
key,
|
|
172
|
+
hydratedState: maybeSnapshot.value.state,
|
|
173
|
+
initialVersion: maybeSnapshot.value.version
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
adapter,
|
|
177
|
+
key,
|
|
178
|
+
hydratedState: void 0,
|
|
179
|
+
initialVersion: 0
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
/**
|
|
183
|
+
* Append a single event to the journal, incrementing version.
|
|
184
|
+
*
|
|
185
|
+
* On failure: defects the entity activation. The cluster's defectRetryPolicy
|
|
186
|
+
* restarts the entity, which rehydrates from the last consistent snapshot +
|
|
187
|
+
* whatever events made it to the journal. This is correct because the in-memory
|
|
188
|
+
* state has already advanced — we can't un-ring that bell — so the activation
|
|
189
|
+
* is now unreliable and must restart.
|
|
190
|
+
*/
|
|
191
|
+
const persistEvent = (adapter, key, versionRef, event) => Effect.gen(function* () {
|
|
192
|
+
const expectedVersion = yield* Ref.get(versionRef);
|
|
193
|
+
const newVersion = expectedVersion + 1;
|
|
194
|
+
const persisted = {
|
|
195
|
+
event,
|
|
196
|
+
version: newVersion,
|
|
197
|
+
timestamp: yield* Clock.currentTimeMillis
|
|
198
|
+
};
|
|
199
|
+
yield* adapter.appendEvents(key, [persisted], expectedVersion);
|
|
200
|
+
yield* Ref.set(versionRef, newVersion);
|
|
201
|
+
}).pipe(Effect.tapError((error) => Effect.logWarning("Journal append failed, defecting entity", {
|
|
202
|
+
key,
|
|
203
|
+
error
|
|
204
|
+
})), Effect.orDie);
|
|
79
205
|
//#endregion
|
|
80
206
|
export { EntityMachine };
|
package/dist/cluster/index.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { EntityPersistenceConfig, PersistedEvent, PersistenceAdapter, PersistenceKey, Snapshot } from "./persistence.js";
|
|
2
|
+
import { makeInMemoryPersistenceAdapter } from "./adapters/in-memory.js";
|
|
3
|
+
import { EntityRpcs, ToEntityOptions, toEntity } from "./to-entity.js";
|
|
4
|
+
import { EntityActorRef, makeEntityActorRef } from "./entity-actor-ref.js";
|
|
1
5
|
import { EntityMachine, EntityMachineOptions } from "./entity-machine.js";
|
|
2
|
-
|
|
3
|
-
export { EntityMachine, type EntityMachineOptions, type ToEntityOptions, toEntity };
|
|
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 };
|
package/dist/cluster/index.js
CHANGED
|
@@ -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
|
-
|
|
5
|
+
import { makeInMemoryPersistenceAdapter } from "./adapters/in-memory.js";
|
|
6
|
+
export { EntityMachine, PersistenceAdapter, makeEntityActorRef, makeInMemoryPersistenceAdapter, toEntity };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PersistenceError, VersionConflictError } from "../errors.js";
|
|
2
|
+
import { Effect, Option, Schedule, ServiceMap } 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
|
+
* Only applies to snapshot strategy (or journal strategy's periodic snapshots).
|
|
29
|
+
* Default: save on every state change.
|
|
30
|
+
*/
|
|
31
|
+
readonly snapshotSchedule?: Schedule.Schedule<any, any>;
|
|
32
|
+
/** Override entityType in the persistence key (defaults to Entity.type). */
|
|
33
|
+
readonly machineType?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Storage backend for entity state persistence. */
|
|
36
|
+
interface PersistenceAdapter {
|
|
37
|
+
/** Save a state snapshot. Fails with VersionConflictError if version is stale. */
|
|
38
|
+
readonly saveSnapshot: (key: PersistenceKey, snapshot: Snapshot<unknown>) => Effect.Effect<void, PersistenceError | VersionConflictError>;
|
|
39
|
+
/** Load the latest snapshot, or None if no snapshot exists. */
|
|
40
|
+
readonly loadSnapshot: (key: PersistenceKey) => Effect.Effect<Option.Option<Snapshot<unknown>>, PersistenceError>;
|
|
41
|
+
/** Append events to the journal. Fails with VersionConflictError if expectedVersion doesn't match. */
|
|
42
|
+
readonly appendEvents: (key: PersistenceKey, events: ReadonlyArray<PersistedEvent<unknown>>, expectedVersion: number) => Effect.Effect<void, PersistenceError | VersionConflictError>;
|
|
43
|
+
/** Load events from the journal, optionally after a given version. */
|
|
44
|
+
readonly loadEvents: (key: PersistenceKey, afterVersion?: number) => Effect.Effect<ReadonlyArray<PersistedEvent<unknown>>, PersistenceError>;
|
|
45
|
+
}
|
|
46
|
+
/** Service tag for PersistenceAdapter — resolve from context for shared infra. */
|
|
47
|
+
declare const PersistenceAdapter: ServiceMap.Service<PersistenceAdapter, PersistenceAdapter>;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { EntityPersistenceConfig, PersistedEvent, PersistenceAdapter, PersistenceKey, Snapshot };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ServiceMap } from "effect";
|
|
2
|
+
//#region src/cluster/persistence.ts
|
|
3
|
+
/**
|
|
4
|
+
* Entity persistence types and adapter interface.
|
|
5
|
+
*
|
|
6
|
+
* Provides snapshot and event journal persistence for entity-machine
|
|
7
|
+
* state across deactivation/reactivation cycles.
|
|
8
|
+
*
|
|
9
|
+
* Two strategies:
|
|
10
|
+
* - **snapshot**: Save full state periodically. Simple, fast.
|
|
11
|
+
* - **journal**: Append events inline, replay on reactivation. Full audit trail.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
/** Service tag for PersistenceAdapter — resolve from context for shared infra. */
|
|
16
|
+
const PersistenceAdapter = ServiceMap.Service("@effect-machine/cluster/PersistenceAdapter");
|
|
17
|
+
//#endregion
|
|
18
|
+
export { PersistenceAdapter };
|
|
@@ -2,6 +2,7 @@ import { Machine } from "../machine.js";
|
|
|
2
2
|
import { Schema } from "effect";
|
|
3
3
|
import { Entity } from "effect/unstable/cluster";
|
|
4
4
|
import { Rpc } from "effect/unstable/rpc";
|
|
5
|
+
import * as _$effect_unstable_rpc_RpcSchema0 from "effect/unstable/rpc/RpcSchema";
|
|
5
6
|
|
|
6
7
|
//#region src/cluster/to-entity.d.ts
|
|
7
8
|
/**
|
|
@@ -16,12 +17,15 @@ interface ToEntityOptions {
|
|
|
16
17
|
/**
|
|
17
18
|
* Default RPC protocol for entity machines.
|
|
18
19
|
*
|
|
19
|
-
* - `Send` - Send event to machine, returns new state
|
|
20
|
+
* - `Send` - Send event to machine (fire-and-forget), returns new state
|
|
21
|
+
* - `Ask` - Send event and get domain reply (typed via Event.reply() schemas)
|
|
20
22
|
* - `GetState` - Get current state
|
|
21
23
|
*/
|
|
22
24
|
type EntityRpcs<StateSchema extends Schema.Top, EventSchema extends Schema.Top> = readonly [Rpc.Rpc<"Send", Schema.Struct<{
|
|
23
25
|
readonly event: EventSchema;
|
|
24
|
-
}>, StateSchema, typeof Schema.Never, never>, Rpc.Rpc<"
|
|
26
|
+
}>, StateSchema, typeof Schema.Never, never>, Rpc.Rpc<"Ask", Schema.Struct<{
|
|
27
|
+
readonly event: EventSchema;
|
|
28
|
+
}>, typeof Schema.Unknown, typeof Schema.Never, never>, Rpc.Rpc<"GetState", typeof Schema.Void, StateSchema, typeof Schema.Never, never>];
|
|
25
29
|
/**
|
|
26
30
|
* Generate an Entity definition from a machine.
|
|
27
31
|
*
|
|
@@ -59,6 +63,8 @@ declare const toEntity: <S extends {
|
|
|
59
63
|
readonly _tag: string;
|
|
60
64
|
}, R>(machine: Machine<S, E, R, any, any, any, any>, options: ToEntityOptions) => Entity.Entity<string, Rpc.Rpc<"Send", Schema.Struct<{
|
|
61
65
|
event: Schema.Schema<E>;
|
|
62
|
-
}>, Schema.Schema<S>, Schema.Never, never, never> | Rpc.Rpc<"
|
|
66
|
+
}>, Schema.Schema<S>, Schema.Never, never, never> | Rpc.Rpc<"Ask", Schema.Struct<{
|
|
67
|
+
event: Schema.Schema<E>;
|
|
68
|
+
}>, Schema.Unknown, Schema.Never, never, never> | Rpc.Rpc<"GetState", Schema.Void, Schema.Schema<S>, Schema.Never, never, never> | Rpc.Rpc<"WatchState", Schema.Void, _$effect_unstable_rpc_RpcSchema0.Stream<Schema.Schema<S>, Schema.Never>, Schema.Never, never, never>>;
|
|
63
69
|
//#endregion
|
|
64
70
|
export { EntityRpcs, ToEntityOptions, toEntity };
|