@xstate/effect 0.1.0-alpha.4 → 0.1.0-alpha.5

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 CHANGED
@@ -1,8 +1,13 @@
1
1
  # @xstate/effect
2
2
 
3
- Effect integration for XState v6.
3
+ Combine XState's event-driven workflows with Effect services, scopes, clocks and tracing.
4
4
 
5
- This package is experimental and targets XState v6 alpha and Effect 4 RC.
5
+ - Model approval, cancellation, deadlines and retry as explicit states and events.
6
+ - Invoke Effect tasks when their result determines the next state.
7
+ - Provide services once for the actor and its child logic.
8
+ - Keep actor lifetime inside your Effect application.
9
+
10
+ This package is experimental. It targets XState v6 alpha and Effect 4 RC.
6
11
 
7
12
  ## Installation
8
13
 
@@ -12,602 +17,249 @@ This package is experimental and targets XState v6 alpha and Effect 4 RC.
12
17
  npm install @xstate/effect@alpha xstate@alpha effect@rc
13
18
  ```
14
19
 
15
- ## Quick start
20
+ ## XState Effect: Quick start
16
21
 
17
22
  <!-- public API from src/index.ts; actor lifetime from src/createEffectActor.ts -->
18
23
 
19
- `createEffectActor` runs an XState actor as an Effect interpreter over pure transitions: each step is `transition(snapshot, event)`, and an Effect fiber owns the mailbox, the timers and the actions it produces. The actor is a scoped resource: it stops when the enclosing `Scope` closes. `fromEffect` turns an Effect into actor logic that a machine can invoke. The machine itself is an ordinary v6 machine.
24
+ A release waits for approval, expires after 30 seconds, and allows cancellation during deployment. A failed deployment waits for an explicit retry:
25
+
26
+ <!-- example from examples/effect-workflows/src/approval.ts -->
20
27
 
21
28
  ```ts
22
29
  import { Context, Effect, Schema } from 'effect';
23
- import { createEffectActor, fromEffect, waitFor } from '@xstate/effect';
24
- import { setup } from 'xstate';
30
+ import {
31
+ createEffectActor,
32
+ fromEffect,
33
+ send,
34
+ setupEffect,
35
+ waitFor
36
+ } from '@xstate/effect';
25
37
 
