effect-machine 0.1.0 → 0.2.1

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/src/machine.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  * @module
44
44
  */
45
45
  import type { Schema, Schedule, Scope, Context } from "effect";
46
- import { Effect } from "effect";
46
+ import { Cause, Effect, Exit } from "effect";
47
47
  import type { Pipeable } from "effect/Pipeable";
48
48
  import { pipeArguments } from "effect/Pipeable";
49
49
 
@@ -54,6 +54,8 @@ import type { MachineStateSchema, MachineEventSchema, VariantsUnion } from "./sc
54
54
  import type { PersistentMachine, WithPersistenceConfig } from "./persistence/persistent-machine.js";
55
55
  import { persist as persistImpl } from "./persistence/persistent-machine.js";
56
56
  import { SlotProvisionError, ProvisionValidationError } from "./errors.js";
57
+ import type { UnprovidedSlotsError } from "./errors.js";
58
+ import { invalidateIndex } from "./internal/transition.js";
57
59
  import type {
58
60
  GuardsSchema,
59
61
  EffectsSchema,
@@ -248,6 +250,10 @@ export class Machine<
248
250
  string,
249
251
  (params: unknown, ctx: SlotContext<State, Event>) => Effect.Effect<void, never, R>
250
252
  >;
253
+ /** @internal */ readonly _slots: {
254
+ guards: GuardSlots<GD>;
255
+ effects: EffectSlots<EFD>;
256
+ };
251
257
  readonly stateSchema?: Schema.Schema<State, unknown, never>;
252
258
  readonly eventSchema?: Schema.Schema<Event, unknown, never>;
253
259
 
@@ -302,6 +308,36 @@ export class Machine<
302
308
  this._effectHandlers = new Map();
303
309
  this.stateSchema = stateSchema;
304
310
  this.eventSchema = eventSchema;
311
+
312
+ const guardSlots =
313
+ this._guardsSchema !== undefined
314
+ ? this._guardsSchema._createSlots((name: string, params: unknown) =>
315
+ Effect.flatMap(Effect.serviceOptional(this.Context).pipe(Effect.orDie), (ctx) => {
316
+ const handler = this._guardHandlers.get(name);
317
+ if (handler === undefined) {
318
+ return Effect.die(new SlotProvisionError({ slotName: name, slotType: "guard" }));
319
+ }
320
+ const result = handler(params, ctx);
321
+ const normalized = typeof result === "boolean" ? Effect.succeed(result) : result;
322
+ return normalized as Effect.Effect<boolean, never, never>;
323
+ }),
324
+ )
325
+ : ({} as GuardSlots<GD>);
326
+
327
+ const effectSlots =
328
+ this._effectsSchema !== undefined
329
+ ? this._effectsSchema._createSlots((name: string, params: unknown) =>
330
+ Effect.flatMap(Effect.serviceOptional(this.Context).pipe(Effect.orDie), (ctx) => {
331
+ const handler = this._effectHandlers.get(name);
332
+ if (handler === undefined) {
333
+ return Effect.die(new SlotProvisionError({ slotName: name, slotType: "effect" }));
334
+ }
335
+ return handler(params, ctx) as Effect.Effect<void, never, never>;
336
+ }),
337
+ )
338
+ : ({} as EffectSlots<EFD>);
339
+
340
+ this._slots = { guards: guardSlots, effects: effectSlots };
305
341
  }
306
342
 
307
343
  pipe() {
@@ -360,6 +396,7 @@ export class Machine<
360
396
 
361
397
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
362
398
  (this._transitions as any[]).push(transition);
399
+ invalidateIndex(this);
363
400
 
364
401
  return this;
365
402
  }
@@ -398,9 +435,63 @@ export class Machine<
398
435
  stateTag,
399
436
  handler: handler as unknown as SpawnEffect<State, Event, EFD, R>["handler"],
400
437
  });
438
+ invalidateIndex(this);
401
439
  return this;
402
440
  }
403
441
 
