effect-machine 0.12.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 +42 -27
- package/dist/actor.js +212 -305
- package/dist/cluster/entity-machine.js +20 -9
- package/dist/cluster/to-entity.d.ts +2 -2
- package/dist/errors.d.ts +16 -19
- package/dist/errors.js +3 -5
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/internal/runtime.d.ts +81 -6
- package/dist/internal/runtime.js +166 -57
- package/dist/internal/transition.d.ts +5 -4
- package/dist/internal/transition.js +9 -9
- package/dist/machine.d.ts +65 -44
- package/dist/machine.js +68 -67
- package/dist/schema.js +1 -1
- 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 -25
- package/package.json +5 -5
- package/v3/dist/actor.d.ts +50 -34
- package/v3/dist/actor.js +209 -289
- package/v3/dist/cluster/entity-machine.js +5 -5
- package/v3/dist/errors.d.ts +3 -8
- package/v3/dist/errors.js +2 -4
- package/v3/dist/index.d.ts +5 -4
- package/v3/dist/index.js +3 -2
- package/v3/dist/internal/runtime.d.ts +82 -5
- package/v3/dist/internal/runtime.js +147 -48
- package/v3/dist/internal/transition.d.ts +5 -4
- package/v3/dist/internal/transition.js +8 -8
- package/v3/dist/machine.d.ts +18 -36
- package/v3/dist/machine.js +54 -64
- 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
|
|
package/dist/actor.d.ts
CHANGED
|
@@ -1,30 +1,15 @@
|
|
|
1
|
-
import { EffectsDef, GuardsDef, MachineContext } from "./slot.js";
|
|
2
1
|
import { ExtractReply, ReplyTypeBrand } from "./internal/brands.js";
|
|
3
2
|
import { ActorStoppedError, DuplicateActorError, NoReplyError } from "./errors.js";
|
|
3
|
+
import { EffectsDef, GuardsDef } from "./slot.js";
|
|
4
|
+
import { ActorExit, Supervision } from "./supervision.js";
|
|
4
5
|
import { ProcessEventError, ProcessEventHooks, ProcessEventResult, processEventCore, resolveTransition, runSpawnEffects } from "./internal/transition.js";
|
|
5
|
-
import {
|
|
6
|
+
import { Machine } from "./machine.js";
|
|
7
|
+
import { RuntimeQueuedEvent } from "./internal/runtime.js";
|
|
6
8
|
import { Deferred, Effect, Layer, Option, PubSub, Queue, Ref, Scope, ServiceMap, Stream, SubscriptionRef } from "effect";
|
|
7
|
-
import * as effect_Tracer0 from "effect/Tracer";
|
|
8
9
|
|
|
9
10
|
//#region src/actor.d.ts
|
|
10
|
-
/** Discriminated mailbox request */
|
|
11
|
-
type QueuedEvent<E> =
|
|
12
|
-
readonly _tag: "send";
|
|
13
|
-
readonly event: E;
|
|
14
|
-
} | {
|
|
15
|
-
readonly _tag: "call";
|
|
16
|
-
readonly event: E;
|
|
17
|
-
readonly reply: Deferred.Deferred<ProcessEventResult<{
|
|
18
|
-
readonly _tag: string;
|
|
19
|
-
}>, ActorStoppedError>;
|
|
20
|
-
} | {
|
|
21
|
-
readonly _tag: "ask";
|
|
22
|
-
readonly event: E;
|
|
23
|
-
readonly reply: Deferred.Deferred<unknown, NoReplyError | ActorStoppedError>;
|
|
24
|
-
};
|
|
25
|
-
/**
|
|
26
|
-
* Reference to a running actor.
|
|
27
|
-
*/
|
|
11
|
+
/** Discriminated mailbox request — alias for RuntimeQueuedEvent */
|
|
12
|
+
type QueuedEvent<E> = RuntimeQueuedEvent<E>;
|
|
28
13
|
/**
|
|
29
14
|
* Sync projection of ActorRef for non-Effect boundaries (React hooks, framework callbacks).
|
|
30
15
|
*/
|
|
@@ -104,6 +89,25 @@ interface ActorRef<State extends {
|
|
|
104
89
|
};
|
|
105
90
|
/** Subscribe to state changes (sync callback). Returns unsubscribe function. */
|
|
106
91
|
readonly subscribe: (fn: (state: State) => void) => () => void;
|
|
92
|
+
/**
|
|
93
|
+
* Wait for this actor's terminal exit. Resolves with the exit reason.
|
|
94
|
+
* Set exactly once when the actor terminates (final, stop, drain, or defect).
|
|
95
|
+
*/
|
|
96
|
+
readonly awaitExit: Effect.Effect<ActorExit<State>>;
|
|
97
|
+
/**
|
|
98
|
+
* Watch another actor. Returns an Effect that resolves with the exit reason
|
|
99
|
+
* when the watched actor terminally stops. Ignores restarts (Step 3).
|
|
100
|
+
* Built on the other actor's exitDeferred — authoritative, not system events.
|
|
101
|
+
*/
|
|
102
|
+
readonly watch: (other: {
|
|
103
|
+
readonly id: string;
|
|
104
|
+
readonly awaitExit: Effect.Effect<ActorExit<unknown>>;
|
|
105
|
+
}) => Effect.Effect<ActorExit<unknown>>;
|
|
106
|
+
/**
|
|
107
|
+
* Drain: process all remaining events in the queue, then stop.
|
|
108
|
+
* Unlike `stop` (which interrupts immediately), `drain` lets the actor finish its work.
|
|
109
|
+
*/
|
|
110
|
+
readonly drain: Effect.Effect<void>;
|
|
107
111
|
/** Sync helpers for non-Effect boundaries. */
|
|
108
112
|
readonly sync: ActorRefSync<State, Event>;
|
|
109
113
|
/** The actor system this actor belongs to. */
|
|
@@ -122,10 +126,17 @@ type SystemEvent = {
|
|
|
122
126
|
readonly _tag: "ActorSpawned";
|
|
123
127
|
readonly id: string;
|
|
124
128
|
readonly actor: ActorRef<AnyState, unknown>;
|
|
129
|
+
} | {
|
|
130
|
+
readonly _tag: "ActorRestarted";
|
|
131
|
+
readonly id: string;
|
|
132
|
+
readonly actor: ActorRef<AnyState, unknown>;
|
|
133
|
+
readonly generation: number;
|
|
134
|
+
readonly exit: ActorExit<unknown>;
|
|
125
135
|
} | {
|
|
126
136
|
readonly _tag: "ActorStopped";
|
|
127
137
|
readonly id: string;
|
|
128
138
|
readonly actor: ActorRef<AnyState, unknown>;
|
|
139
|
+
readonly exit: ActorExit<unknown>;
|
|
129
140
|
};
|
|
130
141
|
/**
|
|
131
142
|
* Listener callback for system events.
|
|
@@ -140,15 +151,16 @@ interface ActorSystem {
|
|
|
140
151
|
*
|
|
141
152
|
* @example
|
|
142
153
|
* ```ts
|
|
143
|
-
* const
|
|
144
|
-
* const actor = yield* system.spawn("my-actor", built);
|
|
154
|
+
* const actor = yield* system.spawn("my-actor", machine);
|
|
145
155
|
* ```
|
|
146
156
|
*/
|
|
147
157
|
readonly spawn: <S extends {
|
|
148
158
|
readonly _tag: string;
|
|
149
159
|
}, E extends {
|
|
150
160
|
readonly _tag: string;
|
|
151
|
-
}, R>(id: string, machine:
|
|
161
|
+
}, R>(id: string, machine: Machine<S, E, R, any, any, any, any>, options?: {
|
|
162
|
+
readonly supervision?: Supervision.Policy;
|
|
163
|
+
}) => Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>;
|
|
152
164
|
/**
|
|
153
165
|
* Get an existing actor by ID
|
|
154
166
|
*/
|
|
@@ -190,9 +202,10 @@ declare const buildActorRefCore: <S extends {
|
|
|
190
202
|
readonly _tag: string;
|
|
191
203
|
}, E extends {
|
|
192
204
|
readonly _tag: string;
|
|
193
|
-
}, R, GD extends GuardsDef, EFD extends EffectsDef>(id: string, machine: Machine<S, E, R, any, any, GD, EFD>, stateRef: SubscriptionRef.SubscriptionRef<S>,
|
|
205
|
+
}, R, GD extends GuardsDef, EFD extends EffectsDef>(id: string, machine: Machine<S, E, R, any, any, GD, EFD>, stateRef: SubscriptionRef.SubscriptionRef<S>, eventQueueRef: Ref.Ref<Queue.Queue<QueuedEvent<E>>>, stoppedRef: Ref.Ref<boolean>, listeners: Listeners<S>, stop: Effect.Effect<void>, system: ActorSystem, childrenMap: ReadonlyMap<string, ActorRef<AnyState, unknown>>, pendingReplies: Set<Deferred.Deferred<unknown, unknown>>, transitionsPubSub: PubSub.PubSub<TransitionInfo<S, E>> | undefined, exitDeferred: Deferred.Deferred<ActorExit<S>, never>) => ActorRef<S, E>;
|
|
194
206
|
/**
|
|
195
|
-
* Create and start an actor for a machine
|
|
207
|
+
* Create and start an actor for a machine.
|
|
208
|
+
* Delegates to the shared runtime kernel with actor-specific lifecycle hooks.
|
|
196
209
|
*/
|
|
197
210
|
declare const createActor: <S extends {
|
|
198
211
|
readonly _tag: string;
|
|
@@ -200,7 +213,9 @@ declare const createActor: <S extends {
|
|
|
200
213
|
readonly _tag: string;
|
|
201
214
|
}, R, GD extends GuardsDef, EFD extends EffectsDef>(id: string, machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>, options?: {
|
|
202
215
|
initialState?: S;
|
|
203
|
-
|
|
216
|
+
supervision?: Supervision.Policy; /** @internal Called by system after each restart — emits ActorRestarted system event */
|
|
217
|
+
onRestart?: (generation: number, exit: ActorExit<unknown>) => Effect.Effect<void>;
|
|
218
|
+
} | undefined) => Effect.Effect<ActorRef<S, E>, never, never>;
|
|
204
219
|
/** Fail all pending call/ask Deferreds with ActorStoppedError. Safe to call multiple times. */
|
|
205
220
|
declare const settlePendingReplies: (pendingReplies: Set<Deferred.Deferred<unknown, unknown>>, actorId: string) => Effect.Effect<void, never, never>;
|
|
206
221
|
/**
|