26
- class Api extends Context.Service<
27
- Api,
28
- {
29
- readonly fetchUser: (id: string) => Effect.Effect<{ id: string }, Error>;
30
- }
31
- >()('@app/Api') {}
32
-
33
- const fetchUser = fromEffect({
34
- schemas: {
35
- input: Schema.Struct({ id: Schema.String }),
36
- output: Schema.Struct({ id: Schema.String })
37
- },
38
- effect: ({ input }) => Api.use((api) => api.fetchUser(input.id))
38
+ export class Deployments extends Context.Service<
39
+ Deployments,
40
+ { readonly deploy: (release: string) => Effect.Effect<string, Error> }
41
+ >()('@app/Deployments') {}
42
+
43
+ const deploy = fromEffect({
44
+ schemas: { input: Schema.Struct({ release: Schema.String }) },
45
+ effect: ({ input }) => Deployments.use((api) => api.deploy(input.release))
39
46
  });
40
47
 
41
- const machine = setup({ actors: { fetchUser } }).createMachine({
42
- initial: 'loading',
48
+ export const approvalMachine = setupEffect({
49
+ schemas: { input: Schema.Struct({ release: Schema.String }) },
50
+ actors: { deploy }
51
+ }).createMachine({
52
+ context: ({ input }) => ({ release: input.release, url: '' }),
53
+ initial: 'awaitingApproval',
43
54
  states: {
44
- loading: {
55
+ awaitingApproval: {
56
+ after: { 30000: { target: 'expired' } },
57
+ on: {
58
+ APPROVE: { target: 'deploying' },
59
+ CANCEL: { target: 'cancelled' }
60
+ }
61
+ },
62
+ deploying: {
45
63
  invoke: {
46
- src: 'fetchUser',
47
- input: { id: '42' },
48
- onDone: { target: 'success' },
49
- onError: { target: 'failure' }
64
+ src: 'deploy',
65
+ input: ({ context }) => ({ release: context.release }),
66
+ onDone: ({ context, event }) => ({
67
+ target: 'deployed',
68
+ context: { ...context, url: event.output }
69
+ }),
70
+ onError: { target: 'failed' }
71
+ },
72
+ on: { CANCEL: { target: 'cancelled' } }
73
+ },
74
+ failed: {
75
+ on: {
76
+ RETRY: { target: 'deploying' },
77
+ CANCEL: { target: 'cancelled' }
50
78
  }
51
79
  },
52
- success: {},
53
- failure: {}
80
+ deployed: { type: 'final' },
81
+ expired: { type: 'final' },
82
+ cancelled: { type: 'final' }
54
83
  }
55
84
  });
56
85
 
57
- const program = Effect.gen(function* () {
58
- const actor = yield* createEffectActor(machine);
59
- const snapshot = yield* waitFor(actor, (s) => s.matches('success'));
60
- return snapshot.value;
86
+ export const program = Effect.gen(function* () {
87
+ const actor = yield* createEffectActor(approvalMachine, {
88
+ input: { release: 'v1.2.0' }
89
+ });
90
+ // A UI, webhook or CLI can send this event after a person approves.
91
+ yield* send(actor, { type: 'APPROVE' });
92
+ const snapshot = yield* waitFor(actor, (s) => s.matches('deployed'), {
93
+ timeout: '5 seconds'
94
+ });
95
+ return snapshot.context.url;
61
96
  });
62
97
 
63
- await Effect.runPromise(
64
- Effect.provideService(Effect.scoped(program), Api, {
65
- fetchUser: (id) => Effect.succeed({ id })
66
- })
67
- );
68
- ```
69
-
70
- Effect-backed logic must run under `createEffectActor`. Starting it with `createActor` puts the actor in the `error` status because no Effect runtime is available.
71
-
72
- ### How it runs
73
-
74
- XState's transition function is pure: `transition(snapshot, event)` returns the next snapshot and the actions to run, without running them. `createEffectActor` drives that function through XState's `createDurable` loop from an Effect fiber and interprets the actions:
75
-
76
- - the mailbox is an Effect `Queue`, so `actor.send` enqueues and the fiber processes events in order;
77
- - timers for `after` transitions and delayed events are `Effect.sleep` fibers in the actor's `Scope`, on the Effect `Clock`;
78
- - declared Effect actions run as forked Effects with the services captured at creation, without blocking the loop;
79
- - built-in actions (spawn, send, emit, stop) run as XState defines them, and child actors run as live XState actors whose Effects use the same host.
80
-
81
- The handle it returns is an `EffectActor`. It implements XState's `ActorRef` contract (`send`, `getSnapshot`, `subscribe`, `on`) plus `inspect`, `getPersistedSnapshot` and `stop`, so `useSelector`, this package's actor functions and the inspection APIs accept it. Because the loop runs on a fiber, `send` returns before the event is processed; read the outcome with `waitFor`, `join` or `snapshots` rather than `getSnapshot` right after a send.
82
-
83
- ## Actors
84
-
85
- ### Lifetime
86
-
87
- `createEffectActor(logic, options?)` returns `Effect<Actor, never, R | Scope>`, where `R` is the union of service requirements of the logic (see [Requirements](#requirements)). The actor stops, and every Effect it hosts is interrupted, when the scope closes. Use `Effect.scoped` to close the scope when the program finishes, or run the actor inside a Layer to tie it to the application's lifetime.
88
-
89
- Effects hosted by the actor see a `Scope` that closes when the actor stops. `Effect.addFinalizer` and `Effect.acquireRelease` inside an actor's Effects release with the actor, not with the Effect that registered them.
90
-
91
- Provide Layers outside `Effect.scoped`, so their resources are released after the actor has stopped:
92
-
93
- ```ts
94
- await Effect.runPromise(program.pipe(Effect.scoped, Effect.provide(AppLayer)));
95
- ```
96
-
97
- `actor.stop()` still works. It stops the actor and closes the actor's scope synchronously; the release step of `createEffectActor` waits for the actor scope's finalizers before the enclosing scope continues closing.
98
-
99
- ### Providing an actor as a service
100
-
101
- An actor built by `createEffectActor` is a scoped Effect, so `Layer.effect` turns it into a service. The actor starts when the Layer is built and stops when the Layer's scope closes.
102
-
103
- ```ts
104
- import { Context, Effect, Layer, ManagedRuntime } from 'effect';
105
- import {
106
- createEffectActor,
107
- send,
108
- waitFor,
109
- type EffectActor
110
- } from '@xstate/effect';
111
-
112
- class CheckoutActor extends Context.Service<
113
- CheckoutActor,
114
- EffectActor<typeof checkoutMachine>
115
- >()('@app/CheckoutActor') {}
116
-
117
- const CheckoutActorLayer = Layer.effect(
118
- CheckoutActor,
119
- createEffectActor(checkoutMachine)
98
+ export const result = await Effect.runPromise(
99
+ program.pipe(
100
+ Effect.scoped,
101
+ Effect.provideService(Deployments, {
102
+ // Replace this demo service with your deployment API.
103
+ deploy: (release) => Effect.succeed(`https://example.com/${release}`)
104
+ })
105
+ )
120
106
  );
107
+ console.log(result); // 'https://example.com/v1.2.0'
121
108
  ```
122
109
 
123
- The Layer's requirements are the machine's requirements, so the services the machine needs are provided the same way as for any other Layer:
110
+ The demo service runs locally. Replace it with your deployment API. The machine defines which events are valid in each state; Effect provides the deployment service and owns the actor's lifetime.
124
111
 
125
- ```ts
126
- const AppLayer = CheckoutActorLayer.pipe(Layer.provide(PaymentsLayer));
127
- ```
112
+ Start Effect-backed logic with `createEffectActor` inside an Effect scope.
128
113
 
129
- At an edge that is not itself an Effect, build the Layer once with a `ManagedRuntime` and run individual Effects against it:
114
+ <details>
115
+ <summary>Runtime and cancellation details</summary>
130
116
 
131
- ```ts
132
- const runtime = ManagedRuntime.make(AppLayer);
133
-
134
- const pay = runtime.runPromise(
135
- Effect.gen(function* () {
136
- const actor = yield* CheckoutActor;
137
- yield* send(actor, { type: 'PAY' });
138
- return yield* waitFor(actor, (s) => s.matches('paid'));
139
- })
140
- );
117
+ - `createEffectActor` runs pure XState transitions from an Effect fiber. Its mailbox processes events in order; timers use Effect's `Clock`.
118
+ - Events are processed asynchronously. Read their outcomes with `waitFor`, `join` or `snapshots`.
119
+ - Starting Effect-backed logic with XState's `createActor` produces an error because the Effect host is missing.
120
+ - Leaving an invoking state interrupts its local Effect. It does not undo work already accepted by an external API; model cancellation or rollback explicitly when needed.
141
121
 
142
- // When the process shuts down:
143
- await runtime.dispose();
144
- ```
122
+ </details>
145
123
 
146
- `runtime.dispose()` closes the runtime's scope, which stops the actor and releases the Layers it was built from.
124
+ ## Guides
147
125
 
148
- ### Clock
126
+ | Guide | Examples |
127
+ | --- | --- |
128
+ | [Quick start](docs/quick-start.md) | Release approval, expiry, cancellation and retry. |
129
+ | [Actors](docs/actors.md) | Share an actor through a Layer and ManagedRuntime. |
130
+ | [Observing actors](docs/observing-actors.md) | Snapshot history, output and streamed reminders. |
131
+ | [Matching states](docs/matching-states.md) | Exhaustive matching and per-state context. |
132
+ | [Effect actor logic](docs/effect-logic.md) | Report tasks, upload progress and rollout health events. |
133
+ | [Schemas and actions](docs/schemas-and-actions.md) | Runtime event validation and background audit entries. |
134
+ | [Atoms and React](docs/atoms-and-react.md) | Registry ownership, approval controls and useSelector. |
135
+ | [Testing and errors](docs/testing-and-errors.md) | TestClock, task retries, supervision and typed failures. |
149
136
 
150
- XState timers (`after` transitions and delayed events) use the Effect `Clock` service by default. In production this is the live clock. In tests, `TestClock` from `effect/testing` drives them:
137
+ Runnable copies live in [examples/effect-workflows](../../examples/effect-workflows). Their tests verify the docs use the same code.
151
138
 
152
- ```ts
153
- import { TestClock } from 'effect/testing';
139
+ ## Actors
154
140
 
155
- const program = Effect.gen(function* () {
156
- const actor = yield* createEffectActor(machine);
157
- yield* TestClock.adjust('5 seconds');
158
- return actor.getSnapshot().value;
159
- });
141
+ The actor stops and interrupts its hosted Effects when its enclosing scope closes. Use `Effect.scoped` for a bounded program, or `Layer.effect` to share an actor for an application's lifetime.
160
142
 
161
- await Effect.runPromise(
162
- program.pipe(Effect.scoped, Effect.provide(TestClock.layer()))
163
- );
164
- ```
143
+ - Invoked Effect tasks and streams own their resources and release them on completion, failure or cancellation.
144
+ - Background actions use the owning actor's scope.
145
+ - Use `withActorScope` around resource acquisition to keep a resource until the owning actor stops.
165
146
 
166
- Timers are fibers in the actor's scope, so they are interrupted when the actor stops.
147
+ Provide Layers outside the actor's scope, so services remain available during cleanup. Dispose a `ManagedRuntime` when its owner shuts down. See [actors](docs/actors.md).
167
148
 
168
149
  ### Requirements
169
150
 
170
- `RequirementsFrom<Logic>` is the `R` channel of `createEffectActor`. It collects the requirements of:
151
+ `RequirementsFrom<TLogic>` collects services from declared Effect actions, declared actors and inline invocations, including child machines up to 10 levels deep. `createEffectActor` returns `Effect<EffectActor<TLogic>, never, R | Scope>`. Provided action and actor overrides update that service union. Actor and invocation scopes are supplied automatically.
171
152
 
172
- - actions registered with `setupEffect({ actions })`,
173
- - actors registered with `setup({ actors })` or `setupEffect({ actors })`,
174
- - logic used inline as `invoke.src`, at the root or in any state,
175
- - all of the above inside child machines, whether registered or invoked inline, up to 10 levels of machine nesting.
153
+ <details>
154
+ <summary>Requirement limits</summary>
176
155
 
177
- Past 10 levels the type stops recursing and contributes `never`, so requirements introduced deeper than that are not part of `R`. TypeScript then accepts a program that does not provide the service, and the actor fails at runtime when the missing service is requested. Flatten the machine tree, or provide those services explicitly, if a machine nests that deeply.
156
+ Deeper requirements are not inferred. Provide them explicitly or flatten the machine tree. Inline spawned Effect logic is rejected; register it in `actors` before spawning it.
178
157
 
179
- `never` in the `R` channel means "no requirement collected", not "no failure possible". `createEffectActor` is typed `Effect<Actor, never, R | Scope>` and has no typed failures at all. Starting Effect-backed logic outside `createEffectActor`, spawning undeclared Effect logic, and using a service that was not collected are programming errors: they surface as the actor's `error` status, not as an Effect failure. Input rejected by a runtime `validator` is thrown while the actor is created, so `createEffectActor` dies with that error as a defect.
158
+ The actor's later errors are observed through `join` or snapshots. Invalid input rejected during creation is a defect.
180
159
 
181
- Logic passed inline to `enq.spawn` lives inside a transition function body and is not visible to the type. Spawning inline Effect logic is therefore rejected at runtime; spawn a declared actor instead (`enq.spawn(args.actors.worker)`). See [Declared only](#declared-only).
160
+ </details>
182
161
 
183
162
  ### Observing actors
184
163
 
185
164
  <!-- actor surface from src/actor.ts -->
186
165
 
187
- These are free functions that take any XState `ActorRef`, including the `EffectActor` handle, children read from `snapshot.children` and actors created outside this package. A bundler drops the ones a program does not import, and everything that already accepts an XState actor, such as `useSelector`, works on the same handle.
188
-
189
- | Function | Returns |
190
- | ---------------------------------------- | ----------------------------------------------------------- |
191
- | `send(actor, event)` | `Effect<void>` |
192
- | `snapshots(actor)` | `Stream<Snapshot>`: the current snapshot, then each change |
193
- | `emitted(actor)` | `Stream<Emitted>`: events emitted with `emit` |
194
- | `waitFor(actor, predicate)` | `Effect<Snapshot, ActorStoppedError>` |
195
- | `waitFor(actor, predicate, { timeout })` | `Effect<Snapshot, ActorStoppedError \| Cause.TimeoutError>` |
196
- | `join(actor)` | `Effect<Output, ErrorFrom<Logic> \| ActorStoppedError>` |
197
- | `inspect(actor)` | `Stream<InspectionEvent>` |
198
- | `deadLetters(actor)` | `Stream<EventRejection>` |
199
-
200
- `send` and `waitFor` are dual: each takes the actor first, or returns a function of the actor so it can be piped.
166
+ | Function | Use |
167
+ | --- | --- |
168
+ | `send` | Enqueue an event. |
169
+ | `snapshots` | Stream the current snapshot and changes. |
170
+ | `waitFor` | Await a snapshot predicate, optionally with a timeout. |
171
+ | `join` | Await final output or failure. |
172
+ | `emitted` | Stream notifications emitted by the actor. |
173
+ | `inspect` | Stream system inspection events. |
174
+ | `deadLetters` | Observe delivery failures. |
201
175
 
202
- ```ts
203
- yield* send(actor, { type: 'PAY' });
204
- yield* pipe(actor, send({ type: 'PAY' }));
205
- ```
206
-
207
- When `predicate` is a type predicate, `waitFor` narrows its result to the asserted snapshot type:
208
-
209
- ```ts
210
- const snapshot = yield* waitFor(
211
- actor,
212
- (s): s is typeof s & { status: 'done' } => s.status === 'done'
213
- );
214
- ```
215
-
216
- The `timeout` failure is Effect's `Cause.TimeoutError`, not the `TimeoutError` that `xstate` exports for its own delayed-transition errors.
217
-
218
- `snapshots` ends when the actor completes. If the actor errors, the stream emits the error snapshot and ends. `waitFor` fails with `ActorStoppedError` when the actor stops or errors before the awaited result. `join` fails with `ActorStoppedError` when the actor stops without output, and with `snapshot.error` when the actor's status is `error`.
219
-
220
- For `fromEffect` logic that typed error is the Effect's `E`. For a machine it is `unknown`, because an action can throw an arbitrary value or an unhandled child error can fail the machine, so `join(machineActor)` has `unknown` in its error channel. Model domain failures as final states and read them from the machine's output; treat a machine-level error as a defect with `Effect.orDie`, or handle it with `Effect.catch`, to keep the rest of the program typed.
221
-
222
- ```ts
223
- const program = Effect.gen(function* () {
224
- const actor = yield* createEffectActor(fetchUser, { input: { id: '42' } });
225
- const user = yield* join(actor);
226
- return user.id;
227
- });
228
- ```
229
-
230
- `send` returns `Effect<void>` and never fails. It enqueues the event, like `actor.send(event)`, and XState reports an event it could not deliver as a **dead letter** with a reason: `'stopped'` for a send to a stopped actor, `'invalidEvent'` for a payload the target's schema rejects, and `'internalEvent'` for an internal event type sent from outside its owning actor. A failing `send` could not carry that reason, and a dead letter is not an actor error. Observe them instead:
231
-
232
- ```ts
233
- const program = Effect.gen(function* () {
234
- const actor = yield* createEffectActor(machine);
235
- yield* Effect.forkScoped(
236
- Stream.runForEach(deadLetters(actor), (event) =>
237
- Effect.logWarning(`undelivered ${event.event.type}: ${event.reason}`)
238
- )
239
- );
240
- });
241
- ```
176
+ `send` and `waitFor` support direct and pipeable usage. See [complete observation examples](docs/observing-actors.md).
242
177
 
243
178
  ### Matching states
244
179
 
245
180
  <!-- tagged state view from src/state.ts -->
246
181
 
247
- `taggedState(snapshot)` views a machine snapshot as a member of a tagged union, so `Match.tag` and `Match.exhaustive` work on states. `_tag` is the state's dotted path, such as `'checkout.paying'`, and `context` is that state's context, including any per-state context schema declared in `setupEffect({ states })`. `TaggedState<typeof machine>` names the union.
248
-
249
- ```ts
250
- import { Match, Stream } from 'effect';
251
- import { snapshots, taggedState, type TaggedState } from '@xstate/effect';
252
-
253
- const describe = Match.type<TaggedState<typeof checkoutMachine>>().pipe(
254
- Match.tag('cart', ({ context }) => `${context.items.length} items`),
255
- Match.tag('paying', ({ context }) => `paying ${context.paymentId}`),
256
- Match.tag('done.paid', 'done.declined', ({ _tag }) => _tag),
257
- Match.exhaustive
258
- );
259
-
260
- const labels = snapshots(actor).pipe(Stream.map(taggedState), Stream.map(describe));
261
- ```
262
-
263
- A parallel state has no single path. Its tag stops at the parallel state, or is `'(machine)'` when the machine itself is parallel; match on `value` or `snapshot.matches` there. The `state` atom from `createActorAtoms` exposes the same view.
264
-
265
- ## Atoms
266
-
267
- <!-- atom surface from src/atom.ts -->
268
-
269
- `@xstate/effect/atom` exposes an actor through `effect/unstable/reactivity`, so a reactive UI reads it the way it reads any other Effect state. `createActorAtoms(runtime, logic, options?)` takes an `Atom.runtime` whose Layer provides the logic's services and returns:
270
-
271
- | Atom | Type |
272
- | ----------- | ------------------------------------------------------------------------------- |
273
- | `actor` | `Atom<AsyncResult<Actor>>` |
274
- | `snapshot` | `Atom<AsyncResult<Snapshot>>` |
275
- | `result` | `Atom<AsyncResult<Snapshot, ErrorFrom<Logic>>>`: a `Failure` once the actor errors. The runtime's own error type is also in every atom's error channel |
276
- | `send` | `Writable<AsyncResult<void, NotReadyError>, Event>`: set it with an event; `NotReadyError` is exported from `@xstate/effect/atom` |
277
- | `select(f)` | `Atom<AsyncResult<T>>` derived from `snapshot` |
278
- | `state` | `Atom<AsyncResult<TaggedState>>`: the snapshot as a tagged union, see [Matching states](#matching-states) |
279
-
280
- ```ts
281
- import { Effect, Layer } from 'effect';
282
- import { Atom, AtomRegistry, AsyncResult } from 'effect/unstable/reactivity';
283
- import { createActorAtoms } from '@xstate/effect/atom';
284
-
285
- const runtime = Atom.runtime(
286
- Layer.succeed(Api, { fetchUser: (id: string) => Effect.succeed({ id }) })
287
- );
288
- const user = createActorAtoms(runtime, machine);
289
- const status = user.select((snapshot) => snapshot.value);
290
-
291
- const registry = AtomRegistry.make();
292
- registry.subscribe(
293
- status,
294
- (result) => {
295
- if (AsyncResult.isSuccess(result)) {
296
- console.log(result.value);
297
- }
298
- },
299
- { immediate: true }
300
- );
301
- registry.set(user.send, { type: 'RETRY' });
302
- ```
303
-
304
- The actor starts when one of its atoms is first read and stops when nothing reads or mounts them anymore. Results are `AsyncResult` values because the runtime's Layer builds asynchronously. `send` enqueues the event, like `actor.send`; setting it before the runtime is ready records a `NotReadyError` failure instead. `result` is `snapshot` with an errored actor reported as a `Failure`, for error boundaries. Wrap an atom with `Atom.keepAlive` to keep the actor for the registry's lifetime, or build the atoms inside `Atom.family` to get one actor per input.
305
-
306
- A runtime that does not provide a service the logic requires is a type error on the `runtime` argument.
307
-
308
- `effect/unstable/reactivity` is an unstable Effect module. This entry point follows it and may change independently of the rest of the package.
309
-
310
- ## React
311
-
312
- `useMachine`, `useActor` and `useActorRef` call `createActor` internally, so they cannot start Effect-backed logic. In an Effect application the actor lives in the runtime and React reads it through atoms: `@xstate/effect/atom` builds them and `@effect/atom-react` provides the hooks.
313
-
314
- ```tsx
315
- import { Suspense } from 'react';
316
- import { Atom } from 'effect/unstable/reactivity';
317
- import { useAtomSet, useAtomSuspense } from '@effect/atom-react';
318
- import { createActorAtoms } from '@xstate/effect/atom';
319
-
320
- const runtime = Atom.runtime(AppLayer);
321
- const checkout = createActorAtoms(runtime, checkoutMachine);
322
- const status = checkout.select((snapshot) => snapshot.value);
323
-
324
- function Checkout() {
325
- const { value } = useAtomSuspense(status);
326
- const send = useAtomSet(checkout.send);
327
-
328
- return (
329
- <button onClick={() => send({ type: 'PAY' })}>{String(value)}</button>
330
- );
331
- }
332
-
333
- export function App() {
334
- return (
335
- <Suspense fallback={null}>
336
- <Checkout />
337
- </Suspense>
338
- );
339
- }
340
- ```
341
-
342
- `useAtomSuspense` suspends until the runtime and the actor are ready. `useAtomValue` returns the `AsyncResult` instead, for components that render their own loading state. The actor starts when the first component reads one of its atoms and stops when the last one unmounts. An owner component can hold it with `useAtomMount(checkout.actor)` while children read selectors; pin it with `Atom.keepAlive` when it must outlive the components.
343
-
344
- Without atoms, create the actor through a `ManagedRuntime` and read it with `useSelector` from `@xstate/react`, which takes an existing actor reference. `actor.send` works directly there.
182
+ `taggedState(snapshot)` exposes `_tag`, `value`, `context` and `snapshot`. Use `TaggedState<typeof machine>` with Effect's `Match.tag` and `Match.exhaustive`. Per-state context narrows with the tag. See [matching states](docs/matching-states.md).
345
183
 
346
184
  ## Effect actor logic
347
185
 
348
186
  <!-- fromEffect, fromEffectStream, fromEffectEventStream from src/fromEffect.ts -->
349
187
 
350
- ### `fromEffect`
351
-
352
- `fromEffect` accepts an Effect, a function that returns an Effect, or a config object:
188
+ | Function | Behavior |
189
+ | --- | --- |
190
+ | `fromEffect` | Run a task; its success value is the actor's output. |
191
+ | `fromEffectStream` | Expose the latest stream item as snapshot context. |
192
+ | `fromEffectEventStream` | Relay stream items to the parent as events. |
353
193
 
354
- ```ts
355
- fromEffect(Effect.succeed('done'));
194
+ For example, a progress stream can feed an upload UI:
356
195
 
357
- fromEffect(({ input }: { input: string }) => Effect.succeed(input.length));
358
-
359
- fromEffect({
360
- id: 'loadUser',
361
- schemas: {
362
- input: Schema.Struct({ id: Schema.String }),
363
- output: Schema.Struct({ id: Schema.String })
364
- },
365
- effect: ({ input }) => Api.use((api) => api.fetchUser(input.id))
366
- });
367
- ```
368
-
369
- The function form receives `{ input, self, system, emit }`. `emit` publishes an event that `actor.on(...)` and `emitted(actor)` observe. Either schema may be omitted; the missing type is inferred from the Effect.
370
-
371
- The actor's result maps from the Effect's exit:
372
-
373
- - Success: status `done` with the value as `output`.
374
- - Failure: status `error` with the `E` value as `error`. `invoke.onError` receives it typed through `ErrorFrom`.
375
- - Defect: status `error` with the squashed cause. Defects are not part of the typed error.
376
- - Interruption caused by the actor stopping or by the invoking state exiting: no error. The actor is stopped.
377
- - Interruption from inside the Effect, such as `Effect.interrupt`: status `error` with an `EffectInterruptedError`. An `Effect.timeout` is a failure with `Cause.TimeoutError`, not an interruption; a lost `Effect.race` inside the Effect interrupts only the loser and the actor completes with the winner.
378
-
379
- Use `Effect.timeout`, `Effect.retry` and the other Effect combinators inside the Effect. `fromEffect` does not add its own options for them.
380
-
381
- ### `fromEffectStream`
382
-
383
- `fromEffectStream` exposes the latest stream item as the actor's `context` and reaches `done` when the stream completes. A stream failure puts the actor in the `error` status.
196
+ <!-- example from examples/effect-workflows/src/latest-stream.ts -->
384
197
 
385
198
  ```ts
386
- fromEffectStream(Stream.make(1, 2, 3));
387
-
388
- fromEffectStream({
389
- schemas: { input: Schema.Struct({ topic: Schema.String }) },
390
- stream: ({ input }) => Stream.fromPubSub(topicPubSub(input.topic))
391
- });
392
- ```
199
+ import { Effect, Option, Stream } from 'effect';
200
+ import { createEffectActor, fromEffectStream, snapshots } from '@xstate/effect';
393
201
 
394
- ### `fromEffectEventStream`
202
+ // Demo upload progress. Replace with your upload SDK's progress stream.
203
+ const uploadProgress = fromEffectStream(Stream.make(0, 25, 60, 100));
395
204
 
396
- `fromEffectEventStream` relays each stream item to the parent machine as an event. It accepts the same forms as `fromEffectStream`.
397
-
398
- ```ts
399
- const machine = createMachine({
400
- context: { seen: 0 },
401
- schemas: { events: { VALUE: types<{ value: number }>() } },
402
- initial: 'active',
403
- states: {
404
- active: {
405
- invoke: {
406
- src: fromEffectEventStream(
407
- Stream.make({ type: 'VALUE', value: 1 }, { type: 'VALUE', value: 2 })
408
- )
409
- },
410
- on: {
411
- VALUE: ({ context, event }) => ({
412
- context: { seen: context.seen + event.value }
413
- })
414
- }
415
- }
416
- }
205
+ const program = Effect.gen(function* () {
206
+ const actor = yield* createEffectActor(uploadProgress);
207
+ return yield* snapshots(actor).pipe(
208
+ Stream.filter((s) => s.context !== undefined),
209
+ Stream.map((s) => s.context),
210
+ Stream.runLast,
211
+ Effect.map(Option.getOrThrow)
212
+ );
417
213
  });
418
- ```
419
-
420
- ## Effect schemas
421
214
 
422
- <!-- schema conversion from src/schema.ts -->
423
-
424
- `setupEffect` accepts Effect schemas in its `schemas` and `states` options, and in `setupEffect(...).extend(...)`. XState infers the decoded `Schema.Type`; there is no need to call `Schema.toStandardSchemaV1`.
425
-
426
- ```ts
427
- import { Effect, Schema } from 'effect';
428
- import { createEffectActor, setupEffect } from '@xstate/effect';
429
-
430
- const machine = setupEffect({
431
- schemas: {
432
- context: Schema.Struct({ count: Schema.Number }),
433
- events: {
434
- ADD: Schema.Struct({ value: Schema.Number })
435
- }
436
- }
437
- }).createMachine({
438
- context: { count: 0 },
439
- on: {
440
- ADD: ({ context, event }) => ({
441
- context: { count: context.count + event.value }
442
- })
443
- }
444
- });
215
+ export const result = await Effect.runPromise(Effect.scoped(program));
216
+ console.log(result); // 100
445
217
  ```
446
218
 
447
- Standard Schemas remain supported and can be mixed with Effect schemas. Schemas provide types without a `validator`; adding `standardSchemaValidator()` from `xstate/validation` enables runtime assertions.
448
-
449
- XState validation checks a value but does not replace it with a transformed value. When runtime validation is enabled, TypeScript rejects schemas whose encoded and decoded types differ, such as `Schema.NumberFromString`. Effect schemas used at this validation seam must decode synchronously and without service requirements.
450
-
451
- ## Effect actions
452
-
453
- <!-- effect action contract from src/setupEffect.ts; spawn guard from src/internal.ts -->
454
-
455
- `setupEffect({ actions })` declares actions that return an Effect. An Effect action is fire-and-forget: the transition enqueues it, the transition commits, and the Effect then runs in the actor's Effect context without blocking the actor. It is interrupted when the actor stops. A failure or defect routes to the state's `onError`.
219
+ See [Effect actor logic](docs/effect-logic.md) for tasks and a stream that drives rollout transitions.
456
220
 
457
- In XState v6 an action is a plain function that a transition enqueues with explicit arguments, `enq(args.actions.audit, args)`. `setupEffect` keeps that contract. The function is called synchronously during the transition, and the Effect it returns is the asynchronous boundary.
221
+ ## Schemas and actions
458
222
 
459
- ```ts
460
- class Audit extends Context.Service<
461
- Audit,
462
- { readonly record: (count: number) => Effect.Effect<void> }
463
- >()('@app/Audit') {}
464
-
465
- const machine = setupEffect({
466
- actions: {
467
- audit: ({ context }) => Audit.use((audit) => audit.record(context.count))
468
- }
469
- }).createMachine({
470
- context: { count: 1 },
471
- initial: 'active',
472
- states: {
473
- active: {
474
- on: {
475
- AUDIT: (args, enq) => enq(args.actions.audit, args)
476
- }
477
- }
478
- }
479
- });
480
- ```
481
-
482
- Use Effect actions for work whose result the machine does not need: logging, telemetry, notifications, cache writes. When the result matters, invoke the Effect as an actor with `fromEffect`, so `onDone` and `onError` receive it typed and the state models the wait.
223
+ <!-- schema conversion from src/schema.ts -->
483
224
 
484
- An Effect action cannot enqueue: it runs after the transition. Enqueue from the transition function, which has the machine's event types. To hand a result back to the machine from an Effect action, send an event to `self`:
485
-
486
- ```ts
487
- const machine = setupEffect({
488
- schemas: {
489
- events: {
490
- SAVE: Schema.Struct({}),
491
- SYNCED: Schema.Struct({ version: Schema.Number })
492
- }
493
- },
494
- actions: {
495
- sync: ({ context, self }) =>
496
- Effect.gen(function* () {
497
- const version = yield* Api.use((api) => api.sync(context.draft));
498
- yield* Effect.sync(() => self.send({ type: 'SYNCED', version }));
499
- })
500
- }
501
- }).createMachine({
502
- context: { draft: '', version: 0 },
503
- initial: 'active',
504
- states: {
505
- active: {
506
- on: {
507
- SAVE: (args, enq) => enq(args.actions.sync, args),
508
- SYNCED: ({ context, event }) => ({
509
- context: { ...context, version: event.version }
510
- })
511
- }
512
- }
513
- }
514
- });
515
- ```
225
+ `setupEffect` accepts Effect schemas for input, context, events, output and per-state context. Types are inferred without manual conversion. Add `standardSchemaValidator()` from `xstate/validation` for runtime checks.
516
226
 
517
227
  ### Declared only
518
228
 
519
- Anything that touches the Effect context must be a declared action or a declared actor. Only declared sources contribute to the actor's requirements, so an inline Effect infers `R = never` and fails at runtime when a service is missing. Declared actions also carry a name for inspection and `machine.provide` overrides.
520
-
521
- - Register Effect actions with `setupEffect({ actions })` and run them with `enq(args.actions.name, args)`.
522
- - Register spawned Effect logic with `setup({ actors })` or `setupEffect({ actors })` and spawn it with `enq.spawn(args.actors.name)`. Spawning inline Effect logic is an error at runtime.
523
- - Effect logic used inline as `invoke.src` runs and contributes to `RequirementsFrom`. Registering it in `actors` gives it a name.
524
-
525
- > **Warning:** An inline action that returns an Effect does not run it. `enq(() => Effect.log('saved'))` creates the Effect and discards it, because XState only awaits returned promises. Register the action with `setupEffect({ actions })` instead.
526
-
527
- Effect actions do not block the actor. The actor processes the next event while the Effect runs.
528
-
529
- ## Persistence
530
-
531
- `actor.getPersistedSnapshot()` returns a serializable snapshot of the actor's state. It records state, not the progress of a running Effect. Restoring a snapshot into a new interpreter is not supported yet; the durable execution loop this package is built on is the intended path for that, and it is the next piece of work.
532
-
533
- ## Testing
534
-
535
- Machines keep their normal test surface, and this package adds Effect-native ways to drive and observe them.
536
-
537
- Delays run on the Effect `Clock`, so `TestClock` advances `after` transitions and delayed sends without real time passing:
538
-
539
- ```ts
540
- import { Effect } from 'effect';
541
- import { TestClock } from 'effect/testing';
542
- import { createEffectActor, waitFor } from '@xstate/effect';
543
-
544
- const test = Effect.gen(function* () {
545
- const actor = yield* createEffectActor(machine);
546
- yield* TestClock.adjust('30 seconds');
547
- yield* waitFor(actor, (s) => s.matches('timedOut'));
548
- });
549
-
550
- await Effect.runPromise(
551
- test.pipe(Effect.scoped, Effect.provide(TestClock.layer()))
552
- );
553
- ```
554
-
555
- Assert with `waitFor` for a state the actor should reach, and with `join` for the actor's final output. Both fail rather than hang when the actor stops first, and `waitFor`'s `timeout` option bounds a test that would otherwise wait forever.
556
-
557
- Observe with `inspect` for every inspection event and `deadLetters` for events the system could not deliver. A test that ends with no dead letters confirms that every event it sent was accepted.
229
+ <!-- effect action contract from src/setupEffect.ts; spawn guard from src/internal.ts -->
558
230
 
559
- Path generation in `xstate/graph` (`getShortestPaths`, `getSimplePaths`) operates on the machine, not on a running actor, so it works on an Effect-backed machine unchanged. Execute the generated paths against an actor from `createEffectActor`, or run them with `testPaths()` from `@xstate/test`.
231
+ - Register background Effect actions in `setupEffect({ actions })`; enqueue them with explicit arguments.
232
+ - Invoke `fromEffect` logic when the machine needs to wait for a result.
233
+ - Register spawned Effect logic in `actors`, then use `enq.spawn(args.actors.worker)`.
560
234
 
561
- ## Retries and supervision
235
+ See [schemas and actions](docs/schemas-and-actions.md) for a complete audit example, runtime schema constraints and inline-source pitfalls.
562
236
 
563
- `createEffectActor` is an ordinary scoped Effect, so Effect's retry combinators supervise an actor. Wrap the actor and the work that depends on it in `Effect.scoped`, then retry that unit: each attempt builds a fresh actor and the failed attempt's actor is stopped when its scope closes.
237
+ ## Atoms and React
564
238
 
565
- ```ts
566
- import { Effect, Schedule } from 'effect';
567
- import { createEffectActor, join } from '@xstate/effect';
239
+ <!-- atom surface from src/atom.ts -->
568
240
 
569
- const program = Effect.gen(function* () {
570
- const actor = yield* createEffectActor(machine);
571
- return yield* join(actor);
572
- });
241
+ `createActorAtoms` from `@xstate/effect/atom` exposes `actor`, `snapshot`, `result`, `send`, `select` and `state`. Its `Atom.runtime` must provide the logic's services. Pass `{ input }` when the logic requires input; missing or mismatched input is a type error.
573
242
 
574
- const supervised = Effect.retry(Effect.scoped(program), {
575
- schedule: Schedule.exponential('100 millis'),
576
- times: 3
577
- });
578
- ```
243
+ - Read atoms with `@effect/atom-react` in React, or an `AtomRegistry` elsewhere.
244
+ - The registry owns their lifetime; dispose it when its owner shuts down.
245
+ - `useSelector` from `@xstate/react` can also read an existing `EffectActor` directly.
579
246
 
580
- `join` fails when the actor errors or stops before producing output, so the schedule sees the actual failure. For a machine that failure is `unknown` (see [Observing actors](#observing-actors)); narrow it before the retry if the schedule should only see some errors. Retrying `program` without `Effect.scoped` would reuse the outer scope and leak the actors from failed attempts until that scope closes.
247
+ See [atoms and React](docs/atoms-and-react.md) for complete examples with imports, runtime setup and cleanup.
581
248
 
582
- ## Errors
249
+ ## Testing and errors
583
250
 
584
251
  <!-- error types from src/errors.ts; timeout failure from src/actor.ts -->
585
252
 
586
- | Error | Raised by | Fields |
587
- | ------------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------ |
588
- | `ActorStoppedError` | `waitFor` when the actor stops or errors; `join` when it stops without output | `actorId: string`, `snapshot: Snapshot<unknown>` |
589
- | `EffectInterruptedError` | Effect logic interrupted from inside, such as `Effect.interrupt`, reported as `snapshot.error` | `cause: Cause.Cause<never>` |
590
- | `Cause.TimeoutError` | `waitFor` with `{ timeout }` when no snapshot matches in time | Effect's own error; `_tag: 'TimeoutError'` |
591
-
592
- Both package errors are `Data.TaggedError` classes, so `Effect.catchTag('ActorStoppedError', …)` matches them.
593
-
594
- The actor's own failures are not in this table. A `fromEffect` actor reports the Effect's `E` value as `snapshot.error`, typed through `ErrorFrom`, which this package re-exports from `xstate`.
253
+ - Use `TestClock` to drive deadlines without waiting for real time.
254
+ - `ActorStoppedError` reports a stop before an awaited result.
255
+ - `EffectInterruptedError` reports self-interruption inside Effect logic.
256
+ - Timed waits fail with Effect's `Cause.TimeoutError`.
257
+ - `join` preserves a task's typed failure. Unexpected machine errors have type `unknown`.
595
258
 
596
- A root actor that errors does not throw globally the way `createActor(...).start()` does. Its error is a value: read it with `join`, `waitFor`, the `result` atom, or `subscribe`. An errored actor nobody observes is silent, like a failed forked fiber.
259
+ See [testing and errors](docs/testing-and-errors.md) for executable deadline, retry and failure examples. Persisted snapshots record actor state; restoration into a new Effect interpreter is not supported yet.
597
260
 
598
261
  ## Tracing
599
262
 
600
263
  <!-- span names and attributes from src/internal.ts and src/fromEffect.ts -->
601
264
 
602
- Every Effect the actor hosts runs inside a span:
603
-
604
- | Span | Covers |
605
- | ----------------------- | ---------------------------------------- |
606
- | `fromEffect` | a `fromEffect` actor's Effect |
607
- | `fromEffectStream` | a `fromEffectStream` actor's stream |
608
- | `fromEffectEventStream` | a `fromEffectEventStream` actor's stream |
609
- | `action.<name>` | an Effect action registered as `<name>` |
610
-
611
- Each span carries the attributes `xstate.actor.id` and `xstate.actor.address`. `id` is the actor's own name. `address` is its `/`-joined path of ids from the root actor, which is stable across persistence and restore, so it identifies the same logical actor across runs.
612
-
613
- Spans are recorded only when a `Tracer` is provided. Without one they cost nothing and export nothing.
265
+ Hosted work uses spans named `fromEffect`, `fromEffectStream`, `fromEffectEventStream` and `action.<name>`. Each carries `xstate.actor.id` and `xstate.actor.address`. Provide a `Tracer` to record and export them.
@@ -0,0 +1,11 @@
1
+ import { Context, Effect, Scope } from 'effect';
2
+ declare const ActorScope_base: Context.ServiceClass<ActorScope, "@xstate/effect/ActorScope", Scope.Scope>;
3
+ /** The owning Effect actor's scope, supplied by `createEffectActor`. */
4
+ export declare class ActorScope extends ActorScope_base {
5
+ }
6
+ /**
7
+ * Acquires resources in the owning actor's scope. Their finalizers run when
8
+ * that actor stops, even if the invoking task finishes or is cancelled first.
9
+ */
10
+ export declare function withActorScope<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, Exclude<R, Scope.Scope> | ActorScope>;
11
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { AsyncResult, Atom } from 'effect/unstable/reactivity';
2
2
  import type { AnyActorLogic, ErrorFrom, EventFromLogic, SnapshotFrom } from 'xstate';
3
- import { type EffectActorOptions } from "./createEffectActor.js";
3
+ import { type EffectActorOptionsArgs } from "./createEffectActor.js";
4
4
  import type { EffectActor } from "./effectActor.js";
5
5
  import { NotReadyError } from "./errors.js";
6
6
  import { type TaggedStateFrom } from "./state.js";
@@ -45,7 +45,8 @@ interface MissingRequirements<T> {
45
45
  /**
46
46
  * Creates atoms for an actor that runs in an `Atom.runtime`. The runtime's
47
47
  * Layer must provide every service the logic requires; a missing service is
48
- * a type error on the `runtime` argument.
48
+ * a type error on the `runtime` argument. Logic with required input also
49
+ * requires an options object containing that input.
49
50
  *
50
51
  * The actor is created with `createEffectActor` inside the `actor` atom's
51
52
  * scope, so its lifetime follows the atom: it starts on first read and stops
@@ -53,4 +54,4 @@ interface MissingRequirements<T> {
53
54
  * actor for the registry's lifetime, or with `Atom.family` to create one
54
55
  * actor per input.
55
56
  */
56
- export declare function createActorAtoms<TLogic extends AnyActorLogic, R, ER = never>(runtime: Atom.AtomRuntime<R, ER> & ([RequirementsFrom<TLogic>] extends [R] ? unknown : MissingRequirements<Exclude<RequirementsFrom<TLogic>, R>>), logic: TLogic, options?: EffectActorOptions<TLogic>): ActorAtoms<TLogic, ER>;
57
+ export declare function createActorAtoms<TLogic extends AnyActorLogic, R, ER = never>(runtime: Atom.AtomRuntime<R, ER> & ([RequirementsFrom<TLogic>] extends [R] ? unknown : MissingRequirements<Exclude<RequirementsFrom<TLogic>, R>>), logic: TLogic, ...[options]: EffectActorOptionsArgs<TLogic>): ActorAtoms<TLogic, ER>;
@@ -3,15 +3,12 @@ import { type AnyActorLogic, type InputFrom } from 'xstate';
3
3
  import { EffectActor } from "./effectActor.js";
4
4
  import type { RequirementsFrom } from "./types.js";
5
5
  /** Options for {@link createEffectActor}. */
6
- export interface EffectActorOptions<TLogic extends AnyActorLogic> {
7
- /** The actor's input. */
6
+ export type EffectActorOptions<TLogic extends AnyActorLogic> = undefined extends InputFrom<TLogic> ? {
8
7
  readonly input?: InputFrom<TLogic>;
9
- }
10
- type RequiredInput<TLogic extends AnyActorLogic> = undefined extends InputFrom<TLogic> ? {
11
- input?: InputFrom<TLogic>;
12
8
  } : {
13
- input: InputFrom<TLogic>;
9
+ readonly input: InputFrom<TLogic>;
14
10
  };
11
+ export type EffectActorOptionsArgs<TLogic extends AnyActorLogic> = undefined extends InputFrom<TLogic> ? [options?: EffectActorOptions<TLogic>] : [options: EffectActorOptions<TLogic>];
15
12
  /**
16
13
  * Creates and starts an actor as an Effect interpreter over pure transitions.
17
14
  *
@@ -26,5 +23,4 @@ type RequiredInput<TLogic extends AnyActorLogic> = undefined extends InputFrom<T
26
23
  * interrupted, when the enclosing `Scope` closes. The returned Effect never
27
24
  * fails; the actor's own outcome is its snapshot status, read with `join`.
28
25
  */
29
- export declare function createEffectActor<TLogic extends AnyActorLogic>(logic: TLogic, ...[options]: undefined extends InputFrom<TLogic> ? [options?: EffectActorOptions<TLogic>] : [options: EffectActorOptions<TLogic> & RequiredInput<TLogic>]): Effect.Effect<EffectActor<TLogic>, never, RequirementsFrom<TLogic> | Scope.Scope>;
30
- export {};
26
+ export declare function createEffectActor<TLogic extends AnyActorLogic>(logic: TLogic, ...[options]: EffectActorOptionsArgs<TLogic>): Effect.Effect<EffectActor<TLogic>, never, RequirementsFrom<TLogic> | Scope.Scope>;
@@ -101,6 +101,9 @@ export type EffectStreamActorLogic<TItem, TError, TInput, TRequirements> = Actor
101
101
  * an Effect, a function of {@link EffectSourceArgs} returning one, or a config
102
102
  * object with `id`, `schemas`, `validator` and `effect`. The logic must be run
103
103
  * under `createEffectActor`, which provides the Effect services it declares.
104
+ * Resources belong to this task's scope and close on completion, failure or
105
+ * interruption. Use `withActorScope` to retain a resource until the owning
106
+ * Effect actor stops.
104
107
  */
105
108
  export declare function fromEffect<const TInputSchema extends EffectSchemaLike, const TOutputSchema extends EffectSchemaLike, TError = unknown, TRequirements = never>(config: Omit<EffectSourceConfig<SchemaOutput<TOutputSchema>, TError, SchemaOutput<TInputSchema>, TRequirements>, 'schemas' | 'validator'> & EffectSchemaValidationConfig<{
106
109
  input: TInputSchema;
@@ -147,7 +150,8 @@ type StreamRequirements<T> = T extends Stream.Stream<any, any, infer R> ? R : ne
147
150
  * the actor's `context`. The actor reaches `done` when the stream completes
148
151
  * and `error` when it fails. Accepts a Stream, a function of
149
152
  * {@link EffectSourceArgs} returning one, or a config object with `id`,
150
- * `schemas`, `validator` and `stream`.
153
+ * `schemas`, `validator` and `stream`. Its resources close when the stream
154
+ * completes, fails or is interrupted.
151
155
  */
152
156
  export declare function fromEffectStream<const TInputSchema extends EffectSchemaLike, TStream extends Stream.Stream<any, any, any>>(config: Omit<EffectStreamConfig<StreamItem<TStream>, StreamError<TStream>, SchemaOutput<TInputSchema>, StreamRequirements<TStream>>, 'stream' | 'schemas' | 'validator'> & EffectSchemaValidationConfig<{
153
157
  input: TInputSchema;
@@ -162,7 +166,8 @@ export declare function fromEffectStream<TItem, TError = unknown, TInput = undef
162
166
  * Creates actor logic that runs a Stream of events and relays each item to the
163
167
  * parent machine as an event, the way `fromEventObservable` does. The actor
164
168
  * has no output: it reaches `done` when the stream completes and `error` when
165
- * it fails. Accepts the same forms as `fromEffectStream`.
169
+ * it fails. Accepts the same forms as `fromEffectStream`. Its resources close
170
+ * when the stream completes, fails or is interrupted.
166
171
  */
167
172
  export declare function fromEffectEventStream<const TInputSchema extends EffectSchemaLike, TStream extends Stream.Stream<EventObject, any, any>>(config: Omit<EffectStreamConfig<StreamItem<TStream>, StreamError<TStream>, SchemaOutput<TInputSchema>, StreamRequirements<TStream>>, 'stream' | 'schemas' | 'validator'> & EffectSchemaValidationConfig<{
168
173
  input: TInputSchema;
@@ -1,6 +1,7 @@
1
1
  export { fromEffect, fromEffectEventStream, fromEffectStream, type EffectActorLogic, type EffectLogicBrand, type EffectSnapshot, type EffectSource, type EffectSourceArgs, type EffectStreamSource, type EffectStreamActorLogic, type EffectStreamSnapshot } from "./fromEffect.js";
2
2
  export { createEffectActor, type EffectActorOptions } from "./createEffectActor.js";
3
3
  export { EffectActor } from "./effectActor.js";
4
+ export { ActorScope, withActorScope } from "./actorScope.js";
4
5
  export { deadLetters, emitted, inspect, join, send, snapshots, waitFor, type EmittedEventFrom, type SendableEventFrom, type WaitForOptions } from "./actor.js";
5
6
  export { ActorStoppedError, EffectInterruptedError } from "./errors.js";
6
7
  export { setupEffect, type EffectAction, type EffectActionArgs, type EffectSetupReturn } from "./setupEffect.js";
@@ -1,6 +1,7 @@
1
- import type { Effect } from 'effect';
1
+ import type { Effect, Scope } from 'effect';
2
2
  import type { EffectLogicBrand } from "./fromEffect.js";
3
3
  import { effectActionBrand } from "./brands.js";
4
+ import type { ActorScope } from "./actorScope.js";
4
5
  /** The `R` channel of an Effect type, or `never` for anything else. */
5
6
  export type EffectRequirements<T> = T extends Effect.Effect<any, any, infer R> ? R : never;
6
7
  type RequirementsFromActions<T> = T extends Record<string, (...args: any[]) => any> ? T[keyof T] extends infer TAction ? TAction extends {
@@ -38,15 +39,16 @@ type RequirementsFromStateSchema<TSchema, TDepth extends Depth> = (TSchema exten
38
39
  /**
39
40
  * Collects the Effect service requirements of an actor logic.
40
41
  *
41
- * For Effect logic this is the logic's own `R`. For a state machine it is the
42
+ * Actor and invocation scopes are supplied by the host and excluded here.
43
+ * For Effect logic this is the remaining `R`. For a state machine it is the
42
44
  * union of the requirements of its registered Effect actions, its registered
43
45
  * Effect actors, the Effect logic passed inline as `invoke.src` anywhere in
44
46
  * its state tree, and — recursively — the requirements of any child machine
45
47
  * reached either way (capped at {@link MaxDepth} levels of machine nesting).
46
48
  */
47
- export type RequirementsFrom<T, TDepth extends Depth = MaxDepth> = T extends EffectLogicBrand<any, infer R> ? R : T extends {
49
+ export type RequirementsFrom<T, TDepth extends Depth = MaxDepth> = T extends EffectLogicBrand<any, infer R> ? Exclude<R, ActorScope | Scope.Scope> : T extends {
48
50
  readonly _actionMap: infer TActionMap;
49
51
  readonly _actorMap: infer TActorMap;
50
52
  readonly _stateSchema: infer TStateSchema;
51
- } ? RequirementsFromActions<TActionMap> | ([TDepth] extends [0] ? never : RequirementsFromActors<TActorMap, PrevDepth[TDepth]> | RequirementsFromStateSchema<TStateSchema, PrevDepth[TDepth]>) : never;
53
+ } ? Exclude<RequirementsFromActions<TActionMap>, ActorScope | Scope.Scope> | ([TDepth] extends [0] ? never : RequirementsFromActors<TActorMap, PrevDepth[TDepth]> | RequirementsFromStateSchema<TStateSchema, PrevDepth[TDepth]>) : never;
52
54
  export {};
@@ -1,4 +1,4 @@
1
- import { Queue, Effect, Scope, Exit, Cause, Context, Fiber, Duration, Data } from 'effect';
1
+ import { Queue, Effect, Fiber, Scope, Exit, Cause, Context, Duration, Data } from 'effect';
2
2
  import { stopActor, terminateActor, deliverEvent } from 'xstate';
3
3
  import { createDurable } from 'xstate/durable';
4
4
 
@@ -216,7 +216,8 @@ function createEffectHost(context, scope) {
216
216
  context,
217
217
  scope,
218
218
  interruptors: new Map(),
219
- subscriptions: new Map()
219
+ subscriptions: new Map(),
220
+ fibers: new Set()
220
221
  };
221
222
  }
222
223
  function bindEffectHost(target, host) {
@@ -311,9 +312,8 @@ function trackEffect(actor, host, interrupt) {
311
312
  * the actor stops or when the returned function is called; `onExit` is not
312
313
  * called after that.
313
314
  *
314
- * This mirrors how Effect's own `unstable/reactivity` bridges callback code:
315
- * `Effect.runCallbackWith` with the captured services, and a synchronous
316
- * interruptor kept per actor.
315
+ * The fiber belongs to the host scope, so closing the owning actor also
316
+ * waits for asynchronous invocation finalizers.
317
317
  */
318
318
  function startHostedEffect(actor, effect, spanName, onExit) {
319
319
  const host = requireEffectHost(actor);
@@ -334,15 +334,17 @@ function startHostedEffect(actor, effect, spanName, onExit) {
334
334
  untrackEffect(actor, host, cancel);
335
335
  interrupt();
336
336
  };
337
- const interrupt = Effect.runCallbackWith(host.context)(traced, {
338
- onExit: exit => {
339
- if (!active) {
340
- return;
341
- }
342
- active = false;
343
- untrackEffect(actor, host, cancel);
344
- onExit(exit);
337
+ const fiber = Fiber.runIn(Effect.runForkWith(host.context)(traced), host.scope);
338
+ const interrupt = () => fiber.interruptUnsafe();
339
+ host.fibers.add(fiber);
340
+ fiber.addObserver(exit => {
341
+ host.fibers.delete(fiber);
342
+ if (!active) {
343
+ return;
345
344
  }
345
+ active = false;
346
+ untrackEffect(actor, host, cancel);
347
+ onExit(exit);
346
348
  });
347
349
  if (active) {
348
350
  trackEffect(actor, host, cancel);
@@ -370,10 +372,13 @@ function runHostedEffect(actor, effect, spanName) {
370
372
  }
371
373
 
372
374
  /**
373
- * Closes the host scope immediately and runs its finalizers, with the host's
374
- * services, on a fiber that `createEffectActor`'s release can await.
375
+ * Finishes hosted task cleanup before closing the owning actor's resources.
376
+ * `createEffectActor`'s release awaits this fiber before its parent scope closes.
375
377
  */
376
378
  function closeEffectHost(host) {
379
+ if (host.closing) {
380
+ return;
381
+ }
377
382
  for (const [actor, interruptors] of host.interruptors) {
378
383
  host.interruptors.delete(actor);
379
384
  for (const interrupt of interruptors) {
@@ -382,10 +387,10 @@ function closeEffectHost(host) {
382
387
  host.subscriptions.get(actor)?.unsubscribe();
383
388
  host.subscriptions.delete(actor);
384
389
  }
385
- const finalizers = Scope.closeUnsafe(host.scope, Exit.void);
386
- if (finalizers) {
387
- host.closing = Effect.runForkWith(host.context)(finalizers);
388
- }
390
+ host.closing = Effect.runForkWith(host.context)(Effect.forEach([...host.fibers], fiber => Fiber.interrupt(fiber), {
391
+ concurrency: 'unbounded',
392
+ discard: true
393
+ }).pipe(Effect.andThen(Scope.close(host.scope, Exit.void))));
389
394
  }
390
395
 
391
396
  /**
@@ -399,6 +404,17 @@ function relayToParent(actor, event) {
399
404
  }
400
405
  }
401
406
 
407
+ /** The owning Effect actor's scope, supplied by `createEffectActor`. */
408
+ class ActorScope extends Context.Service()('@xstate/effect/ActorScope') {}
409
+
410
+ /**
411
+ * Acquires resources in the owning actor's scope. Their finalizers run when
412
+ * that actor stops, even if the invoking task finishes or is cancelled first.
413
+ */
414
+ function withActorScope(effect) {
415
+ return ActorScope.use(scope => Scope.provide(effect, scope));
416
+ }
417
+
402
418
  const XSTATE_TIMER = 'xstate.timer';
403
419
 
404
420
  /** Options for {@link createEffectActor}. */
@@ -422,7 +438,7 @@ function createEffectActor(logic, ...[options]) {
422
438
  const parentScope = yield* Effect.scope;
423
439
  const actorScope = yield* Scope.fork(parentScope);
424
440
  const baseContext = yield* Effect.context();
425
- const context = Context.add(baseContext, Scope.Scope, actorScope);
441
+ const context = Context.add(Context.add(baseContext, Scope.Scope, actorScope), ActorScope, actorScope);
426
442
  const host = createEffectHost(context, actorScope);
427
443
  const runFork = Effect.runForkWith(context);
428
444
  const runPromise = Effect.runPromiseWith(context);
@@ -730,4 +746,4 @@ function taggedState(snapshot) {
730
746
  };
731
747
  }
732
748
 
733
- export { ActorStoppedError as A, EffectInterruptedError as E, NotReadyError as N, runHostedEffect as a, EffectActor as b, createEffectActor as c, relayToParent as r, startHostedEffect as s, taggedState as t };
749
+ export { ActorStoppedError as A, EffectInterruptedError as E, NotReadyError as N, runHostedEffect as a, EffectActor as b, createEffectActor as c, ActorScope as d, relayToParent as r, startHostedEffect as s, taggedState as t, withActorScope as w };
@@ -1,7 +1,7 @@
1
1
  import { Cause } from 'effect';
2
2
  import { Atom, AsyncResult } from 'effect/unstable/reactivity';
3
- import { c as createEffectActor, N as NotReadyError, t as taggedState } from './state-798ea663.js';
4
- export { N as NotReadyError } from './state-798ea663.js';
3
+ import { c as createEffectActor, N as NotReadyError, t as taggedState } from './state-14a1fecf.js';
4
+ export { N as NotReadyError } from './state-14a1fecf.js';
5
5
  import 'xstate';
6
6
  import 'xstate/durable';
7
7
 
@@ -14,7 +14,8 @@ import 'xstate/durable';
14
14
  /**
15
15
  * Creates atoms for an actor that runs in an `Atom.runtime`. The runtime's
16
16
  * Layer must provide every service the logic requires; a missing service is
17
- * a type error on the `runtime` argument.
17
+ * a type error on the `runtime` argument. Logic with required input also
18
+ * requires an options object containing that input.
18
19
  *
19
20
  * The actor is created with `createEffectActor` inside the `actor` atom's
20
21
  * scope, so its lifetime follows the atom: it starts on first read and stops
@@ -22,7 +23,7 @@ import 'xstate/durable';
22
23
  * actor for the registry's lifetime, or with `Atom.family` to create one
23
24
  * actor per input.
24
25
  */
25
- function createActorAtoms(runtime, logic, options) {
26
+ function createActorAtoms(runtime, logic, ...[options]) {
26
27
  const host = runtime;
27
28
  const actor = host.atom(createEffectActor(logic, options));
28
29
  const snapshot = Atom.make(get => {
@@ -1,7 +1,7 @@
1
1
  import { Schema, Effect, Stream, Exit, Cause, Queue } from 'effect';
2
2
  import { createLogic, setup } from 'xstate';
3
- import { s as startHostedEffect, r as relayToParent, E as EffectInterruptedError, A as ActorStoppedError, a as runHostedEffect } from './state-798ea663.js';
4
- export { A as ActorStoppedError, b as EffectActor, E as EffectInterruptedError, c as createEffectActor, t as taggedState } from './state-798ea663.js';
3
+ import { s as startHostedEffect, r as relayToParent, E as EffectInterruptedError, A as ActorStoppedError, a as runHostedEffect } from './state-14a1fecf.js';
4
+ export { d as ActorScope, A as ActorStoppedError, b as EffectActor, E as EffectInterruptedError, c as createEffectActor, t as taggedState, w as withActorScope } from './state-14a1fecf.js';
5
5
  import { dual } from 'effect/Function';
6
6
  import 'xstate/durable';
7
7
 
@@ -169,6 +169,9 @@ function brandLogic(logic, error, requirements) {
169
169
  * an Effect, a function of {@link EffectSourceArgs} returning one, or a config
170
170
  * object with `id`, `schemas`, `validator` and `effect`. The logic must be run
171
171
  * under `createEffectActor`, which provides the Effect services it declares.
172
+ * Resources belong to this task's scope and close on completion, failure or
173
+ * interruption. Use `withActorScope` to retain a resource until the owning
174
+ * Effect actor stops.
172
175
  */
173
176
 
174
177
  function fromEffect(sourceOrConfig) {
@@ -213,7 +216,7 @@ function fromEffect(sourceOrConfig) {
213
216
  return;
214
217
  }
215
218
  const effect = typeof source === 'function' ? source(createSourceArgs(input, self, system)) : source;
216
- enq.effect(() => startHostedEffect(self, effect, 'fromEffect', exit => {
219
+ enq.effect(() => startHostedEffect(self, Effect.scoped(effect), 'fromEffect', exit => {
217
220
  if (self.getSnapshot().status !== 'active') {
218
221
  return;
219
222
  }
@@ -256,7 +259,8 @@ function toLogicSchemas(schemas) {
256
259
  * the actor's `context`. The actor reaches `done` when the stream completes
257
260
  * and `error` when it fails. Accepts a Stream, a function of
258
261
  * {@link EffectSourceArgs} returning one, or a config object with `id`,
259
- * `schemas`, `validator` and `stream`.
262
+ * `schemas`, `validator` and `stream`. Its resources close when the stream
263
+ * completes, fails or is interrupted.
260
264
  */
261
265
 
262
266
  function fromEffectStream(sourceOrConfig) {
@@ -303,7 +307,7 @@ function fromEffectStream(sourceOrConfig) {
303
307
  });
304
308
  }
305
309
  }));
306
- enq.effect(() => startHostedEffect(self, consume, 'fromEffectStream', exit => {
310
+ enq.effect(() => startHostedEffect(self, Effect.scoped(consume), 'fromEffectStream', exit => {
307
311
  if (self.getSnapshot().status !== 'active') {
308
312
  return;
309
313
  }
@@ -327,7 +331,8 @@ function fromEffectStream(sourceOrConfig) {
327
331
  * Creates actor logic that runs a Stream of events and relays each item to the
328
332
  * parent machine as an event, the way `fromEventObservable` does. The actor
329
333
  * has no output: it reaches `done` when the stream completes and `error` when
330
- * it fails. Accepts the same forms as `fromEffectStream`.
334
+ * it fails. Accepts the same forms as `fromEffectStream`. Its resources close
335
+ * when the stream completes, fails or is interrupted.
331
336
  */
332
337
 
333
338
  function fromEffectEventStream(sourceOrConfig) {
@@ -362,7 +367,7 @@ function fromEffectEventStream(sourceOrConfig) {
362
367
  }
363
368
  const streamValue = typeof stream === 'function' ? stream(createSourceArgs(input, self, system)) : stream;
364
369
  const consume = Stream.runForEach(streamValue, value => Effect.sync(() => relayToParent(self, value)));
365
- enq.effect(() => startHostedEffect(self, consume, 'fromEffectEventStream', exit => {
370
+ enq.effect(() => startHostedEffect(self, Effect.scoped(consume), 'fromEffectEventStream', exit => {
366
371
  if (self.getSnapshot().status !== 'active') {
367
372
  return;
368
373
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xstate/effect",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.5",
4
4
  "type": "module",
5
5
  "description": "Effect integration for XState",
6
6
  "keywords": [
@@ -30,11 +30,11 @@
30
30
  },
31
31
  "peerDependencies": {
32
32
  "effect": "^4.0.0-rc.115",
33
- "xstate": "^6.0.0-alpha.61"
33
+ "xstate": "^6.0.0-alpha.62"
34
34
  },
35
35
  "devDependencies": {
36
36
  "effect": "4.0.0-rc.115",
37
- "xstate": "^6.0.0-alpha.61"
37
+ "xstate": "^6.0.0-alpha.62"
38
38
  },
39
39
  "preconstruct": {
40
40
  "entrypoints": [