442
+ // ---- task ----
443
+
444
+ /**
445
+ * State-scoped task that runs on entry and sends success/failure events.
446
+ * Interrupts do not emit failure events.
447
+ */
448
+ task<
449
+ NS extends VariantsUnion<_SD> & BrandedState,
450
+ A,
451
+ E1,
452
+ ES extends VariantsUnion<_ED> & BrandedEvent,
453
+ EF extends VariantsUnion<_ED> & BrandedEvent,
454
+ >(
455
+ state: TaggedOrConstructor<NS>,
456
+ run: (
457
+ ctx: StateHandlerContext<NS, VariantsUnion<_ED> & BrandedEvent, EFD>,
458
+ ) => Effect.Effect<A, E1, Scope.Scope>,
459
+ options: {
460
+ readonly onSuccess: (
461
+ value: A,
462
+ ctx: StateHandlerContext<NS, VariantsUnion<_ED> & BrandedEvent, EFD>,
463
+ ) => ES;
464
+ readonly onFailure?: (
465
+ cause: Cause.Cause<E1>,
466
+ ctx: StateHandlerContext<NS, VariantsUnion<_ED> & BrandedEvent, EFD>,
467
+ ) => EF;
468
+ },
469
+ ): Machine<State, Event, R, _SD, _ED, GD, EFD> {
470
+ const handler = Effect.fn("effect-machine.task")(function* (
471
+ ctx: StateHandlerContext<NS, VariantsUnion<_ED> & BrandedEvent, EFD>,
472
+ ) {
473
+ const exit = yield* Effect.exit(run(ctx));
474
+ if (Exit.isSuccess(exit)) {
475
+ yield* ctx.self.send(options.onSuccess(exit.value, ctx));
476
+ yield* Effect.yieldNow();
477
+ return;
478
+ }
479
+
480
+ const cause = exit.cause;
481
+ if (Cause.isInterruptedOnly(cause)) {
482
+ return;
483
+ }
484
+ if (options.onFailure !== undefined) {
485
+ yield* ctx.self.send(options.onFailure(cause, ctx));
486
+ yield* Effect.yieldNow();
487
+ return;
488
+ }
489
+ return yield* Effect.failCause(cause).pipe(Effect.orDie);
490
+ });
491
+
492
+ return this.spawn(state, handler);
493
+ }
494
+
404
495
  // ---- background ----
405
496
 
406
497
  /**
@@ -495,15 +586,15 @@ export class Machine<
495
586
  this._effectsSchema,
496
587
  );
497
588
 
498
- // Share immutable arrays (never mutated after provide)
589
+ // Copy arrays/sets to avoid mutation bleed
499
590
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
500
- (result as any)._transitions = this._transitions;
591
+ (result as any)._transitions = [...this._transitions];
501
592
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
502
- (result as any)._finalStates = this._finalStates;
593
+ (result as any)._finalStates = new Set(this._finalStates);
503
594
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
504
- (result as any)._spawnEffects = this._spawnEffects;
595
+ (result as any)._spawnEffects = [...this._spawnEffects];
505
596
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
506
- (result as any)._backgroundEffects = this._backgroundEffects;
597
+ (result as any)._backgroundEffects = [...this._backgroundEffects];
507
598
 
508
599
  // Register handlers from provided object
509
600
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -537,50 +628,22 @@ export class Machine<
537
628
  }
538
629
 
539
630
  /**
540
- * Create guard and effect slot accessors for use in handlers.
541
- * @internal Used by the event loop to create typed accessors.
631
+ * Missing slot handlers (guards + effects).
632
+ * @internal Used by actor creation to fail fast.
542
633
  */
