effect-machine 0.2.4 → 0.3.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 +93 -52
- package/package.json +2 -2
- package/src/actor.ts +126 -111
- package/src/errors.ts +1 -1
- package/src/index.ts +1 -0
- package/src/internal/transition.ts +8 -2
- package/src/machine.ts +228 -145
- package/src/persistence/adapter.ts +2 -2
- package/src/persistence/persistent-actor.ts +4 -10
- package/src/schema.ts +52 -6
- package/src/testing.ts +35 -27
package/README.md
CHANGED
|
@@ -25,13 +25,13 @@ npm install effect-machine effect
|
|
|
25
25
|
|
|
26
26
|
```ts
|
|
27
27
|
import { Effect, Schema } from "effect";
|
|
28
|
-
import { Machine, State, Event, Slot } from "effect-machine";
|
|
28
|
+
import { Machine, State, Event, Slot, type BuiltMachine } from "effect-machine";
|
|
29
29
|
|
|
30
30
|
// Define state schema - states ARE schemas
|
|
31
31
|
const OrderState = State({
|
|
32
32
|
Pending: { orderId: Schema.String },
|
|
33
33
|
Processing: { orderId: Schema.String },
|
|
34
|
-
Shipped: { trackingId: Schema.String },
|
|
34
|
+
Shipped: { orderId: Schema.String, trackingId: Schema.String },
|
|
35
35
|
Cancelled: {},
|
|
36
36
|
});
|
|
37
37
|
|
|
@@ -54,36 +54,36 @@ const orderMachine = Machine.make({
|
|
|
54
54
|
effects: OrderEffects,
|
|
55
55
|
initial: OrderState.Pending({ orderId: "order-1" }),
|
|
56
56
|
})
|
|
57
|
-
.on(OrderState.Pending, OrderEvent.Process, ({ state }) =>
|
|
58
|
-
|
|
57
|
+
.on(OrderState.Pending, OrderEvent.Process, ({ state }) => OrderState.Processing.derive(state))
|
|
58
|
+
.on(OrderState.Processing, OrderEvent.Ship, ({ state, event }) =>
|
|
59
|
+
OrderState.Shipped.derive(state, { trackingId: event.trackingId }),
|
|
59
60
|
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
.on(OrderState.Pending, OrderEvent.Cancel, () => OrderState.Cancelled)
|
|
64
|
-
.on(OrderState.Processing, OrderEvent.Cancel, () => OrderState.Cancelled)
|
|
61
|
+
// Cancel from any state
|
|
62
|
+
.onAny(OrderEvent.Cancel, () => OrderState.Cancelled)
|
|
65
63
|
// Effect runs when entering Processing, cancelled on exit
|
|
66
64
|
.spawn(OrderState.Processing, ({ effects, state }) =>
|
|
67
65
|
effects.notifyWarehouse({ orderId: state.orderId }),
|
|
68
66
|
)
|
|
69
|
-
.provide({
|
|
70
|
-
notifyWarehouse: ({ orderId }) => Effect.log(`Warehouse notified: ${orderId}`),
|
|
71
|
-
})
|
|
72
67
|
.final(OrderState.Shipped)
|
|
73
|
-
.final(OrderState.Cancelled)
|
|
68
|
+
.final(OrderState.Cancelled)
|
|
69
|
+
.build({
|
|
70
|
+
notifyWarehouse: ({ orderId }) => Effect.log(`Warehouse notified: ${orderId}`),
|
|
71
|
+
});
|
|
74
72
|
|
|
75
|
-
// Run as actor (simple)
|
|
73
|
+
// Run as actor (simple — no scope required)
|
|
76
74
|
const program = Effect.gen(function* () {
|
|
77
75
|
const actor = yield* Machine.spawn(orderMachine);
|
|
78
76
|
|
|
79
77
|
yield* actor.send(OrderEvent.Process);
|
|
80
78
|
yield* actor.send(OrderEvent.Ship({ trackingId: "TRACK-123" }));
|
|
81
79
|
|
|
82
|
-
const state = yield* actor.
|
|
83
|
-
console.log(state); // Shipped { trackingId: "TRACK-123" }
|
|
80
|
+
const state = yield* actor.waitFor(OrderState.Shipped);
|
|
81
|
+
console.log(state); // Shipped { orderId: "order-1", trackingId: "TRACK-123" }
|
|
82
|
+
|
|
83
|
+
yield* actor.stop;
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
Effect.runPromise(
|
|
86
|
+
Effect.runPromise(program);
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
## Core Concepts
|
|
@@ -102,6 +102,34 @@ MyState.Idle; // Value (no parens)
|
|
|
102
102
|
MyState.Loading({ url: "/api" }); // Constructor
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
### State.derive()
|
|
106
|
+
|
|
107
|
+
Construct new states from existing ones — picks overlapping fields, applies overrides:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// Same-state: preserve fields, override specific ones
|
|
111
|
+
.on(State.Active, Event.Update, ({ state, event }) =>
|
|
112
|
+
State.Active.derive(state, { count: event.count })
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// Cross-state: picks only target fields from source
|
|
116
|
+
.on(State.Processing, Event.Ship, ({ state, event }) =>
|
|
117
|
+
State.Shipped.derive(state, { trackingId: event.trackingId })
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Multi-State Transitions
|
|
122
|
+
|
|
123
|
+
Handle the same event from multiple states:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// Array of states — handler receives union type
|
|
127
|
+
.on([State.Draft, State.Review], Event.Cancel, () => State.Cancelled)
|
|
128
|
+
|
|
129
|
+
// Wildcard — fires from any state (specific .on() takes priority)
|
|
130
|
+
.onAny(Event.Cancel, () => State.Cancelled)
|
|
131
|
+
```
|
|
132
|
+
|
|
105
133
|
### Guards and Effects as Slots
|
|
106
134
|
|
|
107
135
|
Define parameterized guards and effects, provide implementations:
|
|
@@ -126,7 +154,7 @@ machine
|
|
|
126
154
|
)
|
|
127
155
|
// Fetch runs when entering Loading, auto-cancelled if state changes
|
|
128
156
|
.spawn(MyState.Loading, ({ effects, state }) => effects.fetchData({ url: state.url }))
|
|
129
|
-
.
|
|
157
|
+
.build({
|
|
130
158
|
canRetry: ({ max }, { state }) => state.attempts < max,
|
|
131
159
|
fetchData: ({ url }, { self }) =>
|
|
132
160
|
Effect.gen(function* () {
|
|
@@ -189,49 +217,62 @@ See the [primer](./primer/) for comprehensive documentation:
|
|
|
189
217
|
|
|
190
218
|
### Building
|
|
191
219
|
|
|
192
|
-
| Method | Purpose
|
|
193
|
-
| ----------------------------------------- |
|
|
194
|
-
| `Machine.make({ state, event, initial })` | Create machine
|
|
195
|
-
| `.on(State.X, Event.Y, handler)` | Add transition
|
|
196
|
-
| `.
|
|
197
|
-
| `.
|
|
198
|
-
| `.
|
|
199
|
-
| `.
|
|
200
|
-
| `.
|
|
201
|
-
| `.
|
|
202
|
-
| `.
|
|
220
|
+
| Method | Purpose |
|
|
221
|
+
| ----------------------------------------- | ----------------------------------------------------------- |
|
|
222
|
+
| `Machine.make({ state, event, initial })` | Create machine |
|
|
223
|
+
| `.on(State.X, Event.Y, handler)` | Add transition |
|
|
224
|
+
| `.on([State.X, State.Y], Event.Z, h)` | Multi-state transition |
|
|
225
|
+
| `.onAny(Event.X, handler)` | Wildcard transition (any state) |
|
|
226
|
+
| `.reenter(State.X, Event.Y, handler)` | Force re-entry on same state |
|
|
227
|
+
| `.spawn(State.X, handler)` | State-scoped effect |
|
|
228
|
+
| `.task(State.X, run, { onSuccess })` | State-scoped task |
|
|
229
|
+
| `.background(handler)` | Machine-lifetime effect |
|
|
230
|
+
| `.final(State.X)` | Mark final state |
|
|
231
|
+
| `.build({ slot: impl })` | Provide implementations, returns `BuiltMachine` (terminal) |
|
|
232
|
+
| `.build()` | Finalize no-slot machine, returns `BuiltMachine` (terminal) |
|
|
233
|
+
| `.persist(config)` | Enable persistence |
|
|
234
|
+
|
|
235
|
+
### State Constructors
|
|
236
|
+
|
|
237
|
+
| Method | Purpose |
|
|
238
|
+
| -------------------------------------- | ------------------------------ |
|
|
239
|
+
| `State.X.derive(source)` | Pick target fields from source |
|
|
240
|
+
| `State.X.derive(source, { field: v })` | Pick fields + apply overrides |
|
|
241
|
+
| `State.$is("X")(value)` | Type guard |
|
|
242
|
+
| `State.$match(value, { X: fn, ... })` | Pattern matching |
|
|
203
243
|
|
|
204
244
|
### Running
|
|
205
245
|
|
|
206
|
-
| Method | Purpose
|
|
207
|
-
| ---------------------------- |
|
|
208
|
-
| `Machine.spawn(machine)` |
|
|
209
|
-
| `Machine.spawn(machine, id)` |
|
|
210
|
-
| `system.spawn(id, machine)` |
|
|
246
|
+
| Method | Purpose |
|
|
247
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
248
|
+
| `Machine.spawn(machine)` | Single actor, no registry. Caller manages lifetime via `actor.stop`. Auto-cleans up if `Scope` present. |
|
|
249
|
+
| `Machine.spawn(machine, id)` | Same as above with custom ID |
|
|
250
|
+
| `system.spawn(id, machine)` | Registry, lookup by ID, bulk ops, persistence. Cleans up on system teardown. |
|
|
211
251
|
|
|
212
252
|
### Testing
|
|
213
253
|
|
|
214
|
-
| Function | Description
|
|
215
|
-
| ------------------------------------------ |
|
|
216
|
-
| `simulate(machine, events)` | Run events, get all states |
|
|
217
|
-
| `createTestHarness(machine)` | Step-by-step testing |
|
|
218
|
-
| `assertPath(machine, events, path)` | Assert exact path
|
|
219
|
-
| `assertReaches(machine, events, tag)` | Assert final state
|
|
220
|
-
| `assertNeverReaches(machine, events, tag)` | Assert state never visited
|
|
254
|
+
| Function | Description |
|
|
255
|
+
| ------------------------------------------ | ---------------------------------------------------------------- |
|
|
256
|
+
| `simulate(machine, events)` | Run events, get all states (accepts `Machine` or `BuiltMachine`) |
|
|
257
|
+
| `createTestHarness(machine)` | Step-by-step testing (accepts `Machine` or `BuiltMachine`) |
|
|
258
|
+
| `assertPath(machine, events, path)` | Assert exact path |
|
|
259
|
+
| `assertReaches(machine, events, tag)` | Assert final state |
|
|
260
|
+
| `assertNeverReaches(machine, events, tag)` | Assert state never visited |
|
|
221
261
|
|
|
222
262
|
### Actor
|
|
223
263
|
|
|
224
|
-
| Method
|
|
225
|
-
|
|
|
226
|
-
| `actor.send(event)`
|
|
227
|
-
| `actor.
|
|
228
|
-
| `actor.
|
|
229
|
-
| `actor.
|
|
230
|
-
| `actor.
|
|
231
|
-
| `actor.
|
|
232
|
-
| `actor.
|
|
233
|
-
| `actor.
|
|
234
|
-
| `actor.
|
|
264
|
+
| Method | Description |
|
|
265
|
+
| -------------------------------- | ---------------------------------- |
|
|
266
|
+
| `actor.send(event)` | Queue event |
|
|
267
|
+
| `actor.sendSync(event)` | Fire-and-forget (sync, for UI) |
|
|
268
|
+
| `actor.snapshot` | Get current state |
|
|
269
|
+
| `actor.matches(tag)` | Check state tag |
|
|
270
|
+
| `actor.can(event)` | Can handle event? |
|
|
271
|
+
| `actor.changes` | Stream of changes |
|
|
272
|
+
| `actor.waitFor(State.X)` | Wait for state (constructor or fn) |
|
|
273
|
+
| `actor.awaitFinal` | Wait final state |
|
|
274
|
+
| `actor.sendAndWait(ev, State.X)` | Send + wait for state |
|
|
275
|
+
| `actor.subscribe(fn)` | Sync callback |
|
|
235
276
|
|
|
236
277
|
## License
|
|
237
278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-machine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/cevr/effect-machine.git"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"fmt:check": "oxfmt --check",
|
|
26
26
|
"test": "bun test",
|
|
27
27
|
"test:watch": "bun test --watch",
|
|
28
|
-
"gate": "concurrently -n type,lint,fmt,test -c blue,yellow,magenta,green \"bun run typecheck\" \"bun run lint\" \"bun run fmt\" \"bun run test\"",
|
|
28
|
+
"gate": "concurrently -n type,lint,fmt,test -c blue,yellow,magenta,green \"bun run typecheck\" \"bun run lint:fix\" \"bun run fmt\" \"bun run test\"",
|
|
29
29
|
"prepare": "lefthook install || true && effect-language-service patch",
|
|
30
30
|
"version": "changeset version",
|
|
31
31
|
"release": "changeset publish"
|
package/src/actor.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* - ActorSystem service (spawn/stop/get actors)
|
|
7
7
|
* - Actor creation and event loop
|
|
8
8
|
*/
|
|
9
|
+
import type { Stream } from "effect";
|
|
9
10
|
import {
|
|
10
11
|
Cause,
|
|
11
12
|
Context,
|
|
13
|
+
Deferred,
|
|
12
14
|
Effect,
|
|
13
15
|
Exit,
|
|
14
16
|
Fiber,
|
|
@@ -17,12 +19,12 @@ import {
|
|
|
17
19
|
Option,
|
|
18
20
|
Queue,
|
|
19
21
|
Ref,
|
|
22
|
+
Runtime,
|
|
20
23
|
Scope,
|
|
21
|
-
Stream,
|
|
22
24
|
SubscriptionRef,
|
|
23
25
|
} from "effect";
|
|
24
26
|
|
|
25
|
-
import type { Machine, MachineRef } from "./machine.js";
|
|
27
|
+
import type { Machine, MachineRef, BuiltMachine } from "./machine.js";
|
|
26
28
|
import type { Inspector } from "./inspection.js";
|
|
27
29
|
import { Inspector as InspectorTag } from "./inspection.js";
|
|
28
30
|
import { processEventCore, runSpawnEffects, resolveTransition } from "./internal/transition.js";
|
|
@@ -37,7 +39,7 @@ export type {
|
|
|
37
39
|
ProcessEventResult,
|
|
38
40
|
} from "./internal/transition.js";
|
|
39
41
|
import type { GuardsDef, EffectsDef } from "./slot.js";
|
|
40
|
-
import { DuplicateActorError
|
|
42
|
+
import { DuplicateActorError } from "./errors.js";
|
|
41
43
|
import { INTERNAL_INIT_EVENT } from "./internal/utils.js";
|
|
42
44
|
import type {
|
|
43
45
|
ActorMetadata,
|
|
@@ -118,9 +120,13 @@ export interface ActorRef<State extends { readonly _tag: string }, Event> {
|
|
|
118
120
|
readonly changes: Stream.Stream<State>;
|
|
119
121
|
|
|
120
122
|
/**
|
|
121
|
-
* Wait for a state that matches predicate (includes current snapshot)
|
|
123
|
+
* Wait for a state that matches predicate or state variant (includes current snapshot).
|
|
124
|
+
* Accepts a predicate function or a state constructor/value (e.g. `State.Active`).
|
|
122
125
|
*/
|
|
123
|
-
readonly waitFor:
|
|
126
|
+
readonly waitFor: {
|
|
127
|
+
(predicate: (state: State) => boolean): Effect.Effect<State>;
|
|
128
|
+
(state: { readonly _tag: State["_tag"] }): Effect.Effect<State>;
|
|
129
|
+
};
|
|
124
130
|
|
|
125
131
|
/**
|
|
126
132
|
* Wait for a final state (includes current snapshot)
|
|
@@ -128,12 +134,21 @@ export interface ActorRef<State extends { readonly _tag: string }, Event> {
|
|
|
128
134
|
readonly awaitFinal: Effect.Effect<State>;
|
|
129
135
|
|
|
130
136
|
/**
|
|
131
|
-
* Send event and wait for predicate or final state
|
|
137
|
+
* Send event and wait for predicate, state variant, or final state.
|
|
138
|
+
* Accepts a predicate function or a state constructor/value (e.g. `State.Active`).
|
|
139
|
+
*/
|
|
140
|
+
readonly sendAndWait: {
|
|
141
|
+
(event: Event, predicate: (state: State) => boolean): Effect.Effect<State>;
|
|
142
|
+
(event: Event, state: { readonly _tag: State["_tag"] }): Effect.Effect<State>;
|
|
143
|
+
(event: Event): Effect.Effect<State>;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Send event synchronously (fire-and-forget).
|
|
148
|
+
* No-op on stopped actors. Use when you need to send from sync contexts
|
|
149
|
+
* (e.g. framework hooks, event handlers).
|
|
132
150
|
*/
|
|
133
|
-
readonly
|
|
134
|
-
event: Event,
|
|
135
|
-
predicate?: (state: State) => boolean,
|
|
136
|
-
) => Effect.Effect<State>;
|
|
151
|
+
readonly sendSync: (event: Event) => void;
|
|
137
152
|
|
|
138
153
|
/**
|
|
139
154
|
* Subscribe to state changes (sync callback)
|
|
@@ -159,14 +174,13 @@ export interface ActorSystem {
|
|
|
159
174
|
* For regular machines, returns ActorRef.
|
|
160
175
|
* For persistent machines (created with Machine.persist), returns PersistentActorRef.
|
|
161
176
|
*
|
|
162
|
-
*
|
|
163
|
-
* Attempting to spawn a machine with unprovided effect slots will fail.
|
|
177
|
+
* All effect slots must be provided via `.build()` before spawning.
|
|
164
178
|
*
|
|
165
179
|
* @example
|
|
166
180
|
* ```ts
|
|
167
|
-
* // Regular machine (
|
|
168
|
-
* const
|
|
169
|
-
* const actor = yield* system.spawn("my-actor",
|
|
181
|
+
* // Regular machine (built)
|
|
182
|
+
* const built = machine.build({ fetchData: ... })
|
|
183
|
+
* const actor = yield* system.spawn("my-actor", built);
|
|
170
184
|
*
|
|
171
185
|
* // Persistent machine (auto-detected)
|
|
172
186
|
* const persistentActor = yield* system.spawn("my-actor", persistentMachine);
|
|
@@ -175,18 +189,11 @@ export interface ActorSystem {
|
|
|
175
189
|
* ```
|
|
176
190
|
*/
|
|
177
191
|
readonly spawn: {
|
|
178
|
-
// Regular machine overload
|
|
179
|
-
<
|
|
180
|
-
S extends { readonly _tag: string },
|
|
181
|
-
E extends { readonly _tag: string },
|
|
182
|
-
R,
|
|
183
|
-
GD extends GuardsDef = Record<string, never>,
|
|
184
|
-
EFD extends EffectsDef = Record<string, never>,
|
|
185
|
-
>(
|
|
192
|
+
// Regular machine overload (BuiltMachine)
|
|
193
|
+
<S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
|
|
186
194
|
id: string,
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
): Effect.Effect<ActorRef<S, E>, DuplicateActorError | UnprovidedSlotsError, R | Scope.Scope>;
|
|
195
|
+
machine: BuiltMachine<S, E, R>,
|
|
196
|
+
): Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>;
|
|
190
197
|
|
|
191
198
|
// Persistent machine overload
|
|
192
199
|
<S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
|
|
@@ -194,8 +201,8 @@ export interface ActorSystem {
|
|
|
194
201
|
machine: PersistentMachine<S, E, R>,
|
|
195
202
|
): Effect.Effect<
|
|
196
203
|
PersistentActorRef<S, E, R>,
|
|
197
|
-
PersistenceError | VersionConflictError | DuplicateActorError
|
|
198
|
-
R |
|
|
204
|
+
PersistenceError | VersionConflictError | DuplicateActorError,
|
|
205
|
+
R | PersistenceAdapterTag
|
|
199
206
|
>;
|
|
200
207
|
};
|
|
201
208
|
|
|
@@ -218,8 +225,8 @@ export interface ActorSystem {
|
|
|
218
225
|
machine: PersistentMachine<S, E, R>,
|
|
219
226
|
) => Effect.Effect<
|
|
220
227
|
Option.Option<PersistentActorRef<S, E, R>>,
|
|
221
|
-
PersistenceError | DuplicateActorError
|
|
222
|
-
R |
|
|
228
|
+
PersistenceError | DuplicateActorError,
|
|
229
|
+
R | PersistenceAdapterTag
|
|
223
230
|
>;
|
|
224
231
|
|
|
225
232
|
/**
|
|
@@ -267,7 +274,7 @@ export interface ActorSystem {
|
|
|
267
274
|
>(
|
|
268
275
|
ids: ReadonlyArray<string>,
|
|
269
276
|
machine: PersistentMachine<S, E, R>,
|
|
270
|
-
) => Effect.Effect<RestoreResult<S, E, R>, never, R |
|
|
277
|
+
) => Effect.Effect<RestoreResult<S, E, R>, never, R | PersistenceAdapterTag>;
|
|
271
278
|
|
|
272
279
|
/**
|
|
273
280
|
* Restore all persisted actors for a machine type.
|
|
@@ -288,11 +295,7 @@ export interface ActorSystem {
|
|
|
288
295
|
>(
|
|
289
296
|
machine: PersistentMachine<S, E, R>,
|
|
290
297
|
options?: { filter?: (meta: ActorMetadata) => boolean },
|
|
291
|
-
) => Effect.Effect<
|
|
292
|
-
RestoreResult<S, E, R>,
|
|
293
|
-
PersistenceError,
|
|
294
|
-
R | Scope.Scope | PersistenceAdapterTag
|
|
295
|
-
>;
|
|
298
|
+
) => Effect.Effect<RestoreResult<S, E, R>, PersistenceError, R | PersistenceAdapterTag>;
|
|
296
299
|
}
|
|
297
300
|
|
|
298
301
|
/**
|
|
@@ -362,16 +365,41 @@ export const buildActorRefCore = <
|
|
|
362
365
|
});
|
|
363
366
|
|
|
364
367
|
const waitFor = Effect.fn("effect-machine.actor.waitFor")(function* (
|
|
365
|
-
|
|
368
|
+
predicateOrState: ((state: S) => boolean) | { readonly _tag: S["_tag"] },
|
|
366
369
|
) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
370
|
+
const predicate =
|
|
371
|
+
typeof predicateOrState === "function" && !("_tag" in predicateOrState)
|
|
372
|
+
? predicateOrState
|
|
373
|
+
: (s: S) => s._tag === (predicateOrState as { readonly _tag: string })._tag;
|
|
374
|
+
|
|
375
|
+
// Check current state first — SubscriptionRef.get acquires/releases
|
|
376
|
+
// the semaphore quickly (read-only), no deadlock risk.
|
|
377
|
+
const current = yield* SubscriptionRef.get(stateRef);
|
|
378
|
+
if (predicate(current)) return current;
|
|
379
|
+
|
|
380
|
+
// Use sync listener + Deferred to avoid holding the SubscriptionRef
|
|
381
|
+
// semaphore for the duration of a stream (which causes deadlock when
|
|
382
|
+
// send triggers SubscriptionRef.set concurrently).
|
|
383
|
+
const done = yield* Deferred.make<S>();
|
|
384
|
+
const rt = yield* Effect.runtime<never>();
|
|
385
|
+
const runFork = Runtime.runFork(rt);
|
|
386
|
+
const listener = (state: S) => {
|
|
387
|
+
if (predicate(state)) {
|
|
388
|
+
runFork(Deferred.succeed(done, state));
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
listeners.add(listener);
|
|
392
|
+
|
|
393
|
+
// Re-check after subscribing to close the race window
|
|
394
|
+
const afterSubscribe = yield* SubscriptionRef.get(stateRef);
|
|
395
|
+
if (predicate(afterSubscribe)) {
|
|
396
|
+
listeners.delete(listener);
|
|
397
|
+
return afterSubscribe;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const result = yield* Deferred.await(done);
|
|
401
|
+
listeners.delete(listener);
|
|
402
|
+
return result;
|
|
375
403
|
});
|
|
376
404
|
|
|
377
405
|
const awaitFinal = waitFor((state) => machine.finalStates.has(state._tag)).pipe(
|
|
@@ -380,11 +408,11 @@ export const buildActorRefCore = <
|
|
|
380
408
|
|
|
381
409
|
const sendAndWait = Effect.fn("effect-machine.actor.sendAndWait")(function* (
|
|
382
410
|
event: E,
|
|
383
|
-
|
|
411
|
+
predicateOrState?: ((state: S) => boolean) | { readonly _tag: S["_tag"] },
|
|
384
412
|
) {
|
|
385
413
|
yield* send(event);
|
|
386
|
-
if (
|
|
387
|
-
return yield* waitFor(
|
|
414
|
+
if (predicateOrState !== undefined) {
|
|
415
|
+
return yield* waitFor(predicateOrState);
|
|
388
416
|
}
|
|
389
417
|
return yield* awaitFinal;
|
|
390
418
|
});
|
|
@@ -407,6 +435,12 @@ export const buildActorRefCore = <
|
|
|
407
435
|
waitFor,
|
|
408
436
|
awaitFinal,
|
|
409
437
|
sendAndWait,
|
|
438
|
+
sendSync: (event) => {
|
|
439
|
+
const stopped = Effect.runSync(Ref.get(stoppedRef));
|
|
440
|
+
if (!stopped) {
|
|
441
|
+
Effect.runSync(Queue.offer(eventQueue, event));
|
|
442
|
+
}
|
|
443
|
+
},
|
|
410
444
|
subscribe: (fn) => {
|
|
411
445
|
listeners.add(fn);
|
|
412
446
|
return () => {
|
|
@@ -432,11 +466,6 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
|
|
|
432
466
|
>(id: string, machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>) {
|
|
433
467
|
yield* Effect.annotateCurrentSpan("effect_machine.actor.id", id);
|
|
434
468
|
|
|
435
|
-
const missing = machine._missingSlots();
|
|
436
|
-
if (missing.length > 0) {
|
|
437
|
-
return yield* new UnprovidedSlotsError({ slots: missing });
|
|
438
|
-
}
|
|
439
|
-
|
|
440
469
|
// Get optional inspector from context
|
|
441
470
|
const inspectorValue = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as
|
|
442
471
|
| Inspector<S, E>
|
|
@@ -477,7 +506,7 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
|
|
|
477
506
|
const { effects: effectSlots } = machine._slots;
|
|
478
507
|
|
|
479
508
|
for (const bg of machine.backgroundEffects) {
|
|
480
|
-
const fiber = yield* Effect.
|
|
509
|
+
const fiber = yield* Effect.forkDaemon(
|
|
481
510
|
bg
|
|
482
511
|
.handler({ state: machine.initial, event: initEvent, self, effects: effectSlots })
|
|
483
512
|
.pipe(Effect.provideService(machine.Context, ctx)),
|
|
@@ -520,10 +549,9 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
|
|
|
520
549
|
return buildActorRefCore(id, machine, stateRef, eventQueue, stoppedRef, listeners, stop);
|
|
521
550
|
}
|
|
522
551
|
|
|
523
|
-
// Start the event loop — use
|
|
524
|
-
// is
|
|
525
|
-
|
|
526
|
-
const loopFiber = yield* Effect.forkScoped(
|
|
552
|
+
// Start the event loop — use forkDaemon so the event loop fiber's lifetime
|
|
553
|
+
// is detached from any parent scope/fiber. actor.stop handles cleanup.
|
|
554
|
+
const loopFiber = yield* Effect.forkDaemon(
|
|
527
555
|
eventLoop(
|
|
528
556
|
machine,
|
|
529
557
|
stateRef,
|
|
@@ -774,7 +802,16 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
|
|
|
774
802
|
const spawnGate = yield* Effect.makeSemaphore(1);
|
|
775
803
|
const withSpawnGate = spawnGate.withPermits(1);
|
|
776
804
|
|
|
777
|
-
|
|
805
|
+
// Stop all actors on system teardown
|
|
806
|
+
yield* Effect.addFinalizer(() => {
|
|
807
|
+
const stops: Effect.Effect<void>[] = [];
|
|
808
|
+
MutableHashMap.forEach(actors, (actor) => {
|
|
809
|
+
stops.push(actor.stop);
|
|
810
|
+
});
|
|
811
|
+
return Effect.all(stops, { concurrency: "unbounded" }).pipe(Effect.asVoid);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
/** Check for duplicate ID, register actor, attach scope cleanup if available */
|
|
778
815
|
const registerActor = Effect.fn("effect-machine.actorSystem.register")(function* <
|
|
779
816
|
T extends { stop: Effect.Effect<void> },
|
|
780
817
|
>(id: string, actor: T) {
|
|
@@ -788,13 +825,17 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
|
|
|
788
825
|
// Register it - O(1)
|
|
789
826
|
MutableHashMap.set(actors, id, actor as unknown as ActorRef<AnyState, unknown>);
|
|
790
827
|
|
|
791
|
-
//
|
|
792
|
-
yield* Effect.
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
828
|
+
// If scope available, attach per-actor cleanup
|
|
829
|
+
const maybeScope = yield* Effect.serviceOption(Scope.Scope);
|
|
830
|
+
if (Option.isSome(maybeScope)) {
|
|
831
|
+
yield* Scope.addFinalizer(
|
|
832
|
+
maybeScope.value,
|
|
833
|
+
Effect.gen(function* () {
|
|
834
|
+
yield* actor.stop;
|
|
835
|
+
MutableHashMap.remove(actors, id);
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
}
|
|
798
839
|
|
|
799
840
|
return actor;
|
|
800
841
|
});
|
|
@@ -803,14 +844,12 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
|
|
|
803
844
|
S extends { readonly _tag: string },
|
|
804
845
|
E extends { readonly _tag: string },
|
|
805
846
|
R,
|
|
806
|
-
|
|
807
|
-
EFD extends EffectsDef = Record<string, never>,
|
|
808
|
-
>(id: string, machine: Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>) {
|
|
847
|
+
>(id: string, built: BuiltMachine<S, E, R>) {
|
|
809
848
|
if (MutableHashMap.has(actors, id)) {
|
|
810
849
|
return yield* new DuplicateActorError({ actorId: id });
|
|
811
850
|
}
|
|
812
851
|
// Create and register the actor
|
|
813
|
-
const actor = yield* createActor(id,
|
|
852
|
+
const actor = yield* createActor(id, built._inner);
|
|
814
853
|
return yield* registerActor(id, actor);
|
|
815
854
|
});
|
|
816
855
|
|
|
@@ -846,68 +885,44 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
|
|
|
846
885
|
S extends { readonly _tag: string },
|
|
847
886
|
E extends { readonly _tag: string },
|
|
848
887
|
R,
|
|
849
|
-
|
|
850
|
-
EFD extends EffectsDef = Record<string, never>,
|
|
851
|
-
>(
|
|
852
|
-
id: string,
|
|
853
|
-
machine:
|
|
854
|
-
| Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>
|
|
855
|
-
| PersistentMachine<S, E, R>,
|
|
856
|
-
) {
|
|
888
|
+
>(id: string, machine: BuiltMachine<S, E, R> | PersistentMachine<S, E, R>) {
|
|
857
889
|
if (isPersistentMachine(machine)) {
|
|
858
890
|
// TypeScript can't narrow union with invariant generic params
|
|
859
891
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
860
892
|
return yield* spawnPersistent(id, machine as PersistentMachine<S, E, R>);
|
|
861
893
|
}
|
|
862
|
-
return yield* spawnRegular(
|
|
863
|
-
id,
|
|
864
|
-
machine as Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>,
|
|
865
|
-
);
|
|
894
|
+
return yield* spawnRegular(id, machine as BuiltMachine<S, E, R>);
|
|
866
895
|
});
|
|
867
896
|
|
|
868
897
|
// Type-safe overloaded spawn implementation
|
|
869
|
-
function spawn<
|
|
870
|
-
S extends { readonly _tag: string },
|
|
871
|
-
E extends { readonly _tag: string },
|
|
872
|
-
R,
|
|
873
|
-
GD extends GuardsDef = Record<string, never>,
|
|
874
|
-
EFD extends EffectsDef = Record<string, never>,
|
|
875
|
-
>(
|
|
898
|
+
function spawn<S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
|
|
876
899
|
id: string,
|
|
877
|
-
machine:
|
|
878
|
-
): Effect.Effect<ActorRef<S, E>, DuplicateActorError
|
|
900
|
+
machine: BuiltMachine<S, E, R>,
|
|
901
|
+
): Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>;
|
|
879
902
|
function spawn<S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
|
|
880
903
|
id: string,
|
|
881
904
|
machine: PersistentMachine<S, E, R>,
|
|
882
905
|
): Effect.Effect<
|
|
883
906
|
PersistentActorRef<S, E, R>,
|
|
884
|
-
PersistenceError | VersionConflictError | DuplicateActorError
|
|
885
|
-
R |
|
|
907
|
+
PersistenceError | VersionConflictError | DuplicateActorError,
|
|
908
|
+
R | PersistenceAdapterTag
|
|
886
909
|
>;
|
|
887
|
-
function spawn<
|
|
888
|
-
S extends { readonly _tag: string },
|
|
889
|
-
E extends { readonly _tag: string },
|
|
890
|
-
R,
|
|
891
|
-
GD extends GuardsDef = Record<string, never>,
|
|
892
|
-
EFD extends EffectsDef = Record<string, never>,
|
|
893
|
-
>(
|
|
910
|
+
function spawn<S extends { readonly _tag: string }, E extends { readonly _tag: string }, R>(
|
|
894
911
|
id: string,
|
|
895
|
-
machine:
|
|
896
|
-
| Machine<S, E, R, Record<string, never>, Record<string, never>, GD, EFD>
|
|
897
|
-
| PersistentMachine<S, E, R>,
|
|
912
|
+
machine: BuiltMachine<S, E, R> | PersistentMachine<S, E, R>,
|
|
898
913
|
):
|
|
899
|
-
| Effect.Effect<ActorRef<S, E>, DuplicateActorError
|
|
914
|
+
| Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>
|
|
900
915
|
| Effect.Effect<
|
|
901
916
|
PersistentActorRef<S, E, R>,
|
|
902
|
-
PersistenceError | VersionConflictError | DuplicateActorError
|
|
903
|
-
R |
|
|
917
|
+
PersistenceError | VersionConflictError | DuplicateActorError,
|
|
918
|
+
R | PersistenceAdapterTag
|
|
904
919
|
> {
|
|
905
920
|
return withSpawnGate(spawnImpl(id, machine)) as
|
|
906
|
-
| Effect.Effect<ActorRef<S, E>, DuplicateActorError
|
|
921
|
+
| Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>
|
|
907
922
|
| Effect.Effect<
|
|
908
923
|
PersistentActorRef<S, E, R>,
|
|
909
|
-
PersistenceError | VersionConflictError | DuplicateActorError
|
|
910
|
-
R |
|
|
924
|
+
PersistenceError | VersionConflictError | DuplicateActorError,
|
|
925
|
+
R | PersistenceAdapterTag
|
|
911
926
|
>;
|
|
912
927
|
}
|
|
913
928
|
|
|
@@ -961,7 +976,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
|
|
|
961
976
|
const restored: PersistentActorRef<S, E, R>[] = [];
|
|
962
977
|
const failed: {
|
|
963
978
|
id: string;
|
|
964
|
-
error: PersistenceError | DuplicateActorError
|
|
979
|
+
error: PersistenceError | DuplicateActorError;
|
|
965
980
|
}[] = [];
|
|
966
981
|
|
|
967
982
|
for (const id of ids) {
|
|
@@ -1032,4 +1047,4 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
|
|
|
1032
1047
|
/**
|
|
1033
1048
|
* Default ActorSystem layer
|
|
1034
1049
|
*/
|
|
1035
|
-
export const Default = Layer.
|
|
1050
|
+
export const Default = Layer.scoped(ActorSystem, make());
|