@xstate/effect 0.1.0-alpha.2

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.
@@ -0,0 +1,733 @@
1
+ import { Queue, Effect, Scope, Exit, Cause, Context, Fiber, Duration, Data } from 'effect';
2
+ import { stopActor, terminateActor, deliverEvent } from 'xstate';
3
+ import { createDurable } from 'xstate/durable';
4
+
5
+ /** A mailbox item that reports a failed fire-and-forget action. */
6
+
7
+ const actionFailure = Symbol.for('@xstate/effect/actionFailure');
8
+ function isActionFailure(value) {
9
+ return typeof value === 'object' && value !== null && actionFailure in value;
10
+ }
11
+ const symbolObservable = (() => typeof Symbol === 'function' && Symbol.observable || '@@observable')();
12
+
13
+ /**
14
+ * Calls a listener and reports an exception it throws without letting it
15
+ * escape into the interpreter, matching core's `safeCall`.
16
+ */
17
+ function safeCall(fn, arg) {
18
+ try {
19
+ fn?.(arg);
20
+ } catch (err) {
21
+ setTimeout(() => {
22
+ throw err;
23
+ });
24
+ }
25
+ }
26
+ function toObserver(nextHandler, errorHandler, completionHandler) {
27
+ if (typeof nextHandler === 'object') {
28
+ return nextHandler;
29
+ }
30
+ return {
31
+ next: nextHandler,
32
+ error: errorHandler,
33
+ complete: completionHandler
34
+ };
35
+ }
36
+
37
+ /**
38
+ * The handle `createEffectActor` returns. It implements XState's `ActorRef`
39
+ * contract (`send`, `getSnapshot`, `subscribe`, `on`), so it works with
40
+ * `useSelector`, this package's actor functions and the inspection APIs, while
41
+ * the actor itself is driven by an Effect fiber over pure transitions.
42
+ */
43
+ class EffectActor {
44
+ constructor(logic, root, snapshot, _mailbox, _stopExecution, _inspectors) {
45
+ this.logic = logic;
46
+ this._mailbox = _mailbox;
47
+ this._stopExecution = _stopExecution;
48
+ this._inspectors = _inspectors;
49
+ this.id = void 0;
50
+ this.address = void 0;
51
+ this.sessionId = void 0;
52
+ /** The actor system that hosts this actor and its children. */
53
+ this.system = void 0;
54
+ /** @internal */
55
+ this._root = void 0;
56
+ this._snapshot = void 0;
57
+ this._observers = new Set();
58
+ this._listeners = new Map();
59
+ this._settled = false;
60
+ this._root = root;
61
+ this.id = root.id;
62
+ this.address = root.address;
63
+ this.sessionId = root.sessionId;
64
+ this.system = root.system;
65
+ this._snapshot = snapshot;
66
+ }
67
+ getSnapshot() {
68
+ return this._snapshot;
69
+ }
70
+ getPersistedSnapshot() {
71
+ return this.logic.getPersistedSnapshot(this._snapshot);
72
+ }
73
+
74
+ /** Sends an event; the actor processes it on its own fiber. */
75
+ send(event) {
76
+ if (this._settled) {
77
+ this.system.deadLetter(undefined, this._root, event, 'stopped');
78
+ return;
79
+ }
80
+ Queue.offerUnsafe(this._mailbox, event);
81
+ }
82
+
83
+ /** Stops the actor, its children and every Effect it hosts. */
84
+ stop() {
85
+ this._stopExecution();
86
+ return this;
87
+ }
88
+ subscribe(nextListenerOrObserver, errorListener, completeListener) {
89
+ const observer = toObserver(nextListenerOrObserver, errorListener, completeListener);
90
+ if (this._settled) {
91
+ const snapshot = this._snapshot;
92
+ if (snapshot.status === 'error') {
93
+ safeCall(observer.error, snapshot.error);
94
+ } else {
95
+ safeCall(observer.complete);
96
+ }
97
+ return {
98
+ unsubscribe: () => {}
99
+ };
100
+ }
101
+ this._observers.add(observer);
102
+ return {
103
+ unsubscribe: () => {
104
+ this._observers.delete(observer);
105
+ }
106
+ };
107
+ }
108
+ on(type, handler) {
109
+ let listeners = this._listeners.get(type);
110
+ if (!listeners) {
111
+ listeners = new Set();
112
+ this._listeners.set(type, listeners);
113
+ }
114
+ const listener = handler;
115
+ listeners.add(listener);
116
+ return {
117
+ unsubscribe: () => {
118
+ listeners.delete(listener);
119
+ }
120
+ };
121
+ }
122
+
123
+ /** @internal Delivers an emitted event to `on` listeners. */
124
+ _emit(event) {
125
+ const listeners = [...(this._listeners.get(event.type) ?? []), ...(this._listeners.get('*') ?? [])];
126
+ for (const listener of listeners) {
127
+ safeCall(listener, event);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Observes the inspection events of this actor and its children: every
133
+ * transition, event delivery and dead letter of the execution.
134
+ */
135
+ inspect(observer) {
136
+ const handler = typeof observer === 'function' ? observer : event => observer.next?.(event);
137
+ this._inspectors.add(handler);
138
+ return {
139
+ unsubscribe: () => {
140
+ this._inspectors.delete(handler);
141
+ }
142
+ };
143
+ }
144
+ [symbolObservable]() {
145
+ return this;
146
+ }
147
+ toJSON() {
148
+ return {
149
+ xstate$$type: 1,
150
+ id: this.id
151
+ };
152
+ }
153
+
154
+ /** @internal Publishes a snapshot produced by the execution loop. */
155
+ _publish(snapshot) {
156
+ this._snapshot = snapshot;
157
+ const status = snapshot.status;
158
+ if (status === 'active') {
159
+ for (const observer of this._observers) {
160
+ safeCall(observer.next, snapshot);
161
+ }
162
+ return;
163
+ }
164
+ this._settle(snapshot);
165
+ }
166
+
167
+ /** @internal Marks the actor stopped and notifies observers. */
168
+ _settle(snapshot) {
169
+ if (this._settled) {
170
+ return;
171
+ }
172
+ this._snapshot = snapshot;
173
+ this._settled = true;
174
+ const observers = [...this._observers];
175
+ this._observers.clear();
176
+ const status = snapshot.status;
177
+ if (status === 'done') {
178
+ for (const observer of observers) {
179
+ safeCall(observer.next, snapshot);
180
+ }
181
+ }
182
+ for (const observer of observers) {
183
+ if (status === 'error') {
184
+ safeCall(observer.error, snapshot.error);
185
+ } else {
186
+ safeCall(observer.complete);
187
+ }
188
+ }
189
+ }
190
+
191
+ /** @internal */
192
+ get _isSettled() {
193
+ return this._settled;
194
+ }
195
+ }
196
+
197
+ const effectHosts = new WeakMap();
198
+ let ambientHost;
199
+
200
+ /**
201
+ * Runs `fn` with `host` as the ambient host, so Effects started synchronously
202
+ * inside it (declared actions executed by an execution loop) resolve their
203
+ * host without an identity binding.
204
+ */
205
+ function withEffectHost(host, fn) {
206
+ const previous = ambientHost;
207
+ ambientHost = host;
208
+ try {
209
+ return fn();
210
+ } finally {
211
+ ambientHost = previous;
212
+ }
213
+ }
214
+ function createEffectHost(context, scope) {
215
+ return {
216
+ context,
217
+ scope,
218
+ interruptors: new Map(),
219
+ subscriptions: new Map()
220
+ };
221
+ }
222
+ function bindEffectHost(target, host) {
223
+ effectHosts.set(target, host);
224
+ }
225
+ function findEffectHost(actor) {
226
+ let current = actor;
227
+ while (current) {
228
+ const host = effectHosts.get(current);
229
+ if (host) {
230
+ return host;
231
+ }
232
+ current = current._parent;
233
+ }
234
+ return ambientHost;
235
+ }
236
+ /**
237
+ * Rejects Effect logic that a machine spawned inline. Only declared actors
238
+ * (`setup({ actors })`) and `invoke` sources are visible to
239
+ * `RequirementsFrom`, so anything else would infer `R = never` and fail
240
+ * later with a missing service.
241
+ *
242
+ * A spawned declared actor carries its registered key as `src`; an invoked
243
+ * child carries the id of the `invoke` entry that created it.
244
+ */
245
+ function assertDeclaredLogic(actor) {
246
+ const self = actor;
247
+ const parent = self._parent;
248
+ const machine = parent?.logic;
249
+ if (!machine?.sources || !machine.idMap || typeof self.src === 'string') {
250
+ return;
251
+ }
252
+ if (Object.values(machine.sources.actors ?? {}).includes(self.logic)) {
253
+ return;
254
+ }
255
+ for (const stateNode of machine.idMap.values()) {
256
+ for (const definition of stateNode.invoke ?? []) {
257
+ if (definition.id === self.id) {
258
+ return;
259
+ }
260
+ }
261
+ }
262
+ throw new Error(`Effect logic spawned by "${parent.id}" must be declared in setup({ actors }). Spawn a declared actor instead, for example enq.spawn(args.actors.name).`);
263
+ }
264
+ function requireEffectHost(actor) {
265
+ assertDeclaredLogic(actor);
266
+ const host = findEffectHost(actor);
267
+ if (!host) {
268
+ throw new Error('Effect-backed actor logic must be created with createEffectActor().');
269
+ }
270
+ return host;
271
+ }
272
+ function untrackEffect(actor, host, interrupt) {
273
+ const interruptors = host.interruptors.get(actor);
274
+ if (!interruptors) {
275
+ return;
276
+ }
277
+ interruptors.delete(interrupt);
278
+ if (interruptors.size === 0) {
279
+ host.interruptors.delete(actor);
280
+ host.subscriptions.get(actor)?.unsubscribe();
281
+ host.subscriptions.delete(actor);
282
+ }
283
+ }
284
+ function cleanupActorEffects(actor, host) {
285
+ const interruptors = host.interruptors.get(actor);
286
+ if (interruptors) {
287
+ host.interruptors.delete(actor);
288
+ for (const interrupt of interruptors) {
289
+ interrupt();
290
+ }
291
+ }
292
+ host.subscriptions.get(actor)?.unsubscribe();
293
+ host.subscriptions.delete(actor);
294
+ }
295
+ function trackEffect(actor, host, interrupt) {
296
+ let interruptors = host.interruptors.get(actor);
297
+ if (!interruptors) {
298
+ interruptors = new Set();
299
+ host.interruptors.set(actor, interruptors);
300
+ host.subscriptions.set(actor, actor.subscribe({
301
+ passive: true,
302
+ error: () => cleanupActorEffects(actor, host),
303
+ complete: () => cleanupActorEffects(actor, host)
304
+ }));
305
+ }
306
+ interruptors.add(interrupt);
307
+ }
308
+
309
+ /**
310
+ * Runs an Effect in the actor's host context. The Effect is interrupted when
311
+ * the actor stops or when the returned function is called; `onExit` is not
312
+ * called after that.
313
+ *
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.
317
+ */
318
+ function startHostedEffect(actor, effect, spanName, onExit) {
319
+ const host = requireEffectHost(actor);
320
+ let active = true;
321
+ const traced = Effect.withSpan(spanName, {
322
+ attributes: {
323
+ 'xstate.actor.id': actor.id,
324
+ 'xstate.actor.address': actor.address
325
+ }
326
+ }, {
327
+ captureStackTrace: false
328
+ })(effect);
329
+ const cancel = () => {
330
+ if (!active) {
331
+ return;
332
+ }
333
+ active = false;
334
+ untrackEffect(actor, host, cancel);
335
+ interrupt();
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);
345
+ }
346
+ });
347
+ if (active) {
348
+ trackEffect(actor, host, cancel);
349
+ }
350
+ return cancel;
351
+ }
352
+
353
+ /**
354
+ * Runs an Effect in the actor's host context and settles when it exits.
355
+ * Interruption settles without error; failures and defects reject with the
356
+ * squashed cause so the actor's error handling can observe them.
357
+ */
358
+ function runHostedEffect(actor, effect, spanName) {
359
+ return new Promise((resolve, reject) => {
360
+ startHostedEffect(actor, effect, spanName, exit => {
361
+ if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) {
362
+ resolve();
363
+ } else {
364
+ // Effect failures are arbitrary values, not necessarily Errors.
365
+ // oxlint-disable-next-line typescript/prefer-promise-reject-errors
366
+ reject(Cause.squash(exit.cause));
367
+ }
368
+ });
369
+ });
370
+ }
371
+
372
+ /**
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
+ */
376
+ function closeEffectHost(host) {
377
+ for (const [actor, interruptors] of host.interruptors) {
378
+ host.interruptors.delete(actor);
379
+ for (const interrupt of interruptors) {
380
+ interrupt();
381
+ }
382
+ host.subscriptions.get(actor)?.unsubscribe();
383
+ host.subscriptions.delete(actor);
384
+ }
385
+ const finalizers = Scope.closeUnsafe(host.scope, Exit.void);
386
+ if (finalizers) {
387
+ host.closing = Effect.runForkWith(host.context)(finalizers);
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Delivers a stream item to the parent as an event. This runs outside a
393
+ * transition, so it uses the system relay like core's observable logic does.
394
+ */
395
+ function relayToParent(actor, event) {
396
+ const actorWithParent = actor;
397
+ if (actorWithParent._parent) {
398
+ actor.system._relay(actorWithParent, actorWithParent._parent, event);
399
+ }
400
+ }
401
+
402
+ const XSTATE_TIMER = 'xstate.timer';
403
+
404
+ /** Options for {@link createEffectActor}. */
405
+
406
+ /**
407
+ * Creates and starts an actor as an Effect interpreter over pure transitions.
408
+ *
409
+ * Each step is `transition(snapshot, event)`, a pure function that returns
410
+ * the next snapshot and the actions to run. An Effect fiber owns the loop:
411
+ * the mailbox is a `Queue`, timers are `Effect.sleep` fibers on the Effect
412
+ * `Clock`, and declared Effect actions run as forked Effects in the actor's
413
+ * `Scope` with the services captured here. Child actors are started as live
414
+ * XState actors whose Effects run in the same host.
415
+ *
416
+ * The actor is a scoped resource: it stops, and every Effect it hosts is
417
+ * interrupted, when the enclosing `Scope` closes. The returned Effect never
418
+ * fails; the actor's own outcome is its snapshot status, read with `join`.
419
+ */
420
+ function createEffectActor(logic, ...[options]) {
421
+ return Effect.acquireRelease(Effect.gen(function* () {
422
+ const parentScope = yield* Effect.scope;
423
+ const actorScope = yield* Scope.fork(parentScope);
424
+ const baseContext = yield* Effect.context();
425
+ const context = Context.add(baseContext, Scope.Scope, actorScope);
426
+ const host = createEffectHost(context, actorScope);
427
+ const runFork = Effect.runForkWith(context);
428
+ const runPromise = Effect.runPromiseWith(context);
429
+ const mailbox = yield* Queue.unbounded();
430
+ const timers = new Map();
431
+ // `root` and `actor` are declared after the adapter below; its
432
+ // callbacks only run once they are initialized.
433
+ let stopped = false;
434
+ const isRoot = candidate => candidate.address === durable.rootAddress;
435
+ const offer = item => {
436
+ if (!stopped) {
437
+ Queue.offerUnsafe(mailbox, item);
438
+ }
439
+ };
440
+ const timerKey = (source, id) => `${source.sessionId}:${id}`;
441
+ const inspectors = new Set();
442
+ let rootAnnounced = false;
443
+ const durable = createDurable(logic, {
444
+ executeAction: (action, _metadata, runtime) => {
445
+ // Fire-and-forget: the action starts now and the loop continues.
446
+ // A rejection reaches the machine as an execution error.
447
+ try {
448
+ const result = withEffectHost(host, () => action.exec(runtime));
449
+ if (result && typeof result.then === 'function') {
450
+ void Promise.resolve(result).catch(error => {
451
+ offer({
452
+ [actionFailure]: true,
453
+ error
454
+ });
455
+ });
456
+ }
457
+ } catch (error) {
458
+ offer({
459
+ [actionFailure]: true,
460
+ error
461
+ });
462
+ }
463
+ },
464
+ spawnActor: (_source, child) => {
465
+ // Every actor of this execution hosts its Effects here.
466
+ bindEffectHost(child, host);
467
+ },
468
+ startActor: child => {
469
+ child.start();
470
+ },
471
+ stopActor: child => {
472
+ if (!isRoot(child)) {
473
+ stopActor(child);
474
+ }
475
+ },
476
+ terminateActor: (child, termination) => {
477
+ if (!isRoot(child)) {
478
+ terminateActor(child, termination);
479
+ }
480
+ },
481
+ sendEvent: (source, target, event) => {
482
+ if (isRoot(target)) {
483
+ offer(event);
484
+ return;
485
+ }
486
+ deliverEvent(source, target, event);
487
+ },
488
+ emitEvent: (source, event) => {
489
+ if (isRoot(source)) {
490
+ actor._emit(event);
491
+ return;
492
+ }
493
+ source._emit(event);
494
+ },
495
+ scheduleTimer: (source, id, delay) => {
496
+ const key = timerKey(source, id);
497
+ timers.get(key)?.interruptUnsafe();
498
+ const fiber = Fiber.runIn(runFork(Effect.andThen(Effect.sleep(Duration.millis(delay)), Effect.sync(() => {
499
+ timers.delete(key);
500
+ const timerEvent = {
501
+ type: XSTATE_TIMER,
502
+ id
503
+ };
504
+ if (isRoot(source)) {
505
+ offer(timerEvent);
506
+ } else {
507
+ deliverEvent(source, source, timerEvent);
508
+ }
509
+ }))), actorScope);
510
+ timers.set(key, fiber);
511
+ },
512
+ cancelTimer: (source, id) => {
513
+ const key = timerKey(source, id);
514
+ timers.get(key)?.interruptUnsafe();
515
+ timers.delete(key);
516
+ },
517
+ cancelAllTimers: source => {
518
+ for (const [key, fiber] of timers) {
519
+ if (key.startsWith(`${source.sessionId}:`)) {
520
+ fiber.interruptUnsafe();
521
+ timers.delete(key);
522
+ }
523
+ }
524
+ },
525
+ waitForEvent: () => runPromise(Queue.take(mailbox))
526
+ }, {
527
+ inspect: event => {
528
+ // The pure step scope re-materializes the root ref per step and
529
+ // announces it again; observers should see the root once.
530
+ if (event.type === '@xstate.actor' && event.actorRef.address === durable.rootAddress) {
531
+ if (rootAnnounced) {
532
+ return;
533
+ }
534
+ rootAnnounced = true;
535
+ }
536
+ for (const inspector of inspectors) {
537
+ safeCall(inspector, event);
538
+ }
539
+ }
540
+ });
541
+ const errorSnapshot = (snapshot, error) => ({
542
+ ...snapshot,
543
+ status: 'error',
544
+ error
545
+ });
546
+ const stopChildren = snapshot => {
547
+ const children = snapshot.children;
548
+ for (const child of Object.values(children ?? {})) {
549
+ if (child && !isRoot(child)) {
550
+ stopActor(child);
551
+ }
552
+ }
553
+ };
554
+ const stop = () => {
555
+ if (stopped) {
556
+ return;
557
+ }
558
+ stopped = true;
559
+ for (const fiber of timers.values()) {
560
+ fiber.interruptUnsafe();
561
+ }
562
+ timers.clear();
563
+ const current = actor.getSnapshot();
564
+ if (current) {
565
+ stopChildren(current);
566
+ }
567
+ runFork(Queue.shutdown(mailbox));
568
+ if (!actor._isSettled) {
569
+ actor._settle({
570
+ ...actor.getSnapshot(),
571
+ status: 'stopped'
572
+ });
573
+ }
574
+ closeEffectHost(host);
575
+ };
576
+
577
+ // The first transition runs here so the handle is ready when this
578
+ // Effect succeeds, and the initial actions start before any send.
579
+ let [snapshot, effects] = durable.initialTransition(options?.input);
580
+ const root = durable.getActorRef(snapshot);
581
+ // The root exists from here on; later announcements are step
582
+ // re-materializations, not new actors.
583
+ rootAnnounced = true;
584
+ const actor = new EffectActor(logic, root, snapshot, mailbox, stop, inspectors);
585
+ bindEffectHost(actor, host);
586
+ const executeEffects = batch => Effect.promise(() => durable.executeEffects(batch).then(() => undefined, error => {
587
+ offer({
588
+ [actionFailure]: true,
589
+ error
590
+ });
591
+ }));
592
+ const loop = Effect.gen(function* () {
593
+ yield* executeEffects(effects);
594
+ while (snapshot.status === 'active' && !stopped) {
595
+ const item = yield* Effect.promise(() => durable.waitForEvent().then(event => event, () => undefined));
596
+ if (item === undefined || stopped) {
597
+ break;
598
+ }
599
+ let event;
600
+ if (isActionFailure(item)) {
601
+ const errorEvent = logic.getExecutionErrorEvent?.(snapshot, item.error);
602
+ if (!errorEvent) {
603
+ snapshot = errorSnapshot(snapshot, item.error);
604
+ break;
605
+ }
606
+ event = errorEvent;
607
+ } else {
608
+ event = item;
609
+ }
610
+ try {
611
+ [snapshot, effects] = durable.transition(snapshot, event);
612
+ } catch (error) {
613
+ snapshot = errorSnapshot(snapshot, error);
614
+ break;
615
+ }
616
+ actor._publish(snapshot);
617
+ yield* executeEffects(effects);
618
+ }
619
+ if (!stopped) {
620
+ actor._publish(snapshot);
621
+ if (snapshot.status !== 'active') {
622
+ stopChildren(snapshot);
623
+ closeEffectHost(host);
624
+ }
625
+ }
626
+ });
627
+ yield* Effect.forkIn(loop, actorScope);
628
+ return {
629
+ actor,
630
+ host
631
+ };
632
+ }), ({
633
+ actor,
634
+ host
635
+ }) => Effect.gen(function* () {
636
+ actor.stop();
637
+ if (host.closing) {
638
+ yield* Fiber.join(host.closing);
639
+ } else {
640
+ yield* Scope.close(host.scope, Exit.void);
641
+ }
642
+ })).pipe(Effect.map(({
643
+ actor
644
+ }) => actor));
645
+ }
646
+
647
+ /**
648
+ * The failure an Effect-backed actor reports when its Effect was interrupted
649
+ * by something other than the actor being stopped, such as `Effect.interrupt`,
650
+ * losing an `Effect.race`, or an `Effect.timeout` that interrupts.
651
+ */
652
+ class EffectInterruptedError extends Data.TaggedError('EffectInterruptedError') {
653
+ get message() {
654
+ return 'Effect was interrupted before the actor completed';
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Reported by `waitFor` when the actor stops or errors before a snapshot
660
+ * matches, and by `join` when the actor stops without output. `join` reports
661
+ * an errored actor's own `snapshot.error` instead.
662
+ */
663
+ class ActorStoppedError extends Data.TaggedError('ActorStoppedError') {
664
+ get message() {
665
+ return `Actor "${this.actorId}" ${this.snapshot.status === 'error' ? 'errored' : 'stopped'} before completing`;
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Reported by the `send` atom of `createActorAtoms` when an event is sent
671
+ * before the actor's runtime has finished building.
672
+ */
673
+ class NotReadyError extends Data.TaggedError('NotReadyError') {
674
+ get message() {
675
+ return 'The actor is not ready yet';
676
+ }
677
+ }
678
+
679
+ /** The tag of the machine itself, used when the root state is parallel. */
680
+ const MACHINE_TAG = '(machine)';
681
+
682
+ /**
683
+ * The dotted path of a state value: `'idle'`, `'form.editing'`. A parallel
684
+ * state has no single path, so its tag stops at the parallel state, or is
685
+ * `'(machine)'` when the machine itself is parallel.
686
+ */
687
+
688
+ /**
689
+ * A machine snapshot as a tagged union over its states. `_tag` is the state's
690
+ * dotted path and `context` is the context of that state, including any
691
+ * per-state context schema, so `Match.tag` narrows both.
692
+ */
693
+
694
+ /** The tagged state union of a machine. */
695
+
696
+ /** Computes the dotted path tag of a state value. */
697
+ function stateTag(value, prefix = '') {
698
+ if (typeof value === 'string') {
699
+ return prefix ? `${prefix}.${value}` : value;
700
+ }
701
+ const keys = Object.keys(value);
702
+ if (keys.length !== 1) {
703
+ return prefix || MACHINE_TAG;
704
+ }
705
+ const key = keys[0];
706
+ return stateTag(value[key], prefix ? `${prefix}.${key}` : key);
707
+ }
708
+
709
+ /**
710
+ * Views a machine snapshot as a tagged union member for `Match.tag`,
711
+ * `Match.tags` or a `switch` on `_tag`.
712
+ *
713
+ * @example
714
+ *
715
+ * ```ts
716
+ * const view = Match.type<TaggedStateFrom<typeof snapshot>>().pipe(
717
+ * Match.tag('loading', ({ context }) => `Loading ${context.id}`),
718
+ * Match.tag('done', () => 'Done'),
719
+ * Match.exhaustive
720
+ * );
721
+ * view(taggedState(snapshot));
722
+ * ```
723
+ */
724
+ function taggedState(snapshot) {
725
+ return {
726
+ _tag: stateTag(snapshot.value),
727
+ value: snapshot.value,
728
+ context: snapshot.context,
729
+ snapshot
730
+ };
731
+ }
732
+
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 };