543
- _createSlotAccessors(ctx: MachineContext<State, Event, MachineRef<Event>>): {
544
- guards: GuardSlots<GD>;
545
- effects: EffectSlots<EFD>;
546
- } {
547
- // eslint-disable-next-line @typescript-eslint/no-this-alias
548
- const machine = this;
549
-
550
- // Create guard slots that resolve to actual handlers
551
- const guards =
552
- this._guardsSchema !== undefined
553
- ? this._guardsSchema._createSlots((name: string, params: unknown) => {
554
- const handler = machine._guardHandlers.get(name);
555
- if (handler === undefined) {
556
- return Effect.die(new SlotProvisionError({ slotName: name, slotType: "guard" }));
557
- }
558
- const result = handler(params, ctx);
559
- // Handler may return boolean or Effect<boolean>
560
- const normalized = typeof result === "boolean" ? Effect.succeed(result) : result;
561
- return normalized.pipe(Effect.provideService(machine.Context, ctx)) as Effect.Effect<
562
- boolean,
563
- never,
564
- never
565
- >;
566
- })
567
- : ({} as GuardSlots<GD>);
568
-
569
- // Create effect slots that resolve to actual handlers
570
- const effects =
571
- this._effectsSchema !== undefined
572
- ? this._effectsSchema._createSlots((name: string, params: unknown) => {
573
- const handler = machine._effectHandlers.get(name);
574
- if (handler === undefined) {
575
- return Effect.die(new SlotProvisionError({ slotName: name, slotType: "effect" }));
576
- }
577
- return handler(params, ctx).pipe(
578
- Effect.provideService(machine.Context, ctx),
579
- ) as Effect.Effect<void, never, never>;
580
- })
581
- : ({} as EffectSlots<EFD>);
582
-
583
- return { guards, effects };
634
+ _missingSlots(): string[] {
635
+ const missing: string[] = [];
636
+ if (this._guardsSchema !== undefined) {
637
+ for (const name of Object.keys(this._guardsSchema.definitions)) {
638
+ if (!this._guardHandlers.has(name)) missing.push(name);
639
+ }
640
+ }
641
+ if (this._effectsSchema !== undefined) {
642
+ for (const name of Object.keys(this._effectsSchema.definitions)) {
643
+ if (!this._effectHandlers.has(name)) missing.push(name);
644
+ }
645
+ }
646
+ return missing;
584
647
  }
585
648
 
586
649
  // ---- Static factory ----
@@ -634,6 +697,30 @@ import { createActor } from "./actor.js";
634
697
  * Effect.runPromise(Effect.scoped(program));
635
698
  * ```
636
699
  */
700
+ const spawnImpl = Effect.fn("effect-machine.spawn")(function* <
701
+ S extends { readonly _tag: string },
702
+ E extends { readonly _tag: string },
703
+ R,
704
+ GD extends GuardsDef,
705
+ EFD extends EffectsDef,
706
+ >(
707
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
708
+ machine: Machine<S, E, R, any, any, GD, EFD>,
709
+ id?: string,
710
+ ) {
711
+ const actorId = id ?? `actor-${Math.random().toString(36).slice(2)}`;
712
+ const actor = yield* createActor(actorId, machine);
713
+
714
+ // Register cleanup on scope finalization
715
+ yield* Effect.addFinalizer(
716
+ Effect.fn("effect-machine.spawn.finalizer")(function* () {
717
+ yield* actor.stop;
718
+ }),
719
+ );
720
+
721
+ return actor;
722
+ });
723
+
637
724
  export const spawn: {
638
725
  <
639
726
  S extends { readonly _tag: string },
@@ -644,7 +731,7 @@ export const spawn: {
644
731
  >(
645
732
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
646
733
  machine: Machine<S, E, R, any, any, GD, EFD>,
647
- ): Effect.Effect<ActorRef<S, E>, never, R | Scope.Scope>;
734
+ ): Effect.Effect<ActorRef<S, E>, UnprovidedSlotsError, R | Scope.Scope>;
648
735
 
649
736
  <
650
737
  S extends { readonly _tag: string },
@@ -656,27 +743,8 @@ export const spawn: {
656
743
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
657
744
  machine: Machine<S, E, R, any, any, GD, EFD>,
658
745
  id: string,
659
- ): Effect.Effect<ActorRef<S, E>, never, R | Scope.Scope>;
660
- } = <
661
- S extends { readonly _tag: string },
662
- E extends { readonly _tag: string },
663
- R,
664
- GD extends GuardsDef,
665
- EFD extends EffectsDef,
666
- >(
667
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance
668
- machine: Machine<S, E, R, any, any, GD, EFD>,
669
- id?: string,
670
- ): Effect.Effect<ActorRef<S, E>, never, R | Scope.Scope> =>
671
- Effect.gen(function* () {
672
- const actorId = id ?? `actor-${Math.random().toString(36).slice(2)}`;
673
- const actor = yield* createActor(actorId, machine);
674
-
675
- // Register cleanup on scope finalization
676
- yield* Effect.addFinalizer(() => actor.stop);
677
-
678
- return actor;
679
- });
746
+ ): Effect.Effect<ActorRef<S, E>, UnprovidedSlotsError, R | Scope.Scope>;
747
+ } = spawnImpl;
680
748
 
681
749
  // Transition lookup (introspection)
682
750
  export { findTransitions } from "./internal/transition.js";
@@ -2,7 +2,7 @@ import { Context, Schema } from "effect";
2
2
  import type { Effect, Option } from "effect";
3
3
 
4
4
  import type { PersistentActorRef } from "./persistent-actor.js";
5
- import type { DuplicateActorError } from "../errors.js";
5
+ import type { DuplicateActorError, UnprovidedSlotsError } from "../errors.js";
6
6
 
7
7
  /**
8
8
  * Metadata for a persisted actor.
@@ -26,8 +26,9 @@ export interface ActorMetadata {
26
26
  export interface RestoreResult<
27
27
  S extends { readonly _tag: string },
28
28
  E extends { readonly _tag: string },
29
+ R = never,
29
30
  > {
30
- readonly restored: ReadonlyArray<PersistentActorRef<S, E>>;
31
+ readonly restored: ReadonlyArray<PersistentActorRef<S, E, R>>;
31
32
  readonly failed: ReadonlyArray<RestoreFailure>;
32
33
  }
33
34
 
@@ -36,7 +37,7 @@ export interface RestoreResult<
36
37
  */
37
38
  export interface RestoreFailure {
38
39
  readonly id: string;
39
- readonly error: PersistenceError | DuplicateActorError;
40
+ readonly error: PersistenceError | DuplicateActorError | UnprovidedSlotsError;
40
41
  }
41
42
 
42
43
  /**
@@ -165,5 +166,5 @@ export class VersionConflictError extends Schema.TaggedError<VersionConflictErro
165
166
  * PersistenceAdapter service tag
166
167
  */
167
168
  export class PersistenceAdapterTag extends Context.Tag(
168
- "effect-machine/persistence/adapter/PersistenceAdapterTag",
169
+ "effect-machine/src/persistence/adapter/PersistenceAdapterTag",
169
170
  )<PersistenceAdapterTag, PersistenceAdapter>() {}