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
package/README.md
CHANGED
|
@@ -1,401 +1,205 @@
|
|
|
1
1
|
# effect-machine
|
|
2
2
|
|
|
3
|
-
Type-safe state machines for Effect.
|
|
3
|
+
Type-safe state machines for [Effect](https://effect.website).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Complex workflows usually fail the same way: one `status` field, a few side booleans, and effects scattered across callbacks. `effect-machine` gives you one typed model for state, events, and transitions, then runs it as a real actor.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Use it when a feature has:
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
9
|
+
- multiple valid and invalid states
|
|
10
|
+
- async work tied to state entry
|
|
11
|
+
- retries, timeouts, cancellation, or backpressure
|
|
12
|
+
- logic you want to reuse in-process, in tests, and in distributed systems
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
bun add effect-machine effect
|
|
18
|
-
# or
|
|
19
|
-
pnpm add effect-machine effect
|
|
20
|
-
# or
|
|
21
|
-
npm install effect-machine effect
|
|
22
18
|
```
|
|
23
19
|
|
|
24
|
-
##
|
|
20
|
+
## Core Pattern
|
|
21
|
+
|
|
22
|
+
States and events are schemas. Types, validation, and serialization from one place.
|
|
25
23
|
|
|
26
24
|
```ts
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
Cancelled: {},
|
|
25
|
+
import { Schema } from "effect";
|
|
26
|
+
import { Event, Machine, Slot, State } from "effect-machine";
|
|
27
|
+
|
|
28
|
+
const CheckoutState = State({
|
|
29
|
+
ReviewingCart: { cartId: Schema.String, totalCents: Schema.Number },
|
|
30
|
+
ChargingCard: { cartId: Schema.String, totalCents: Schema.Number },
|
|
31
|
+
Confirmed: { cartId: Schema.String, receiptId: Schema.String },
|
|
32
|
+
Failed: { cartId: Schema.String, reason: Schema.String },
|
|
36
33
|
});
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
const CheckoutEvent = Event({
|
|
36
|
+
Submit: {},
|
|
37
|
+
Charged: { receiptId: Schema.String },
|
|
38
|
+
Declined: { reason: Schema.String },
|
|
42
39
|
Cancel: {},
|
|
43
40
|
});
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
notifyWarehouse: { orderId: Schema.String },
|
|
42
|
+
const CheckoutEffects = Slot.Effects({
|
|
43
|
+
chargeCard: { cartId: Schema.String, totalCents: Schema.Number },
|
|
48
44
|
});
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
initial: OrderState.Pending({ orderId: "order-1" }),
|
|
46
|
+
const checkoutMachine = Machine.make({
|
|
47
|
+
state: CheckoutState,
|
|
48
|
+
event: CheckoutEvent,
|
|
49
|
+
effects: CheckoutEffects,
|
|
50
|
+
initial: CheckoutState.ReviewingCart({ cartId: "cart_123", totalCents: 4200 }),
|
|
56
51
|
})
|
|
57
|
-
.on(
|
|
58
|
-
|
|
59
|
-
OrderState.Shipped.derive(state, { trackingId: event.trackingId }),
|
|
52
|
+
.on(CheckoutState.ReviewingCart, CheckoutEvent.Submit, ({ state }) =>
|
|
53
|
+
CheckoutState.ChargingCard.derive(state),
|
|
60
54
|
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// Effect runs when entering Processing, cancelled on exit
|
|
64
|
-
.spawn(OrderState.Processing, ({ effects, state }) =>
|
|
65
|
-
effects.notifyWarehouse({ orderId: state.orderId }),
|
|
55
|
+
.on(CheckoutState.ChargingCard, CheckoutEvent.Charged, ({ state, event }) =>
|
|
56
|
+
CheckoutState.Confirmed.derive(state, { receiptId: event.receiptId }),
|
|
66
57
|
)
|
|
67
|
-
.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
yield* actor.send(OrderEvent.Process);
|
|
79
|
-
|
|
80
|
-
// request-reply — get ProcessEventResult back
|
|
81
|
-
const result = yield* actor.call(OrderEvent.Ship({ trackingId: "TRACK-123" }));
|
|
82
|
-
console.log(result.transitioned); // true
|
|
83
|
-
|
|
84
|
-
const state = yield* actor.waitFor(OrderState.Shipped);
|
|
85
|
-
console.log(state); // Shipped { orderId: "order-1", trackingId: "TRACK-123" }
|
|
86
|
-
|
|
87
|
-
yield* actor.stop;
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
Effect.runPromise(program);
|
|
58
|
+
.on(CheckoutState.ChargingCard, CheckoutEvent.Declined, ({ state, event }) =>
|
|
59
|
+
CheckoutState.Failed.derive(state, { reason: event.reason }),
|
|
60
|
+
)
|
|
61
|
+
.onAny(CheckoutEvent.Cancel, ({ state }) =>
|
|
62
|
+
CheckoutState.Failed.derive(state, { reason: "cancelled" }),
|
|
63
|
+
)
|
|
64
|
+
.spawn(CheckoutState.ChargingCard, ({ effects, state }) =>
|
|
65
|
+
effects.chargeCard({ cartId: state.cartId, totalCents: state.totalCents }),
|
|
66
|
+
)
|
|
67
|
+
.final(CheckoutState.Confirmed)
|
|
68
|
+
.final(CheckoutState.Failed);
|
|
91
69
|
```
|
|
92
70
|
|
|
93
|
-
|
|
71
|
+
A few things to notice:
|
|
94
72
|
|
|
95
|
-
|
|
73
|
+
- Empty variants are values: `State.Idle`. Non-empty are constructors: `State.Loading({ url })`.
|
|
74
|
+
- `State.derive(source, overrides)` carries overlapping fields forward without manual copying.
|
|
75
|
+
- `.onAny(...)` is a fallback; a specific `.on(...)` wins.
|
|
76
|
+
- `.spawn(...)` runs work on state entry and cancels it on state exit.
|
|
96
77
|
|
|
97
|
-
|
|
78
|
+
The builder also supports `.timeout(state, { duration, event })`, `.postpone(state, event)` for buffering, and `.reenter(...)` for re-running lifecycle on same-state transitions.
|
|
98
79
|
|
|
99
|
-
|
|
100
|
-
const MyState = State({
|
|
101
|
-
Idle: {}, // Empty = plain value
|
|
102
|
-
Loading: { url: Schema.String }, // Non-empty = constructor
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
MyState.Idle; // Value (no parens)
|
|
106
|
-
MyState.Loading({ url: "/api" }); // Constructor
|
|
107
|
-
```
|
|
80
|
+
## Slots
|
|
108
81
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
Construct new states from existing ones — picks overlapping fields, applies overrides:
|
|
82
|
+
Slots separate what a machine needs from how the app provides it. Declare them on the machine, provide implementations where you run it.
|
|
112
83
|
|
|
113
84
|
```ts
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
85
|
+
const actor =
|
|
86
|
+
yield *
|
|
87
|
+
Machine.spawn(checkoutMachine, {
|
|
88
|
+
slots: {
|
|
89
|
+
chargeCard: ({ cartId, totalCents }, { self }) =>
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const result = yield* PaymentService.charge(cartId, totalCents);
|
|
92
|
+
yield* self.send(
|
|
93
|
+
result.ok
|
|
94
|
+
? CheckoutEvent.Charged({ receiptId: result.receiptId })
|
|
95
|
+
: CheckoutEvent.Declined({ reason: result.error }),
|
|
96
|
+
);
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
123
100
|
```
|
|
124
101
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
Handle the same event from multiple states:
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
// Array of states — handler receives union type
|
|
131
|
-
.on([State.Draft, State.Review], Event.Cancel, () => State.Cancelled)
|
|
102
|
+
The same machine can run with different slot implementations in tests, local apps, or production. Slots are accepted everywhere the machine runs:
|
|
132
103
|
|
|
133
|
-
|
|
134
|
-
.
|
|
135
|
-
|
|
104
|
+
- `Machine.spawn(machine, { slots })`
|
|
105
|
+
- `Machine.replay(machine, events, { slots })`
|
|
106
|
+
- `simulate(machine, events, { slots })`
|
|
107
|
+
- `createTestHarness(machine, { slots })`
|
|
136
108
|
|
|
137
|
-
|
|
109
|
+
## Running Actors
|
|
138
110
|
|
|
139
|
-
|
|
111
|
+
`Machine.spawn` gives you a live actor with a queue and lifecycle management.
|
|
140
112
|
|
|
141
113
|
```ts
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
114
|
+
const program = Effect.gen(function* () {
|
|
115
|
+
const actor = yield* Machine.spawn(checkoutMachine, {
|
|
116
|
+
slots: {
|
|
117
|
+
chargeCard: ({ cartId }, { self }) =>
|
|
118
|
+
self.send(CheckoutEvent.Charged({ receiptId: `rcpt_${cartId}` })),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
145
121
|
|
|
146
|
-
|
|
147
|
-
|
|
122
|
+
yield* actor.send(CheckoutEvent.Submit);
|
|
123
|
+
const finalState = yield* actor.awaitFinal;
|
|
148
124
|
});
|
|
149
125
|
|
|
150
|
-
|
|
151
|
-
.on(MyState.Error, MyEvent.Retry, ({ state, guards }) =>
|
|
152
|
-
Effect.gen(function* () {
|
|
153
|
-
if (yield* guards.canRetry({ max: 3 })) {
|
|
154
|
-
return MyState.Loading({ url: state.url }); // Transition first
|
|
155
|
-
}
|
|
156
|
-
return MyState.Failed;
|
|
157
|
-
}),
|
|
158
|
-
)
|
|
159
|
-
// Fetch runs when entering Loading, auto-cancelled if state changes
|
|
160
|
-
.spawn(MyState.Loading, ({ effects, state }) => effects.fetchData({ url: state.url }))
|
|
161
|
-
.build({
|
|
162
|
-
canRetry: ({ max }, { state }) => state.attempts < max,
|
|
163
|
-
fetchData: ({ url }, { self }) =>
|
|
164
|
-
Effect.gen(function* () {
|
|
165
|
-
const data = yield* Http.get(url);
|
|
166
|
-
yield* self.send(MyEvent.Resolve({ data }));
|
|
167
|
-
}),
|
|
168
|
-
});
|
|
126
|
+
Effect.runPromise(Effect.scoped(program));
|
|
169
127
|
```
|
|
170
128
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
`.spawn()` runs effects when entering a state, auto-cancelled on exit:
|
|
129
|
+
Key actor operations:
|
|
174
130
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
131
|
+
- `send(event)` queues and returns immediately
|
|
132
|
+
- `call(event)` returns full transition info
|
|
133
|
+
- `ask(event)` returns a typed domain reply (requires `Event.reply(...)`)
|
|
134
|
+
- `waitFor(...)` / `awaitFinal` for coordination
|
|
135
|
+
- `stop` interrupts now; `drain` processes the remaining queue first
|
|
136
|
+
- `watch(other)` completes when another actor stops
|
|
180
137
|
|
|
181
|
-
|
|
138
|
+
For named actors or shared lookup, use an actor system:
|
|
182
139
|
|
|
183
140
|
```ts
|
|
184
|
-
|
|
185
|
-
onSuccess: (data) => MyEvent.Resolve({ data }),
|
|
186
|
-
onFailure: () => MyEvent.Reject,
|
|
187
|
-
});
|
|
188
|
-
```
|
|
141
|
+
import { ActorSystemDefault, ActorSystemService } from "effect-machine";
|
|
189
142
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
machine
|
|
196
|
-
.timeout(State.Loading, {
|
|
197
|
-
duration: Duration.seconds(30),
|
|
198
|
-
event: Event.Timeout,
|
|
199
|
-
})
|
|
200
|
-
// Dynamic duration from state
|
|
201
|
-
.timeout(State.Retrying, {
|
|
202
|
-
duration: (state) => Duration.seconds(state.backoff),
|
|
203
|
-
event: Event.GiveUp,
|
|
204
|
-
});
|
|
143
|
+
const program = Effect.gen(function* () {
|
|
144
|
+
const system = yield* ActorSystemService;
|
|
145
|
+
const actor = yield* system.spawn("checkout-123", checkoutMachine);
|
|
146
|
+
yield* actor.send(CheckoutEvent.Submit);
|
|
147
|
+
}).pipe(Effect.provide(ActorSystemDefault));
|
|
205
148
|
```
|
|
206
149
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
### Event Postpone
|
|
150
|
+
### Typed Replies
|
|
210
151
|
|
|
211
|
-
|
|
152
|
+
Events can declare typed reply schemas:
|
|
212
153
|
|
|
213
154
|
```ts
|
|
214
|
-
|
|
215
|
-
.
|
|
216
|
-
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
Reply-bearing events (`call`/`ask`) in the postpone buffer are settled with `ActorStoppedError` on stop/interrupt/final-state.
|
|
220
|
-
|
|
221
|
-
### ask / reply
|
|
222
|
-
|
|
223
|
-
Handlers can return a domain reply via `{ state, reply }`:
|
|
155
|
+
const CartEvent = Event({
|
|
156
|
+
GetTotal: Event.reply({}, Schema.Number),
|
|
157
|
+
});
|
|
224
158
|
|
|
225
|
-
|
|
226
|
-
.on(State.Active, Event.GetCount, ({ state }) => ({
|
|
227
|
-
state, // stay in same state
|
|
228
|
-
reply: state.count, // domain value returned to caller
|
|
229
|
-
}))
|
|
159
|
+
machine.on(State.Active, CartEvent.GetTotal, ({ state }) => Machine.reply(state, state.totalCents));
|
|
230
160
|
|
|
231
|
-
//
|
|
232
|
-
const count = yield* actor.ask<number>(Event.GetCount);
|
|
161
|
+
const total = yield * actor.ask(CartEvent.GetTotal); // number
|
|
233
162
|
```
|
|
234
163
|
|
|
235
|
-
|
|
164
|
+
## Testing
|
|
236
165
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
Spawn children from `.spawn()` handlers with `self.spawn`. Children are state-scoped — auto-stopped on state exit:
|
|
166
|
+
Test transitions without spawning actors:
|
|
240
167
|
|
|
241
168
|
```ts
|
|
242
|
-
machine
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}),
|
|
249
|
-
|
|
250
|
-
|
|
169
|
+
import { simulate } from "effect-machine";
|
|
170
|
+
|
|
171
|
+
const result =
|
|
172
|
+
yield *
|
|
173
|
+
simulate(
|
|
174
|
+
checkoutMachine,
|
|
175
|
+
[CheckoutEvent.Submit, CheckoutEvent.Charged({ receiptId: "rcpt_123" })],
|
|
176
|
+
{ slots: { chargeCard: () => Effect.void } },
|
|
177
|
+
);
|
|
251
178
|
|
|
252
|
-
|
|
253
|
-
const parent = yield * Machine.spawn(parentMachine);
|
|
254
|
-
yield * parent.send(Event.Activate);
|
|
255
|
-
const child = yield * parent.system.get("worker-1"); // Option<ActorRef>
|
|
179
|
+
expect(result.states.map((s) => s._tag)).toEqual(["ReviewingCart", "ChargingCard", "Confirmed"]);
|
|
256
180
|
```
|
|
257
181
|
|
|
258
|
-
|
|
182
|
+
`simulate` and `createTestHarness` test transition logic. They do not run `.spawn()` or `.background()` effects.
|
|
259
183
|
|
|
260
|
-
|
|
184
|
+
## Cluster
|
|
261
185
|
|
|
262
|
-
|
|
186
|
+
When the same machine needs to run behind `@effect/cluster`, turn it into an entity:
|
|
263
187
|
|
|
264
188
|
```ts
|
|
265
|
-
|
|
266
|
-
yield * actor.changes.pipe(Stream.runForEach((state) => saveSnapshot(actor.id, state)));
|
|
267
|
-
|
|
268
|
-
// Event journal — observe transitions
|
|
269
|
-
yield * actor.transitions.pipe(Stream.runForEach(({ event }) => appendEvent(actor.id, event)));
|
|
189
|
+
import { EntityMachine, toEntity } from "effect-machine/cluster";
|
|
270
190
|
|
|
271
|
-
|
|
272
|
-
const savedState = yield * loadSnapshot(id);
|
|
273
|
-
const actor = yield * Machine.spawn(machine, { hydrate: savedState });
|
|
191
|
+
const CheckoutEntity = toEntity(checkoutMachine, { type: "Checkout" });
|
|
274
192
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const actor = yield * Machine.spawn(machine, { hydrate: state });
|
|
279
|
-
|
|
280
|
-
// Restore from snapshot + tail events
|
|
281
|
-
const state = yield * Machine.replay(machine, tailEvents, { from: snapshot });
|
|
282
|
-
const actor = yield * Machine.spawn(machine, { hydrate: state });
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
### System Observation
|
|
286
|
-
|
|
287
|
-
React to actors joining and leaving the system:
|
|
288
|
-
|
|
289
|
-
```ts
|
|
290
|
-
const system = yield * ActorSystemService;
|
|
291
|
-
|
|
292
|
-
// Sync callback — like ActorRef.subscribe
|
|
293
|
-
const unsub = system.subscribe((event) => {
|
|
294
|
-
// event._tag: "ActorSpawned" | "ActorStopped"
|
|
295
|
-
console.log(`${event._tag}: ${event.id}`);
|
|
193
|
+
const CheckoutEntityLayer = EntityMachine.layer(CheckoutEntity, checkoutMachine, {
|
|
194
|
+
initializeState: (entityId) => CheckoutState.ReviewingCart({ cartId: entityId, totalCents: 0 }),
|
|
195
|
+
persistence: { strategy: "journal" },
|
|
296
196
|
});
|
|
297
|
-
|
|
298
|
-
// Sync snapshot of all registered actors
|
|
299
|
-
const actors = system.actors; // ReadonlyMap<string, ActorRef>
|
|
300
|
-
|
|
301
|
-
// Async stream (each subscriber gets own queue)
|
|
302
|
-
yield *
|
|
303
|
-
system.events.pipe(
|
|
304
|
-
Stream.tap((e) => Effect.log(e._tag, e.id)),
|
|
305
|
-
Stream.runDrain,
|
|
306
|
-
);
|
|
307
197
|
```
|
|
308
198
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
Test transitions without actors:
|
|
312
|
-
|
|
313
|
-
```ts
|
|
314
|
-
import { simulate, assertPath } from "effect-machine";
|
|
315
|
-
|
|
316
|
-
// Simulate events and check path
|
|
317
|
-
const result = yield * simulate(machine, [MyEvent.Start, MyEvent.Complete]);
|
|
318
|
-
expect(result.states.map((s) => s._tag)).toEqual(["Idle", "Loading", "Done"]);
|
|
319
|
-
|
|
320
|
-
// Assert specific path
|
|
321
|
-
yield * assertPath(machine, events, ["Idle", "Loading", "Done"]);
|
|
322
|
-
```
|
|
199
|
+
Persistence strategies:
|
|
323
200
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
### Building
|
|
327
|
-
|
|
328
|
-
| Method | Purpose |
|
|
329
|
-
| ----------------------------------------- | ----------------------------------------------------------- |
|
|
330
|
-
| `Machine.make({ state, event, initial })` | Create machine |
|
|
331
|
-
| `.on(State.X, Event.Y, handler)` | Add transition |
|
|
332
|
-
| `.on([State.X, State.Y], Event.Z, h)` | Multi-state transition |
|
|
333
|
-
| `.onAny(Event.X, handler)` | Wildcard transition (any state) |
|
|
334
|
-
| `.reenter(State.X, Event.Y, handler)` | Force re-entry on same state |
|
|
335
|
-
| `.spawn(State.X, handler)` | State-scoped effect |
|
|
336
|
-
| `.task(State.X, run, { onSuccess })` | State-scoped task |
|
|
337
|
-
| `.timeout(State.X, { duration, event })` | State timeout (gen_statem) |
|
|
338
|
-
| `.postpone(State.X, Event.Y)` | Postpone event in state (gen_statem) |
|
|
339
|
-
| `.background(handler)` | Machine-lifetime effect |
|
|
340
|
-
| `.final(State.X)` | Mark final state |
|
|
341
|
-
| `.build({ slot: impl })` | Provide implementations, returns `BuiltMachine` (terminal) |
|
|
342
|
-
| `.build()` | Finalize no-slot machine, returns `BuiltMachine` (terminal) |
|
|
343
|
-
|
|
344
|
-
### Running
|
|
345
|
-
|
|
346
|
-
| Method | Purpose |
|
|
347
|
-
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
348
|
-
| `Machine.spawn(machine)` | Single actor, no registry. Caller manages lifetime via `actor.stop`. Auto-cleans up if `Scope` present. |
|
|
349
|
-
| `Machine.spawn(machine, id)` | Same as above with custom ID |
|
|
350
|
-
| `Machine.spawn(machine, { hydrate: s })` | Restore from saved state — re-runs spawn effects for that state |
|
|
351
|
-
| `Machine.replay(machine, events)` | Fold events through handlers to compute state (for event sourcing restore) |
|
|
352
|
-
| `system.spawn(id, machine)` | Registry, lookup by ID, bulk ops. Cleans up on system teardown. |
|
|
353
|
-
|
|
354
|
-
### Actor
|
|
355
|
-
|
|
356
|
-
| Method | Description |
|
|
357
|
-
| -------------------------------- | ----------------------------------------------- |
|
|
358
|
-
| `actor.send(event)` | Fire-and-forget (queue event) |
|
|
359
|
-
| `actor.cast(event)` | Alias for send (OTP gen_server:cast) |
|
|
360
|
-
| `actor.call(event)` | Request-reply, returns `ProcessEventResult` |
|
|
361
|
-
| `actor.ask<R>(event)` | Typed domain reply from handler |
|
|
362
|
-
| `actor.snapshot` | Get current state |
|
|
363
|
-
| `actor.matches(tag)` | Check state tag |
|
|
364
|
-
| `actor.can(event)` | Can handle event? |
|
|
365
|
-
| `actor.changes` | Stream of state changes |
|
|
366
|
-
| `actor.transitions` | Stream of `{ fromState, toState, event }` edges |
|
|
367
|
-
| `actor.waitFor(State.X)` | Wait for state (constructor or fn) |
|
|
368
|
-
| `actor.awaitFinal` | Wait final state |
|
|
369
|
-
| `actor.sendAndWait(ev, State.X)` | Send + wait for state |
|
|
370
|
-
| `actor.subscribe(fn)` | Sync callback |
|
|
371
|
-
| `actor.sync.send(event)` | Sync fire-and-forget (for UI) |
|
|
372
|
-
| `actor.sync.stop()` | Sync stop |
|
|
373
|
-
| `actor.sync.snapshot()` | Sync get state |
|
|
374
|
-
| `actor.sync.matches(tag)` | Sync check state tag |
|
|
375
|
-
| `actor.sync.can(event)` | Sync can handle event? |
|
|
376
|
-
| `actor.system` | Access the actor's `ActorSystem` |
|
|
377
|
-
| `actor.children` | Child actors (`ReadonlyMap`) |
|
|
378
|
-
|
|
379
|
-
### ActorSystem
|
|
380
|
-
|
|
381
|
-
| Method / Property | Description |
|
|
382
|
-
| ---------------------- | ------------------------------------------- |
|
|
383
|
-
| `system.spawn(id, m)` | Spawn actor |
|
|
384
|
-
| `system.get(id)` | Get actor by ID |
|
|
385
|
-
| `system.stop(id)` | Stop actor by ID |
|
|
386
|
-
| `system.actors` | Sync snapshot of all actors (`ReadonlyMap`) |
|
|
387
|
-
| `system.subscribe(fn)` | Sync callback for spawn/stop events |
|
|
388
|
-
| `system.events` | Async `Stream<SystemEvent>` for spawn/stop |
|
|
389
|
-
|
|
390
|
-
### Testing
|
|
391
|
-
|
|
392
|
-
| Function | Description |
|
|
393
|
-
| ------------------------------------------ | ---------------------------------------------------------------- |
|
|
394
|
-
| `simulate(machine, events)` | Run events, get all states (accepts `Machine` or `BuiltMachine`) |
|
|
395
|
-
| `createTestHarness(machine)` | Step-by-step testing (accepts `Machine` or `BuiltMachine`) |
|
|
396
|
-
| `assertPath(machine, events, path)` | Assert exact path |
|
|
397
|
-
| `assertReaches(machine, events, tag)` | Assert final state |
|
|
398
|
-
| `assertNeverReaches(machine, events, tag)` | Assert state never visited |
|
|
201
|
+
- **Snapshot** — saves state periodically, restores on reactivation
|
|
202
|
+
- **Journal** — appends events on each RPC, replays on reactivation
|
|
399
203
|
|
|
400
204
|
## License
|
|
401
205
|
|