From 516d27c2fe5f76838db81ab5801a6afe46535d4c Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Sat, 29 Aug 2026 12:03:01 -0700 Subject: [PATCH 1/4] chore: enforce widen-then-assert rule in tests HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04eba-6fce-73bb-ae5f-a529a8fa351f --- .oxlintrc.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 258e50c..fd94cb6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -71,7 +71,6 @@ "anti-slop/no-object-parameters": "off", "anti-slop/no-unknown-type-aliases": "off", "anti-slop/no-unsafe-dictionary-type": "off", - "anti-slop/no-widen-then-assert": "off", "anti-slop/require-safety-comment-for-type-assertion": "off", "typescript/no-non-null-assertion": "off", "typescript/no-explicit-any": "off" From d530f21cdf7b8855a578eecb11e050a91092aa17 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Sat, 29 Aug 2026 14:13:12 -0700 Subject: [PATCH 2/4] refactor: eliminate chained type assertions HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04eba-6fce-73bb-ae5f-a529a8fa351f --- .oxlintrc.json | 4 +- AGENTS.md | 12 +- README.md | 18 +- SKILL.md | 18 +- src/actor.ts | 170 ++++--- src/cluster/entity-actor-ref.ts | 73 ++- src/cluster/entity-machine.ts | 441 ++++++++++--------- src/cluster/index.ts | 9 +- src/cluster/to-entity.ts | 80 ++-- src/index.ts | 2 + src/internal/runtime.ts | 25 +- src/internal/transition.ts | 30 +- src/internal/utils.ts | 4 +- src/machine.ts | 142 ++++-- src/schema.ts | 15 +- src/slot.ts | 2 +- src/testing.ts | 7 +- test/cluster-type-constraints.test.ts | 85 ++++ test/integration/cluster-persistence.test.ts | 18 +- test/integration/cluster.test.ts | 75 ++-- test/machine.test.ts | 24 + test/schema.test.ts | 33 +- test/type-constraints.test.ts | 54 ++- 23 files changed, 831 insertions(+), 510 deletions(-) create mode 100644 test/cluster-type-constraints.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index fd94cb6..f8de3ad 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -48,7 +48,7 @@ "no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], "typescript/no-unsafe-type-assertion": "off", "typescript/no-unnecessary-type-parameters": "off", - "typescript/no-unnecessary-type-assertion": "off", + "typescript/no-unnecessary-type-assertion": "error", "typescript/consistent-return": "off", "typescript/no-unnecessary-type-arguments": "off", "typescript/unbound-method": "off", @@ -66,13 +66,13 @@ { "files": ["**/*.test.ts"], "rules": { - "anti-slop/no-chained-type-assertions": "off", "anti-slop/no-known-value-widening": "off", "anti-slop/no-object-parameters": "off", "anti-slop/no-unknown-type-aliases": "off", "anti-slop/no-unsafe-dictionary-type": "off", "anti-slop/require-safety-comment-for-type-assertion": "off", "typescript/no-non-null-assertion": "off", + "typescript/no-unnecessary-type-assertion": "off", "typescript/no-explicit-any": "off" } }, diff --git a/AGENTS.md b/AGENTS.md index 0008024..55e6aa4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ const machine = Machine.make({ state, event, initial }) .final(State.Done); ``` -- Builder methods mutate `this`, return `this` +- Transition/final/postpone methods mutate `this`; requirement-growing `.spawn()`, `.task()`, `.timeout()`, and `.background()` methods are copy-on-write - Builder chain ends naturally — no terminal method needed - `.onAny()` fires when no specific `.on()` matches for that event @@ -297,15 +297,15 @@ Wire machines to `@effect/cluster` for distributed actors: import { toEntity, EntityMachine } from "@humanlayer/effect-machine/cluster"; const OrderEntity = toEntity(orderMachine, { type: "Order" }); -const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, { +const OrderEntityLayer = EntityMachine.layer(OrderEntity, { initializeState: (entityId) => OrderState.Pending({ orderId: entityId }), persistence: { strategy: "journal" }, }); ``` -- `toEntity` generates Entity with Send/Ask/GetState/WatchState RPCs -- `EntityMachine.layer` wires machine to cluster via shared runtime kernel -- `EntityActorRef`: typed client wrapper (send/ask/snapshot/watch/waitFor) +- `toEntity` generates a machine-owned Entity with Send/Ask/GetState/WatchState RPCs +- `EntityMachine.layer` wires the Entity's machine to cluster via the shared runtime kernel +- `EntityActorRef`: `makeEntityActorRef(entity, client, id)` decodes Ask replies and preserves client errors ### Entity Persistence @@ -320,7 +320,7 @@ Opt-in via `EntityMachineOptions.persistence`: ### Cluster Gotchas - Entity tests use `Entity.makeTestClient` + `ShardingConfig.layer` + `Effect.scoped` -- `EntityMachine.layer` accepts raw `Machine` +- `EntityMachine.layer` accepts the `MachineEntity` returned by `toEntity`; the entity owns its machine and protocol - Entity RPCs use `.tag` field (not `._tag`) to distinguish request types - WatchState test skipped due to effect beta Queue bug diff --git a/README.md b/README.md index 2d1d82b..76725dd 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,18 @@ The fluent builder keeps state behavior beside the transitions that make it rele Use `self.send(...)` from a state effect to feed work back into the machine. State effects can use Effect services and can be asynchronous; transition handlers stay pure. +Methods that can add Effect requirements—`.spawn(...)`, `.task(...)`, `.timeout(...)`, and `.background(...)`—are copy-on-write. Always use their returned machine. This keeps an earlier alias truthful and unchanged: + +```ts +const base = Machine.make({ state, event, initial }); +const withWorker = base.spawn(State.Running, worker); + +base.spawnEffects.length; // 0 +withWorker.spawnEffects.length; // 1 +``` + +State-effect contexts expose an honest lifecycle event union: initial effects receive `$init`, while effects started after a state transition receive `$enter`. + ## Services And Layers New machines use Effect's service system for dependencies, not actor-local slot maps. Define a dependency with `Context.Service` (the Effect v4 replacement for `ServiceMap.Service`), access it with `yield*` inside a state effect, and provide an implementation with a `Layer` at the program boundary. @@ -206,7 +218,7 @@ const program = Effect.gen(function* () { }).pipe(Effect.provide(ActorSystemDefault), Effect.provide(PaymentsLive)); ``` -`ActorSystemService` also exposes `get(id)`, `stop(id)`, a snapshot `actors` map, an event `Stream`, and `subscribe(...)` for synchronous `ActorSpawned`, `ActorRestarted`, and `ActorStopped` notifications. +`ActorSystemService` also exposes `get(id)`, `stop(id)`, a snapshot `actors` map, an event `Stream`, and `subscribe(...)` for synchronous `ActorSpawned`, `ActorRestarted`, and `ActorStopped` notifications. Typed `spawn` returns a full `ActorRef`. Heterogeneous lookups, maps, child collections, and system events expose `ActorHandle`, which supports lifecycle and read-only observation but cannot accept an event without a type witness. ## Recovery, Durability, And Supervision @@ -306,13 +318,13 @@ import { EntityMachine, toEntity } from "@humanlayer/effect-machine/cluster"; const CheckoutEntity = toEntity(checkoutMachine, { type: "Checkout" }); -const CheckoutEntityLayer = EntityMachine.layer(CheckoutEntity, checkoutMachine, { +const CheckoutEntityLayer = EntityMachine.layer(CheckoutEntity, { initializeState: (entityId) => CheckoutState.ReviewingCart({ cartId: entityId, totalCents: 0 }), persistence: { strategy: "journal" }, }); ``` -`toEntity` requires a machine made with `Machine.make({ state, event, initial })`, then creates `Send`, `Ask`, `GetState`, and `WatchState` RPCs. `makeEntityActorRef(client, entityId)` wraps that protocol with a typed `send`, `ask`, `snapshot`, `watch`, and `waitFor` API. +`toEntity` requires a machine made with `Machine.make({ state, event, initial })`, then returns a machine-owned entity with canonical `Send`, `Ask`, `GetState`, and `WatchState` RPCs. `EntityMachine.layer(entity, options?)` uses the machine carried by that entity, preventing protocol/machine mismatches. `makeEntityActorRef(entity, client, entityId)` wraps the protocol with typed `send`, `ask`, `snapshot`, `watch`, and `waitFor`; remote Ask values are decoded with the event's reply schema and client transport errors remain in each operation's error channel. Persistence is opt-in and resolves `PersistenceAdapter` from the entity layer's services: diff --git a/SKILL.md b/SKILL.md index 156c0b2..abeb0bb 100644 --- a/SKILL.md +++ b/SKILL.md @@ -206,7 +206,7 @@ interface ProcessEventResult { const unsub = system.subscribe((event) => console.log(event._tag, event.id)); // Sync snapshot of all registered actors -const actors: ReadonlyMap = system.actors; +const actors: ReadonlyMap = system.actors; // Async stream (late subscribers miss prior events) system.events.pipe(Stream.take(10), Stream.runCollect); @@ -251,19 +251,19 @@ import { toEntity, EntityMachine, PersistenceAdapter } from "@humanlayer/effect- const OrderEntity = toEntity(orderMachine, { type: "Order" }); -const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, { +const OrderEntityLayer = EntityMachine.layer(OrderEntity, { initializeState: (entityId) => OrderState.Pending({ orderId: entityId }), persistence: { strategy: "journal" }, // or "snapshot" (default) }); ``` -| Export | Purpose | -| --------------------------------------------- | ------------------------------------------------------------------- | -| `toEntity(machine, { type })` | Generate `Entity` definition with Send/Ask/GetState/WatchState RPCs | -| `EntityMachine.layer(entity, machine, opts?)` | Wire machine to cluster Entity layer | -| `makeEntityActorRef(client, id)` | Typed client wrapper (send/ask/snapshot/watch/waitFor) | -| `PersistenceAdapter` | Service tag for storage backend | -| `makeInMemoryPersistenceAdapter` | In-memory adapter for testing | +| Export | Purpose | +| ---------------------------------------- | ------------------------------------------------------------------- | +| `toEntity(machine, { type })` | Generate `Entity` definition with Send/Ask/GetState/WatchState RPCs | +| `EntityMachine.layer(entity, opts?)` | Wire the entity's machine to a truthful cluster Layer | +| `makeEntityActorRef(entity, client, id)` | Typed client wrapper; decodes Ask replies and preserves errors | +| `PersistenceAdapter` | Service tag for storage backend | +| `makeInMemoryPersistenceAdapter` | In-memory adapter for testing | **Persistence strategies:** diff --git a/src/actor.ts b/src/actor.ts index 17614f3..959b595 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -26,7 +26,7 @@ import { SubscriptionRef, } from "effect"; -import type { Machine, Lifecycle } from "./machine.js"; +import type { Machine, Lifecycle, LifecycleEvent } from "./machine.js"; import { materializeMachine } from "./machine.js"; import { ActorExit, type Supervision } from "./supervision.js"; import type { ReplyTypeBrand, ExtractReply } from "./internal/brands.js"; @@ -85,7 +85,33 @@ export interface TransitionInfo { readonly event: Event; } -export interface ActorRef { +type AnyState = { readonly _tag: string }; + +/** + * Sound view of an actor whose concrete state and event types are not known. + * Heterogeneous registries expose this surface instead of pretending that an + * unknown actor can safely receive arbitrary events. + */ +export interface ActorHandle { + readonly id: string; + readonly stop: Effect.Effect; + readonly start: Effect.Effect; + readonly snapshot: Effect.Effect; + readonly awaitExit: Effect.Effect>; + readonly watch: (other: { + readonly id: string; + readonly awaitExit: Effect.Effect>; + }) => Effect.Effect>; + readonly drain: Effect.Effect; + readonly sync: { + readonly stop: () => void; + readonly snapshot: () => AnyState; + }; + readonly system: ActorSystemService; + readonly children: ReadonlyMap; +} + +export interface ActorRef extends ActorHandle { readonly id: string; /** Send an event (fire-and-forget). */ @@ -194,16 +220,13 @@ export interface ActorRef { readonly system: ActorSystemService; /** Child actors spawned via `self.spawn` in this actor's handlers. */ - readonly children: ReadonlyMap>; + readonly children: ReadonlyMap; } // ============================================================================ // ActorSystem Interface // ============================================================================ -/** Base type for stored actors (internal) */ -type AnyState = { readonly _tag: string }; - interface MutableCell { current: T; } @@ -211,12 +234,15 @@ interface MutableCell { // eslint-disable-next-line typescript/no-explicit-any, anti-slop/no-unsafe-dictionary-type -- deprecated slots erase handlers here type LegacySlotHandlers = Record; -const eraseDeferred = ( - deferred: Deferred.Deferred, -): Deferred.Deferred => - // SAFETY: pending reply cleanup only fails or deletes the Deferred; it never reads a typed value. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- Deferred is invariant in both channels - deferred as unknown as Deferred.Deferred; +interface PendingReply { + readonly failStopped: (error: ActorStoppedError) => Effect.Effect; +} + +const pendingReply = ( + deferred: Deferred.Deferred, +): PendingReply => ({ + failStopped: (error) => Deferred.fail(deferred, error).pipe(Effect.asVoid), +}); const isStatePredicate = ( value: ((state: S) => boolean) | { readonly _tag: S["_tag"] }, @@ -233,19 +259,19 @@ export type SystemEvent = | { readonly _tag: "ActorSpawned"; readonly id: string; - readonly actor: ActorRef; + readonly actor: ActorHandle; } | { readonly _tag: "ActorRestarted"; readonly id: string; - readonly actor: ActorRef; + readonly actor: ActorHandle; readonly generation: number; readonly exit: ActorExit; } | { readonly _tag: "ActorStopped"; readonly id: string; - readonly actor: ActorRef; + readonly actor: ActorHandle; readonly exit: ActorExit; }; @@ -286,7 +312,7 @@ export interface ActorSystemService { /** * Get an existing actor by ID */ - readonly get: (id: string) => Effect.Effect>>; + readonly get: (id: string) => Effect.Effect>; /** * Stop an actor by ID @@ -303,7 +329,7 @@ export interface ActorSystemService { * Sync snapshot of all currently registered actors. * Returns a new Map on each access (not live). */ - readonly actors: ReadonlyMap>; + readonly actors: ReadonlyMap; /** * Subscribe to system events synchronously. @@ -371,8 +397,8 @@ export const buildActorRefCore = < stop: Effect.Effect, start: Effect.Effect, system: ActorSystemService, - childrenMap: ReadonlyMap>, - pendingReplies: Set>, + childrenMap: ReadonlyMap, + pendingReplies: Set, transitionsPubSub: PubSub.PubSub> | undefined, exitDeferred: Deferred.Deferred>, ): ActorRef => { @@ -408,13 +434,13 @@ export const buildActorRefCore = < ProcessEventResult<{ readonly _tag: string }>, ActorStoppedError >(); - const pendingReply = eraseDeferred(reply); + const pending = pendingReply(reply); // SAFETY: the runtime queue reports ActorStoppedError through its wider unknown error channel. const queuedReply = reply as Deferred.Deferred< ProcessEventResult<{ readonly _tag: string }>, unknown >; - pendingReplies.add(pendingReply); + pendingReplies.add(pending); const q = yield* Ref.get(eventQueueRef); yield* Queue.offer(q, { _tag: "call", @@ -422,7 +448,7 @@ export const buildActorRefCore = < reply: queuedReply, }); const result = yield* Deferred.await(reply).pipe( - Effect.ensuring(Effect.sync(() => pendingReplies.delete(pendingReply))), + Effect.ensuring(Effect.sync(() => pendingReplies.delete(pending))), Effect.catchTag("ActorStoppedError", () => SubscriptionRef.get(stateRef).pipe( Effect.map( @@ -452,10 +478,10 @@ export const buildActorRefCore = < return yield* new ActorStoppedError({ actorId: id }); } const reply = yield* Deferred.make(); - const pendingReply = eraseDeferred(reply); + const pending = pendingReply(reply); // SAFETY: queue processing emits NoReplyError; ActorStoppedError is managed by pending reply cleanup. const queuedReply = reply as Deferred.Deferred; - pendingReplies.add(pendingReply); + pendingReplies.add(pending); const q = yield* Ref.get(eventQueueRef); yield* Queue.offer(q, { _tag: "ask", @@ -463,7 +489,7 @@ export const buildActorRefCore = < reply: queuedReply, }); return yield* Deferred.await(reply).pipe( - Effect.ensuring(Effect.sync(() => pendingReplies.delete(pendingReply))), + Effect.ensuring(Effect.sync(() => pendingReplies.delete(pending))), ); }); @@ -603,7 +629,7 @@ const buildInspectionHooks = < E extends { readonly _tag: string }, >( actorId: string, - inspector: InspectorService, + inspector: InspectorService, ): ProcessEventHooks => ({ onSpawnEffect: (state) => emitWithTimestamp(inspector, (timestamp) => ({ @@ -663,14 +689,13 @@ const runSupervisionLoop = < id: string; runtimeRef: { current: RuntimeHandle | undefined }; terminalExitDeferred: Deferred.Deferred>; - pendingReplies: Set>; + pendingReplies: Set; eventQueueRef: Ref.Ref>>; stateRef: SubscriptionRef.SubscriptionRef; stoppedRef: Ref.Ref; - childrenMap: Map>; + childrenMap: Map; listeners: Listeners; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - spawnGeneration: (m: any) => Effect.Effect>; + spawnGeneration: (initialState: S) => Effect.Effect>; lifecycle?: Lifecycle; generationRef: { get: () => number; set: (g: number) => void }; onRestart?: (generation: number, exit: ActorExit) => Effect.Effect; @@ -731,14 +756,7 @@ const runSupervisionLoop = < yield* Ref.set(params.stoppedRef, false); params.childrenMap.clear(); - // SAFETY: the prototype is the same machine and only its immutable initial value is overridden. - const machineForRestart = - restartState !== params.machine.initial - ? (Object.create(params.machine, { - initial: { value: restartState, enumerable: true }, - }) as typeof params.machine) - : params.machine; - const newRuntime = yield* params.spawnGeneration(machineForRestart); + const newRuntime = yield* params.spawnGeneration(restartState); params.runtimeRef.current = newRuntime; yield* newRuntime.start; @@ -785,27 +803,18 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < // Get optional inspector from context // SAFETY: Inspector is intentionally type-erased in Context and is used with this actor's S/E pair. const inspectorValue = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as - | InspectorService + | InspectorService | undefined; // Actor-specific state - const childrenMap = new Map>(); - const pendingReplies = new Set>(); + const childrenMap = new Map(); + const pendingReplies = new Set(); const listeners: Listeners = new Set(); const transitionsPubSub = yield* PubSub.unbounded>(); // Build hooks from inspector const hooks = inspectorValue !== undefined ? buildInspectionHooks(id, inspectorValue) : undefined; - // Use initial state override if provided - // SAFETY: the prototype is the same machine and only its immutable initial value is overridden. - const machineWithState = - initial !== machine.initial - ? (Object.create(machine, { - initial: { value: initial, enumerable: true }, - }) as typeof machine) - : machine; - // Cell-owned resources: stable across generations (supervision) const stateRef = yield* SubscriptionRef.make(initial); const stoppedRef = yield* Ref.make(false); @@ -921,14 +930,15 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < }; }; - /** Create a single runtime generation. machineForGen is machineWithState for initial, machine for restarts. */ - // SAFETY: createRuntime is instantiated from machineForGen with this actor's S/E pair. - const spawnGeneration = (machineForGen: typeof machine) => + /** Create a single runtime generation with an explicit hydrated/recovered state. */ + const spawnGeneration = (generationInitial: S) => Ref.get(eventQueueRef).pipe( Effect.flatMap( (currentQueue) => - createRuntime(machineForGen, system, { + // SAFETY: createRuntime is instantiated from this actor's exact machine, state, and event types. + createRuntime(machine, system, { actorId: id, + initialState: generationInitial, hooks, skipFinalizer: true, cellResources: { stateRef, stoppedRef, eventQueue: currentQueue }, @@ -955,10 +965,7 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < child: ActorRef, ) => Effect.gen(function* () { - // SAFETY: the children registry intentionally erases each child's invariant state/event pair. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- ActorRef is invariant - const childRef = child as unknown as ActorRef; - childrenMap.set(childId, childRef); + childrenMap.set(childId, child); // Use Scope.Scope here intentionally — this is the spawn handler's // state-scoped scope, not an ambient scope. When the state exits, // this scope closes and the child is removed from the map. @@ -977,7 +984,7 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < ); // Spawn initial generation (with hydrated state if provided) - const runtime = yield* spawnGeneration(machineWithState); + const runtime = yield* spawnGeneration(initial); runtimeRef.current = runtime; const supervision = options?.supervision; @@ -1021,12 +1028,7 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < // Update cell stateRef yield* SubscriptionRef.set(stateRef, resolved.value); // Runtime was created with cold initial — recreate with recovered state. - // The runtime reads machine.initial for background/spawn effects. - // SAFETY: the prototype is the same machine and recovery supplied a state of S. - const recoveredMachine = Object.create(machine, { - initial: { value: resolved.value, enumerable: true }, - }) as typeof machine; - const newRuntime = yield* spawnGeneration(recoveredMachine); + const newRuntime = yield* spawnGeneration(resolved.value); runtimeRef.current = newRuntime; } } @@ -1107,15 +1109,12 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < }); /** Fail all pending call/ask Deferreds with ActorStoppedError. Safe to call multiple times. */ -export const settlePendingReplies = ( - pendingReplies: Set>, - actorId: string, -) => +export const settlePendingReplies = (pendingReplies: Set, actorId: string) => Effect.sync(() => { const error = new ActorStoppedError({ actorId }); - for (const deferred of pendingReplies) { + for (const pending of pendingReplies) { // Deferred.fail returns false if already completed — safe to double-settle - Effect.runFork(Deferred.fail(deferred, error)); + Effect.runFork(pending.failStopped(error)); } pendingReplies.clear(); }); @@ -1137,7 +1136,7 @@ const notifySystemListeners = (listeners: Set, event: Syste const make = Effect.fn("effect-machine.actorSystem.make")(function* () { // MutableHashMap for O(1) spawn/stop/get operations - const actorsMap = MutableHashMap.empty>(); + const actorsMap = MutableHashMap.empty(); const spawnGate = yield* Semaphore.make(1); const withSpawnGate = spawnGate.withPermits(1); @@ -1166,7 +1165,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { /** Check for duplicate ID, register actor, attach scope cleanup if available */ const registerActor = Effect.fn("effect-machine.actorSystem.register")(function* < - T extends { stop: Effect.Effect }, + T extends ActorHandle, >(id: string, actor: T) { // Check if actor already exists if (MutableHashMap.has(actorsMap, id)) { @@ -1175,15 +1174,11 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { return yield* new DuplicateActorError({ actorId: id }); } - // SAFETY: the registry erases actor state/event parameters but never sends untyped events through them. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- ActorRef is invariant - const actorRef = actor as unknown as ActorRef; - // Register it - O(1) - MutableHashMap.set(actorsMap, id, actorRef); + MutableHashMap.set(actorsMap, id, actor); // Emit spawned event - yield* emitSystemEvent({ _tag: "ActorSpawned", id, actor: actorRef }); + yield* emitSystemEvent({ _tag: "ActorSpawned", id, actor }); // If ActorScope available, attach per-actor cleanup const maybeScope = yield* Effect.serviceOption(ActorScope); @@ -1198,7 +1193,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { yield* emitSystemEvent({ _tag: "ActorStopped", id, - actor: actorRef, + actor, exit: ActorExit.Stopped, }); MutableHashMap.remove(actorsMap, id); @@ -1233,7 +1228,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { const materialized = spawnOptions?.slots !== undefined ? materializeMachine(machine, spawnOptions.slots) : machine; // Mutable ref for the actor �� onRestart closure needs it, but actor isn't registered yet - let actorRef: ActorRef | undefined; + let actorRef: ActorHandle | undefined; const actor = yield* createActor(id, materialized, { supervision: spawnOptions?.supervision, lifecycle: spawnOptions?.lifecycle, @@ -1251,9 +1246,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { : Effect.void : undefined, }); - // SAFETY: the registry erases actor state/event parameters but preserves the complete ActorRef value. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- ActorRef is invariant - actorRef = actor as unknown as ActorRef; + actorRef = actor; // Register before start — actor is in the map before lifecycle hooks fire yield* registerActor(id, actor); // Auto-start: system.spawn returns a running actor @@ -1279,12 +1272,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { readonly lifecycle?: Lifecycle; }, ): Effect.Effect, DuplicateActorError, R> => - // SAFETY: the spawn gate changes scheduling only; spawnRegular preserves S, E, and R. - withSpawnGate(spawnRegular(id, machine, options)) as Effect.Effect< - ActorRef, - DuplicateActorError, - R - >; + withSpawnGate(spawnRegular(id, machine, options)); const get = Effect.fn("effect-machine.actorSystem.get")(function* (id: string) { return yield* Effect.sync(() => MutableHashMap.get(actorsMap, id)); @@ -1315,7 +1303,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { stop, events: Stream.fromPubSub(eventPubSub), get actors() { - const snapshot = new Map>(); + const snapshot = new Map(); MutableHashMap.forEach(actorsMap, (actor, id) => { snapshot.set(id, actor); }); diff --git a/src/cluster/entity-actor-ref.ts b/src/cluster/entity-actor-ref.ts index 437d1db..4707c50 100644 --- a/src/cluster/entity-actor-ref.ts +++ b/src/cluster/entity-actor-ref.ts @@ -11,12 +11,11 @@ * @module */ import type { RpcClient } from "effect/unstable/rpc"; -import { Effect, Option, Stream } from "effect"; +import { Effect, Option, Schema, Stream } from "effect"; import type { ExtractReply, ReplyTypeBrand } from "../internal/brands.js"; -import type { NoReplyError } from "../errors.js"; -import { ActorStoppedError } from "../errors.js"; -import type { EntityRpcs } from "./to-entity.js"; +import { ActorStoppedError, NoReplyError } from "../errors.js"; +import type { EntityRpcs, MachineEntity } from "./to-entity.js"; /** * Typed client wrapper for remote entity machines. @@ -26,7 +25,7 @@ import type { EntityRpcs } from "./to-entity.js"; * * @example * ```ts - * const ref = makeEntityActorRef(client, "order-123") + * const ref = makeEntityActorRef(entity, client, "order-123") * yield* ref.send(OrderEvent.Ship({ trackingId: "abc" })) * const state = yield* ref.snapshot * yield* ref.waitFor((s) => s._tag === "Shipped") @@ -35,39 +34,28 @@ import type { EntityRpcs } from "./to-entity.js"; export interface EntityActorRef< State extends { readonly _tag: string }, Event extends { readonly _tag: string }, + ClientError = never, > { readonly entityId: string; /** Send event. Returns new state after processing. */ - readonly send: (event: Event) => Effect.Effect; + readonly send: (event: Event) => Effect.Effect; /** Send event and get typed domain reply (via Event.reply() schema). */ readonly ask: >( event: E, - ) => Effect.Effect, NoReplyError>; + ) => Effect.Effect, ClientError | NoReplyError | Schema.SchemaError>; /** Get current state. */ - readonly snapshot: Effect.Effect; + readonly snapshot: Effect.Effect; /** Stream of state changes (via WatchState streaming RPC). */ - readonly watch: Stream.Stream; + readonly watch: Stream.Stream; /** Wait for a state matching the predicate. Snapshots first, then watches stream. */ readonly waitFor: ( predicate: (state: State) => boolean, - ) => Effect.Effect; -} - -interface TypedEntityClient< - State extends { readonly _tag: string }, - Event extends { readonly _tag: string }, -> { - readonly Send: (request: { readonly event: Event }) => Effect.Effect; - readonly Ask: >(request: { - readonly event: E; - }) => Effect.Effect, NoReplyError>; - readonly GetState: () => Effect.Effect; - readonly WatchState: () => Stream.Stream; + ) => Effect.Effect; } /** @@ -77,36 +65,47 @@ interface TypedEntityClient< * ```ts * const makeClient = yield* Entity.makeTestClient(entity, entityLayer) * const client = yield* makeClient("order-123") - * const ref = makeEntityActorRef(client, "order-123") + * const ref = makeEntityActorRef(entity, client, "order-123") * yield* ref.send(OrderEvent.Process) * ``` */ export const makeEntityActorRef = < State extends { readonly _tag: string }, Event extends { readonly _tag: string }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema types need wide acceptance - Rpcs extends EntityRpcs[number], + R, + EntityType extends string, + ClientError, >( - client: RpcClient.RpcClient, + entity: MachineEntity, + client: RpcClient.RpcClient< + EntityRpcs, Schema.Codec>[number], + ClientError + >, entityId: string, -): EntityActorRef => { - // SAFETY: EntityRpcs guarantees the generated client exposes Send, Ask, GetState, and WatchState. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- RpcClient does not preserve method correlations - const c = client as unknown as TypedEntityClient; - +): EntityActorRef => { + const ask = >(event: ReplyEvent) => { + const replySchema = entity.machine.replySchemas.get(event._tag); + if (replySchema === undefined) { + return Effect.fail(new NoReplyError({ actorId: entityId, eventTag: event._tag })); + } + const typedReplySchema = Schema.make>>(replySchema.ast); + return client + .Ask({ event }) + .pipe(Effect.flatMap((reply) => Schema.decodeUnknownEffect(typedReplySchema)(reply))); + }; return { entityId, - send: (event: Event) => c.Send({ event }), - ask: (event) => c.Ask({ event }), - snapshot: c.GetState(), - watch: c.WatchState(), + send: (event: Event) => client.Send({ event }), + ask, + snapshot: client.GetState(), + watch: client.WatchState(), waitFor: (predicate: (state: State) => boolean) => Effect.gen(function* () { // Snapshot first — if current state already matches, return immediately - const current = yield* c.GetState(); + const current = yield* client.GetState(); if (predicate(current)) return current; // Fall through to streaming observation - const result = yield* c + const result = yield* client .WatchState() .pipe(Stream.filter(predicate), Stream.take(1), Stream.runHead); if (Option.isSome(result)) return result.value; diff --git a/src/cluster/entity-machine.ts b/src/cluster/entity-machine.ts index 893cd04..ce9dac2 100644 --- a/src/cluster/entity-machine.ts +++ b/src/cluster/entity-machine.ts @@ -12,7 +12,7 @@ * @module */ import { Entity } from "effect/unstable/cluster"; -import type { Envelope } from "effect/unstable/cluster"; +import type { Envelope, Sharding } from "effect/unstable/cluster"; import type { Rpc } from "effect/unstable/rpc"; import { Clock, @@ -22,6 +22,7 @@ import { Option, Queue, Ref, + type Schema, type Schedule, Stream, SubscriptionRef, @@ -40,6 +41,12 @@ import { type PersistedEvent, type Snapshot, } from "./persistence.js"; +import type { EntityRpcs, MachineEntity } from "./to-entity.js"; + +const matchesRpc = ( + rpc: Selected, + request: Envelope.Request, +): request is Envelope.Request & Envelope.Request => request.tag === rpc._tag; /** * Options for EntityMachine.layer @@ -87,6 +94,22 @@ export interface EntityMachineOptions { readonly persistence?: EntityPersistenceConfig; } +type EntityOptionsWithoutPersistence = Omit, "persistence"> & { + readonly persistence?: undefined; +}; + +type EntityOptionsWithPersistence = Omit, "persistence"> & { + readonly persistence: EntityPersistenceConfig; +}; + +type EntityLayerRequirements = R | Persistence | Sharding.Sharding; + +type EntityLayer = Layer.Layer< + never, + never, + EntityLayerRequirements +>; + interface ClusterQueueOptions { maxIdleTime?: Duration.Input; mailboxCapacity?: number | "unbounded"; @@ -106,230 +129,224 @@ interface ClusterQueueOptions { * ```ts * const OrderEntity = toEntity(orderMachine, { type: "Order" }) * - * const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, { + * const OrderEntityLayer = EntityMachine.layer(OrderEntity, { * initializeState: (entityId) => OrderState.Pending({ orderId: entityId }), * }) * ``` */ -export const EntityMachine = { - layer: < - S extends { readonly _tag: string }, - E extends { readonly _tag: string }, - R, - EntityType extends string, - Rpcs extends Rpc.Any, - >( - entity: Entity.Entity, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Machine type params need wide acceptance - machine: Machine, - options?: EntityMachineOptions, - ): Layer.Layer => { - const persistence = options?.persistence; - - // Build function receives (queue, replier) from Entity.toLayerQueue - const build = Effect.gen(function* () { - // Get entity ID from context (provided by Entity activation) - const entityId = yield* Effect.serviceOption(Entity.CurrentAddress).pipe( - Effect.map((opt) => (opt._tag === "Some" ? opt.value.entityId : "")), - ); - - // Resolve actor system from context, or create implicit one - const existingSystem = yield* Effect.serviceOption(ActorSystemTag); - const system: ActorSystemService = Option.isSome(existingSystem) - ? existingSystem.value - : yield* makeSystem(); - - // ---------------------------------------------------------------- - // Persistence: hydration - // ---------------------------------------------------------------- - const persistCtx = yield* hydratePersistence( - persistence, - entity, - entityId, - machine, - options?.initializeState, - ); - - // Compute final initial state: hydrated > initializeState > machine.initial - const initialState = - persistCtx.hydratedState ?? - (options?.initializeState !== undefined ? options.initializeState(entityId) : undefined); - - const machineWithState = - initialState !== undefined - ? Object.create(machine, { - initial: { value: initialState, enumerable: true }, - }) - : machine; - - // Version tracking - const versionRef = yield* Ref.make(persistCtx.initialVersion); - - // Cell-owned resources — stable identity for this entity activation - const computedInitial = initialState ?? machine.initial; - const stateRef = yield* SubscriptionRef.make(computedInitial); - const stoppedRef = yield* Ref.make(false); - const eventQueue = yield* Queue.unbounded>(); - - // Create runtime kernel — single queue, sequential processing - const runtime = yield* createRuntime(machineWithState, system, { - actorId: entityId, - hooks: options?.hooks, - childIdPrefix: `${entityId}/`, - cellResources: { stateRef, stoppedRef, eventQueue }, - }); - yield* runtime.start; - - // ---------------------------------------------------------------- - // Persistence: snapshot scheduling - // ---------------------------------------------------------------- - if (persistCtx.adapter !== undefined) { - const { adapter: pAdapter, key } = persistCtx; - const strategy = persistence?.strategy ?? "snapshot"; - const schedule = persistence?.snapshotSchedule; - - if (strategy === "snapshot") { - // Snapshot-only mode: background scheduler is safe (no journal to tear against) - yield* SubscriptionRef.changes(runtime.stateRef).pipe( - schedule !== undefined ? Stream.schedule(schedule) : (s: Stream.Stream) => s, - Stream.runForEach((state) => - Effect.gen(function* () { - const version = yield* Ref.get(versionRef); - const now = yield* Clock.currentTimeMillis; - yield* pAdapter.saveSnapshot(key, { - state, - version, - timestamp: now, - } satisfies Snapshot); - }).pipe(Effect.catch(() => Effect.void)), - ), - Effect.forkScoped, - ); - } - // Journal mode: no background scheduler — snapshot only on deactivation - // to avoid state/version tear between concurrent SubscriptionRef and versionRef reads - - // Deactivation finalizer — save final snapshot (safe: runs after event loop stops) - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const state = yield* SubscriptionRef.get(runtime.stateRef); - const version = yield* Ref.get(versionRef); - const now = yield* Clock.currentTimeMillis; - yield* pAdapter.saveSnapshot(key, { - state, - version, - timestamp: now, - } satisfies Snapshot); - }).pipe(Effect.catch(() => Effect.void)), +function layer< + S extends { readonly _tag: string }, + E extends { readonly _tag: string }, + R, + EntityType extends string, +>( + entity: MachineEntity, + options?: EntityOptionsWithoutPersistence, +): EntityLayer; +function layer< + S extends { readonly _tag: string }, + E extends { readonly _tag: string }, + R, + EntityType extends string, +>( + entity: MachineEntity, + options: EntityOptionsWithPersistence, +): EntityLayer; +function layer< + S extends { readonly _tag: string }, + E extends { readonly _tag: string }, + R, + EntityType extends string, +>( + entity: MachineEntity, + options: EntityMachineOptions, +): EntityLayer; +function layer< + S extends { readonly _tag: string }, + E extends { readonly _tag: string }, + R, + EntityType extends string, +>(entity: MachineEntity, options?: EntityMachineOptions) { + type Rpcs = EntityRpcs, Schema.Codec>[number]; + const machine = entity.machine; + const persistence = options?.persistence; + + // Build function receives (queue, replier) from Entity.toLayerQueue + const build = Effect.gen(function* () { + // Get entity ID from context (provided by Entity activation) + const entityId = yield* Effect.serviceOption(Entity.CurrentAddress).pipe( + Effect.map((opt) => (opt._tag === "Some" ? opt.value.entityId : "")), + ); + + // Resolve actor system from context, or create implicit one + const existingSystem = yield* Effect.serviceOption(ActorSystemTag); + const system: ActorSystemService = Option.isSome(existingSystem) + ? existingSystem.value + : yield* makeSystem(); + + // ---------------------------------------------------------------- + // Persistence: hydration + // ---------------------------------------------------------------- + const persistCtx = yield* hydratePersistence( + persistence, + entity, + entityId, + machine, + options?.initializeState, + ); + + // Compute final initial state: hydrated > initializeState > machine.initial + const initialState = + persistCtx.hydratedState ?? + (options?.initializeState !== undefined ? options.initializeState(entityId) : undefined); + + // Version tracking + const versionRef = yield* Ref.make(persistCtx.initialVersion); + + // Cell-owned resources — stable identity for this entity activation + const computedInitial = initialState ?? machine.initial; + const stateRef = yield* SubscriptionRef.make(computedInitial); + const stoppedRef = yield* Ref.make(false); + const eventQueue = yield* Queue.unbounded>(); + + // Create runtime kernel — single queue, sequential processing + const runtime = yield* createRuntime(machine, system, { + actorId: entityId, + initialState: computedInitial, + hooks: options?.hooks, + childIdPrefix: `${entityId}/`, + cellResources: { stateRef, stoppedRef, eventQueue }, + }); + yield* runtime.start; + + // ---------------------------------------------------------------- + // Persistence: snapshot scheduling + // ---------------------------------------------------------------- + if (persistCtx.adapter !== undefined) { + const { adapter: pAdapter, key } = persistCtx; + const strategy = persistence?.strategy ?? "snapshot"; + const schedule = persistence?.snapshotSchedule; + + if (strategy === "snapshot") { + // Snapshot-only mode: background scheduler is safe (no journal to tear against) + yield* SubscriptionRef.changes(runtime.stateRef).pipe( + schedule !== undefined ? Stream.schedule(schedule) : (s: Stream.Stream) => s, + Stream.runForEach((state) => + Effect.gen(function* () { + const version = yield* Ref.get(versionRef); + const now = yield* Clock.currentTimeMillis; + yield* pAdapter.saveSnapshot(key, { + state, + version, + timestamp: now, + } satisfies Snapshot); + }).pipe(Effect.catch(() => Effect.void)), + ), + Effect.forkScoped, ); } + // Journal mode: no background scheduler — snapshot only on deactivation + // to avoid state/version tear between concurrent SubscriptionRef and versionRef reads - // Return the queue-draining loop function - return (mailbox: Queue.Dequeue>, replier: Entity.Replier) => + // Deactivation finalizer — save final snapshot (safe: runs after event loop stops) + yield* Effect.addFinalizer(() => Effect.gen(function* () { - const hasPersistence = persistCtx.adapter !== undefined; - const journalCtx = - hasPersistence && (persistence?.strategy ?? "snapshot") === "journal" - ? { adapter: persistCtx.adapter, key: persistCtx.key } - : undefined; - - // eslint-disable-next-line no-constant-condition - while (true) { - const request = yield* Queue.take(mailbox); - // SAFETY: Envelope.Request is discriminated by its protocol tag at runtime. - const tag = (request as { readonly tag: string }).tag; - - switch (tag) { - case "Send": { - // SAFETY: the Send tag selects the RPC payload carrying machine event E. - const event = (request as { readonly payload: { readonly event: E } }).payload - .event; - // sendWait fails on defect — orDie propagates to toLayerQueue infrastructure - yield* runtime.sendWait(event).pipe(Effect.orDie); - - if (journalCtx !== undefined) { - // Journal append — inline, before replying. Defects entity on failure. - yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, event); - } else if (hasPersistence) { - // Snapshot-only: bump version for consistent snapshot versioning - yield* Ref.update(versionRef, (v) => v + 1); - } - - const state = yield* runtime.getState; - yield* replier.succeed( - request, - // SAFETY: the Send RPC success schema is the machine state schema S. - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RPC success type - state as any, - ); - break; - } - case "Ask": { - // SAFETY: the Ask tag selects the RPC payload carrying machine event E. - const event = (request as { readonly payload: { readonly event: E } }).payload - .event; - const reply = yield* runtime.ask(event).pipe(Effect.orDie); - - if (journalCtx !== undefined) { - yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, event); - } else if (hasPersistence) { - yield* Ref.update(versionRef, (v) => v + 1); - } - - yield* replier.succeed( - request, - // SAFETY: runtime.ask validates replies against the event's registered reply schema. - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RPC success type - reply as any, - ); - break; - } - case "GetState": { - const state = yield* runtime.getState; - yield* replier.succeed( - request, - // SAFETY: the GetState RPC success schema is the machine state schema S. - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RPC success type - state as any, - ); - break; + const state = yield* SubscriptionRef.get(runtime.stateRef); + const version = yield* Ref.get(versionRef); + const now = yield* Clock.currentTimeMillis; + yield* pAdapter.saveSnapshot(key, { + state, + version, + timestamp: now, + } satisfies Snapshot); + }).pipe(Effect.catch(() => Effect.void)), + ); + } + + // Return the queue-draining loop function + return (mailbox: Queue.Dequeue>, replier: Entity.Replier) => + Effect.gen(function* () { + const hasPersistence = persistCtx.adapter !== undefined; + const journalCtx = + hasPersistence && (persistence?.strategy ?? "snapshot") === "journal" + ? { adapter: persistCtx.adapter, key: persistCtx.key } + : undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const request = yield* Queue.take(mailbox); + const tag = request.tag; + + switch (tag) { + case "Send": { + if (!matchesRpc(entity.rpcs[0], request)) break; + const event = request.payload.event; + // sendWait fails on defect — orDie propagates to toLayerQueue infrastructure + yield* runtime.sendWait(event).pipe(Effect.orDie); + + if (journalCtx !== undefined) { + // Journal append — inline, before replying. Defects entity on failure. + yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, event); + } else if (hasPersistence) { + // Snapshot-only: bump version for consistent snapshot versioning + yield* Ref.update(versionRef, (v) => v + 1); } - case "WatchState": { - // Streaming RPC — respond with SubscriptionRef.changes stream - yield* replier.succeed( - request, - // SAFETY: WatchState streams values from the machine state SubscriptionRef. - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- streaming RPC success type - SubscriptionRef.changes(runtime.stateRef) as any, - ); - break; + + const state = yield* runtime.getState; + yield* replier.succeed(request, state); + break; + } + case "Ask": { + if (!matchesRpc(entity.rpcs[1], request)) break; + const event = request.payload.event; + const reply = yield* runtime.ask(event).pipe(Effect.orDie); + + if (journalCtx !== undefined) { + yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, event); + } else if (hasPersistence) { + yield* Ref.update(versionRef, (v) => v + 1); } - default: - break; + + yield* replier.succeed(request, reply); + break; } + case "GetState": { + if (!matchesRpc(entity.rpcs[2], request)) break; + const state = yield* runtime.getState; + yield* replier.succeed(request, state); + break; + } + case "WatchState": { + if (!matchesRpc(entity.rpcs[3], request)) break; + // Streaming RPC — respond with SubscriptionRef.changes stream + yield* replier.succeed(request, SubscriptionRef.changes(runtime.stateRef)); + break; + } + default: + break; } - }); - }); + } + }); + }); - // Collect cluster options to forward - const clusterOptions: ClusterQueueOptions = {}; - if (options?.maxIdleTime !== undefined) clusterOptions.maxIdleTime = options.maxIdleTime; - if (options?.mailboxCapacity !== undefined) - clusterOptions.mailboxCapacity = options.mailboxCapacity; - if (options?.disableFatalDefects !== undefined) - clusterOptions.disableFatalDefects = options.disableFatalDefects; - if (options?.defectRetryPolicy !== undefined) - clusterOptions.defectRetryPolicy = options.defectRetryPolicy; - - // SAFETY: Entity.toLayerQueue hides the machine Effect requirements that build retains as R. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- Layer's R parameter is invariant - return entity.toLayerQueue( - // orDie: persistence failures during activation are defects (entity retry handles them) - build.pipe(Effect.orDie), - Object.keys(clusterOptions).length > 0 ? clusterOptions : undefined, - ) as unknown as Layer.Layer; - }, -}; + // Collect cluster options to forward + const clusterOptions: ClusterQueueOptions = {}; + if (options?.maxIdleTime !== undefined) clusterOptions.maxIdleTime = options.maxIdleTime; + if (options?.mailboxCapacity !== undefined) + clusterOptions.mailboxCapacity = options.mailboxCapacity; + if (options?.disableFatalDefects !== undefined) + clusterOptions.disableFatalDefects = options.disableFatalDefects; + if (options?.defectRetryPolicy !== undefined) + clusterOptions.defectRetryPolicy = options.defectRetryPolicy; + + return entity.toLayerQueue( + // orDie: persistence failures during activation are defects (entity retry handles them) + build.pipe(Effect.orDie), + Object.keys(clusterOptions).length > 0 ? clusterOptions : undefined, + ); +} + +export const EntityMachine = { layer }; // ============================================================================ // Helpers diff --git a/src/cluster/index.ts b/src/cluster/index.ts index 5f899de..6cedffa 100644 --- a/src/cluster/index.ts +++ b/src/cluster/index.ts @@ -33,13 +33,18 @@ * }) * * // Create layer - * const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine) + * const OrderEntityLayer = EntityMachine.layer(OrderEntity) * ``` * * @module */ -export { toEntity, type ToEntityOptions, type EntityRpcs } from "./to-entity.js"; +export { + toEntity, + type ToEntityOptions, + type EntityRpcs, + type MachineEntity, +} from "./to-entity.js"; export { EntityMachine, type EntityMachineOptions } from "./entity-machine.js"; export { type EntityActorRef, makeEntityActorRef } from "./entity-actor-ref.js"; export { diff --git a/src/cluster/to-entity.ts b/src/cluster/to-entity.ts index 515c7dd..6a836a9 100644 --- a/src/cluster/to-entity.ts +++ b/src/cluster/to-entity.ts @@ -13,11 +13,11 @@ import { MissingSchemaError } from "../errors.js"; /** * Options for toEntity. */ -export interface ToEntityOptions { +export interface ToEntityOptions { /** * Entity type name (e.g., "Order", "User") */ - readonly type: string; + readonly type: EntityType; } /** @@ -27,11 +27,46 @@ export interface ToEntityOptions { * - `Ask` - Send event and get domain reply (typed via Event.reply() schemas) * - `GetState` - Get current state */ -export type EntityRpcs = readonly [ - Rpc.Rpc<"Send", Schema.Struct<{ readonly event: EventSchema }>, StateSchema>, - Rpc.Rpc<"Ask", Schema.Struct<{ readonly event: EventSchema }>, typeof Schema.Unknown>, - Rpc.Rpc<"GetState", typeof Schema.Void, StateSchema>, -]; +const makeEntityRpcs = ( + stateSchema: StateSchema, + eventSchema: EventSchema, +) => + [ + Rpc.make("Send", { + payload: { event: eventSchema }, + success: stateSchema, + }), + Rpc.make("Ask", { + payload: { event: eventSchema }, + success: Schema.Unknown, + }), + Rpc.make("GetState", { + success: stateSchema, + }), + Rpc.make("WatchState", { + success: stateSchema, + stream: true, + }), + ] as const; + +/** Canonical Send / Ask / GetState / WatchState protocol for machine entities. */ +export type EntityRpcs = ReturnType< + typeof makeEntityRpcs +>; + +/** Entity definition tied to the exact machine and schemas that created it. */ +export interface MachineEntity< + State extends { readonly _tag: string }, + Event extends { readonly _tag: string }, + R, + EntityType extends string, +> extends Entity.Entity, Schema.Codec>[number]> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema-definition parameters are carried opaquely by MachineEntity + readonly machine: Machine; + readonly stateSchema: Schema.Codec; + readonly eventSchema: Schema.Codec; + readonly rpcs: EntityRpcs, Schema.Codec>; +} /** * Generate an Entity definition from a machine. @@ -68,11 +103,12 @@ export const toEntity = < S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, + const EntityType extends string, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance machine: Machine, - options: ToEntityOptions, -) => { + options: ToEntityOptions, +): MachineEntity => { const stateSchema = machine.stateSchema; const eventSchema = machine.eventSchema; @@ -80,21 +116,13 @@ export const toEntity = < throw new MissingSchemaError({ operation: "toEntity" }); } - return Entity.make(options.type, [ - Rpc.make("Send", { - payload: { event: eventSchema }, - success: stateSchema, - }), - Rpc.make("Ask", { - payload: { event: eventSchema }, - success: Schema.Unknown, - }), - Rpc.make("GetState", { - success: stateSchema, - }), - Rpc.make("WatchState", { - success: stateSchema, - stream: true, - }), - ]); + const stateCodec = Schema.make>(stateSchema.ast); + const eventCodec = Schema.make>(eventSchema.ast); + const rpcs = makeEntityRpcs(stateCodec, eventCodec); + return Object.assign(Entity.make(options.type, rpcs), { + machine, + stateSchema: stateCodec, + eventSchema: eventCodec, + rpcs, + }); }; diff --git a/src/index.ts b/src/index.ts index e222b94..f07f265 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,12 +58,14 @@ export type { Durability, DurabilityCommit, Lifecycle, + LifecycleEvent, } from "./machine.js"; // Actor types and system export type { ActorRef, ActorRefSync, + ActorHandle, ActorSystemService as ActorSystem, ProcessEventResult, SystemEvent, diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index fe8be0e..65db33f 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -151,6 +151,8 @@ export interface RuntimeLifecycleHooks { /** @internal */ export interface RuntimeConfig { readonly actorId: string; + /** Runtime initial state, used for hydration/recovery without cloning the machine. */ + readonly initialState?: S; readonly hooks?: ProcessEventHooks; /** * Cell-owned resources. When provided, the runtime uses the cell's stateRef, @@ -221,14 +223,14 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function config: RuntimeConfig, ) { const { actorId, hooks, lifecycle } = config; + const initialState = config.initialState ?? machine.initial; // Capture services at allocation so start, stop, and deferred settlement retain them. const services = yield* Effect.context(); const fork = Effect.runForkWith(services); // Resources: use cell-provided or allocate fresh - const stateRef = - config.cellResources?.stateRef ?? (yield* SubscriptionRef.make(machine.initial)); + const stateRef = config.cellResources?.stateRef ?? (yield* SubscriptionRef.make(initialState)); const stoppedRef = config.cellResources?.stoppedRef ?? (yield* Ref.make(false)); const eventQueue = config.cellResources?.eventQueue ?? @@ -287,11 +289,10 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function }; // Shared mutable refs used by both start() and stop() - // SAFETY: internal initialization events are consumed only by lifecycle effects and carry the required tag. - const initEvent = { _tag: INTERNAL_INIT_EVENT } as E; - const ctx: MachineContext> = { + const initEvent = { _tag: INTERNAL_INIT_EVENT } as const; + const ctx: MachineContext> = { actorId, - state: machine.initial, + state: initialState, event: initEvent, self, system, @@ -323,7 +324,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function const fiber = yield* bg .handler({ actorId, - state: machine.initial, + state: initialState, event: initEvent, self, slots, @@ -337,7 +338,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function // For unsupervised actors this fails createActor (correct: don't register dead actors). // For supervised actors (Step 3), the supervision loop will catch and restart. if (lifecycle?.onInitialSpawnEffects !== undefined) { - yield* lifecycle.onInitialSpawnEffects(machine.initial); + yield* lifecycle.onInitialSpawnEffects(initialState); } // Note: onSpawnDefect for initial spawn fibers that defect asynchronously (after forking). // If they defect later, this signals through exitDeferred and interrupts the loop. @@ -355,7 +356,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function ); yield* runSpawnEffects( machine, - machine.initial, + initialState, initEvent, self, stateScopeRef.current, @@ -377,12 +378,12 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function ); // Check if initial state is final — if so, clean up and signal done - if (machine.finalStates.has(machine.initial._tag)) { - if (lifecycle?.onFinal !== undefined) yield* lifecycle.onFinal(machine.initial); + if (machine.finalStates.has(initialState._tag)) { + if (lifecycle?.onFinal !== undefined) yield* lifecycle.onFinal(initialState); yield* Ref.set(stoppedRef, true); yield* Scope.close(stateScopeRef.current, Exit.void); yield* Scope.close(actorScope, Exit.void); - yield* setExit(ActorExit.Final(machine.initial)); + yield* setExit(ActorExit.Final(initialState)); yield* Deferred.succeed(startDeferred, undefined); return; } diff --git a/src/internal/transition.ts b/src/internal/transition.ts index d0f43ab..8c9e663 100644 --- a/src/internal/transition.ts +++ b/src/internal/transition.ts @@ -10,7 +10,14 @@ */ import { Cause, Effect, Exit, Scope } from "effect"; -import type { Machine, MachineRef, Transition, SpawnEffect, HandlerContext } from "../machine.js"; +import type { + Machine, + MachineRef, + Transition, + SpawnEffect, + HandlerContext, + LifecycleEvent, +} from "../machine.js"; import type { ActorSystemService } from "../actor.js"; import type { SlotsDef, MachineContext } from "../slot.js"; import { isEffect, isReplyResult, isDeferReplyResult, INTERNAL_ENTER_EVENT } from "./utils.js"; @@ -61,7 +68,7 @@ export const runTransitionHandler = Effect.fn("effect-machine.runTransitionHandl const slots = machine._slots; const handlerCtx: HandlerContext = { state, event, slots }; - const raw = transition.handler(handlerCtx); + const raw = transition.run(handlerCtx); const resolved = isEffect(raw) ? yield* ( @@ -166,7 +173,7 @@ export interface ProcessEventHooks { /** Called after transition completes */ readonly onTransition?: (from: S, to: S, event: E) => Effect.Effect; /** Called when a transition handler or spawn effect fails with a defect */ - readonly onError?: (info: ProcessEventError) => Effect.Effect; + readonly onError?: (info: ProcessEventError) => Effect.Effect; /** Called when a forked spawn fiber defects — signals the runtime to set exitDeferred */ readonly onSpawnDefect?: (cause: Cause.Cause) => Effect.Effect; } @@ -310,8 +317,7 @@ export const processEventCore = Effect.fn("effect-machine.processEventCore")(fun } // Run spawn effects for new state - // SAFETY: internal lifecycle events are consumed only by state effects and carry the required tag. - const enterEvent = { _tag: INTERNAL_ENTER_EVENT } as E; + const enterEvent = { _tag: INTERNAL_ENTER_EVENT } as const; yield* runSpawnEffects( machine, newState, @@ -352,16 +358,22 @@ export const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(funct // eslint-disable-next-line @typescript-eslint/no-explicit-any machine: Machine, state: S, - event: E, + event: E | LifecycleEvent, self: MachineRef, stateScope: Scope.Closeable, system: ActorSystemService, actorId: string, - onError?: (info: ProcessEventError) => Effect.Effect, + onError?: (info: ProcessEventError) => Effect.Effect, onSpawnDefect?: (cause: Cause.Cause) => Effect.Effect, ) { const spawnEffects = findSpawnEffects(machine, state._tag); - const ctx: MachineContext> = { actorId, state, event, self, system }; + const ctx: MachineContext> = { + actorId, + state, + event, + self, + system, + }; const slots = machine._slots; const reportError = onError; const defectSignal = onSpawnDefect; @@ -369,7 +381,7 @@ export const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(funct for (const spawnEffect of spawnEffects) { // Fork the spawn effect into the state scope - interrupted when scope closes const effect = spawnEffect - .handler({ + .run({ actorId, state, event, diff --git a/src/internal/utils.ts b/src/internal/utils.ts index 9f7236e..1b099f2 100644 --- a/src/internal/utils.ts +++ b/src/internal/utils.ts @@ -140,8 +140,8 @@ export const getTag = ( // Fallback: instantiate (Data.taggedEnum compatibility) // Try zero-arg first, then empty object for record constructors try { - // SAFETY: the remaining union member is a tagged constructor callable without payload data. - return (constructorOrValue as () => { _tag: string })()._tag; + // The _tag check leaves only the callable constructor branch. + return constructorOrValue()._tag; } catch { // SAFETY: Data tagged record constructors accept an empty record when zero-argument invocation fails. return (constructorOrValue as (args: Record) => { _tag: string })({})._tag; diff --git a/src/machine.ts b/src/machine.ts index 8fdb8c1..3596999 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -37,7 +37,7 @@ import type { Context, Duration } from "effect"; import { Cause, Effect, Exit, Option, Random, Schema, Scope } from "effect"; -import type { TransitionResult } from "./internal/utils.js"; +import type { DeferReplyResult, ReplyResult, TransitionResult } from "./internal/utils.js"; import { getTag, stubSystem, makeReply, makeDeferReply } from "./internal/utils.js"; import type { TaggedOrConstructor, @@ -117,12 +117,15 @@ export interface HandlerContext> { readonly actorId: string; readonly state: State; - readonly event: Event; + readonly event: Event | LifecycleEvent; readonly self: MachineRef; readonly slots: SlotCalls; readonly system: ActorSystemService; } +/** Events supplied by the runtime while starting lifetime and state-scoped effects. */ +export type LifecycleEvent = { readonly _tag: "$init" } | { readonly _tag: "$enter" }; + /** * Transition handler function. * When Reply is concrete (event has a reply schema), handler must return Machine.reply(). @@ -139,13 +142,20 @@ export type StateEffectHandler = ( ctx: StateHandlerContext, ) => Effect.Effect; +type RegisteredTransitionResult = + | State + | ReplyResult + | DeferReplyResult + | Effect.Effect | DeferReplyResult>; + /** * Transition definition */ -export interface Transition { +export interface Transition { readonly stateTag: string; readonly eventTag: string; - readonly handler: TransitionHandler; + readonly matches: (state: State, event: Event) => boolean; + readonly run: (ctx: HandlerContext) => RegisteredTransitionResult; readonly reenter?: boolean; } @@ -154,14 +164,15 @@ export interface Transition { */ export interface SpawnEffect { readonly stateTag: string; - readonly handler: StateEffectHandler; + readonly matches: (state: State) => boolean; + readonly run: StateEffectHandler; } /** * Background effect - runs for entire machine lifetime */ export interface BackgroundEffect { - readonly handler: StateEffectHandler; + readonly handler: StateEffectHandler; } // ============================================================================ @@ -256,6 +267,14 @@ const emitTaskInspection = (input: { })), ); +const matchesTagged = < + Variant extends { readonly _tag: string }, + Whole extends { readonly _tag: string }, +>( + tagged: TaggedOrConstructor, + value: Whole, +): value is Whole & Variant => value._tag === getTag(tagged); + // ============================================================================ // MakeConfig // ============================================================================ @@ -292,7 +311,12 @@ export interface MakeConfig< * @internal — used by spawn, replay, simulate, test harness, entity-machine */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const materializeMachine = ( +export const materializeMachine = < + S extends { readonly _tag: string }, + E extends { readonly _tag: string }, + R, + SD extends SlotsDef, +>( // eslint-disable-next-line @typescript-eslint/no-explicit-any machine: Machine, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -397,8 +421,8 @@ export const materializeMachine = ( * - `SD`: Slot definitions */ export class Machine< - State, - Event, + State extends { readonly _tag: string }, + Event extends { readonly _tag: string }, R = never, _SD extends Record = Record, _ED extends Record = Record, @@ -431,7 +455,7 @@ export class Machine< */ readonly Context: Context.Service< MachineContextTag, - MachineContext> + MachineContext> > = MachineContextTag; // Public readonly views @@ -621,7 +645,7 @@ export class Machine< scopeTransition< NS extends VariantsUnion<_SD> & BrandedState, NE extends VariantsUnion<_ED> & BrandedEvent, - RS extends VariantsUnion<_SD> & BrandedState, + RS extends State & VariantsUnion<_SD> & BrandedState, >( states: ReadonlyArray>, event: TaggedOrConstructor, @@ -638,7 +662,7 @@ export class Machine< on< NS extends VariantsUnion<_SD> & BrandedState, NE extends VariantsUnion<_ED> & BrandedEvent, - RS extends VariantsUnion<_SD> & BrandedState, + RS extends State & VariantsUnion<_SD> & BrandedState, >( state: TaggedOrConstructor, event: TaggedOrConstructor, @@ -648,7 +672,7 @@ export class Machine< on< NS extends ReadonlyArray & BrandedState>>, NE extends VariantsUnion<_ED> & BrandedEvent, - RS extends VariantsUnion<_SD> & BrandedState, + RS extends State & VariantsUnion<_SD> & BrandedState, >( states: NS, event: TaggedOrConstructor, @@ -680,7 +704,7 @@ export class Machine< reenter< NS extends VariantsUnion<_SD> & BrandedState, NE extends VariantsUnion<_ED> & BrandedEvent, - RS extends VariantsUnion<_SD> & BrandedState, + RS extends State & VariantsUnion<_SD> & BrandedState, >( state: TaggedOrConstructor, event: TaggedOrConstructor, @@ -719,17 +743,22 @@ export class Machine< * Register a wildcard transition that fires from any state when no specific transition matches. * Specific `.on()` transitions always take priority over `.onAny()`. */ - onAny & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>( + onAny< + NE extends VariantsUnion<_ED> & BrandedEvent, + RS extends State & VariantsUnion<_SD> & BrandedState, + >( event: TaggedOrConstructor, - handler: TransitionHandler & BrandedState, NE, RS, SD, never>, + handler: TransitionHandler>, ): Machine { const eventTag = getTag(event); const transition: Transition = { stateTag: "*", eventTag, - // SAFETY: registration preserves the same machine state, event, slot, and requirement domains. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- handler variance is erased in registry storage - handler: handler as unknown as Transition["handler"], + matches: (_state, candidate) => matchesTagged(event, candidate), + run: (ctx) => + matchesTagged(event, ctx.event) + ? handler({ ...ctx, event: ctx.event }) + : Effect.die("Transition invoked for a non-matching event"), reenter: false, }; this._transitions.push(transition); @@ -741,7 +770,7 @@ export class Machine< private addTransition< NS extends BrandedState, NE extends BrandedEvent, - RS extends BrandedState, + RS extends State & BrandedState, Reply, >( state: TaggedOrConstructor, @@ -755,9 +784,12 @@ export class Machine< const transition: Transition = { stateTag, eventTag, - // SAFETY: registration preserves the same machine state, event, slot, and requirement domains. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- handler variance is erased in registry storage - handler: handler as unknown as Transition["handler"], + matches: (candidateState, candidateEvent) => + matchesTagged(state, candidateState) && matchesTagged(event, candidateEvent), + run: (ctx) => + matchesTagged(state, ctx.state) && matchesTagged(event, ctx.event) + ? handler({ ...ctx, state: ctx.state, event: ctx.event }) + : Effect.die("Transition invoked for a non-matching state/event pair"), reenter, }; @@ -800,18 +832,21 @@ export class Machine< ): Machine; // eslint-disable-next-line @typescript-eslint/no-explicit-any spawn(stateOrStates: any, handler: any): Machine { + const next = this.copyWithAdditional(); const states = Array.isArray(stateOrStates) ? stateOrStates : [stateOrStates]; for (const s of states) { const stateTag = getTag(s); - this._spawnEffects.push({ + next._spawnEffects.push({ stateTag, - // SAFETY: the effect is indexed under the exact state tag supplied with this handler. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- selected-state variance is erased in registry storage - handler: handler as unknown as SpawnEffect["handler"], + matches: (state) => matchesTagged(s, state), + run: (ctx) => + matchesTagged(s, ctx.state) + ? handler({ ...ctx, state: ctx.state }) + : Effect.die("Spawn effect invoked for a non-matching state"), }); } - invalidateIndex(this); - return this; + invalidateIndex(next); + return next; } // ---- task ---- @@ -984,12 +1019,9 @@ export class Machine< background( handler: StateEffectHandler, ): Machine { - this._backgroundEffects.push({ - // SAFETY: the handler retains this machine's state, event, and slot domains. - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- requirement variance is erased in registry storage - handler: handler as unknown as BackgroundEffect["handler"], - }); - return this; + const next = this.copyWithAdditional(); + next._backgroundEffects.push({ handler }); + return next; } // ---- postpone ---- @@ -1036,6 +1068,26 @@ export class Machine< return this; } + /** Copy this definition before adding work that can grow Effect requirements. */ + private copyWithAdditional(): Machine { + const next = new Machine( + this.initial, + this.stateSchema, + this.eventSchema, + this._slotsSchema, + this._slotValidation, + ); + next._transitions.push(...this._transitions); + next._spawnEffects.push(...this._spawnEffects); + next._backgroundEffects.push(...this._backgroundEffects); + for (const tag of this._finalStates) next._finalStates.add(tag); + next._postponeRules.push(...this._postponeRules); + for (const [name, slotHandler] of this._slotHandlers) { + next._slotHandlers.set(name, slotHandler); + } + return next; + } + // ---- build ---- // ---- Static factory ---- @@ -1062,8 +1114,8 @@ export class Machine< } class TransitionScope< - State, - Event, + State extends { readonly _tag: string }, + Event extends { readonly _tag: string }, R, _SD extends Record, _ED extends Record, @@ -1075,7 +1127,10 @@ class TransitionScope< private readonly states: ReadonlyArray>, ) {} - on & BrandedEvent, RS extends VariantsUnion<_SD> & BrandedState>( + on< + NE extends VariantsUnion<_ED> & BrandedEvent, + RS extends State & VariantsUnion<_SD> & BrandedState, + >( event: TaggedOrConstructor, handler: TransitionHandler>, ): TransitionScope { @@ -1085,7 +1140,7 @@ class TransitionScope< reenter< NE extends VariantsUnion<_ED> & BrandedEvent, - RS extends VariantsUnion<_SD> & BrandedState, + RS extends State & VariantsUnion<_SD> & BrandedState, >( event: TaggedOrConstructor, handler: TransitionHandler>, @@ -1137,8 +1192,13 @@ import type { Supervision } from "./supervision.js"; * }))); * ``` */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyMachine = Machine; +/* eslint-disable @typescript-eslint/no-explicit-any -- public spawn accepts machines with opaque schema and slot definitions */ +type AnyMachine< + S extends { readonly _tag: string }, + E extends { readonly _tag: string }, + R, +> = Machine; +/* eslint-enable @typescript-eslint/no-explicit-any */ const spawnImpl = Effect.fn("effect-machine.spawn")(function* < S extends { readonly _tag: string }, diff --git a/src/schema.ts b/src/schema.ts index 185def1..b471543 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -278,7 +278,7 @@ type RuntimeConstructor = /* eslint-enable anti-slop/no-unsafe-dictionary-type */ interface BuiltMachineSchema> { - readonly schema: Schema.Schema>; + readonly schema: Schema.Codec, unknown>; readonly variants: VariantSchemas; readonly constructors: Record; readonly _definition: D; @@ -345,8 +345,8 @@ const buildMachineSchema = >( constructors[tag] = constructor; } else { // Empty: plain value, not callable - // SAFETY: empty variants require no payload and expose only the common runtime with contract. - constructors[tag] = { _tag: tag, with: () => ({ _tag: tag }) } as never; + // Empty variants use the tagged-value arm of RuntimeConstructor. + constructors[tag] = { _tag: tag, with: () => ({ _tag: tag }) }; } } @@ -402,10 +402,13 @@ const buildMachineSchema = >( }; } - // SAFETY: the union and variants are assembled exclusively from the same definition D. + // Re-enter the typed Schema API at its AST boundary. Every AST member above was + // assembled from the corresponding entry in definition D. + const schema = Schema.make, unknown>>(unionSchema.ast); + return { - // eslint-disable-next-line anti-slop/no-chained-type-assertions -- Effect's dynamic union loses D's static relationship - schema: unionSchema as unknown as Schema.Schema>, + schema, + // SAFETY: every key was populated from definition D above. // eslint-disable-next-line anti-slop/no-known-value-widening -- keys were populated from definition D above variants: variants as VariantSchemas, constructors, diff --git a/src/slot.ts b/src/slot.ts index b3422f9..496934a 100644 --- a/src/slot.ts +++ b/src/slot.ts @@ -299,7 +299,7 @@ export const define = (definitions: D): SlotsSchema => { } const buildUnion = (schemas: Array>): Schema.Codec => - schemas.length === 0 ? (Schema.Never as any) : (Schema.Union(schemas as any) as any); + schemas.length === 0 ? Schema.Never : (Schema.Union(schemas as any) as any); const requestSchema = buildUnion>(requestSchemas); const resultSchema = buildUnion>(resultSchemas); diff --git a/src/testing.ts b/src/testing.ts index 568bd3d..ea3765b 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -8,7 +8,12 @@ import { executeTransition, shouldPostpone } from "./internal/transition.js"; import { stubSystem } from "./internal/utils.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any -type MachineInput> = +type MachineInput< + S extends { readonly _tag: string }, + E extends { readonly _tag: string }, + R, + SD extends SlotsDef = Record, +> = // eslint-disable-next-line @typescript-eslint/no-explicit-any Machine; diff --git a/test/cluster-type-constraints.test.ts b/test/cluster-type-constraints.test.ts new file mode 100644 index 0000000..9c0ec7c --- /dev/null +++ b/test/cluster-type-constraints.test.ts @@ -0,0 +1,85 @@ +import type { Sharding } from "effect/unstable/cluster"; +import type { RpcClient } from "effect/unstable/rpc"; +import { Context, Effect, type Layer, Schema } from "effect"; + +import { Event, Machine, State } from "../src/index.js"; +import type { NoReplyError } from "../src/errors.js"; +import { + EntityMachine, + type EntityMachineOptions, + makeEntityActorRef, + type PersistenceAdapter, + toEntity, +} from "../src/cluster/index.js"; + +const ClusterState = State({ Active: { count: Schema.Number } }); +const ClusterEvent = Event({ + GetCount: Event.reply({}, Schema.Number), + Increment: {}, +}); + +class ClusterService extends Context.Service< + ClusterService, + { readonly run: Effect.Effect } +>()("@test/ClusterService") {} + +const clusterMachine = Machine.make({ + state: ClusterState, + event: ClusterEvent, + initial: ClusterState.Active({ count: 0 }), +}) + .on(ClusterState.Active, ClusterEvent.GetCount, ({ state }) => Machine.reply(state, state.count)) + .background(() => ClusterService.pipe(Effect.andThen((service) => service.run))); + +const ClusterEntity = toEntity(clusterMachine, { type: "TypeConstraints" }); +const withoutPersistence = EntityMachine.layer(ClusterEntity); +const withPersistence = EntityMachine.layer(ClusterEntity, { + persistence: { strategy: "journal" }, +}); +const optionsVariable: EntityMachineOptions = + {}; +const withConservativeOptions = EntityMachine.layer(ClusterEntity, optionsVariable); + +type Requirements = Value extends Layer.Layer ? R : never; +type EffectError = Value extends Effect.Effect ? E : never; +type Assert = Condition; + +type _LayerRequiresMachineService = Assert< + ClusterService extends Requirements ? true : false +>; +type _LayerRequiresSharding = Assert< + Sharding.Sharding extends Requirements ? true : false +>; +type _LayerDoesNotRequireDisabledPersistence = Assert< + PersistenceAdapter extends Requirements ? false : true +>; +type _PersistentLayerRequiresAdapter = Assert< + PersistenceAdapter extends Requirements ? true : false +>; +type _ConservativeOptionsLayerRequiresAdapter = Assert< + PersistenceAdapter extends Requirements ? true : false +>; + +interface TransportError { + readonly _tag: "TransportError"; +} + +const _entityActorRefPreservesClientErrors = ( + client: RpcClient.RpcClient<(typeof ClusterEntity.rpcs)[number], TransportError>, +) => { + const ref = makeEntityActorRef(ClusterEntity, client, "entity-1"); + const send = ref.send(ClusterEvent.Increment); + const ask = ref.ask(ClusterEvent.GetCount); + type _SendPreservesClientError = Assert< + TransportError extends EffectError ? true : false + >; + type _AskPreservesClientError = Assert< + TransportError extends EffectError ? true : false + >; + type _AskPreservesDomainAndDecodeErrors = Assert< + NoReplyError | Schema.SchemaError extends EffectError ? true : false + >; + return { send, ask }; +}; + +export {}; diff --git a/test/integration/cluster-persistence.test.ts b/test/integration/cluster-persistence.test.ts index 8c64d96..5110025 100644 --- a/test/integration/cluster-persistence.test.ts +++ b/test/integration/cluster-persistence.test.ts @@ -87,7 +87,7 @@ const runPersistenceTest = (opts: { const { layer: adapterLayer } = yield* makeInMemoryPersistenceAdapter; const entity = toEntity(counterMachine, { type: opts.entityType }); - const entityLayer = EntityMachine.layer(entity, counterMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => CounterState.Active({ count: 0 }), persistence: { strategy: opts.strategy }, }); @@ -179,7 +179,7 @@ describe("Entity Persistence", () => { const { layer: adapterLayer } = yield* makeInMemoryPersistenceAdapter; const entity = toEntity(counterMachine, { type: "Fresh" }); - const entityLayer = EntityMachine.layer(entity, counterMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => CounterState.Active({ count: 42 }), persistence: { strategy: "snapshot" }, }); @@ -210,7 +210,7 @@ describe("Entity Persistence", () => { const { storeRef, layer: adapterLayer } = yield* makeInMemoryPersistenceAdapter; const entity = toEntity(counterMachine, { type: "SameTag" }); - const entityLayer = EntityMachine.layer(entity, counterMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => CounterState.Active({ count: 0 }), persistence: { strategy: "journal" }, }); @@ -249,7 +249,7 @@ describe("Entity Persistence", () => { const { storeRef, layer: adapterLayer } = yield* makeInMemoryPersistenceAdapter; const entity = toEntity(counterMachine, { type: "DeactivSnap" }); - const entityLayer = EntityMachine.layer(entity, counterMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => CounterState.Active({ count: 0 }), persistence: { strategy: "snapshot" }, }); @@ -288,7 +288,7 @@ describe("Entity Persistence", () => { const { storeRef, layer: adapterLayer } = yield* makeInMemoryPersistenceAdapter; const entity = toEntity(counterMachine, { type: "VersionTrack" }); - const entityLayer = EntityMachine.layer(entity, counterMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => CounterState.Active({ count: 0 }), persistence: { strategy: "journal" }, }); @@ -326,7 +326,7 @@ describe("Entity Persistence", () => { const { storeRef, layer: adapterLayer } = yield* makeInMemoryPersistenceAdapter; const entity = toEntity(counterMachine, { type: "JournalSnap" }); - const entityLayer = EntityMachine.layer(entity, counterMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => CounterState.Active({ count: 0 }), persistence: { strategy: "journal" }, }); @@ -400,7 +400,7 @@ describe("Entity Persistence", () => { const entity = toEntity(counterMachine, { type: "FailAppend" }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const entityLayer = EntityMachine.layer(entity, counterMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => CounterState.Active({ count: 0 }), persistence: { strategy: "journal" }, // disableFatalDefects prevents the defect from crashing the test @@ -460,7 +460,7 @@ describe("Entity Persistence", () => { const wrappedLayer = Layer.succeed(PersistenceAdapter, wrappedAdapter); const entity = toEntity(counterMachine, { type: "RecoverSnap" }); - const entityLayer = EntityMachine.layer(entity, counterMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => CounterState.Active({ count: 0 }), persistence: { strategy: "journal" }, disableFatalDefects: true, @@ -515,7 +515,7 @@ describe("Entity Persistence", () => { const { storeRef, layer: adapterLayer } = yield* makeInMemoryPersistenceAdapter; const entity = toEntity(counterMachine, { type: "SnapVersion" }); - const entityLayer = EntityMachine.layer(entity, counterMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => CounterState.Active({ count: 0 }), persistence: { strategy: "snapshot" }, }); diff --git a/test/integration/cluster.test.ts b/test/integration/cluster.test.ts index 7851481..3902c46 100644 --- a/test/integration/cluster.test.ts +++ b/test/integration/cluster.test.ts @@ -24,7 +24,7 @@ import { Event, Slot, } from "../../src/index.js"; -import { toEntity, EntityMachine } from "../../src/cluster/index.js"; +import { toEntity, EntityMachine, makeEntityActorRef } from "../../src/cluster/index.js"; // ============================================================================= // Schema-first definitions using MachineSchema @@ -284,28 +284,11 @@ describe("Entity.makeTestClient with machine handler", () => { Send: (envelope) => Effect.gen(function* () { const currentState = yield* Ref.get(stateRef); - const event = envelope.payload.event as unknown as OrderEvent; - - const transitions = Machine.findTransitions( - orderMachine, - currentState._tag, - event._tag, - ); - - const transition = transitions[0]; - if (transition === undefined) { - return currentState; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test helper - const handlerResult = transition.handler({ - state: currentState, - event, - slots: {} as any, + const event = envelope.payload.event; + + const newState = yield* Machine.replay(orderMachine, [event], { + from: currentState, }); - const newState = Effect.isEffect(handlerResult) - ? yield* handlerResult - : handlerResult; yield* Ref.set(stateRef, newState); return newState; }), @@ -382,7 +365,7 @@ describe("EntityMachine.layer", () => { // --------------------------------------------------------------------------- test("basic send changes state via EntityMachine.layer", async () => { const entity = toEntity(orderMachine, { type: "OrderSend" }); - const entityLayer = EntityMachine.layer(entity, orderMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: (entityId) => OrderState.Pending({ orderId: entityId }), }); @@ -426,7 +409,7 @@ describe("EntityMachine.layer", () => { .on(AskState.Active, AskEvent.GetCount, ({ state }) => Machine.reply(state, state.count)); const entity = toEntity(askMachine, { type: "AskReply" }); - const entityLayer = EntityMachine.layer(entity, askMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: () => AskState.Active({ count: 42 }), }); @@ -438,10 +421,31 @@ describe("EntityMachine.layer", () => { ); const client = yield* makeClient("ask-1"); - const reply = yield* client.Ask({ event: AskEvent.GetCount }); + const ref = makeEntityActorRef(entity, client, "ask-1"); + const reply: number = yield* ref.ask(AskEvent.GetCount); expect(reply).toBe(42); }).pipe(Effect.scoped, Effect.provide(TestShardingConfig)) as Effect.Effect, ); + + const invalidReplyLayer = entity.toLayer({ + Send: () => Effect.succeed(AskState.Active({ count: 0 })), + Ask: () => Effect.succeed("not-a-number"), + GetState: () => Effect.succeed(AskState.Active({ count: 0 })), + WatchState: () => Stream.empty, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const makeClient = yield* Entity.makeTestClient(entity, invalidReplyLayer); + const client = yield* makeClient("invalid-reply"); + const ref = makeEntityActorRef(entity, client, "invalid-reply"); + const exit = yield* Effect.result(ref.ask(AskEvent.GetCount)); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(Schema.isSchemaError(exit.failure)).toBe(true); + } + }).pipe(Effect.scoped, Effect.provide(TestShardingConfig)) as Effect.Effect, + ); }); // --------------------------------------------------------------------------- @@ -470,7 +474,7 @@ describe("EntityMachine.layer", () => { .final(BgState.Done); const entity = toEntity(bgMachine, { type: "Background" }); - const entityLayer = EntityMachine.layer(entity, bgMachine, {}); + const entityLayer = EntityMachine.layer(entity, {}); await Effect.runPromise( Effect.gen(function* () { @@ -496,7 +500,7 @@ describe("EntityMachine.layer", () => { // --------------------------------------------------------------------------- test("final state rejects further events", async () => { const entity = toEntity(orderMachine, { type: "OrderFinal" }); - const entityLayer = EntityMachine.layer(entity, orderMachine, { + const entityLayer = EntityMachine.layer(entity, { initializeState: (entityId) => OrderState.Pending({ orderId: entityId }), }); @@ -553,7 +557,7 @@ describe("EntityMachine.layer", () => { .final(SpawnState.Done); const entity = toEntity(spawnMachine, { type: "SpawnEffect" }); - const entityLayer = EntityMachine.layer(entity, spawnMachine, {}); + const entityLayer = EntityMachine.layer(entity, {}); await Effect.runPromise( Effect.gen(function* () { @@ -605,7 +609,7 @@ describe("EntityMachine.layer", () => { .final(TimeoutState.TimedOut); const entity = toEntity(timeoutMachine, { type: "Timeout" }); - const entityLayer = EntityMachine.layer(entity, timeoutMachine, {}); + const entityLayer = EntityMachine.layer(entity, {}); await Effect.runPromise( Effect.gen(function* () { @@ -655,7 +659,7 @@ describe("EntityMachine.layer", () => { .final(PostponeState.Done); const entity = toEntity(postponeMachine, { type: "Postpone" }); - const entityLayer = EntityMachine.layer(entity, postponeMachine, {}); + const entityLayer = EntityMachine.layer(entity, {}); await Effect.runPromise( Effect.gen(function* () { @@ -723,7 +727,7 @@ describe("EntityMachine.layer", () => { .final(TaskState.Failed); const entity = toEntity(taskMachine, { type: "Task" }); - const entityLayer = EntityMachine.layer(entity, taskMachine, {}); + const entityLayer = EntityMachine.layer(entity, {}); await Effect.runPromise( Effect.gen(function* () { @@ -789,7 +793,7 @@ describe("EntityMachine.layer", () => { .final(RaceState.Done); const entity = toEntity(raceMachine, { type: "Race" }); - const entityLayer = EntityMachine.layer(entity, raceMachine, {}); + const entityLayer = EntityMachine.layer(entity, {}); await Effect.runPromise( Effect.gen(function* () { @@ -866,7 +870,7 @@ describe("EntityMachine.layer", () => { const entity = toEntity(spawnChildMachine, { type: "SpawnChild" }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const entityLayer = EntityMachine.layer(entity, spawnChildMachine, {}); + const entityLayer = EntityMachine.layer(entity, {}); await Effect.runPromise( Effect.gen(function* () { @@ -911,7 +915,7 @@ describe("EntityMachine.layer", () => { const entity = toEntity(watchMachine, { type: "Watch" }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const entityLayer = EntityMachine.layer(entity, watchMachine, {}); + const entityLayer = EntityMachine.layer(entity, {}); await Effect.runPromise( Effect.gen(function* () { @@ -923,8 +927,7 @@ describe("EntityMachine.layer", () => { // Collect state changes in background via WatchState streaming RPC const collected: string[] = []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const watchStream = (client as any).WatchState() as Stream.Stream; + const watchStream = client.WatchState(); const collectFiber = yield* Effect.forkScoped( watchStream.pipe( diff --git a/test/machine.test.ts b/test/machine.test.ts index 13ab188..704ab9a 100644 --- a/test/machine.test.ts +++ b/test/machine.test.ts @@ -19,6 +19,30 @@ const CounterEvent = Event({ }); describe("Machine", () => { + test("requirement-growing methods are copy-on-write", () => { + const base = Machine.make({ + state: CounterState, + event: CounterEvent, + initial: CounterState.Idle({ count: 0 }), + }).on(CounterState.Idle, CounterEvent.Start, ({ state }) => + CounterState.Counting({ count: state.count }), + ); + + const withSpawn = base.spawn(CounterState.Counting, () => Effect.void); + const withBackground = base.background(() => Effect.void); + const withTask = base.task(CounterState.Counting, () => Effect.succeed(CounterEvent.Stop), {}); + + expect(withSpawn).not.toBe(base); + expect(withBackground).not.toBe(base); + expect(withTask).not.toBe(base); + expect(base.spawnEffects).toHaveLength(0); + expect(base.backgroundEffects).toHaveLength(0); + expect(withSpawn.spawnEffects).toHaveLength(1); + expect(withBackground.backgroundEffects).toHaveLength(1); + expect(withTask.spawnEffects).toHaveLength(1); + expect(withSpawn.transitions).toHaveLength(1); + }); + test("creates machine with initial state using .pipe() syntax", () => { const machine = Machine.make({ state: CounterState, diff --git a/test/schema.test.ts b/test/schema.test.ts index c94510a..efc5345 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -167,6 +167,22 @@ describe("State (schema-first)", () => { const off = ToggleState.Off; expect(off._tag).toBe("Off"); }); + + test("single-variant schemas retain decoding and narrowing", () => { + const OnlyState = State({ + Ready: { value: Schema.Number }, + }); + + const decoded = Schema.decodeUnknownSync(OnlyState)({ _tag: "Ready", value: 42 }); + if (decoded._tag === "Ready") { + const value: number = decoded.value; + expect(value).toBe(42); + } + }); + + test("rejects an empty definition", () => { + expect(() => State({})).toThrow(); + }); }); describe("State.with()", () => { @@ -207,7 +223,7 @@ describe("State.with()", () => { expect(b._tag).toBe("B"); expect(b.x).toBe(42); - expect((b as unknown as Record)["y"]).toBeUndefined(); + expect(Object.hasOwn(b, "y")).toBe(false); }); test("cross-state: picks + overrides", () => { @@ -272,7 +288,7 @@ describe("State.with()", () => { const b = TS.B.with(a); expect(b.x).toBe(1); - expect((b as unknown as Record)["extra"]).toBeUndefined(); + expect(Object.hasOwn(b, "extra")).toBe(false); }); }); @@ -353,7 +369,7 @@ describe("State.with() (union-level)", () => { expect(updated._tag).toBe("Idle"); expect(updated.queue).toEqual(["a"]); - expect((updated as unknown as Record)["model"]).toBeUndefined(); + expect(Object.hasOwn(updated, "model")).toBe(false); }); test("throws on unknown _tag", () => { @@ -424,6 +440,17 @@ describe("Event (schema-first)", () => { }); expect(result).toBe("Shipping: abc"); }); + + test("preserves reply schema metadata through dynamic schema construction", () => { + const QueryEvent = Event({ + GetCount: Event.reply({}, Schema.Number), + Reset: {}, + }); + + expect(QueryEvent._replySchemas.get("GetCount")).toBe(Schema.Number); + expect(QueryEvent._replySchemas.has("Reset")).toBe(false); + expect(Schema.decodeUnknownSync(QueryEvent)({ _tag: "GetCount" })._tag).toBe("GetCount"); + }); }); describe("State/Event with Machine", () => { diff --git a/test/type-constraints.test.ts b/test/type-constraints.test.ts index 9361eb8..ec06025 100644 --- a/test/type-constraints.test.ts +++ b/test/type-constraints.test.ts @@ -14,6 +14,7 @@ */ import { Effect, Schema, Context } from "effect"; import { Machine, State, Event, Slot } from "../src/index.js"; +import type { ActorHandle } from "../src/index.js"; import type { ProvideSlots } from "../src/slot.js"; const MyState = State({ @@ -86,7 +87,14 @@ const _test5 = Machine.make({ .on(MyState.Idle, MyEvent.Start, () => MyState.Loading({ url: "/" })) .spawn(MyState.Loading, () => MyService.pipe(Effect.asVoid)); -const _test5RequiresService: Effect.Effect = Machine.spawn(_test5); +type EffectRequirements = + Value extends Effect.Effect ? R : never; +type Assert = Condition; + +const _test5Spawn = Machine.spawn(_test5); +type _Test5RequiresService = Assert< + MyService extends EffectRequirements ? true : false +>; // Test 6: task handler can require a service, which also propagates to Machine.spawn const _test6 = Machine.make({ @@ -97,7 +105,32 @@ const _test6 = Machine.make({ onSuccess: () => MyEvent.Complete, }); -const _test6RequiresService: Effect.Effect = Machine.spawn(_test6); +const _test6Spawn = Machine.spawn(_test6); +type _Test6RequiresService = Assert< + MyService extends EffectRequirements ? true : false +>; + +// Test 6b: requirement-growing methods do not mutate the type of an earlier alias +const _test6bBase = Machine.make({ + state: MyState, + event: MyEvent, + initial: MyState.Idle, +}); +const _test6bWithService = _test6bBase.background(() => MyService.pipe(Effect.asVoid)); +const _test6bBaseSpawn = Machine.spawn(_test6bBase); +const _test6bWithServiceSpawn = Machine.spawn(_test6bWithService); +type _Test6bBaseStillServiceFree = Assert< + MyService extends EffectRequirements ? false : true +>; +type _Test6bRequiresService = Assert< + MyService extends EffectRequirements ? true : false +>; + +// Heterogeneous lookup is intentionally eventless; exact spawn results remain ActorRef. +const _actorHandleCannotSend = (handle: ActorHandle) => { + // @ts-expect-error - ActorHandle has no event type witness + handle.send(MyEvent.Start); +}; // ============================================================================ // Reply Schema Type Constraints @@ -123,6 +156,23 @@ const _test7 = Machine.make({ Machine.reply(ReplyState.Active({ count: state.count }), state.count), ); +// Test 7b: onAny supports reply-bearing events with the same contract as on +const _test7b = Machine.make({ + state: ReplyState, + event: ReplyEvent, + initial: ReplyState.Active({ count: 0 }), +}).onAny(ReplyEvent.GetCount, ({ state }) => + Machine.reply(state, state._tag === "Active" ? state.count : 0), +); + +// Test 7c: onAny requires Machine.reply() for reply-bearing events +const _test7c = Machine.make({ + state: ReplyState, + event: ReplyEvent, + initial: ReplyState.Active({ count: 0 }), + // @ts-expect-error - reply-bearing onAny handler requires Machine.reply() +}).onAny(ReplyEvent.GetCount, ({ state }) => state); + // Test 8: Handler for reply-bearing event CANNOT return plain state const _test8 = Machine.make({ state: ReplyState, From bb1e37719535046c4b605436cd0fc89c6143889f Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 31 Aug 2026 10:51:54 -0700 Subject: [PATCH 3/4] refactor: remove slots and harden type boundaries HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04eba-6fce-73bb-ae5f-a529a8fa351f --- .changeset/remove-slots-and-harden-types.md | 26 + .oxlintrc.json | 13 +- README.md | 29 +- src/actor.ts | 215 +++---- src/cluster/entity-machine.ts | 78 ++- src/cluster/persistence.ts | 4 +- src/cluster/to-entity.ts | 4 +- src/errors.ts | 25 - src/index.ts | 28 +- src/internal/brands.ts | 3 +- src/internal/runtime.ts | 148 ++--- src/internal/transition.ts | 107 +--- src/internal/utils.ts | 25 +- src/machine.ts | 580 +++++-------------- src/schema.ts | 314 +++++----- src/slot.ts | 376 ------------ src/testing.ts | 140 +---- test/actor.test.ts | 126 +--- test/ask.test.ts | 96 ++- test/child-actor.test.ts | 34 +- test/conditional-transitions.test.ts | 195 ++----- test/integration/cluster-persistence.test.ts | 73 +++ test/integration/cluster.test.ts | 79 +-- test/internal/runtime.test.ts | 59 ++ test/internal/transition.test.ts | 27 +- test/machine.test.ts | 107 +--- test/patterns/menu-navigation.test.ts | 149 ++--- test/patterns/payment-flow.test.ts | 57 +- test/patterns/session-lifecycle.test.ts | 34 +- test/reenter.test.ts | 23 +- test/slot.test.ts | 466 --------------- test/spawn-slots.test.ts | 233 -------- test/timeouts.task.test.ts | 67 +-- test/type-constraints.test.ts | 92 +-- tsconfig.json | 8 - 35 files changed, 1075 insertions(+), 2965 deletions(-) create mode 100644 .changeset/remove-slots-and-harden-types.md delete mode 100644 src/slot.ts create mode 100644 test/internal/runtime.test.ts delete mode 100644 test/slot.test.ts delete mode 100644 test/spawn-slots.test.ts diff --git a/.changeset/remove-slots-and-harden-types.md b/.changeset/remove-slots-and-harden-types.md new file mode 100644 index 0000000..b4f0f2b --- /dev/null +++ b/.changeset/remove-slots-and-harden-types.md @@ -0,0 +1,26 @@ +--- +"@humanlayer/effect-machine": minor +--- + +Remove the deprecated Slot API in favor of Effect services supplied through Layers to `.task()`, `.spawn()`, and `.background()`. + +This release also strengthens the package's type and runtime boundaries: + +- Requirement-growing builder methods are copy-on-write, keeping earlier machine aliases unchanged and truthfully typed. +- Heterogeneous actor registries expose an eventless `ActorHandle`; exact typed spawn results remain `ActorRef`. +- Transition and state-effect registries retain their state/event correlations without chained assertions. +- State-effect contexts expose explicit `$init` and `$enter` lifecycle events. +- Entity machines own their RPC protocol; remote Ask replies are decoded with event-specific schemas and client errors remain typed. +- Persistence writes encode state and events through machine codecs, while loaded records remain `unknown` until full schema decoding. +- Local and entity Ask paths support transforming reply codecs without duplicate decoding or stranded deferred replies. +- Repeated and concurrent `actor.start` callers observe the original startup failure cause. +- Source enforces unsafe, chained, widening, and unnecessary type-assertion rules. Tests remain exempt from unsafe and unnecessary assertion checks. + +Intentional API changes: + +- Remove `Slot`, slot schemas/types/errors, `Machine.make({ slots })`, `ctx.slots`, and slot provision options. +- `toEntity(machine)` returns a machine-owned `MachineEntity`; call `EntityMachine.layer(entity, options?)`. +- Call `makeEntityActorRef(entity, client, entityId)` so the wrapper can decode replies and preserve client errors. +- `system.get`, `system.actors`, system events, and `actor.children` expose `ActorHandle`. +- Transition and spawn-effect introspection expose guarded `matches` / `run` operations instead of erased handlers. +- Legacy compatible constructors without a static tag can use `Machine.tagged(tag, constructor)`. diff --git a/.oxlintrc.json b/.oxlintrc.json index f8de3ad..7636b71 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -46,7 +46,7 @@ "import/no-duplicates": "error", "no-underscore-dangle": "off", "no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], - "typescript/no-unsafe-type-assertion": "off", + "typescript/no-unsafe-type-assertion": "error", "typescript/no-unnecessary-type-parameters": "off", "typescript/no-unnecessary-type-assertion": "error", "typescript/consistent-return": "off", @@ -73,6 +73,7 @@ "anti-slop/require-safety-comment-for-type-assertion": "off", "typescript/no-non-null-assertion": "off", "typescript/no-unnecessary-type-assertion": "off", + "typescript/no-unsafe-type-assertion": "off", "typescript/no-explicit-any": "off" } }, @@ -81,16 +82,6 @@ "rules": { "anti-slop-effect/no-service-constructor-imports": "off" } - }, - { - "files": ["src/slot.ts"], - "rules": { - "anti-slop/no-known-value-widening": "off", - "anti-slop/no-unknown-parameters": "off", - "anti-slop/no-unknown-returns": "off", - "anti-slop/no-unsafe-dictionary-type": "off", - "anti-slop/require-safety-comment-for-type-assertion": "off" - } } ] } diff --git a/README.md b/README.md index 76725dd..b469745 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,15 @@ A few things to notice: - `.onAny(...)` is a fallback; a specific `.on(...)` wins. - `.task(...)` runs work on state entry, sends mapped completion events, and cancels work on state exit. +Generated state and event constructors carry a static `_tag`, so `State.Active(...)` and +`Event.Start(...)` remain directly usable in builder methods. If an older constructor returns a +compatible tagged value but does not expose a static tag, adapt it explicitly instead of invoking +it during registration: + +```ts +const LegacyActive = Machine.tagged("Active", legacyActiveConstructor); +``` + ## Transitions And Effects The fluent builder keeps state behavior beside the transitions that make it relevant: @@ -117,7 +126,7 @@ State-effect contexts expose an honest lifecycle event union: initial effects re ## Services And Layers -New machines use Effect's service system for dependencies, not actor-local slot maps. Define a dependency with `Context.Service` (the Effect v4 replacement for `ServiceMap.Service`), access it with `yield*` inside a state effect, and provide an implementation with a `Layer` at the program boundary. +Machines use Effect's service system for dependencies. Define a dependency with `Context.Service` (the Effect v4 replacement for `ServiceMap.Service`), access it with `yield*` inside a state effect, and provide an implementation with a `Layer` at the program boundary. Requirements from `.task()`, `.spawn()`, and `.background()` are inferred by the machine and flow through `Machine.spawn`, `system.spawn`, and `EntityMachine.layer`. Transition handlers remain pure: they cannot require services or fail. Move I/O into a state effect and communicate its outcome with an event. @@ -136,19 +145,6 @@ const program = Effect.gen(function* () { This also makes testing conventional Effect code: provide a test layer around the actor program. `simulate` and `createTestHarness` do not run state effects, so they do not require their services. -### Migrating From Slots - -`Slot`, `Machine.make({ slots })`, handler `({ slots })`, and `{ slots }` spawn options remain as deprecated compatibility APIs. Use them only while migrating an existing machine; they are not the DI mechanism for new code. - -| Legacy slot pattern | Effect service replacement | -| --------------------------------------- | ------------------------------------------------------------------------ | -| `Slot.define({ charge: Slot.fn(...) })` | `class Payments extends Context.Service<...>()("@app/Payments") {}` | -| `Machine.make({ ..., slots })` | Read the service in `.task(...)`, `.spawn(...)`, or `.background(...)` | -| `Machine.spawn(machine, { slots })` | `Machine.spawn(machine).pipe(Effect.provide(PaymentsLive))` | -| `system.spawn(id, machine, { slots })` | Provide `PaymentsLive` around the program that calls `system.spawn(...)` | - -Legacy slot handlers must still be supplied explicitly at every execution boundary that uses them, such as `Machine.spawn`, `system.spawn`, `simulate`, `createTestHarness`, and `Machine.replay`. Their dependencies are not inferred through the machine type, so migrate them to Effect services when possible. - ## Request And Reply Declare a reply schema on an event to make it valid for `actor.ask(...)`. Its transition returns `Machine.reply(nextState, value)`, so the reply type is inferred from the schema. @@ -331,6 +327,11 @@ Persistence is opt-in and resolves `PersistenceAdapter` from the entity layer's - **Snapshot** is the default. It saves on each state change unless `snapshotSchedule` controls the cadence, then restores on reactivation. - **Journal** appends every `Send` and `Ask` event inline, replays events after the latest snapshot, and saves a snapshot when the entity deactivates. +Adapter writes encode runtime state and events through the machine codecs, and load methods return +unknown stored records. Entity activation decodes the complete snapshot or journal +record—including payload, version, and timestamp—exactly once before hydration or replay; +malformed storage data defects activation rather than entering the machine. + Entity options also include `maxIdleTime`, `mailboxCapacity`, `defectRetryPolicy`, and `disableFatalDefects`, which are forwarded to `@effect/cluster`. ## License diff --git a/src/actor.ts b/src/actor.ts index 959b595..4e67a3b 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -19,6 +19,7 @@ import { Queue, Ref, Schedule, + Schema, Scope, Semaphore, Context, @@ -27,17 +28,14 @@ import { } from "effect"; import type { Machine, Lifecycle, LifecycleEvent } from "./machine.js"; -import { materializeMachine } from "./machine.js"; import { ActorExit, type Supervision } from "./supervision.js"; import type { ReplyTypeBrand, ExtractReply } from "./internal/brands.js"; -import type { SlotsDef, ProvideSlots } from "./slot.js"; import type { InspectorService } from "./inspection.js"; import { Inspector as InspectorTag } from "./inspection.js"; import { resolveTransition } from "./internal/transition.js"; import type { ProcessEventHooks, ProcessEventResult } from "./internal/transition.js"; import { emitWithTimestamp } from "./internal/inspection.js"; -import type { NoReplyError } from "./errors.js"; -import { DuplicateActorError, ActorStoppedError } from "./errors.js"; +import { DuplicateActorError, ActorStoppedError, NoReplyError } from "./errors.js"; import { createRuntime, type RuntimeLifecycleHooks, @@ -57,8 +55,8 @@ export type { // QueuedEvent — re-export from runtime kernel // ============================================================================ -/** Discriminated mailbox request — alias for RuntimeQueuedEvent */ -export type QueuedEvent = RuntimeQueuedEvent; +/** Discriminated mailbox request used by a local actor cell. */ +export type QueuedEvent = RuntimeQueuedEvent; // ============================================================================ // ActorRef Interface @@ -231,9 +229,6 @@ interface MutableCell { current: T; } -// eslint-disable-next-line typescript/no-explicit-any, anti-slop/no-unsafe-dictionary-type -- deprecated slots erase handlers here -type LegacySlotHandlers = Record; - interface PendingReply { readonly failStopped: (error: ActorStoppedError) => Effect.Effect; } @@ -292,19 +287,12 @@ export interface ActorSystemService { * const actor = yield* system.spawn("my-actor", machine); * ``` */ - readonly spawn: < - S extends { readonly _tag: string }, - E extends { readonly _tag: string }, - R, - SD extends SlotsDef = Record, - >( + readonly spawn: ( id: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, options?: { readonly supervision?: Supervision.Policy; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly slots?: ProvideSlots; readonly lifecycle?: Lifecycle; }, ) => Effect.Effect, DuplicateActorError, R>; @@ -385,13 +373,12 @@ export const buildActorRefCore = < S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef, >( id: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance - machine: Machine, + machine: Machine, stateRef: SubscriptionRef.SubscriptionRef, - eventQueueRef: Ref.Ref>>, + eventQueueRef: Ref.Ref>>, stoppedRef: Ref.Ref, listeners: Listeners, stop: Effect.Effect, @@ -430,22 +417,14 @@ export const buildActorRefCore = < isFinal: machine.finalStates.has(currentState._tag), } satisfies ProcessEventResult; } - const reply = yield* Deferred.make< - ProcessEventResult<{ readonly _tag: string }>, - ActorStoppedError - >(); + const reply = yield* Deferred.make, ActorStoppedError>(); const pending = pendingReply(reply); - // SAFETY: the runtime queue reports ActorStoppedError through its wider unknown error channel. - const queuedReply = reply as Deferred.Deferred< - ProcessEventResult<{ readonly _tag: string }>, - unknown - >; pendingReplies.add(pending); const q = yield* Ref.get(eventQueueRef); yield* Queue.offer(q, { _tag: "call", event, - reply: queuedReply, + reply, }); const result = yield* Deferred.await(reply).pipe( Effect.ensuring(Effect.sync(() => pendingReplies.delete(pending))), @@ -468,30 +447,32 @@ export const buildActorRefCore = < ), ), ); - // SAFETY: this ActorRef and its queue share the same state type S. - return result as ProcessEventResult; + return result; }); - const ask = Effect.fn("effect-machine.actor.ask")(function* (event: E) { - const stopped = yield* Ref.get(stoppedRef); - if (stopped) { - return yield* new ActorStoppedError({ actorId: id }); - } - const reply = yield* Deferred.make(); - const pending = pendingReply(reply); - // SAFETY: queue processing emits NoReplyError; ActorStoppedError is managed by pending reply cleanup. - const queuedReply = reply as Deferred.Deferred; - pendingReplies.add(pending); - const q = yield* Ref.get(eventQueueRef); - yield* Queue.offer(q, { - _tag: "ask", - event, - reply: queuedReply, - }); - return yield* Deferred.await(reply).pipe( - Effect.ensuring(Effect.sync(() => pendingReplies.delete(pending))), - ); - }); + const ask = >(event: ReplyEvent) => + Effect.gen(function* () { + const registeredSchema = machine.replySchemas.get(event._tag); + if (registeredSchema === undefined) { + return yield* new NoReplyError({ actorId: id, eventTag: event._tag }); + } + + const stopped = yield* Ref.get(stoppedRef); + if (stopped) { + return yield* new ActorStoppedError({ actorId: id }); + } + + const reply = yield* Deferred.make(); + const pending = pendingReply(reply); + pendingReplies.add(pending); + const q = yield* Ref.get(eventQueueRef); + yield* Queue.offer(q, { _tag: "ask", event, reply }); + const input: unknown = yield* Deferred.await(reply).pipe( + Effect.ensuring(Effect.sync(() => pendingReplies.delete(pending))), + ); + const decoder = Schema.make>>(registeredSchema.ast); + return yield* Schema.decodeUnknownEffect(decoder)(input).pipe(Effect.orDie); + }).pipe(Effect.withSpan("effect-machine.actor.ask")); const snapshot = SubscriptionRef.get(stateRef).pipe( Effect.withSpan("effect-machine.actor.snapshot"), @@ -565,8 +546,7 @@ export const buildActorRefCore = < send, cast: send, call, - // SAFETY: the public ask signature narrows events using the reply brand carried by E. - ask: ask as ActorRef["ask"], + ask, state: stateRef, stop, start, @@ -685,17 +665,17 @@ const runSupervisionLoop = < >(params: { supervision: Supervision.Policy; // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine; + machine: Machine; id: string; - runtimeRef: { current: RuntimeHandle | undefined }; + runtimeRef: { current: RuntimeHandle | undefined }; terminalExitDeferred: Deferred.Deferred>; pendingReplies: Set; - eventQueueRef: Ref.Ref>>; + eventQueueRef: Ref.Ref>>; stateRef: SubscriptionRef.SubscriptionRef; stoppedRef: Ref.Ref; childrenMap: Map; listeners: Listeners; - spawnGeneration: (initialState: S) => Effect.Effect>; + spawnGeneration: (initialState: S) => Effect.Effect>; lifecycle?: Lifecycle; generationRef: { get: () => number; set: (g: number) => void }; onRestart?: (generation: number, exit: ActorExit) => Effect.Effect; @@ -703,7 +683,6 @@ const runSupervisionLoop = < Effect.gen(function* () { const step = yield* Schedule.toStepWithSleep(params.supervision.schedule); - // eslint-disable-next-line no-constant-condition while (true) { const currentRuntime = params.runtimeRef.current; if (currentRuntime === undefined) return; @@ -750,7 +729,7 @@ const runSupervisionLoop = < } yield* settlePendingReplies(params.pendingReplies, params.id); - const freshQueue = yield* Queue.unbounded>(); + const freshQueue = yield* Queue.unbounded>(); yield* Ref.set(params.eventQueueRef, freshQueue); yield* SubscriptionRef.set(params.stateRef, restartState); yield* Ref.set(params.stoppedRef, false); @@ -776,11 +755,10 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef, >( id: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, options?: { initialState?: S; supervision?: Supervision.Policy; @@ -818,7 +796,7 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < // Cell-owned resources: stable across generations (supervision) const stateRef = yield* SubscriptionRef.make(initial); const stoppedRef = yield* Ref.make(false); - const initialQueue = yield* Queue.unbounded>(); + const initialQueue = yield* Queue.unbounded>(); const eventQueueRef = yield* Ref.make(initialQueue); // Terminal exit deferred — set exactly once when the actor truly terminates. @@ -832,7 +810,9 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < let generation = 0; // Mutable ref for the current runtime — supervision loop updates this - const runtimeRef: MutableCell | undefined> = { current: undefined }; + const runtimeRef: MutableCell | undefined> = { + current: undefined, + }; // Mutable ref for supervisor fiber — set during start, used by stop const supervisorFiberRef: MutableCell | undefined> = { @@ -933,53 +913,50 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* < /** Create a single runtime generation with an explicit hydrated/recovered state. */ const spawnGeneration = (generationInitial: S) => Ref.get(eventQueueRef).pipe( - Effect.flatMap( - (currentQueue) => - // SAFETY: createRuntime is instantiated from this actor's exact machine, state, and event types. - createRuntime(machine, system, { - actorId: id, - initialState: generationInitial, - hooks, - skipFinalizer: true, - cellResources: { stateRef, stoppedRef, eventQueue: currentQueue }, - lifecycle: buildRuntimeLifecycle(), - wrapProcess: (state, event, inner) => - Effect.withSpan("effect-machine.event.process", { - attributes: { - "effect_machine.actor.id": id, - "effect_machine.state.current": state._tag, - "effect_machine.event.type": event._tag, - }, - })( - inner.pipe( - Effect.tap((r) => - Effect.annotateCurrentSpan( - "effect_machine.transition.matched", - r.result.transitioned, - ), + Effect.flatMap((currentQueue) => + createRuntime(machine, system, { + actorId: id, + initialState: generationInitial, + hooks, + cellResources: { stateRef, stoppedRef, eventQueue: currentQueue }, + lifecycle: buildRuntimeLifecycle(), + wrapProcess: (state, event, inner) => + Effect.withSpan("effect-machine.event.process", { + attributes: { + "effect_machine.actor.id": id, + "effect_machine.state.current": state._tag, + "effect_machine.event.type": event._tag, + }, + })( + inner.pipe( + Effect.tap((r) => + Effect.annotateCurrentSpan( + "effect_machine.transition.matched", + r.result.transitioned, ), ), ), - onChildSpawned: ( - childId: string, - child: ActorRef, - ) => - Effect.gen(function* () { - childrenMap.set(childId, child); - // Use Scope.Scope here intentionally — this is the spawn handler's - // state-scoped scope, not an ambient scope. When the state exits, - // this scope closes and the child is removed from the map. - const maybeScope = yield* Effect.serviceOption(Scope.Scope); - if (Option.isSome(maybeScope)) { - yield* Scope.addFinalizer( - maybeScope.value, - Effect.sync(() => { - childrenMap.delete(childId); - }), - ); - } - }), - }) as Effect.Effect>, + ), + onChildSpawned: ( + childId: string, + child: ActorRef, + ) => + Effect.gen(function* () { + childrenMap.set(childId, child); + // Use Scope.Scope here intentionally — this is the spawn handler's + // state-scoped scope, not an ambient scope. When the state exits, + // this scope closes and the child is removed from the map. + const maybeScope = yield* Effect.serviceOption(Scope.Scope); + if (Option.isSome(maybeScope)) { + yield* Scope.addFinalizer( + maybeScope.value, + Effect.sync(() => { + childrenMap.delete(childId); + }), + ); + } + }), + }).pipe(Effect.setContext(serviceContext)), ), ); @@ -1213,23 +1190,18 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { >( id: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, spawnOptions?: { readonly supervision?: Supervision.Policy; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly slots?: LegacySlotHandlers; readonly lifecycle?: Lifecycle; }, ) { if (MutableHashMap.has(actorsMap, id)) { return yield* new DuplicateActorError({ actorId: id }); } - // Materialize slots if provided - const materialized = - spawnOptions?.slots !== undefined ? materializeMachine(machine, spawnOptions.slots) : machine; // Mutable ref for the actor �� onRestart closure needs it, but actor isn't registered yet let actorRef: ActorHandle | undefined; - const actor = yield* createActor(id, materialized, { + const actor = yield* createActor(id, machine, { supervision: spawnOptions?.supervision, lifecycle: spawnOptions?.lifecycle, onRestart: @@ -1256,19 +1228,12 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () { return actor; }); - const spawn = < - S extends { readonly _tag: string }, - E extends { readonly _tag: string }, - R, - SD extends SlotsDef = Record, - >( + const spawn = ( id: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, options?: { readonly supervision?: Supervision.Policy; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly slots?: ProvideSlots; readonly lifecycle?: Lifecycle; }, ): Effect.Effect, DuplicateActorError, R> => diff --git a/src/cluster/entity-machine.ts b/src/cluster/entity-machine.ts index ce9dac2..d59cce6 100644 --- a/src/cluster/entity-machine.ts +++ b/src/cluster/entity-machine.ts @@ -22,7 +22,7 @@ import { Option, Queue, Ref, - type Schema, + Schema, type Schedule, Stream, SubscriptionRef, @@ -207,7 +207,7 @@ function layer< const computedInitial = initialState ?? machine.initial; const stateRef = yield* SubscriptionRef.make(computedInitial); const stoppedRef = yield* Ref.make(false); - const eventQueue = yield* Queue.unbounded>(); + const eventQueue = yield* Queue.unbounded>(); // Create runtime kernel — single queue, sequential processing const runtime = yield* createRuntime(machine, system, { @@ -217,6 +217,7 @@ function layer< childIdPrefix: `${entityId}/`, cellResources: { stateRef, stoppedRef, eventQueue }, }); + yield* Effect.addFinalizer(() => runtime.stop); yield* runtime.start; // ---------------------------------------------------------------- @@ -235,11 +236,14 @@ function layer< Effect.gen(function* () { const version = yield* Ref.get(versionRef); const now = yield* Clock.currentTimeMillis; + const encodedState = yield* Schema.encodeEffect(entity.stateSchema)(state).pipe( + Effect.orDie, + ); yield* pAdapter.saveSnapshot(key, { - state, + state: encodedState, version, timestamp: now, - } satisfies Snapshot); + }); }).pipe(Effect.catch(() => Effect.void)), ), Effect.forkScoped, @@ -254,11 +258,14 @@ function layer< const state = yield* SubscriptionRef.get(runtime.stateRef); const version = yield* Ref.get(versionRef); const now = yield* Clock.currentTimeMillis; + const encodedState = yield* Schema.encodeEffect(entity.stateSchema)(state).pipe( + Effect.orDie, + ); yield* pAdapter.saveSnapshot(key, { - state, + state: encodedState, version, timestamp: now, - } satisfies Snapshot); + }); }).pipe(Effect.catch(() => Effect.void)), ); } @@ -272,7 +279,6 @@ function layer< ? { adapter: persistCtx.adapter, key: persistCtx.key } : undefined; - // eslint-disable-next-line no-constant-condition while (true) { const request = yield* Queue.take(mailbox); const tag = request.tag; @@ -286,7 +292,13 @@ function layer< if (journalCtx !== undefined) { // Journal append — inline, before replying. Defects entity on failure. - yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, event); + yield* persistEvent( + journalCtx.adapter, + journalCtx.key, + versionRef, + entity.eventSchema, + event, + ); } else if (hasPersistence) { // Snapshot-only: bump version for consistent snapshot versioning yield* Ref.update(versionRef, (v) => v + 1); @@ -302,7 +314,13 @@ function layer< const reply = yield* runtime.ask(event).pipe(Effect.orDie); if (journalCtx !== undefined) { - yield* persistEvent(journalCtx.adapter, journalCtx.key, versionRef, event); + yield* persistEvent( + journalCtx.adapter, + journalCtx.key, + versionRef, + entity.eventSchema, + event, + ); } else if (hasPersistence) { yield* Ref.update(versionRef, (v) => v + 1); } @@ -383,10 +401,14 @@ const hydratePersistence = < E extends { readonly _tag: string }, >( persistence: EntityPersistenceConfig | undefined, - entityDef: { readonly type: string }, + entityDef: { + readonly type: string; + readonly stateSchema: Schema.Codec; + readonly eventSchema: Schema.Codec; + }, entityId: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Machine type params need wide acceptance - machine: Machine, + machine: Machine, initializeState?: (entityId: string) => S, ) => Effect.gen(function* () { @@ -396,11 +418,23 @@ const hydratePersistence = < const entityType = persistence.machineType ?? entityDef.type; const key: PersistenceKey = { entityType, entityId }; - // Load snapshot - // SAFETY: this persistence key belongs to a machine whose state schema is S. - const maybeSnapshot = yield* adapter.loadSnapshot(key) as Effect.Effect< - Option.Option> - >; + const snapshotSchema = Schema.Struct({ + state: entityDef.stateSchema, + version: Schema.Number, + timestamp: Schema.Number, + }); + const persistedEventSchema = Schema.Struct({ + event: entityDef.eventSchema, + version: Schema.Number, + timestamp: Schema.Number, + }); + + const storedSnapshot = yield* adapter.loadSnapshot(key); + const maybeSnapshot = yield* Option.match(storedSnapshot, { + onNone: () => Effect.succeed(Option.none>()), + onSome: (input) => + Schema.decodeUnknownEffect(snapshotSchema)(input).pipe(Effect.map(Option.some)), + }); const strategy = persistence.strategy ?? "snapshot"; @@ -412,10 +446,10 @@ const hydratePersistence = < : machine.initial; const snapshotVersion = Option.isSome(maybeSnapshot) ? maybeSnapshot.value.version : 0; - // SAFETY: journal entries for this persistence key were written from events of E. - const events = (yield* adapter.loadEvents(key, snapshotVersion)) as ReadonlyArray< - PersistedEvent - >; + const storedEvents = yield* adapter.loadEvents(key, snapshotVersion); + const events = yield* Effect.forEach(storedEvents, (input) => + Schema.decodeUnknownEffect(persistedEventSchema)(input), + ); if (events.length > 0) { const eventValues = events.map((e: PersistedEvent) => e.event); @@ -459,14 +493,16 @@ const persistEvent = ( adapter: PersistenceAdapterService, key: PersistenceKey, versionRef: Ref.Ref, + eventSchema: Schema.Codec, event: E, ): Effect.Effect => Effect.gen(function* () { const expectedVersion = yield* Ref.get(versionRef); const newVersion = expectedVersion + 1; const now = yield* Clock.currentTimeMillis; + const encodedEvent = yield* Schema.encodeEffect(eventSchema)(event).pipe(Effect.orDie); const persisted: PersistedEvent = { - event, + event: encodedEvent, version: newVersion, timestamp: now, }; diff --git a/src/cluster/persistence.ts b/src/cluster/persistence.ts index d6042ed..52452d9 100644 --- a/src/cluster/persistence.ts +++ b/src/cluster/persistence.ts @@ -74,7 +74,7 @@ export interface PersistenceAdapterService { /** Load the latest snapshot, or None if no snapshot exists. */ readonly loadSnapshot: ( key: PersistenceKey, - ) => Effect.Effect>, PersistenceError>; + ) => Effect.Effect, PersistenceError>; /** Append events to the journal. Fails with VersionConflictError if expectedVersion doesn't match. */ readonly appendEvents: ( @@ -87,7 +87,7 @@ export interface PersistenceAdapterService { readonly loadEvents: ( key: PersistenceKey, afterVersion?: number, - ) => Effect.Effect>, PersistenceError>; + ) => Effect.Effect, PersistenceError>; } // ============================================================================ diff --git a/src/cluster/to-entity.ts b/src/cluster/to-entity.ts index 6a836a9..8677f8c 100644 --- a/src/cluster/to-entity.ts +++ b/src/cluster/to-entity.ts @@ -62,7 +62,7 @@ export interface MachineEntity< EntityType extends string, > extends Entity.Entity, Schema.Codec>[number]> { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema-definition parameters are carried opaquely by MachineEntity - readonly machine: Machine; + readonly machine: Machine; readonly stateSchema: Schema.Codec; readonly eventSchema: Schema.Codec; readonly rpcs: EntityRpcs, Schema.Codec>; @@ -106,7 +106,7 @@ export const toEntity = < const EntityType extends string, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance - machine: Machine, + machine: Machine, options: ToEntityOptions, ): MachineEntity => { const stateSchema = machine.stateSchema; diff --git a/src/errors.ts b/src/errors.ts index 981e777..d7a683f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -34,24 +34,6 @@ export class MissingMatchHandlerError extends Schema.TaggedError()( - "SlotProvisionError", - { - slotName: Schema.String, - slotType: Schema.Literal("slot"), - }, -) {} - -/** Slot provision validation failed — missing or extra handlers */ -export class ProvisionValidationError extends Schema.TaggedError()( - "ProvisionValidationError", - { - missing: Schema.Array(Schema.String), - extra: Schema.Array(Schema.String), - }, -) {} - /** Assertion failed in testing utilities */ export class AssertionError extends Schema.TaggedError()("AssertionError", { message: Schema.String, @@ -74,13 +56,6 @@ export class PersistenceError extends Schema.TaggedError()("Pe message: Schema.String, }) {} -/** Slot input/output schema validation failed */ -export class SlotCodecError extends Schema.TaggedError()("SlotCodecError", { - slotName: Schema.String, - phase: Schema.Literals(["input", "output"]), - message: Schema.String, -}) {} - /** Optimistic locking failure — stored version doesn't match expected */ export class VersionConflictError extends Schema.TaggedError()( "VersionConflictError", diff --git a/src/index.ts b/src/index.ts index f07f265..eb18a69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,6 @@ // Machine namespace (Effect-style) export * as Machine from "./machine.js"; -// Legacy slot module -/** @deprecated Prefer Effect services and layers for machine dependencies. */ -export { Slot } from "./slot.js"; -export type { - SlotsDef, - SlotsSchema, - SlotCalls, - SlotCall, - SlotFnDef, - SlotHandler, - SlotRequest, - SlotResult, - SlotInvocation, - ProvideSlots, - HasSlotKeys, - MachineContext, -} from "./slot.js"; - // Errors export { ActorStoppedError, @@ -29,15 +11,17 @@ export { MissingSchemaError, NoReplyError, PersistenceError, - ProvisionValidationError, - SlotCodecError, - SlotProvisionError, VersionConflictError, } from "./errors.js"; // Schema-first State/Event definitions export { State, Event } from "./schema.js"; -export type { MachineStateSchema, MachineEventSchema, ReplyFields } from "./schema.js"; +export type { + MachineStateSchema, + MachineEventSchema, + ReplyFields, + ReplyVariant, +} from "./schema.js"; // Core machine types (for advanced use) export type { diff --git a/src/internal/brands.ts b/src/internal/brands.ts index 93757e5..13805fa 100644 --- a/src/internal/brands.ts +++ b/src/internal/brands.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line eslint-plugin-import/namespace -- false positive: Brand is a type namespace in effect import type { Brand } from "effect"; // String-based type IDs for branding (v4 Brand requires string keys) @@ -56,4 +55,4 @@ export type ExtractReply = E extends ReplyTypeBrand ? R : never; */ export type TaggedOrConstructor = | T - | ((...args: never[]) => T); + | (((...args: never[]) => T) & { readonly _tag: T["_tag"] }); diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index 65db33f..83855a1 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -29,12 +29,12 @@ import { Scope, SubscriptionRef, } from "effect"; +import type { Context } from "effect"; import type { Machine, MachineRef } from "../machine.js"; import type { ActorRef, ActorSystemService } from "../actor.js"; import { ActorSystem as ActorSystemTag } from "../actor.js"; import type { ProcessEventHooks, ProcessEventResult } from "./transition.js"; -import type { SlotsDef, MachineContext } from "../slot.js"; import { processEventCore, runSpawnEffects, shouldPostpone } from "./transition.js"; import { NoReplyError } from "../errors.js"; import { INTERNAL_INIT_EVENT } from "./utils.js"; @@ -45,7 +45,7 @@ import { ActorExit, type DefectPhase } from "../supervision.js"; // ============================================================================ /** @internal */ -export type RuntimeQueuedEvent = +export type RuntimeQueuedEvent = | { readonly _tag: "send"; readonly event: E } | { readonly _tag: "sendWait"; @@ -55,12 +55,12 @@ export type RuntimeQueuedEvent = | { readonly _tag: "call"; readonly event: E; - readonly reply: Deferred.Deferred, unknown>; + readonly reply: Deferred.Deferred, StopError>; } | { readonly _tag: "ask"; readonly event: E; - readonly reply: Deferred.Deferred; + readonly reply: Deferred.Deferred; } | { readonly _tag: "drain"; @@ -76,9 +76,9 @@ export type RuntimeQueuedEvent = * When provided, createRuntime uses these instead of allocating its own. * @internal */ -export interface RuntimeCellResources { +export interface RuntimeCellResources { readonly stateRef: SubscriptionRef.SubscriptionRef; - readonly eventQueue: Queue.Queue>; + readonly eventQueue: Queue.Queue>; readonly stoppedRef: Ref.Ref; } @@ -87,13 +87,13 @@ export interface RuntimeCellResources { // ============================================================================ /** @internal */ -export interface RuntimeHandle { +export interface RuntimeHandle { /** Enqueue a fire-and-forget event */ readonly send: (event: E) => Effect.Effect; /** Enqueue event and wait for processing to complete (for RPC Send). Fails on defect. */ readonly sendWait: (event: E) => Effect.Effect; /** Enqueue an ask event, returns the reply value */ - readonly ask: (event: E) => Effect.Effect; + readonly ask: (event: E) => Effect.Effect; /** Get current state */ readonly getState: Effect.Effect; /** SubscriptionRef for state observation (WatchState streaming) */ @@ -109,7 +109,7 @@ export interface RuntimeHandle { */ readonly start: Effect.Effect; /** @internal — raw event queue for direct enqueue (actor.ts uses this for pendingReplies tracking) */ - readonly _queue: Queue.Queue>; + readonly _queue: Queue.Queue>; /** @internal — stopped ref for direct access */ readonly _stoppedRef: Ref.Ref; /** @@ -149,7 +149,7 @@ export interface RuntimeLifecycleHooks { // ============================================================================ /** @internal */ -export interface RuntimeConfig { +export interface RuntimeConfig { readonly actorId: string; /** Runtime initial state, used for hydration/recovery without cloning the machine. */ readonly initialState?: S; @@ -159,13 +159,13 @@ export interface RuntimeConfig { * eventQueue, and stoppedRef instead of creating its own. * Used by actor.ts for supervision (cell owns stable resources across generations). */ - readonly cellResources?: RuntimeCellResources; + readonly cellResources?: RuntimeCellResources; /** * Custom queue factory. Default: `Queue.unbounded()`. * Use `Queue.sliding(n)` or `Queue.dropping(n)` for bounded queues. * Ignored when cellResources is provided. */ - readonly queueFactory?: Effect.Effect>>; + readonly queueFactory?: Effect.Effect>>; /** Lifecycle callbacks for actor-specific concerns */ readonly lifecycle?: RuntimeLifecycleHooks; /** Wrap each processQueued invocation — actor uses for span annotations */ @@ -179,8 +179,6 @@ export interface RuntimeConfig { childId: string, child: ActorRef, ) => Effect.Effect; - /** Skip registering stop as scope finalizer — actor manages its own lifecycle */ - readonly skipFinalizer?: boolean; /** Prefix for child actor IDs in self.spawn. Entity-machine uses `${actorId}/`. Default: no prefix. */ readonly childIdPrefix?: string; } @@ -196,6 +194,12 @@ interface MutableCell { current: T; } +interface DeferredReplyTarget { + readonly deferred: Deferred.Deferred; + readonly replySchema: Schema.Codec | undefined; + claimed: boolean; +} + /** * Create a runtime for a machine. Returns a handle for sending events * and querying state. The runtime owns: @@ -215,12 +219,12 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef, + StopError = never, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- wide acceptance for Machine type params - machine: Machine, + machine: Machine, system: ActorSystemService, - config: RuntimeConfig, + config: RuntimeConfig, ) { const { actorId, hooks, lifecycle } = config; const initialState = config.initialState ?? machine.initial; @@ -234,7 +238,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function const stoppedRef = config.cellResources?.stoppedRef ?? (yield* Ref.make(false)); const eventQueue = config.cellResources?.eventQueue ?? - (yield* config.queueFactory ?? Queue.unbounded>()); + (yield* config.queueFactory ?? Queue.unbounded>()); // Exit deferred — set exactly once with the exit reason const exitDeferred = yield* Deferred.make>(); @@ -244,7 +248,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function // Pending deferred reply — stored when handler returns Machine.deferReply() // Settled by self.reply() from spawn handler - const deferredReplyRef: MutableCell | undefined> = { + const deferredReplyRef: MutableCell | undefined> = { current: undefined, }; @@ -272,15 +276,34 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function ) : defaultSpawn, reply: (value: Reply) => - Effect.sync(() => { - const deferred = deferredReplyRef.current; - if (deferred !== undefined) { - deferredReplyRef.current = undefined; - fork(Deferred.succeed(deferred, value)); - return true; + Effect.gen(function* () { + const target = yield* Effect.sync(() => { + const current = deferredReplyRef.current; + if (current === undefined || current.claimed) return undefined; + current.claimed = true; + return current; + }); + if (target === undefined) return false; + + // Keep the claimed target installed until its Deferred is completed. Encoding defects + // must reach the waiting ask before the spawn effect propagates the same defect. + const encodedExit = + target.replySchema !== undefined + ? yield* Schema.encodeUnknownEffect(target.replySchema)(value).pipe( + Effect.orDie, + Effect.exit, + ) + : Exit.succeed(value); + if (Exit.isFailure(encodedExit)) { + yield* Deferred.failCause(target.deferred, encodedExit.cause); + if (deferredReplyRef.current === target) deferredReplyRef.current = undefined; + return yield* Effect.failCause(encodedExit.cause); } - return false; - }), + + yield* Deferred.succeed(target.deferred, encodedExit.value); + if (deferredReplyRef.current === target) deferredReplyRef.current = undefined; + return true; + }).pipe(Effect.uninterruptible), }; // State scope for spawn effects @@ -290,15 +313,6 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function // Shared mutable refs used by both start() and stop() const initEvent = { _tag: INTERNAL_INIT_EVENT } as const; - const ctx: MachineContext> = { - actorId, - state: initialState, - event: initEvent, - self, - system, - }; - const slots = machine._slots; - // Mutable holder for the loop fiber — needed by stop() and spawn defect signals const loopFiberRef: MutableCell | undefined> = { current: undefined }; @@ -306,7 +320,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function const setExit = (exit: ActorExit) => Deferred.succeed(exitDeferred, exit).pipe(Effect.asVoid); // Idempotent start gate — first caller runs initialization, subsequent callers await - const startDeferred = yield* Deferred.make(); + const startDeferred = yield* Deferred.make(); const startedRef = yield* Ref.make(false); const start = Effect.gen(function* () { @@ -327,10 +341,9 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function state: initialState, event: initEvent, self, - slots, system, }) - .pipe(Effect.provideService(machine.Context, ctx), Effect.forkIn(actorScope)); + .pipe(Effect.forkIn(actorScope)); backgroundFibers.push(fiber); } @@ -419,6 +432,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function actorId, system, exitDeferred, + services, augmentedHooks, deferredReplyRef, lifecycle, @@ -487,16 +501,10 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function yield* setExit(ActorExit.Stopped); }).pipe(Effect.asVoid); - // Register stop as scope finalizer so entity teardown cleans up fibers. - // Skipped for actor.ts which manages its own stop lifecycle. - if (config.skipFinalizer !== true) { - yield* Effect.addFinalizer(() => stop); - } - return { ...makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope), stop: stop.pipe(Effect.provide(services)), - start: start.pipe(Effect.provide(services)), + start: start.pipe(Effect.provideService(Scope.Scope, actorScope), Effect.setContext(services)), }; }); @@ -504,13 +512,17 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function * Build the runtime handle (send/ask/getState/isStopped). * Shared between initial-final and normal paths. */ -const makeHandle = ( +const makeHandle = < + S extends { readonly _tag: string }, + E extends { readonly _tag: string }, + StopError, +>( stateRef: SubscriptionRef.SubscriptionRef, stoppedRef: Ref.Ref, - eventQueue: Queue.Queue>, + eventQueue: Queue.Queue>, exitDeferred: Deferred.Deferred>, actorScope: Scope.Closeable, -): RuntimeHandle => ({ +): RuntimeHandle => ({ send: (event: E) => Effect.gen(function* () { const stopped = yield* Ref.get(stoppedRef); @@ -533,7 +545,7 @@ const makeHandle = (); + const reply = yield* Deferred.make(); yield* Queue.offer(eventQueue, { _tag: "ask", event, reply }); return yield* Deferred.await(reply); }), @@ -556,20 +568,23 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef, + StopError, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- wide acceptance - machine: Machine, + machine: Machine, stateRef: SubscriptionRef.SubscriptionRef, - eventQueue: Queue.Queue>, + eventQueue: Queue.Queue>, stoppedRef: Ref.Ref, self: MachineRef, stateScopeRef: { current: Scope.Closeable }, actorId: string, system: ActorSystemService, exitDeferred: Deferred.Deferred>, + services: Context.Context, hooks?: ProcessEventHooks, - deferredReplyRef?: { current: Deferred.Deferred | undefined }, + deferredReplyRef?: { + current: DeferredReplyTarget | undefined; + }, lifecycle?: RuntimeLifecycleHooks, wrapProcess?: ( state: S, @@ -583,7 +598,7 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* const forkEffect = fork ?? Effect.runFork; // Event-bearing queue variants (excludes drain sentinel) - type EventQueued = Exclude, { readonly _tag: "drain" }>; + type EventQueued = Exclude, { readonly _tag: "drain" }>; /** Set the exit deferred exactly once. */ const setExit = (exit: ActorExit) => Deferred.succeed(exitDeferred, exit).pipe(Effect.asVoid); @@ -597,7 +612,7 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* currentState: S, ) { if (queued._tag === "call") { - const postponedResult: ProcessEventResult<{ readonly _tag: string }> = { + const postponedResult: ProcessEventResult = { newState: currentState, previousState: currentState, transitioned: false, @@ -649,16 +664,20 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* yield* Deferred.succeed(queued.reply, result.reply); return; } - const decoded = yield* Schema.decodeUnknownEffect(replySchema)(result.reply).pipe( - Effect.catch((decodeError) => - Deferred.die(queued.reply, decodeError).pipe(Effect.andThen(Effect.die(decodeError))), + const encoded = yield* Schema.encodeUnknownEffect(replySchema)(result.reply).pipe( + Effect.catch((encodeError) => + Deferred.die(queued.reply, encodeError).pipe(Effect.andThen(Effect.die(encodeError))), ), ); - yield* Deferred.succeed(queued.reply, decoded); + yield* Deferred.succeed(queued.reply, encoded); return; } if (result.deferReply && deferredReplyRef !== undefined) { - deferredReplyRef.current = queued.reply; + deferredReplyRef.current = { + deferred: queued.reply, + replySchema: machine._replySchemas?.get(event._tag), + claimed: false, + }; return; } yield* Deferred.fail(queued.reply, new NoReplyError({ actorId, eventTag: event._tag })); @@ -774,8 +793,7 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* // queued is narrowed: drain is handled above, so it's always an event-bearing variant here const eventQueued = queued; - // SAFETY: createRuntime captures and supplies the machine's R before forking this loop. - const processInner = processQueued(eventQueued) as Effect.Effect>; + const processInner = processQueued(eventQueued).pipe(Effect.setContext(services)); const wrapped = wrapProcess !== undefined ? Effect.gen(function* () { @@ -829,8 +847,8 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function* }); /** Settle all pending Deferreds in the postpone buffer on shutdown. */ -const settlePostponed = ( - postponed: Exclude, { readonly _tag: "drain" }>[], +const settlePostponed = ( + postponed: Exclude, { readonly _tag: "drain" }>[], actorId: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any forkFn: (effect: Effect.Effect) => Fiber.Fiber, diff --git a/src/internal/transition.ts b/src/internal/transition.ts index 8c9e663..e576f98 100644 --- a/src/internal/transition.ts +++ b/src/internal/transition.ts @@ -19,7 +19,6 @@ import type { LifecycleEvent, } from "../machine.js"; import type { ActorSystemService } from "../actor.js"; -import type { SlotsDef, MachineContext } from "../slot.js"; import { isEffect, isReplyResult, isDeferReplyResult, INTERNAL_ENTER_EVENT } from "./utils.js"; import type { ReplyResult, DeferReplyResult } from "./utils.js"; @@ -53,30 +52,19 @@ export const runTransitionHandler = Effect.fn("effect-machine.runTransitionHandl S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, - transition: Transition, + machine: Machine, + transition: Transition, state: S, event: E, - self: MachineRef, - system: ActorSystemService, - actorId: string, ) { - const ctx: MachineContext> = { actorId, state, event, self, system }; - const slots = machine._slots; - - const handlerCtx: HandlerContext = { state, event, slots }; + const handlerCtx: HandlerContext = { state, event }; const raw = transition.run(handlerCtx); + // SAFETY: isEffect established the runtime branch; handler typing supplies its result domains. const resolved = isEffect(raw) - ? yield* ( - // SAFETY: isEffect established the runtime branch; handler typing supplies its result domains. - (raw as Effect.Effect | DeferReplyResult, never, R>).pipe( - Effect.provideService(machine.Context, ctx), - ) - ) + ? yield* raw as Effect.Effect | DeferReplyResult, never, R> : raw; // Detect branded ReplyResult (created via Machine.reply()) @@ -104,7 +92,7 @@ export const runTransitionHandler = Effect.fn("effect-machine.runTransitionHandl /** * Execute a transition for a given state and event. - * Handles transition resolution, handler invocation, and guard/effect slot creation. + * Handles transition resolution and handler invocation. * * Used by: * - processEvent in actor.ts (actual actor event loop) @@ -117,15 +105,11 @@ export const executeTransition = Effect.fn("effect-machine.executeTransition")(f S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, currentState: S, event: E, - self: MachineRef, - system: ActorSystemService, - actorId: string, ) { const transition = resolveTransition(machine, currentState, event); @@ -145,9 +129,6 @@ export const executeTransition = Effect.fn("effect-machine.executeTransition")(f transition, currentState, event, - self, - system, - actorId, ); return { @@ -222,7 +203,7 @@ export const shouldPostpone = < R, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, stateTag: string, eventTag: string, ): boolean => { @@ -250,10 +231,9 @@ export const processEventCore = Effect.fn("effect-machine.processEventCore")(fun S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, currentState: S, event: E, self: MachineRef, @@ -263,7 +243,7 @@ export const processEventCore = Effect.fn("effect-machine.processEventCore")(fun hooks?: ProcessEventHooks, ) { // Execute transition (defect-aware) - const result = yield* executeTransition(machine, currentState, event, self, system, actorId).pipe( + const result = yield* executeTransition(machine, currentState, event).pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.interrupt; @@ -353,10 +333,9 @@ export const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(funct S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, state: S, event: E | LifecycleEvent, self: MachineRef, @@ -367,14 +346,6 @@ export const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(funct onSpawnDefect?: (cause: Cause.Cause) => Effect.Effect, ) { const spawnEffects = findSpawnEffects(machine, state._tag); - const ctx: MachineContext> = { - actorId, - state, - event, - self, - system, - }; - const slots = machine._slots; const reportError = onError; const defectSignal = onSpawnDefect; @@ -386,11 +357,9 @@ export const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(funct state, event, self, - slots, system, }) .pipe( - Effect.provideService(machine.Context, ctx), Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.interrupt; @@ -422,7 +391,7 @@ export const resolveTransition = < R, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance - machine: Machine, + machine: Machine, currentState: S, event: E, ): (typeof machine.transitions)[number] | undefined => { @@ -438,27 +407,24 @@ export const resolveTransition = < * Index structure: stateTag -> eventTag -> transitions[] * Array preserves registration order for guard cascade evaluation. */ -type TransitionIndex = Map< - string, - Map>> ->; +type TransitionIndex = Map>>>; /** * Index for spawn effects: stateTag -> effects[] */ -type SpawnIndex = Map>>; +type SpawnIndex = Map>>; /** * Combined index for a machine */ -interface MachineIndex { - readonly transitions: TransitionIndex; - readonly spawn: SpawnIndex; +interface MachineIndex { + readonly transitions: TransitionIndex; + readonly spawn: SpawnIndex; } // Module-level cache - WeakMap allows GC of unreferenced machines // eslint-disable-next-line @typescript-eslint/no-explicit-any -const indexCache = new WeakMap>(); +const indexCache = new WeakMap>(); /** * Invalidate cached index for a machine (call after mutation). @@ -474,12 +440,11 @@ export const invalidateIndex = (machine: M): void => { const buildTransitionIndex = < S extends { readonly _tag: string }, E extends { readonly _tag: string }, - SD extends SlotsDef, R, >( - transitions: ReadonlyArray>, -): TransitionIndex => { - const index: TransitionIndex = new Map(); + transitions: ReadonlyArray>, +): TransitionIndex => { + const index: TransitionIndex = new Map(); for (const t of transitions) { let stateMap = index.get(t.stateTag); @@ -506,12 +471,11 @@ const buildTransitionIndex = < const buildSpawnIndex = < S extends { readonly _tag: string }, E extends { readonly _tag: string }, - SD extends SlotsDef, R, >( - effects: ReadonlyArray>, -): SpawnIndex => { - const index: SpawnIndex = new Map(); + effects: ReadonlyArray>, +): SpawnIndex => { + const index: SpawnIndex = new Map(); for (const e of effects) { let stateList = index.get(e.stateTag); @@ -528,17 +492,12 @@ const buildSpawnIndex = < /** * Get or build index for a machine. */ -const getIndex = < - S extends { readonly _tag: string }, - E extends { readonly _tag: string }, - R, - SD extends SlotsDef, ->( +const getIndex = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance - machine: Machine, -): MachineIndex => { + machine: Machine, +): MachineIndex => { // SAFETY: each cache entry is created from and keyed by this exact machine instance. - let index = indexCache.get(machine) as MachineIndex | undefined; + let index = indexCache.get(machine) as MachineIndex | undefined; if (index === undefined) { index = { transitions: buildTransitionIndex(machine.transitions), @@ -559,13 +518,12 @@ export const findTransitions = < S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef = Record, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance - machine: Machine, + machine: Machine, stateTag: string, eventTag: string, -): ReadonlyArray> => { +): ReadonlyArray> => { const index = getIndex(machine); const specific = index.transitions.get(stateTag)?.get(eventTag) ?? []; if (specific.length > 0) return specific; @@ -583,12 +541,11 @@ export const findSpawnEffects = < S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef = Record, >( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance - machine: Machine, + machine: Machine, stateTag: string, -): ReadonlyArray> => { +): ReadonlyArray> => { const index = getIndex(machine); return index.spawn.get(stateTag) ?? []; }; diff --git a/src/internal/utils.ts b/src/internal/utils.ts index 1b099f2..c2fce47 100644 --- a/src/internal/utils.ts +++ b/src/internal/utils.ts @@ -125,28 +125,11 @@ export const INTERNAL_ENTER_EVENT = "$enter" as const; /** * Extract _tag from a tagged value or constructor. * - * Supports: - * - Plain values with `_tag` (MachineSchema empty structs) - * - Constructors with static `_tag` (MachineSchema non-empty structs) - * - Data.taggedEnum constructors (fallback via instantiation) + * Supports plain tagged values and constructors with an explicit static `_tag`. + * Legacy constructors without a static tag must be wrapped with `Machine.tagged`. */ -export const getTag = ( - constructorOrValue: { _tag: string } | ((...args: never[]) => { _tag: string }), -): string => { - // Direct _tag property (values or static on constructors) - if ("_tag" in constructorOrValue) { - return constructorOrValue._tag; - } - // Fallback: instantiate (Data.taggedEnum compatibility) - // Try zero-arg first, then empty object for record constructors - try { - // The _tag check leaves only the callable constructor branch. - return constructorOrValue()._tag; - } catch { - // SAFETY: Data tagged record constructors accept an empty record when zero-argument invocation fails. - return (constructorOrValue as (args: Record) => { _tag: string })({})._tag; - } -}; +export const getTag = (constructorOrValue: { readonly _tag: string }): string => + constructorOrValue._tag; /** Check if a value is an Effect */ export const isEffect: (value: unknown) => value is Effect.Effect = diff --git a/src/machine.ts b/src/machine.ts index 3596999..cdd9eec 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -34,19 +34,25 @@ * * @module */ -import type { Context, Duration } from "effect"; -import { Cause, Effect, Exit, Option, Random, Schema, Scope } from "effect"; +import type { Duration, Schema } from "effect"; +import { Cause, Effect, Exit, Option, Random, Scope } from "effect"; import type { DeferReplyResult, ReplyResult, TransitionResult } from "./internal/utils.js"; -import { getTag, stubSystem, makeReply, makeDeferReply } from "./internal/utils.js"; +import { getTag, makeReply, makeDeferReply } from "./internal/utils.js"; import type { TaggedOrConstructor, BrandedState, BrandedEvent, ExtractReply, + FullEventBrand, + FullStateBrand, } from "./internal/brands.js"; -import type { MachineStateSchema, MachineEventSchema, VariantsUnion } from "./schema.js"; -import { SlotProvisionError, SlotCodecError, ProvisionValidationError } from "./errors.js"; +import type { + MachineStateSchema, + MachineEventSchema, + MachineSchemaDefinition, + VariantsUnion, +} from "./schema.js"; import type { DuplicateActorError } from "./errors.js"; import { invalidateIndex, @@ -57,8 +63,6 @@ import { import { emitWithTimestamp } from "./internal/inspection.js"; import type { ActorRef, ActorSystemService } from "./actor.js"; import { Inspector as InspectorTag } from "./inspection.js"; -import type { SlotsDef, SlotsSchema, SlotCalls, ProvideSlots, MachineContext } from "./slot.js"; -import { MachineContextTag } from "./slot.js"; // ============================================================================ // Core types @@ -74,7 +78,7 @@ export interface MachineRef { readonly spawn: ( id: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, ) => Effect.Effect, DuplicateActorError, R2>; /** * Settle a deferred reply from a spawn handler. @@ -85,7 +89,7 @@ export interface MachineRef { } interface ReplySchemaCarrier { - readonly _replySchemas: ReadonlyMap>; + readonly _replySchemas: ReadonlyMap>; } const hasReplySchemas = ( @@ -99,27 +103,22 @@ const isStateResolver = ( const isString = (value: string | Value | undefined): value is string => typeof value === "string"; -// eslint-disable-next-line typescript/no-explicit-any, anti-slop/no-unsafe-dictionary-type -- deprecated slots erase handler types at execution boundaries -type LegacySlotHandlers = Record; - /** * Handler context passed to transition handlers */ -export interface HandlerContext> { +export interface HandlerContext { readonly state: State; readonly event: Event; - readonly slots: SlotCalls; } /** * Handler context passed to state effect handlers (onEnter, spawn, background) */ -export interface StateHandlerContext> { +export interface StateHandlerContext { readonly actorId: string; readonly state: State; readonly event: Event | LifecycleEvent; readonly self: MachineRef; - readonly slots: SlotCalls; readonly system: ActorSystemService; } @@ -131,15 +130,15 @@ export type LifecycleEvent = { readonly _tag: "$init" } | { readonly _tag: "$ent * When Reply is concrete (event has a reply schema), handler must return Machine.reply(). * When Reply is never, handler returns plain state. */ -export type TransitionHandler = ( - ctx: HandlerContext, +export type TransitionHandler = ( + ctx: HandlerContext, ) => TransitionResult; /** * State effect handler function */ -export type StateEffectHandler = ( - ctx: StateHandlerContext, +export type StateEffectHandler = ( + ctx: StateHandlerContext, ) => Effect.Effect; type RegisteredTransitionResult = @@ -151,37 +150,37 @@ type RegisteredTransitionResult = /** * Transition definition */ -export interface Transition { +export interface Transition { readonly stateTag: string; readonly eventTag: string; readonly matches: (state: State, event: Event) => boolean; - readonly run: (ctx: HandlerContext) => RegisteredTransitionResult; + readonly run: (ctx: HandlerContext) => RegisteredTransitionResult; readonly reenter?: boolean; } /** * Spawn effect - state-scoped forked effect */ -export interface SpawnEffect { +export interface SpawnEffect { readonly stateTag: string; readonly matches: (state: State) => boolean; - readonly run: StateEffectHandler; + readonly run: StateEffectHandler; } /** * Background effect - runs for entire machine lifetime */ -export interface BackgroundEffect { - readonly handler: StateEffectHandler; +export interface BackgroundEffect { + readonly handler: StateEffectHandler; } // ============================================================================ // Options types // ============================================================================ -export interface TaskOptions { - readonly onSuccess?: (value: A, ctx: StateHandlerContext) => ES; - readonly onFailure?: (cause: Cause.Cause, ctx: StateHandlerContext) => EF; +export interface TaskOptions { + readonly onSuccess?: (value: A, ctx: StateHandlerContext) => ES; + readonly onFailure?: (cause: Cause.Cause, ctx: StateHandlerContext) => EF; readonly name?: string; } @@ -279,132 +278,21 @@ const matchesTagged = < // MakeConfig // ============================================================================ +type StateOf> = VariantsUnion & + FullStateBrand & { readonly _tag: string }; + +type EventOf = VariantsUnion & + FullEventBrand & { readonly _tag: string }; + export interface MakeConfig< SD extends Record, - ED extends Record, - S extends BrandedState, - E extends BrandedEvent, - SLD extends SlotsDef = Record, + ED extends MachineSchemaDefinition, > { - readonly state: MachineStateSchema & { Type: S }; - readonly event: MachineEventSchema & { Type: E }; - /** @deprecated Prefer Effect `Context.Service` dependencies in state effects. */ - readonly slots?: SlotsSchema; - readonly initial: S; - /** @deprecated Only applies to the legacy slot API. */ - readonly slotValidation?: boolean; + readonly state: MachineStateSchema; + readonly event: MachineEventSchema; + readonly initial: StateOf; } -// ============================================================================ -// Provide types -// ============================================================================ - -// ============================================================================ -// materializeMachine — internal slot binding at execution boundaries -// ============================================================================ - -/** - * Bind slot handlers to a machine, returning a fresh copy with handlers installed. - * If no handlers provided and machine has no slots, returns the machine as-is. - * Validates that all required slots are provided and no extra slots are given. - * - * @internal — used by spawn, replay, simulate, test harness, entity-machine - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const materializeMachine = < - S extends { readonly _tag: string }, - E extends { readonly _tag: string }, - R, - SD extends SlotsDef, ->( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handlers?: LegacySlotHandlers, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Machine => { - if (handlers === undefined) { - // Validate: slot-free machines can skip handlers, slotful machines must provide them - if ( - machine._slotsSchema !== undefined && - Object.keys(machine._slotsSchema.definitions).length > 0 - ) { - const missing = Object.keys(machine._slotsSchema.definitions); - throw new ProvisionValidationError({ missing, extra: [] }); - } - // SAFETY: changing only the erased requirement parameter does not alter the machine value. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return machine as any; - } - - // Collect all required slot names - const requiredSlots = new Set(); - if (machine._slotsSchema !== undefined) { - for (const name of Object.keys(machine._slotsSchema.definitions)) { - requiredSlots.add(name); - } - } - - // Single-pass validation - const providedSlots = new Set(Object.keys(handlers)); - const missing: string[] = []; - const extra: string[] = []; - - for (const name of requiredSlots) { - if (!providedSlots.has(name)) { - missing.push(name); - } - } - for (const name of providedSlots) { - if (!requiredSlots.has(name)) { - extra.push(name); - } - } - - if (missing.length > 0 || extra.length > 0) { - throw new ProvisionValidationError({ missing, extra }); - } - - // Create fresh copy to avoid mutation bleed between actors - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = new Machine( - machine.initial, - machine.stateSchema, - machine.eventSchema, - machine._slotsSchema, - machine._slotValidation, - ); - - // Copy arrays/sets - // SAFETY: this fresh machine has the same state, event, and slot definitions as the source. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (result as any)._transitions = [...machine._transitions]; - // SAFETY: final-state tags are independent of the erased Effect requirement. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (result as any)._finalStates = new Set(machine._finalStates); - // SAFETY: spawn handlers retain the source machine's state, event, and slot definitions. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (result as any)._spawnEffects = [...machine._spawnEffects]; - // SAFETY: background handlers retain the source machine's state, event, and slot definitions. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (result as any)._backgroundEffects = [...machine._backgroundEffects]; - // SAFETY: postpone rules contain only validated state and event tags. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (result as any)._postponeRules = [...machine._postponeRules]; - // SAFETY: reply schemas are immutable metadata copied from the same event schema. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (result as any)._replySchemas = machine._replySchemas; - - // Register handlers — single map - if (machine._slotsSchema !== undefined) { - for (const name of Object.keys(machine._slotsSchema.definitions)) { - result._slotHandlers.set(name, handlers[name]); - } - } - - return result; -}; - // ============================================================================ // Machine class // ============================================================================ @@ -418,54 +306,35 @@ export const materializeMachine = < * - `R`: Effect requirements * - `_SD`: State schema definition (for compile-time validation) * - `_ED`: Event schema definition (for compile-time validation) - * - `SD`: Slot definitions */ export class Machine< State extends { readonly _tag: string }, Event extends { readonly _tag: string }, R = never, _SD extends Record = Record, - _ED extends Record = Record, - SD extends SlotsDef = Record, + _ED extends MachineSchemaDefinition = Record, > { readonly initial: State; - /** @internal */ readonly _transitions: Array>; - /** @internal */ readonly _spawnEffects: Array>; - /** @internal */ readonly _backgroundEffects: Array>; + /** @internal */ readonly _transitions: Array>; + /** @internal */ readonly _spawnEffects: Array>; + /** @internal */ readonly _backgroundEffects: Array>; /** @internal */ readonly _finalStates: Set; /** @internal */ readonly _postponeRules: Array<{ readonly stateTag: string; readonly eventTag: string; }>; - /** @internal */ readonly _slotsSchema?: SlotsSchema; - /** @internal */ readonly _slotHandlers: Map< - string, - // eslint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- deprecated slot boundary - (params: unknown) => unknown | Effect.Effect - >; - /** @internal */ readonly _slots: SlotCalls; - /** @internal */ readonly _slotValidation: boolean; readonly stateSchema?: Schema.Schema; readonly eventSchema?: Schema.Schema; - /** @internal */ readonly _replySchemas: ReadonlyMap>; - - /** - * Context tag for accessing machine state/event/self in slot handlers. - * Uses shared module-level tag for all machines. - */ - readonly Context: Context.Service< - MachineContextTag, - MachineContext> - > = MachineContextTag; + /** @internal */ readonly _replySchemas: ReadonlyMap>; // Public readonly views - get transitions(): ReadonlyArray> { + get transitions(): ReadonlyArray> { return this._transitions; } - get spawnEffects(): ReadonlyArray> { + get spawnEffects(): ReadonlyArray> { return this._spawnEffects; } - get backgroundEffects(): ReadonlyArray> { + get backgroundEffects(): ReadonlyArray> { return this._backgroundEffects; } get finalStates(): ReadonlySet { @@ -474,10 +343,7 @@ export class Machine< get postponeRules(): ReadonlyArray<{ readonly stateTag: string; readonly eventTag: string }> { return this._postponeRules; } - get slotsSchema(): SlotsSchema | undefined { - return this._slotsSchema; - } - get replySchemas(): ReadonlyMap> { + get replySchemas(): ReadonlyMap> { return this._replySchemas; } @@ -486,8 +352,6 @@ export class Machine< initial: State, stateSchema?: Schema.Schema, eventSchema?: Schema.Schema, - slotsSchema?: SlotsSchema, - slotValidation = true, ) { this.initial = initial; this._transitions = []; @@ -495,123 +359,20 @@ export class Machine< this._backgroundEffects = []; this._finalStates = new Set(); this._postponeRules = []; - this._slotsSchema = slotsSchema; this._replySchemas = eventSchema !== undefined && hasReplySchemas(eventSchema) ? eventSchema._replySchemas : new Map(); - this._slotHandlers = new Map(); - this._slotValidation = slotValidation; this.stateSchema = stateSchema; this.eventSchema = eventSchema; - - // Precompile slot validators (decode input, decode output) if validation enabled - const validators = - slotValidation && slotsSchema !== undefined - ? new Map( - Object.entries(slotsSchema.definitions).map(([name, def]) => [ - name, - { - decodeInput: Schema.decodeUnknownSync(def.inputSchema), - decodeOutput: Schema.decodeUnknownSync(def.outputSchema), - }, - ]), - ) - : undefined; - - // Create slot closures — unified single map - // eslint-disable-next-line @typescript-eslint/no-explicit-any, anti-slop/no-unknown-parameters -- deprecated slot boundary - const resolve = (name: string, params: unknown): Effect.Effect => - Effect.flatMap(Effect.serviceOption(this.Context), (maybeCtx) => { - if (Option.isNone(maybeCtx)) { - return Effect.die("MachineContext not available"); - } - const handler = this._slotHandlers.get(name); - if (handler === undefined) { - return Effect.die(new SlotProvisionError({ slotName: name, slotType: "slot" })); - } - - // Validate input - const validatedParams = - validators !== undefined - ? (() => { - try { - const v = validators.get(name); - return v !== undefined ? v.decodeInput(params) : params; - } catch (e) { - return Effect.die( - new SlotCodecError({ - slotName: name, - phase: "input", - message: e instanceof Error ? e.message : String(e), - }), - ); - } - })() - : params; - - // If decodeInput returned an Effect.die, short-circuit - if (Effect.isEffect(validatedParams)) { - // @effect-diagnostics anyUnknownInErrorContext:off - // SAFETY: Effect.isEffect established the branch value is an Effect. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return validatedParams as Effect.Effect; - } - - // Invoke handler - const result = handler(validatedParams); - - // Wrap result into Effect - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let resultEffect: Effect.Effect; - if (result === undefined || result === null) { - resultEffect = Effect.void; - } else if (Effect.isEffect(result)) { - // @effect-diagnostics anyUnknownInErrorContext:off - // SAFETY: Effect.isEffect established the branch value is an Effect. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resultEffect = result as Effect.Effect; - } else { - resultEffect = Effect.succeed(result); - } - - // Validate output - if (validators !== undefined) { - const v = validators.get(name); - if (v !== undefined) { - return Effect.flatMap(resultEffect, (value) => { - try { - const decoded = v.decodeOutput(value); - return Effect.succeed(decoded); - } catch (e) { - return Effect.die( - new SlotCodecError({ - slotName: name, - phase: "output", - message: e instanceof Error ? e.message : String(e), - }), - ); - } - }); - } - } - return resultEffect; - }); - - if (this._slotsSchema !== undefined) { - this._slots = this._slotsSchema._createSlots(resolve); - } else { - // SAFETY: a machine without a slot schema has no callable slot keys. - this._slots = {} as SlotCalls; - } } // ---- on ---- from & BrandedState, R1>( state: TaggedOrConstructor, - build: (scope: TransitionScope) => R1, - ): Machine; + build: (scope: TransitionScope) => R1, + ): Machine; from & BrandedState>>, R1>( states: NS, build: ( @@ -621,19 +382,18 @@ export class Machine< R, _SD, _ED, - SD, NS[number] extends TaggedOrConstructor & BrandedState> ? S : never >, ) => R1, - ): Machine; + ): Machine; from( stateOrStates: | TaggedOrConstructor & BrandedState> | ReadonlyArray & BrandedState>>, build: ( - scope: TransitionScope & BrandedState>, + scope: TransitionScope & BrandedState>, ) => void, ) { const states = Array.isArray(stateOrStates) ? stateOrStates : [stateOrStates]; @@ -649,9 +409,9 @@ export class Machine< >( states: ReadonlyArray>, event: TaggedOrConstructor, - handler: TransitionHandler>, + handler: TransitionHandler>, reenter: boolean, - ): Machine { + ): Machine { for (const state of states) { this.addTransition(state, event, handler, reenter); } @@ -666,8 +426,8 @@ export class Machine< >( state: TaggedOrConstructor, event: TaggedOrConstructor, - handler: TransitionHandler>, - ): Machine; + handler: TransitionHandler>, + ): Machine; /** Register transition for multiple states (handler receives union of state types) */ on< NS extends ReadonlyArray & BrandedState>>, @@ -680,13 +440,12 @@ export class Machine< NS[number] extends TaggedOrConstructor ? S : never, NE, RS, - SD, never, ExtractReply >, - ): Machine; + ): Machine; // eslint-disable-next-line @typescript-eslint/no-explicit-any - on(stateOrStates: any, event: any, handler: any): Machine { + on(stateOrStates: any, event: any, handler: any): Machine { const states = Array.isArray(stateOrStates) ? stateOrStates : [stateOrStates]; for (const s of states) { this.addTransition(s, event, handler, false); @@ -708,8 +467,8 @@ export class Machine< >( state: TaggedOrConstructor, event: TaggedOrConstructor, - handler: TransitionHandler>, - ): Machine; + handler: TransitionHandler>, + ): Machine; /** Multiple states */ reenter< NS extends ReadonlyArray & BrandedState>>, @@ -722,13 +481,12 @@ export class Machine< NS[number] extends TaggedOrConstructor ? S : never, NE, RS, - SD, never, ExtractReply >, - ): Machine; + ): Machine; /* eslint-disable @typescript-eslint/no-explicit-any */ - reenter(stateOrStates: any, event: any, handler: any): Machine { + reenter(stateOrStates: any, event: any, handler: any): Machine { /* eslint-enable @typescript-eslint/no-explicit-any */ const states = Array.isArray(stateOrStates) ? stateOrStates : [stateOrStates]; for (const s of states) { @@ -748,10 +506,10 @@ export class Machine< RS extends State & VariantsUnion<_SD> & BrandedState, >( event: TaggedOrConstructor, - handler: TransitionHandler>, - ): Machine { + handler: TransitionHandler>, + ): Machine { const eventTag = getTag(event); - const transition: Transition = { + const transition: Transition = { stateTag: "*", eventTag, matches: (_state, candidate) => matchesTagged(event, candidate), @@ -775,13 +533,13 @@ export class Machine< >( state: TaggedOrConstructor, event: TaggedOrConstructor, - handler: TransitionHandler, + handler: TransitionHandler, reenter: boolean, - ): Machine { + ): Machine { const stateTag = getTag(state); const eventTag = getTag(event); - const transition: Transition = { + const transition: Transition = { stateTag, eventTag, matches: (candidateState, candidateEvent) => @@ -818,29 +576,38 @@ export class Machine< /** Single state */ spawn & BrandedState, R1>( state: TaggedOrConstructor, - handler: StateEffectHandler & BrandedEvent, SD, Scope.Scope | R1>, - ): Machine; + handler: StateEffectHandler, + ): Machine; /** Multiple states */ spawn & BrandedState>>, R1>( states: NS, handler: StateEffectHandler< NS[number] extends TaggedOrConstructor ? S : never, - VariantsUnion<_ED> & BrandedEvent, - SD, + Event, Scope.Scope | R1 >, - ): Machine; + ): Machine; // eslint-disable-next-line @typescript-eslint/no-explicit-any - spawn(stateOrStates: any, handler: any): Machine { - const next = this.copyWithAdditional(); - const states = Array.isArray(stateOrStates) ? stateOrStates : [stateOrStates]; + spawn(stateOrStates: any, handler: any): Machine { + return this.registerStateEffect(stateOrStates, handler); + } + + private registerStateEffect & BrandedState, R1>( + stateOrStates: TaggedOrConstructor | ReadonlyArray>, + handler: StateEffectHandler, + ): Machine { + const next = this.copyWithAdditional(); + const states: ReadonlyArray> = Array.isArray(stateOrStates) + ? stateOrStates + : [stateOrStates]; for (const s of states) { const stateTag = getTag(s); + const matches = (state: State): state is State & NS => matchesTagged(s, state); next._spawnEffects.push({ stateTag, - matches: (state) => matchesTagged(s, state), + matches, run: (ctx) => - matchesTagged(s, ctx.state) + matches(ctx.state) ? handler({ ...ctx, state: ctx.state }) : Effect.die("Spawn effect invoked for a non-matching state"), }); @@ -860,7 +627,7 @@ export class Machine< * - `.task(State.X, run, { onFailure })` — shorthand when run returns Event directly * - `.task([State.X, State.Y], run, opts)` — multi-state */ - /** Single state — onSuccess optional (defaults to identity when task returns Event) */ + /** Single state — onSuccess optional (defaults to identity when task returns Event). */ task< NS extends VariantsUnion<_SD> & BrandedState, A, @@ -871,11 +638,11 @@ export class Machine< >( state: TaggedOrConstructor, run: ( - ctx: StateHandlerContext & BrandedEvent, SD>, + ctx: StateHandlerContext & BrandedEvent>, ) => Effect.Effect, - options: TaskOptions & BrandedEvent, SD, A, E1, ES, EF>, - ): Machine; - /** Multiple states, explicit onSuccess */ + options: TaskOptions & BrandedEvent, A, E1, ES, EF>, + ): Machine; + /** Multiple states — handler receives the selected state union. */ task< NS extends ReadonlyArray & BrandedState>>, A, @@ -888,25 +655,27 @@ export class Machine< run: ( ctx: StateHandlerContext< NS[number] extends TaggedOrConstructor ? S : never, - VariantsUnion<_ED> & BrandedEvent, - SD + VariantsUnion<_ED> & BrandedEvent >, ) => Effect.Effect, options: TaskOptions< NS[number] extends TaggedOrConstructor ? S : never, VariantsUnion<_ED> & BrandedEvent, - SD, A, E1, ES, EF >, - ): Machine; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - task(stateOrStates: any, run: any, options: any): Machine { - const handler = Effect.fn("effect-machine.task")(function* ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ctx: StateHandlerContext, + ): Machine; + /* eslint-disable @typescript-eslint/no-explicit-any -- public overloads preserve selection/task correlation at this implementation boundary */ + task( + stateOrStates: any, + run: (ctx: StateHandlerContext) => Effect.Effect, + options: any, + ): Machine { + const handler: StateEffectHandler = Effect.fn("effect-machine.task")(function* ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation is checked by public overloads + ctx: StateHandlerContext, ) { yield* emitTaskInspection({ actorId: ctx.actorId, @@ -915,7 +684,6 @@ export class Machine< phase: "start", }); - // @effect-diagnostics anyUnknownInErrorContext:off — implementation overload uses `any` const exit = yield* Effect.exit(run(ctx)); if (Exit.isSuccess(exit)) { @@ -958,10 +726,9 @@ export class Machine< return yield* Effect.failCause(cause).pipe(Effect.orDie); }); - // SAFETY: the overload implementation accepts the same state selection and task handler contract. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.spawn(stateOrStates, handler as any); + return this.registerStateEffect(stateOrStates, handler); } + /* eslint-enable @typescript-eslint/no-explicit-any */ // ---- timeout ---- @@ -989,7 +756,7 @@ export class Machine< timeout & BrandedState>( state: TaggedOrConstructor, config: TimeoutConfig & BrandedEvent>, - ): Machine { + ): Machine { const stateTag = getTag(state); const duration = config.duration; const event = config.event; @@ -1017,8 +784,8 @@ export class Machine< * ``` */ background( - handler: StateEffectHandler, - ): Machine { + handler: StateEffectHandler, + ): Machine { const next = this.copyWithAdditional(); next._backgroundEffects.push({ handler }); return next; @@ -1048,7 +815,7 @@ export class Machine< events: | TaggedOrConstructor & BrandedEvent> | ReadonlyArray & BrandedEvent>>, - ): Machine { + ): Machine { const stateTag = getTag(state); const eventList = Array.isArray(events) ? events : [events]; for (const ev of eventList) { @@ -1062,29 +829,24 @@ export class Machine< final & BrandedState>( state: TaggedOrConstructor, - ): Machine { + ): Machine { const stateTag = getTag(state); this._finalStates.add(stateTag); return this; } /** Copy this definition before adding work that can grow Effect requirements. */ - private copyWithAdditional(): Machine { - const next = new Machine( + private copyWithAdditional(): Machine { + const next = new Machine( this.initial, this.stateSchema, this.eventSchema, - this._slotsSchema, - this._slotValidation, ); next._transitions.push(...this._transitions); next._spawnEffects.push(...this._spawnEffects); next._backgroundEffects.push(...this._backgroundEffects); for (const tag of this._finalStates) next._finalStates.add(tag); next._postponeRules.push(...this._postponeRules); - for (const [name, slotHandler] of this._slotHandlers) { - next._slotHandlers.set(name, slotHandler); - } return next; } @@ -1092,23 +854,13 @@ export class Machine< // ---- Static factory ---- - static make< - SD extends Record, - ED extends Record, - S extends BrandedState, - E extends BrandedEvent, - SLD extends SlotsDef = Record, - >(config: MakeConfig): Machine { - // SAFETY: MakeConfig ties S to the Type member of this exact state schema. - const stateSchema = config.state as Schema.Schema; - // SAFETY: MakeConfig ties E to the Type member of this exact event schema. - const eventSchema = config.event as Schema.Schema; - return new Machine( + static make, ED extends MachineSchemaDefinition>( + config: MakeConfig, + ): Machine, EventOf, never, SD, ED> { + return new Machine, EventOf, never, SD, ED>( config.initial, - stateSchema, - eventSchema, - config.slots, - config.slotValidation ?? true, + config.state, + config.event, ); } } @@ -1118,12 +870,11 @@ class TransitionScope< Event extends { readonly _tag: string }, R, _SD extends Record, - _ED extends Record, - SD extends SlotsDef, + _ED extends MachineSchemaDefinition, SelectedState extends VariantsUnion<_SD> & BrandedState, > { constructor( - private readonly machine: Machine, + private readonly machine: Machine, private readonly states: ReadonlyArray>, ) {} @@ -1132,8 +883,8 @@ class TransitionScope< RS extends State & VariantsUnion<_SD> & BrandedState, >( event: TaggedOrConstructor, - handler: TransitionHandler>, - ): TransitionScope { + handler: TransitionHandler>, + ): TransitionScope { this.machine.scopeTransition(this.states, event, handler, false); return this; } @@ -1143,8 +894,8 @@ class TransitionScope< RS extends State & VariantsUnion<_SD> & BrandedState, >( event: TaggedOrConstructor, - handler: TransitionHandler>, - ): TransitionScope { + handler: TransitionHandler>, + ): TransitionScope { this.machine.scopeTransition(this.states, event, handler, true); return this; } @@ -1156,6 +907,18 @@ class TransitionScope< export const make = Machine.make; +/** + * Add an explicit static tag to a legacy tagged constructor. + * Generated `State` / `Event` constructors already carry this metadata. + */ +export const tagged = < + const Tag extends string, + Constructor extends (...args: never[]) => { readonly _tag: Tag }, +>( + tag: Tag, + constructor: Constructor, +): Constructor & { readonly _tag: Tag } => Object.assign(constructor, { _tag: tag }); + // ============================================================================ // spawn function - simple actor creation without ActorSystem // ============================================================================ @@ -1165,7 +928,7 @@ import type { Supervision } from "./supervision.js"; /** * Spawn an actor directly without ActorSystem ceremony. - * Accepts a `Machine` directly. For slotful machines, pass `{ slots }` in options. + * Accepts a `Machine` directly. * * **Single actor, no registry.** Caller manages lifetime via `actor.stop`. * If an `ActorScope` exists in context, cleanup attaches automatically on scope close. @@ -1192,12 +955,12 @@ import type { Supervision } from "./supervision.js"; * }))); * ``` */ -/* eslint-disable @typescript-eslint/no-explicit-any -- public spawn accepts machines with opaque schema and slot definitions */ +/* eslint-disable @typescript-eslint/no-explicit-any -- public spawn accepts machines with opaque schemas */ type AnyMachine< S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, -> = Machine; +> = Machine; /* eslint-enable @typescript-eslint/no-explicit-any */ const spawnImpl = Effect.fn("effect-machine.spawn")(function* < @@ -1211,16 +974,13 @@ const spawnImpl = Effect.fn("effect-machine.spawn")(function* < | { id?: string; hydrate?: S; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - slots?: LegacySlotHandlers; supervision?: Supervision.Policy; lifecycle?: Lifecycle; }, ) { const opts = isString(idOrOptions) ? { id: idOrOptions } : idOrOptions; const actorId = opts?.id ?? `actor-${(yield* Random.next).toString(36).slice(2)}`; - const materialized = materializeMachine(machine, opts?.slots); - const actor = yield* createActor(actorId, materialized, { + const actor = yield* createActor(actorId, machine, { initialState: opts?.hydrate, supervision: opts?.supervision, lifecycle: opts?.lifecycle, @@ -1238,18 +998,10 @@ const spawnImpl = Effect.fn("effect-machine.spawn")(function* < /** * Spawn an actor from a machine. * - * For machines with slots, pass implementations via `{ slots: { ... } }`. - * * @example * ```ts - * // No slots * const actor = yield* Machine.spawn(machine); * - * // With slots - * const actor = yield* Machine.spawn(machine, { - * slots: { canRetry: ({ max }) => attempts < max }, - * }); - * * // With lifecycle (recovery + durability) * const actor = yield* Machine.spawn(machine, { * lifecycle: { @@ -1259,21 +1011,14 @@ const spawnImpl = Effect.fn("effect-machine.spawn")(function* < * }); * ``` */ -export const spawn: < - S extends { readonly _tag: string }, - E extends { readonly _tag: string }, - R, - SD extends SlotsDef = Record, ->( +export const spawn: ( // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, options?: | string | { id?: string; hydrate?: S; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - slots?: ProvideSlots; supervision?: Supervision.Policy; lifecycle?: Lifecycle; }, @@ -1311,7 +1056,6 @@ export const scoped = ( * Folds events through transition handlers — the same state computation * that runs in a live actor, minus runtime side effects: * - Transition handlers run (pure or effectful — they compute state) - * - `self.send`/`self.spawn` are no-ops (stubbed) * - Spawn effects, background effects, and timeouts do NOT run * - Postpone rules are respected (postponed events drain on state change) * - Final states stop replay (remaining events ignored) @@ -1334,26 +1078,13 @@ const replayImpl = Effect.fn("effect-machine.replay")(function* < S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, ->( - input: AnyMachine, - events: ReadonlyArray, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options?: { from?: S; slots?: LegacySlotHandlers }, -) { - const machine = materializeMachine(input, options?.slots); +>(input: AnyMachine, events: ReadonlyArray, options?: { from?: S }) { + const machine = input; let state: S = options?.from ?? machine.initial; const hasPostponeRules = machine.postponeRules.length > 0; const postponed: E[] = []; - const dummySend = Effect.fn("effect-machine.replay.send")((_event: E) => Effect.void); - const self: MachineRef = { - send: dummySend, - cast: dummySend, - spawn: () => Effect.die("spawn not supported in replay"), - reply: () => Effect.succeed(false), - }; - for (const event of events) { // Final state stops replay if (machine.finalStates.has(state._tag)) break; @@ -1366,15 +1097,7 @@ const replayImpl = Effect.fn("effect-machine.replay")(function* < const transition = resolveTransition(machine, state, event); if (transition !== undefined) { - const result = yield* runTransitionHandler( - machine, - transition, - state, - event, - self, - stubSystem, - "replay", - ); + const result = yield* runTransitionHandler(machine, transition, state, event); const previousTag = state._tag; state = result.newState; @@ -1399,9 +1122,6 @@ const replayImpl = Effect.fn("effect-machine.replay")(function* < pTransition, state, postponedEvent, - self, - stubSystem, - "replay", ); state = pResult.newState; } @@ -1415,17 +1135,11 @@ const replayImpl = Effect.fn("effect-machine.replay")(function* < }); export const replay: { - < - S extends { readonly _tag: string }, - E extends { readonly _tag: string }, - R, - SD extends SlotsDef = Record, - >( + ( // eslint-disable-next-line @typescript-eslint/no-explicit-any - machine: Machine, + machine: Machine, events: ReadonlyArray, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options?: { from?: S; slots?: ProvideSlots }, + options?: { from?: S }, ): Effect.Effect; } = replayImpl; diff --git a/src/schema.ts b/src/schema.ts index b471543..d1cd99f 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -40,28 +40,39 @@ import type { FullStateBrand, FullEventBrand, ReplyTypeBrand } from "./internal/ import { InvalidSchemaError, MissingMatchHandlerError } from "./errors.js"; // ============================================================================ -// Reply Schema Symbol +// Reply metadata // ============================================================================ -const ReplySchemaSymbol: unique symbol = Symbol.for("@humanlayer/effect-machine/ReplySchema"); -export type ReplySchemaSymbol = typeof ReplySchemaSymbol; - /** - * Fields annotated with a reply schema. - * Structurally identical to Schema.Struct.Fields at runtime, - * but carries the reply schema type at compile time. + * Explicit event-variant metadata created by `Event.reply`. */ -export type ReplyFields> = F & { - readonly [ReplySchemaSymbol]: RS; -}; +export interface ReplyVariant< + F extends Schema.Struct.Fields, + RS extends Schema.Codec, +> { + readonly _kind: "ReplyVariant"; + readonly fields: F; + readonly replySchema: RS; +} -/** - * Payload fields that actually flow through constructors and runtime values. - * Reply schema metadata is type-only and must not leak into payload shapes. - */ -type PayloadFields = { - readonly [K in keyof F as K extends ReplySchemaSymbol ? never : K]: F[K]; -}; +/** @deprecated Use `ReplyVariant`. Retained as a source-compatible type alias. */ +export type ReplyFields< + F extends Schema.Struct.Fields, + RS extends Schema.Codec, +> = ReplyVariant; + +type VariantDefinition = + | Schema.Struct.Fields + | ReplyVariant>; + +export type MachineSchemaDefinition = Record; + +type FieldsOf = + Definition extends ReplyVariant> + ? F + : Definition extends Schema.Struct.Fields + ? Definition + : never; type TaggedSource = { readonly _tag: string }; @@ -75,45 +86,43 @@ type DynamicFields = Record; /** * Extract the TypeScript type from a TaggedStruct schema */ -type TaggedStructType = Schema.Schema.Type< - Schema.TaggedStruct> +type TaggedStructType = Schema.Schema.Type< + Schema.TaggedStruct> >; /** * Build variant schemas type from definition */ -type VariantSchemas> = { - readonly [K in keyof D & string]: Schema.TaggedStruct>; +type VariantSchemas = { + readonly [K in keyof D & string]: Schema.TaggedStruct>; }; /** * Build union type from variant schemas. * Reply-bearing variants carry ReplyTypeBrand for ask() inference. */ -export type VariantsUnion> = { +export type VariantsUnion = { [K in keyof D & string]: TaggedStructType & - (D[K] extends { readonly [ReplySchemaSymbol]: Schema.Schema } - ? ReplyTypeBrand + (D[K] extends ReplyVariant + ? ReplyTypeBrand> : unknown); -}[keyof D & string]; +}[keyof D & string] & + TaggedSource; /** * Check if fields are empty (no required string properties). * Symbol keys (like ReplySchemaSymbol) are metadata, not payload fields. */ -type IsEmptyFields = string & keyof Fields extends never - ? true - : false; +type IsEmptyFields = string & keyof FieldsOf extends never ? true : false; /** * Resolve the reply brand for a variant's fields. * If fields carry ReplySchemaSymbol, adds ReplyTypeBrand. */ -type VariantReplyBrand = Fields extends { - readonly [ReplySchemaSymbol]: Schema.Schema; -} - ? ReplyTypeBrand - : unknown; +type VariantReplyBrand = + Definition extends ReplyVariant + ? ReplyTypeBrand> + : unknown; /** * Constructor functions for each variant. @@ -125,7 +134,7 @@ type VariantReplyBrand = Fields extends { * The source type uses `object` to accept branded state types without index signature issues. * Reply-bearing variants carry ReplyTypeBrand for ask() type inference. */ -type VariantConstructors, Brand> = { +type VariantConstructors = { readonly [K in keyof D & string]: IsEmptyFields extends true ? TaggedStructType & Brand & @@ -133,11 +142,11 @@ type VariantConstructors, Brand> readonly with: (source: TaggedSource) => TaggedStructType & Brand; } : (( - args: Schema.Struct.Type>, + args: Schema.Struct.Type>, ) => TaggedStructType & Brand & VariantReplyBrand) & { readonly with: ( source: TaggedSource, - partial?: Partial>>, + partial?: Partial>>, ) => TaggedStructType & Brand; readonly _tag: K; }; @@ -148,33 +157,30 @@ type VariantConstructors, Brand> * Used by union-level `with` to accept only fields safe to update * regardless of which variant the source is. */ -type SharedKeys> = keyof D[keyof D & string] & - string; +type SharedKeys = keyof FieldsOf & string; -type SharedFields> = { - readonly [K in SharedKeys]?: D[keyof D & string][K] extends Schema.Top - ? Schema.Schema.Type +type SharedFields = { + readonly [K in SharedKeys]?: FieldsOf[K] extends Schema.Top + ? Schema.Schema.Type[K]> : never; }; /** * Pattern matching cases type */ -type MatchCases, R> = { +type MatchCases = { readonly [K in keyof D & string]: (value: TaggedStructType) => R; }; -interface MatchFunction, Brand> { +interface MatchFunction { (cases: MatchCases): (value: VariantsUnion & TaggedSource & Brand) => R; (value: VariantsUnion & TaggedSource & Brand, cases: MatchCases): R; } -type RuntimeMatchCases = Record R>; - /** * Base schema interface with pattern matching helpers */ -interface MachineSchemaBase, Brand> { +interface MachineSchemaBase { /** * Raw definition record for introspection */ @@ -220,7 +226,7 @@ interface MachineSchemaBase, Bran * Reply schemas per variant tag. Only populated for event schemas * with variants defined via `Event.reply()`. */ - readonly _replySchemas: ReadonlyMap>; + readonly _replySchemas: ReadonlyMap>; } // ============================================================================ @@ -253,12 +259,15 @@ export type MachineStateSchema> = * The D type parameter captures the definition, creating a unique brand * per distinct schema definition shape. */ -export type MachineEventSchema> = Schema.Codec< +export type MachineEventSchema = Schema.Codec< VariantsUnion & FullEventBrand, unknown > & MachineSchemaBase> & - VariantConstructors>; + VariantConstructors> & { + /** Schema for persistence, config, and registration. */ + readonly schema: Schema.Schema & FullEventBrand>; + }; // ============================================================================ // Implementation @@ -277,15 +286,16 @@ type RuntimeConstructor = | (TaggedSource & { with: RuntimeWith }); /* eslint-enable anti-slop/no-unsafe-dictionary-type */ -interface BuiltMachineSchema> { - readonly schema: Schema.Codec, unknown>; - readonly variants: VariantSchemas; - readonly constructors: Record; - readonly _definition: D; - readonly replySchemas: Map>; - readonly $is: MachineSchemaBase["$is"]; - readonly $match: MatchFunction; -} +type RuntimeMatchCases = Record R>; + +type MachineSchemaOwner = Schema.Codec< + VariantsUnion & Brand, + unknown +> & + MachineSchemaBase & + VariantConstructors & { + readonly schema: Schema.Schema & Brand>; + }; const hasTag = (value: unknown): value is TaggedSource => typeof value === "object" && value !== null && "_tag" in value; @@ -294,29 +304,41 @@ const readDynamicField = (source: TaggedSource, key: string) => // SAFETY: schema-derived state values are records whose enumerable payload fields are keyed by strings. (source as DynamicFields)[key]; -const buildMachineSchema = >( +const isReplyVariant = ( + definition: VariantDefinition, +): definition is ReplyVariant> => + "_kind" in definition && definition._kind === "ReplyVariant"; + +const invokeMatch = (value: TaggedSource, cases: RuntimeMatchCases): R => { + const handler = cases[value._tag]; + if (handler === undefined) { + throw new MissingMatchHandlerError({ tag: value._tag }); + } + return handler(value); +}; + +const buildMachineSchema = ( definition: D, -): BuiltMachineSchema => { - // Build variant schemas - // SAFETY: every key is populated from definition before the schema is exposed. - const variants = {} as Record>; +): MachineSchemaOwner => { + const tags = Object.keys(definition); + if (tags.length === 0) { + throw new InvalidSchemaError({ message: "Schema must have at least one variant" }); + } + + const fieldsByTag: Record = {}; const constructors: Record = {}; - const replySchemas = new Map>(); + const replySchemas = new Map>(); - for (const tag of Object.keys(definition)) { - const fields = definition[tag]; - if (fields === undefined) continue; + for (const tag of tags) { + const variantDefinition = definition[tag]; + if (variantDefinition === undefined) continue; - // Detect reply schema before passing to TaggedStruct - if (ReplySchemaSymbol in fields) { - // SAFETY: ReplySchemaSymbol membership establishes the hidden decoder metadata property. - const rs = (fields as Record>)[ReplySchemaSymbol]; - if (rs !== undefined) replySchemas.set(tag, rs); + const fields = isReplyVariant(variantDefinition) ? variantDefinition.fields : variantDefinition; + fieldsByTag[tag] = fields; + if (isReplyVariant(variantDefinition)) { + replySchemas.set(tag, variantDefinition.replySchema); } - const variantSchema = Schema.TaggedStruct(tag, fields); - variants[tag] = variantSchema; - // Create constructor that builds tagged struct directly // Like Data.taggedEnum, this doesn't validate at construction time // Use Schema.decode for validation when needed @@ -350,20 +372,7 @@ const buildMachineSchema = >( } } - // Build union schema from all variants - const variantArray = Object.values(variants); - if (variantArray.length === 0) { - throw new InvalidSchemaError({ message: "Schema must have at least one variant" }); - } - - // Schema.Union requires at least 2 members, handle single variant case - const unionSchema = - variantArray.length === 1 - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- checked length above - variantArray[0]! - : // SAFETY: the length check establishes the non-empty tuple required by Schema.Union. - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic schema union - Schema.Union(variantArray as any); + const tagged = Schema.TaggedUnion(fieldsByTag); // Type guard const $is = @@ -371,62 +380,20 @@ const buildMachineSchema = >( (value: unknown): value is TaggedStructType => hasTag(value) && value._tag === tag; - // Pattern matching - function $match(cases: MatchCases): (value: VariantsUnion & TaggedSource) => R; - function $match(value: VariantsUnion & TaggedSource, cases: MatchCases): R; - function $match( - valueOrCases: (VariantsUnion & TaggedSource) | MatchCases, - maybeCases?: MatchCases, - ): R | ((value: VariantsUnion & TaggedSource) => R) { - if (maybeCases !== undefined) { - // Uncurried: $match(value, cases) - // SAFETY: the two-argument overload requires a tagged value as its first argument. - const value = valueOrCases as TaggedSource; - // SAFETY: every handler receives a variant selected by that value's matching tag. - const cases = maybeCases as RuntimeMatchCases; - const handler = cases[value._tag]; - if (handler === undefined) { - throw new MissingMatchHandlerError({ tag: value._tag }); - } - return handler(value); + const $match = ( + ...args: + | readonly [cases: RuntimeMatchCases] + | readonly [value: TaggedSource, cases: RuntimeMatchCases] + ): R | ((value: TaggedSource) => R) => { + if (args.length === 2) { + return invokeMatch(args[0], args[1]); } - // Curried: $match(cases) -> (value) => result - // SAFETY: the one-argument overload requires the complete case map. - const cases = valueOrCases as RuntimeMatchCases; - return (value: VariantsUnion & TaggedSource): R => { - const handler = cases[value._tag]; - if (handler === undefined) { - throw new MissingMatchHandlerError({ tag: value._tag }); - } - return handler(value); - }; - } - - // Re-enter the typed Schema API at its AST boundary. Every AST member above was - // assembled from the corresponding entry in definition D. - const schema = Schema.make, unknown>>(unionSchema.ast); - - return { - schema, - // SAFETY: every key was populated from definition D above. - // eslint-disable-next-line anti-slop/no-known-value-widening -- keys were populated from definition D above - variants: variants as VariantSchemas, - constructors, - _definition: definition, - replySchemas, - $is, - $match, + const cases = args[0]; + return (value: TaggedSource) => invokeMatch(value, cases); }; -}; -/** - * Internal helper to create a machine schema (shared by State and Event). - * Builds the schema object with variants, constructors, $is, and $match. - */ -const createMachineSchema = >(definition: D) => { - const { schema, variants, constructors, _definition, replySchemas, $is, $match } = - buildMachineSchema(definition); - // Union-level with: dispatch to per-variant with based on _tag + const schema = Schema.make & Brand, unknown>>(tagged.ast); + const withFn = (source: TaggedSource, partial?: DynamicFields) => { const ctor = constructors[source._tag]; if (ctor === undefined) { @@ -435,18 +402,39 @@ const createMachineSchema = >(def return ctor.with(source, partial); }; - return Object.assign(Object.create(schema), { - variants, - _definition, - _replySchemas: replySchemas, + Object.assign( schema, - $is, - $match, - with: withFn, - ...constructors, - }); + { + variants: tagged.cases, + _definition: definition, + _replySchemas: replySchemas, + schema, + $is, + $match, + with: withFn, + }, + constructors, + ); + + const complete = tags.every( + (tag) => Object.hasOwn(tagged.cases, tag) && Object.hasOwn(constructors, tag), + ); + if (!complete) { + throw new InvalidSchemaError({ message: "Schema owner construction was incomplete" }); + } + + // SAFETY: every definition tag is runtime-checked above to own both its schema case and + // constructor; all helpers close over those same records, and brands are type-only. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return schema as MachineSchemaOwner; }; +/** + * Internal helper to create a machine schema (shared by State and Event). + */ +const createMachineSchema = (definition: D) => + buildMachineSchema(definition); + /** * Create a schema-first State definition. * @@ -478,9 +466,7 @@ const createMachineSchema = >(def */ export const State = >( definition: D, -): MachineStateSchema => - // SAFETY: createMachineSchema builds every constructor and schema member from definition D. - createMachineSchema(definition) as MachineStateSchema; +): MachineStateSchema => createMachineSchema>(definition); /** * Create a schema-first Event definition. @@ -509,28 +495,16 @@ export const State = >( * const total = yield* actor.ask(OrderEvent.GetTotal) // number * ``` */ -const EventImpl = >( - definition: D, -): MachineEventSchema => - // SAFETY: createMachineSchema builds every constructor and schema member from definition D. - createMachineSchema(definition) as MachineEventSchema; +const EventImpl = (definition: D): MachineEventSchema => + createMachineSchema>(definition); /** * Annotate event fields with a reply schema. * Events defined with `Event.reply(fields, replySchema)` enable typed `ask()`. */ -const replyFieldsFn = >( +const replyFieldsFn = >( fields: F, replySchema: RS, -): ReplyFields => { - // SAFETY: Object.defineProperty below installs the non-enumerable metadata property on this copy. - const annotated = { ...fields } as ReplyFields; - Object.defineProperty(annotated, ReplySchemaSymbol, { - value: replySchema, - enumerable: false, - writable: false, - }); - return annotated; -}; +): ReplyVariant => ({ _kind: "ReplyVariant", fields, replySchema }); export const Event = Object.assign(EventImpl, { reply: replyFieldsFn }); diff --git a/src/slot.ts b/src/slot.ts deleted file mode 100644 index 496934a..0000000 --- a/src/slot.ts +++ /dev/null @@ -1,376 +0,0 @@ -/** - * Slot module — unified, schema-based parameterized slots. - * - * Replaces the split Guards/Effects API with a single `Slot.define` + `Slot.fn`. - * Each slot declares its parameter schema and (optional) return schema. - * Handlers receive only params — machine context is accessed via `yield* machine.Context`. - * - * @example - * ```ts - * import { Slot } from "@humanlayer/effect-machine" - * import { Schema } from "effect" - * - * const MySlots = Slot.define({ - * canRetry: Slot.fn({ max: Schema.Number }, Schema.Boolean), - * isValid: Slot.fn({}, Schema.Boolean), - * fetchData: Slot.fn({ url: Schema.String }), - * notify: Slot.fn({ message: Schema.String }), - * }) - * - * // Used in handlers: - * .on(State.X, Event.Y, ({ slots }) => - * Effect.gen(function* () { - * if (yield* slots.canRetry({ max: 3 })) { - * yield* slots.fetchData({ url: "/api" }) - * return State.Next - * } - * return state - * }) - * ) - * ``` - * - * @module - */ -import { Schema, Context, Effect } from "effect"; -import type { ActorSystemService } from "./actor.js"; - -// ============================================================================ -// Type-level utilities -// ============================================================================ - -/** Schema fields definition (like Schema.Struct.Fields) */ -type Fields = Record; - -/** Extract the type from schema fields (used for parameters) */ -type FieldsToParams = keyof F extends never - ? void - : Schema.Schema.Type>; - -// ============================================================================ -// SlotFnDef — individual slot definition -// ============================================================================ - -/** - * Definition of a single slot function. - * Created via `Slot.fn(params, returnSchema?)`. - * - * Carries both type-level information and materialized schemas - * for runtime validation and serialization. - */ -export interface SlotFnDef { - readonly _tag: "SlotFnDef"; - readonly fields: F; - /** Return schema — undefined means void */ - readonly returnSchema: Schema.Schema | undefined; - /** Materialized input schema (Schema.Struct of fields, or Schema.Void for empty) */ - readonly inputSchema: Schema.Codec>; - /** Materialized output schema (returnSchema or Schema.Void) */ - readonly outputSchema: Schema.Codec; -} - -/** - * Define a single slot function with parameter schema and optional return schema. - * - * @example - * ```ts - * // Guard-like: returns boolean - * Slot.fn({ max: Schema.Number }, Schema.Boolean) - * - * // Effect-like: returns void (default) - * Slot.fn({ url: Schema.String }) - * - * // No params, returns boolean - * Slot.fn({}, Schema.Boolean) - * ``` - */ -export const fn: { - (fields: F, returnSchema: Schema.Schema): SlotFnDef; - (fields: F): SlotFnDef; -} = ( - fields: F, - returnSchema?: Schema.Schema, -): SlotFnDef => { - const hasFields = Object.keys(fields).length > 0; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const inputSchema = hasFields ? Schema.Struct(fields) : (Schema.Void as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const outputSchema = returnSchema ?? (Schema.Void as any); - return { - _tag: "SlotFnDef", - fields, - returnSchema: returnSchema, - inputSchema, - outputSchema, - }; -}; - -// ============================================================================ -// SlotsDef — definition record -// ============================================================================ - -/** - * Record of slot definitions. Keys are slot names, values are SlotFnDef. - */ -export type SlotsDef = Record>; - -// ============================================================================ -// SlotsSchema — returned by Slot.define() -// ============================================================================ - -/** - * Slots schema — returned by `Slot.define()`. Passed to `Machine.make({ slots })`. - */ -export interface SlotsSchema { - readonly _tag: "SlotsSchema"; - readonly definitions: D; - /** Schema for slot requests `{ _tag: "SlotRequest", name, params }`. For RPC request payloads. */ - readonly requestSchema: Schema.Codec>; - /** Schema for slot results `{ _tag: "SlotResult", name, result }`. For RPC response payloads. */ - readonly resultSchema: Schema.Codec>; - /** Schema for slot invocations `{ _tag: "SlotInvocation", name, params, result }`. For persistence/logging. */ - readonly invocationSchema: Schema.Codec>; - /** Create callable slot proxies (used by Machine internally) */ - readonly _createSlots: ( - resolve: ( - name: N, - params: SlotParams, - ) => Effect.Effect>, - ) => SlotCalls; -} - -/** - * A serialized slot request — captures name and params (no result). - * Used for RPC request payloads. - */ -export type SlotRequest = { - readonly [K in keyof D & string]: { - readonly _tag: "SlotRequest"; - readonly name: K; - readonly params: SlotParams; - }; -}[keyof D & string]; - -/** - * A serialized slot result — captures name and result (no params). - * Used for RPC response payloads. - */ -export type SlotResult = { - readonly [K in keyof D & string]: { - readonly _tag: "SlotResult"; - readonly name: K; - readonly result: SlotReturn; - }; -}[keyof D & string]; - -/** - * A serialized slot invocation — captures name, params, and result. - * Used for persistence, logging, and audit trails. - */ -export type SlotInvocation = { - readonly [K in keyof D & string]: { - readonly _tag: "SlotInvocation"; - readonly name: K; - readonly params: SlotParams; - readonly result: SlotReturn; - }; -}[keyof D & string]; - -// ============================================================================ -// SlotCalls — callable slot proxies available in handler context -// ============================================================================ - -/** Extract params type from a SlotFnDef */ -type SlotParams> = - D extends SlotFnDef ? FieldsToParams : never; - -/** Extract return type from a SlotFnDef */ -type SlotReturn> = - D extends SlotFnDef ? R : never; - -/** - * A callable slot — function that takes params and returns Effect. - */ -export interface SlotCall { - readonly _tag: "Slot"; - readonly name: Name; - (params: Params): Effect.Effect; -} - -/** - * Convert slot definitions to callable slot proxies. - */ -export type SlotCalls = { - readonly [K in keyof D & string]: SlotCall, SlotReturn>; -}; - -// ============================================================================ -// SlotHandler / ProvideSlots — handler implementations at spawn time -// ============================================================================ - -/** - * Slot handler implementation. - * Receives only params — use `yield* machine.Context` for machine context. - */ -export type SlotHandler = ( - params: Params, -) => Return | Effect.Effect; - -/** - * Handler implementations for all slots in a definition. - */ -export type ProvideSlots = { - readonly [K in keyof D & string]: SlotHandler, SlotReturn, R>; -}; - -/** Check if a SlotsDef has any actual keys */ -export type HasSlotKeys = [keyof SD] extends [never] - ? false - : SD extends Record - ? false - : true; - -// ============================================================================ -// Machine Context Tag -// ============================================================================ - -/** - * Type for machine context — state, event, and self reference. - * Shared across all machines via MachineContextTag. - */ -export interface MachineContext { - readonly actorId: string; - readonly state: State; - readonly event: Event; - readonly self: Self; - readonly system: ActorSystemService; -} - -/** - * Shared Context tag for all machines. - * Single module-level tag instead of per-machine allocation. - * @internal - */ -/* eslint-disable @typescript-eslint/no-explicit-any -- generic context tag */ -export class MachineContextTag extends Context.Service< - MachineContextTag, - MachineContext ->()("@humanlayer/effect-machine/slot/MachineContextTag") {} -/* eslint-enable @typescript-eslint/no-explicit-any */ - -// ============================================================================ -// Slot.define — factory -// ============================================================================ - -/** - * Define a set of slots with parameter and return schemas. - * - * @example - * ```ts - * const MySlots = Slot.define({ - * canRetry: Slot.fn({ max: Schema.Number }, Schema.Boolean), - * fetchData: Slot.fn({ url: Schema.String }), - * notify: Slot.fn({ message: Schema.String }), - * }) - * ``` - */ -export const define = (definitions: D): SlotsSchema => { - // Build per-slot invocation schemas, then union them - const names = Object.keys(definitions); - /* eslint-disable @typescript-eslint/no-explicit-any */ - const requestSchemas: Array> = []; - const resultSchemas: Array> = []; - const invocationSchemas: Array> = []; - for (const name of names) { - const def = definitions[name]; - if (def === undefined) continue; - requestSchemas.push( - Schema.TaggedStruct("SlotRequest", { name: Schema.Literal(name), params: def.inputSchema }), - ); - resultSchemas.push( - Schema.TaggedStruct("SlotResult", { name: Schema.Literal(name), result: def.outputSchema }), - ); - invocationSchemas.push( - Schema.TaggedStruct("SlotInvocation", { - name: Schema.Literal(name), - params: def.inputSchema, - result: def.outputSchema, - }), - ); - } - - const buildUnion = (schemas: Array>): Schema.Codec => - schemas.length === 0 ? Schema.Never : (Schema.Union(schemas as any) as any); - - const requestSchema = buildUnion>(requestSchemas); - const resultSchema = buildUnion>(resultSchemas); - const invocationSchema = buildUnion>(invocationSchemas); - /* eslint-enable @typescript-eslint/no-explicit-any */ - - return { - _tag: "SlotsSchema", - definitions, - requestSchema, - resultSchema, - invocationSchema, - _createSlots: (resolve) => { - const slots: Record = {}; - for (const name of names) { - const slot = (params: unknown) => resolve(name, params as SlotParams); - Object.defineProperty(slot, "_tag", { value: "Slot", enumerable: true }); - Object.defineProperty(slot, "name", { value: name, enumerable: true }); - slots[name] = slot; - } - return slots as SlotCalls; - }, - }; -}; - -// ============================================================================ -// Slot.of — normalize ProvideSlots into SlotCalls -// ============================================================================ - -/** - * Convert raw slot handler implementations into the callable `SlotCalls` form. - * - * Handlers that return plain values are wrapped in `Effect.succeed`. - * Handlers that return Effects are called directly inside `Effect.suspend`. - * - * @example - * ```ts - * const provided = yield* myExtension.slots(ctx) - * const slots = Slot.of(slotsSchema, provided) - * // slots.mySlot({ param: 1 }) returns Effect - * ``` - */ -const of = ( - slotsSchema: SlotsSchema, - provided: ProvideSlots, -): SlotCalls => { - const slots: Record = {}; - for (const name of Object.keys(slotsSchema.definitions)) { - const handler = (provided as Record unknown>)[name]; - if (handler === undefined) continue; - const call = (params: unknown): Effect.Effect => - Effect.suspend((): Effect.Effect => { - const result = handler(params); - return Effect.isEffect(result) - ? (result as Effect.Effect) - : Effect.succeed(result); - }); - Object.defineProperty(call, "_tag", { value: "Slot", enumerable: true }); - Object.defineProperty(call, "name", { value: name, enumerable: true }); - slots[name] = call; - } - return slots as SlotCalls; -}; - -// ============================================================================ -// Slot namespace export -// ============================================================================ - -/** @deprecated Prefer Effect `Context.Service` dependencies and `Layer` provisioning. */ -export const Slot = { - fn, - define, - of, -} as const; diff --git a/src/testing.ts b/src/testing.ts index ea3765b..7b8f976 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -1,31 +1,12 @@ import { Effect, SubscriptionRef } from "effect"; -import type { Machine, MachineRef } from "./machine.js"; -import { materializeMachine } from "./machine.js"; +import type { Machine } from "./machine.js"; import { AssertionError } from "./errors.js"; -import type { SlotsDef, ProvideSlots } from "./slot.js"; import { executeTransition, shouldPostpone } from "./internal/transition.js"; -import { stubSystem } from "./internal/utils.js"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type MachineInput< - S extends { readonly _tag: string }, - E extends { readonly _tag: string }, - R, - SD extends SlotsDef = Record, -> = +type MachineInput = // eslint-disable-next-line @typescript-eslint/no-explicit-any - Machine; - -const makeDummySelf = (label: string): MachineRef => { - const dummySend = Effect.fn(label)((_event: E) => Effect.void); - return { - send: dummySend, - cast: dummySend, - spawn: () => Effect.die(`spawn not supported in ${label}`), - reply: () => Effect.succeed(false), - }; -}; + Machine; /** * Result of simulating events through a machine @@ -38,8 +19,7 @@ export interface SimulationResult { /** * Simulate a sequence of events through a machine without running an actor. * Useful for testing state transitions in isolation. - * Does not run onEnter/spawn/background effects, but does run slots - * within transition handlers. + * Does not run state-scoped or background effects. * * @example * ```ts @@ -59,25 +39,7 @@ export const simulate = Effect.fn("effect-machine.simulate")(function* < S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef = Record, ->( - input: MachineInput, - events: ReadonlyArray, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options?: { slots?: ProvideSlots }, -) { - // SAFETY: materialization preserves this input machine's state, event, and slot domains. - const machine = materializeMachine(input, options?.slots) as Machine< - S, - E, - R, - Record, - Record, - SD - >; - - const dummySelf = makeDummySelf("effect-machine.testing.simulate"); - +>(machine: MachineInput, events: ReadonlyArray) { let currentState = machine.initial; const states: S[] = [currentState]; const hasPostponeRules = machine.postponeRules.length > 0; @@ -90,14 +52,7 @@ export const simulate = Effect.fn("effect-machine.simulate")(function* < continue; } - const result = yield* executeTransition( - machine, - currentState, - event, - dummySelf, - stubSystem, - "simulation", - ); + const result = yield* executeTransition(machine, currentState, event); if (!result.transitioned) { continue; @@ -122,14 +77,7 @@ export const simulate = Effect.fn("effect-machine.simulate")(function* < postponed.push(postponedEvent); continue; } - const drainResult = yield* executeTransition( - machine, - currentState, - postponedEvent, - dummySelf, - stubSystem, - "simulation", - ); + const drainResult = yield* executeTransition(machine, currentState, postponedEvent); if (drainResult.transitioned) { currentState = drainResult.newState; states.push(currentState); @@ -154,15 +102,8 @@ export const assertReaches = Effect.fn("effect-machine.assertReaches")(function* S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef = Record, ->( - input: MachineInput, - events: ReadonlyArray, - expectedTag: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options?: { slots?: ProvideSlots }, -) { - const result = yield* simulate(input, events, options); +>(input: MachineInput, events: ReadonlyArray, expectedTag: string) { + const result = yield* simulate(input, events); if (result.finalState._tag !== expectedTag) { return yield* new AssertionError({ message: @@ -189,15 +130,8 @@ export const assertPath = Effect.fn("effect-machine.assertPath")(function* < S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef = Record, ->( - input: MachineInput, - events: ReadonlyArray, - expectedPath: ReadonlyArray, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options?: { slots?: ProvideSlots }, -) { - const result = yield* simulate(input, events, options); +>(input: MachineInput, events: ReadonlyArray, expectedPath: ReadonlyArray) { + const result = yield* simulate(input, events); const actualPath = result.states.map((s) => s._tag); if (actualPath.length !== expectedPath.length) { @@ -240,15 +174,8 @@ export const assertNeverReaches = Effect.fn("effect-machine.assertNeverReaches") S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef = Record, ->( - input: MachineInput, - events: ReadonlyArray, - forbiddenTag: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options?: { slots?: ProvideSlots }, -) { - const result = yield* simulate(input, events, options); +>(input: MachineInput, events: ReadonlyArray, forbiddenTag: string) { + const result = yield* simulate(input, events); const visitedIndex = result.states.findIndex((s) => s._tag === forbiddenTag); if (visitedIndex !== -1) { @@ -274,21 +201,17 @@ export interface TestHarness { /** * Options for creating a test harness */ -export interface TestHarnessOptions> { +export interface TestHarnessOptions { /** * Called after each transition with the previous state, event, and new state. * Useful for logging or spying on transitions. */ readonly onTransition?: (from: S, event: E, to: S) => void; - /** Slot handler implementations. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly slots?: ProvideSlots; } /** * Create a test harness for step-by-step testing. - * Does not run onEnter/spawn/background effects, but does run slots - * within transition handlers. + * Does not run state-scoped or background effects. * * @example Basic usage * ```ts @@ -310,20 +233,7 @@ export const createTestHarness = Effect.fn("effect-machine.createTestHarness")(f S extends { readonly _tag: string }, E extends { readonly _tag: string }, R, - SD extends SlotsDef = Record, ->(input: MachineInput, options?: TestHarnessOptions) { - // SAFETY: materialization preserves this input machine's state, event, and slot domains. - const machine = materializeMachine(input, options?.slots) as Machine< - S, - E, - R, - Record, - Record, - SD - >; - - const dummySelf = makeDummySelf("effect-machine.testing.harness"); - +>(machine: MachineInput, options?: TestHarnessOptions) { const stateRef = yield* SubscriptionRef.make(machine.initial); const hasPostponeRules = machine.postponeRules.length > 0; const postponed: E[] = []; @@ -337,14 +247,7 @@ export const createTestHarness = Effect.fn("effect-machine.createTestHarness")(f return currentState; } - const result = yield* executeTransition( - machine, - currentState, - event, - dummySelf, - stubSystem, - "test-harness", - ); + const result = yield* executeTransition(machine, currentState, event); if (!result.transitioned) { return currentState; @@ -371,14 +274,7 @@ export const createTestHarness = Effect.fn("effect-machine.createTestHarness")(f postponed.push(postponedEvent); continue; } - const drainResult = yield* executeTransition( - machine, - state, - postponedEvent, - dummySelf, - stubSystem, - "test-harness", - ); + const drainResult = yield* executeTransition(machine, state, postponedEvent); if (drainResult.transitioned) { yield* SubscriptionRef.set(stateRef, drainResult.newState); currentTag = drainResult.newState._tag; diff --git a/test/actor.test.ts b/test/actor.test.ts index fc927a7..305865f 100644 --- a/test/actor.test.ts +++ b/test/actor.test.ts @@ -12,16 +12,7 @@ import { } from "effect"; import type { ActorRef } from "../src/index.js"; -import { - ActorSystemDefault, - ActorSystemService, - Machine, - State, - Event, - Slot, -} from "../src/index.js"; -import { materializeMachine } from "../src/machine.js"; -import { MachineContextTag } from "../src/slot.js"; +import { ActorSystemDefault, ActorSystemService, Machine, State, Event } from "../src/index.js"; import { describe, expect, it, yieldFibers } from "effect-bun-test"; // ============================================================================ @@ -44,39 +35,18 @@ const TestEvent = Event({ }); type TestEvent = typeof TestEvent.Type; -const TestSlots = Slot.define({ - isHighValue: Slot.fn({}, Schema.Boolean), -}); - -const testMachineSlots = { - isHighValue: () => - Effect.gen(function* () { - const ctx = yield* MachineContextTag; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const event = ctx.event; - return event._tag === "Update" && event.value > 100; - }), -}; - const createTestMachine = () => Machine.make({ state: TestState, event: TestEvent, - slots: TestSlots, initial: TestState.Idle, }) .on(TestState.Idle, TestEvent.Start, ({ event }) => TestState.Loading({ value: event.value })) .on(TestState.Loading, TestEvent.Complete, ({ state }) => TestState.Active({ value: state.value }), ) - .on(TestState.Active, TestEvent.Update, ({ event, slots }) => - Effect.gen(function* () { - // If high value (> 100), double it - if (yield* slots.isHighValue()) { - return TestState.Active({ value: event.value * 2 }); - } - return TestState.Active({ value: event.value }); - }), + .on(TestState.Active, TestEvent.Update, ({ event }) => + TestState.Active({ value: event.value > 100 ? event.value * 2 : event.value }), ) .on(TestState.Active, TestEvent.Stop, () => TestState.Done) .final(TestState.Done); @@ -149,19 +119,13 @@ describe("ActorSystem", () => { Effect.gen(function* () { const SimpleState = State({ Idle: {} }); const SimpleEvent = Event({ Ping: {} }); - const TestSlots2 = Slot.define({ mark: Slot.fn({}) }); - const counter = yield* Ref.make(0); - const machine = materializeMachine( - Machine.make({ - state: SimpleState, - event: SimpleEvent, - slots: TestSlots2, - initial: SimpleState.Idle, - }).background(({ slots }) => slots.mark()), - { mark: () => Ref.update(counter, (n) => n + 1) }, - ); + const machine = Machine.make({ + state: SimpleState, + event: SimpleEvent, + initial: SimpleState.Idle, + }).background(() => Ref.update(counter, (n) => n + 1)); const system = yield* ActorSystemService; yield* system.spawn("dup-actor", machine); @@ -183,19 +147,13 @@ describe("ActorSystem", () => { Effect.gen(function* () { const SimpleState = State({ Idle: {} }); const SimpleEvent = Event({ Ping: {} }); - const TestSlots3 = Slot.define({ mark: Slot.fn({}) }); - const counter = yield* Ref.make(0); - const machine = materializeMachine( - Machine.make({ - state: SimpleState, - event: SimpleEvent, - slots: TestSlots3, - initial: SimpleState.Idle, - }).background(({ slots }) => slots.mark()), - { mark: () => Ref.update(counter, (n) => n + 1) }, - ); + const machine = Machine.make({ + state: SimpleState, + event: SimpleEvent, + initial: SimpleState.Idle, + }).background(() => Ref.update(counter, (n) => n + 1)); const system = yield* ActorSystemService; const [resultA, resultB] = yield* Effect.all( @@ -218,24 +176,6 @@ describe("ActorSystem", () => { }).pipe(Effect.provide(ActorSystemDefault)), ); - it.live("materializeMachine validates missing slot handlers", () => - Effect.sync(() => { - const SimpleState = State({ Idle: {} }); - const SimpleEvent = Event({ Ping: {} }); - const TestSlots4 = Slot.define({ mark: Slot.fn({}) }); - - const machine = Machine.make({ - state: SimpleState, - event: SimpleEvent, - slots: TestSlots4, - initial: SimpleState.Idle, - }); - - // materializeMachine without required handlers throws ProvisionValidationError - expect(() => materializeMachine(machine, {})).toThrow(); - }), - ); - it.scopedLive("listener errors do not break event loop", () => Effect.gen(function* () { const machine = Machine.make({ @@ -417,29 +357,23 @@ describe("Machine.spawn", () => { Effect.gen(function* () { const cleanedUp: string[] = []; - const TestSlots5 = Slot.define({ track: Slot.fn({}) }); - const machine = Machine.make({ state: TestState, event: TestEvent, - slots: TestSlots5, initial: TestState.Idle, }) .on(TestState.Idle, TestEvent.Start, ({ event }) => TestState.Active({ value: event.value }), ) - .spawn(TestState.Active, ({ slots }) => slots.track()); + .spawn(TestState.Active, () => + Effect.addFinalizer(() => Effect.sync(() => cleanedUp.push("cleaned"))), + ); // Run in inner scope — Machine.scoped bridges ActorScope from Scope yield* Effect.scoped( Machine.scoped( Effect.gen(function* () { - const actor = yield* Machine.spawn(machine, { - slots: { - track: () => - Effect.addFinalizer(() => Effect.sync(() => cleanedUp.push("cleaned"))), - }, - }); + const actor = yield* Machine.spawn(machine); yield* actor.start; yield* actor.send(TestEvent.Start({ value: 1 })); yield* yieldFibers; @@ -463,7 +397,7 @@ describe("ActorRef", () => { it.scopedLive("snapshot returns current state (Effect)", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; const state = yield* actor.snapshot; @@ -474,7 +408,7 @@ describe("ActorRef", () => { it.scopedLive("snapshotSync returns current state synchronously", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; const state = actor.sync.snapshot(); @@ -485,7 +419,7 @@ describe("ActorRef", () => { it.scopedLive("snapshot updates after transitions", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; const r = yield* actor.call(TestEvent.Start({ value: 42 })); @@ -499,7 +433,7 @@ describe("ActorRef", () => { it.scopedLive("matches returns true for current state", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; const isIdle = yield* actor.matches("Idle"); @@ -513,7 +447,7 @@ describe("ActorRef", () => { it.scopedLive("matchesSync returns synchronously", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; expect(actor.sync.matches("Idle")).toBe(true); @@ -524,7 +458,7 @@ describe("ActorRef", () => { it.scopedLive("matches updates after transitions", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; const r = yield* actor.call(TestEvent.Start({ value: 10 })); @@ -538,7 +472,7 @@ describe("ActorRef", () => { it.scopedLive("can returns true when transition is possible", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; // In Idle state, can Start @@ -554,7 +488,7 @@ describe("ActorRef", () => { it.scopedLive("canSync returns synchronously", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; expect(actor.sync.can(TestEvent.Start({ value: 1 }))).toBe(true); @@ -565,7 +499,7 @@ describe("ActorRef", () => { it.scopedLive("can accounts for guards", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; // Transition to Active state @@ -587,7 +521,7 @@ describe("ActorRef", () => { it.scopedLive("state provides access to SubscriptionRef", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; // Access state directly @@ -599,7 +533,7 @@ describe("ActorRef", () => { it.scopedLive("state changes stream emits on transitions", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; const tags: string[] = []; @@ -637,7 +571,7 @@ describe("ActorRef", () => { it.scopedLive("subscribe notifies on state changes", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; const states: string[] = []; @@ -658,7 +592,7 @@ describe("ActorRef", () => { it.scopedLive("unsubscribe stops notifications", () => Effect.gen(function* () { const machine = createTestMachine(); - const actor = yield* Machine.spawn(machine, { id: "test", slots: testMachineSlots }); + const actor = yield* Machine.spawn(machine, { id: "test" }); yield* actor.start; const states: string[] = []; diff --git a/test/ask.test.ts b/test/ask.test.ts index 3b8e823..594bce3 100644 --- a/test/ask.test.ts +++ b/test/ask.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics strictEffectProvide:off - tests are entry points -import { Cause, Effect, Schema } from "effect"; +import { Cause, Effect, Exit, Schema } from "effect"; import { Machine, State, Event } from "../src/index.js"; import { describe, expect, it } from "effect-bun-test"; @@ -149,6 +149,100 @@ describe("ActorRef.ask", () => { }), ); + it.scopedLive("decodes a transforming reply schema exactly once", () => + Effect.gen(function* () { + const TransformEvent = Event({ + GetCount: Event.reply({}, Schema.NumberFromString), + }); + + const machine = Machine.make({ + state: TestState, + event: TransformEvent, + initial: TestState.Active({ count: 7 }), + }).on(TestState.Active, TransformEvent.GetCount, ({ state }) => + Machine.reply(state, state.count), + ); + + const actor = yield* Machine.spawn(machine); + yield* actor.start; + + const count = yield* actor.ask(TransformEvent.GetCount); + expect(count).toBe(7); + const typedCount: number = count; + expect(typedCount).toBe(7); + }), + ); + + it.scopedLive("decodes a deferred transforming reply exactly once", () => + Effect.gen(function* () { + const DeferredState = State({ + Idle: {}, + Replying: {}, + }); + const DeferredEvent = Event({ + GetCount: Event.reply({}, Schema.NumberFromString), + }); + const machine = Machine.make({ + state: DeferredState, + event: DeferredEvent, + initial: DeferredState.Idle, + }) + .on(DeferredState.Idle, DeferredEvent.GetCount, () => + Machine.deferReply(DeferredState.Replying), + ) + .spawn(DeferredState.Replying, ({ self }) => + Effect.sleep("10 millis").pipe(Effect.andThen(self.reply(9)), Effect.asVoid), + ); + + const actor = yield* Machine.spawn(machine); + yield* actor.start; + + const count = yield* actor.ask(DeferredEvent.GetCount).pipe(Effect.timeout("2 seconds")); + expect(count).toBe(9); + const typedCount: number = count; + expect(typedCount).toBe(9); + }), + ); + + it.scopedLive("defects a deferred ask when transforming reply encoding fails", () => + Effect.gen(function* () { + const DeferredState = State({ + Idle: {}, + Replying: {}, + }); + const DeferredEvent = Event({ + GetCount: Event.reply({}, Schema.NumberFromString), + }); + const machine = Machine.make({ + state: DeferredState, + event: DeferredEvent, + initial: DeferredState.Idle, + }) + .on(DeferredState.Idle, DeferredEvent.GetCount, () => + Machine.deferReply(DeferredState.Replying), + ) + .spawn(DeferredState.Replying, ({ self }) => + Effect.sleep("10 millis").pipe( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional encoding mismatch + Effect.andThen(self.reply("not-a-number" as any)), + Effect.asVoid, + ), + ); + + const actor = yield* Machine.spawn(machine); + yield* actor.start; + + const exit = yield* actor + .ask(DeferredEvent.GetCount) + .pipe(Effect.timeout("2 seconds"), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toHaveProperty("_tag", "SchemaError"); + } + }), + ); + it.scopedLive("reply schema mismatch is a defect (die)", () => Effect.gen(function* () { // Build a machine where the handler lies about the reply type diff --git a/test/child-actor.test.ts b/test/child-actor.test.ts index bb5e4ee..f3dcfd7 100644 --- a/test/child-actor.test.ts +++ b/test/child-actor.test.ts @@ -1,14 +1,7 @@ // @effect-diagnostics strictEffectProvide:off - tests are entry points import { Effect, Option } from "effect"; -import { - ActorSystemDefault, - ActorSystemService, - Machine, - State, - Event, - Slot, -} from "../src/index.js"; +import { ActorSystemDefault, ActorSystemService, Machine, State, Event } from "../src/index.js"; import { describe, expect, it, yieldFibers } from "effect-bun-test"; // ============================================================================ @@ -363,39 +356,28 @@ describe("Child Actor Support", () => { ); }); - describe("slot handler self.spawn", () => { - it.scopedLive("build() slot handler can spawn children", () => + describe("state effect self.spawn", () => { + it.scopedLive("spawn handler can spawn children", () => Effect.gen(function* () { - const SpawnSlots = Slot.define({ - spawnWorker: Slot.fn({}), - }); - const parentMachine = Machine.make({ state: ParentState, event: ParentEvent, - slots: SpawnSlots, initial: ParentState.Idle, }) .on(ParentState.Idle, ParentEvent.Activate, () => ParentState.Active) - .spawn(ParentState.Active, ({ slots }) => slots.spawnWorker()) + .spawn(ParentState.Active, ({ self }) => + self.spawn("state-child", childMachine).pipe(Effect.asVoid, Effect.orDie), + ) .final(ParentState.Done); - const parent = yield* Machine.spawn(parentMachine, { - slots: { - spawnWorker: () => - Effect.gen(function* () { - const ctx = yield* parentMachine.Context; - yield* ctx.self.spawn("slot-child", childMachine).pipe(Effect.asVoid, Effect.orDie); - }), - }, - }); + const parent = yield* Machine.spawn(parentMachine); yield* parent.start; yield* parent.send(ParentEvent.Activate); yield* Effect.yieldNow; yield* yieldFibers; yield* Effect.sleep("50 millis"); - const child = yield* parent.system.get("slot-child"); + const child = yield* parent.system.get("state-child"); expect(Option.isSome(child)).toBe(true); yield* parent.stop; diff --git a/test/conditional-transitions.test.ts b/test/conditional-transitions.test.ts index a33b749..a0e5dc1 100644 --- a/test/conditional-transitions.test.ts +++ b/test/conditional-transitions.test.ts @@ -2,70 +2,34 @@ import { Effect, Schema } from "effect"; import { describe, expect, test } from "bun:test"; -import { Event, Machine, simulate, State, Slot } from "../src/index.js"; +import { Event, Machine, simulate, State } from "../src/index.js"; describe("Conditional Transitions (replaces choose combinator)", () => { - test("first matching guard wins", async () => { + test("first matching condition wins", async () => { const TestState = State({ Idle: { value: Schema.Number }, High: {}, Medium: {}, Low: {}, }); - - const TestEvent = Event({ - Check: {}, - }); - - const TestSlots = Slot.define({ - isHigh: Slot.fn({}, Schema.Boolean), - isMedium: Slot.fn({}, Schema.Boolean), - }); - - await Effect.runPromise( - Effect.gen(function* () { - const machine = Machine.make({ - state: TestState, - event: TestEvent, - slots: TestSlots, - initial: TestState.Idle({ value: 75 }), - }) - .on(TestState.Idle, TestEvent.Check, ({ slots }) => - Effect.gen(function* () { - if (yield* slots.isHigh()) { - return TestState.High; - } - if (yield* slots.isMedium()) { - return TestState.Medium; - } - return TestState.Low; - }), - ) - .final(TestState.High) - .final(TestState.Medium) - .final(TestState.Low); - - const result = yield* simulate(machine, [TestEvent.Check], { - slots: { - isHigh: () => - Effect.gen(function* () { - const ctx = yield* machine.Context; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = ctx.state as any; - return s._tag === "Idle" && s.value >= 70; - }), - isMedium: () => - Effect.gen(function* () { - const ctx = yield* machine.Context; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = ctx.state as any; - return s._tag === "Idle" && s.value >= 40; - }), - }, - }); - expect(result.finalState._tag).toBe("High"); - }), - ); + const TestEvent = Event({ Check: {} }); + + const machine = Machine.make({ + state: TestState, + event: TestEvent, + initial: TestState.Idle({ value: 75 }), + }) + .on(TestState.Idle, TestEvent.Check, ({ state }) => { + if (state.value >= 70) return TestState.High; + if (state.value >= 40) return TestState.Medium; + return TestState.Low; + }) + .final(TestState.High) + .final(TestState.Medium) + .final(TestState.Low); + + const result = await Effect.runPromise(simulate(machine, [TestEvent.Check])); + expect(result.finalState._tag).toBe("High"); }); test("fallback branch catches all", async () => { @@ -74,93 +38,42 @@ describe("Conditional Transitions (replaces choose combinator)", () => { High: {}, Low: {}, }); - - const TestEvent = Event({ - Check: {}, - }); - - const TestSlots = Slot.define({ - isHigh: Slot.fn({}, Schema.Boolean), - }); - - await Effect.runPromise( - Effect.gen(function* () { - const machine = Machine.make({ - state: TestState, - event: TestEvent, - slots: TestSlots, - initial: TestState.Idle({ value: 10 }), - }) - .on(TestState.Idle, TestEvent.Check, ({ slots }) => - Effect.gen(function* () { - if (yield* slots.isHigh()) { - return TestState.High; - } - // Fallback - return TestState.Low; - }), - ) - .final(TestState.High) - .final(TestState.Low); - - const result = yield* simulate(machine, [TestEvent.Check], { - slots: { - isHigh: () => - Effect.gen(function* () { - const ctx = yield* machine.Context; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = ctx.state as any; - return s._tag === "Idle" && s.value >= 70; - }), - }, - }); - expect(result.finalState._tag).toBe("Low"); - }), - ); + const TestEvent = Event({ Check: {} }); + + const machine = Machine.make({ + state: TestState, + event: TestEvent, + initial: TestState.Idle({ value: 10 }), + }) + .on(TestState.Idle, TestEvent.Check, ({ state }) => + state.value >= 70 ? TestState.High : TestState.Low, + ) + .final(TestState.High) + .final(TestState.Low); + + const result = await Effect.runPromise(simulate(machine, [TestEvent.Check])); + expect(result.finalState._tag).toBe("Low"); }); test("runs effect in matching branch", async () => { - const TestState = State({ - Idle: {}, - Done: {}, - }); - - const TestEvent = Event({ - Go: {}, - }); - - const TestSlots = Slot.define({ - logAction: Slot.fn({ message: Schema.String }), - }); - - await Effect.runPromise( - Effect.gen(function* () { - const logs: string[] = []; - - const machine = Machine.make({ - state: TestState, - event: TestEvent, - slots: TestSlots, - initial: TestState.Idle, - }) - .on(TestState.Idle, TestEvent.Go, ({ slots }) => - Effect.gen(function* () { - yield* slots.logAction({ message: "effect ran" }); - return TestState.Done; - }), - ) - .final(TestState.Done); - - yield* simulate(machine, [TestEvent.Go], { - slots: { - logAction: ({ message }: { message: string }) => - Effect.sync(() => { - logs.push(message); - }), - }, - }); - expect(logs).toEqual(["effect ran"]); - }), - ); + const TestState = State({ Idle: {}, Done: {} }); + const TestEvent = Event({ Go: {} }); + const logs: string[] = []; + + const machine = Machine.make({ + state: TestState, + event: TestEvent, + initial: TestState.Idle, + }) + .on(TestState.Idle, TestEvent.Go, () => + Effect.sync(() => { + logs.push("effect ran"); + return TestState.Done; + }), + ) + .final(TestState.Done); + + await Effect.runPromise(simulate(machine, [TestEvent.Go])); + expect(logs).toEqual(["effect ran"]); }); }); diff --git a/test/integration/cluster-persistence.test.ts b/test/integration/cluster-persistence.test.ts index 5110025..a434aec 100644 --- a/test/integration/cluster-persistence.test.ts +++ b/test/integration/cluster-persistence.test.ts @@ -53,6 +53,22 @@ const counterMachine = Machine.make({ .on(CounterState.Active, CounterEvent.Finish, () => CounterState.Done) .final(CounterState.Done); +const TransformState = State({ + Active: { count: Schema.NumberFromString }, +}); + +const TransformEvent = Event({ + Add: { amount: Schema.NumberFromString }, +}); + +const transformMachine = Machine.make({ + state: TransformState, + event: TransformEvent, + initial: TransformState.Active({ count: 1 }), +}).on(TransformState.Active, TransformEvent.Add, ({ state, event }) => + TransformState.Active({ count: state.count + event.amount }), +); + // ============================================================================= // Helpers // ============================================================================= @@ -119,11 +135,68 @@ const runPersistenceTest = (opts: { }) as Effect.Effect, ); +const runTransformingPersistenceTest = (strategy: "snapshot" | "journal"): Promise => + Effect.runPromise( + Effect.gen(function* () { + const { storeRef, layer: adapterLayer } = yield* makeInMemoryPersistenceAdapter; + const entityType = strategy === "snapshot" ? "TransformSnapshot" : "TransformJournal"; + const entity = toEntity(transformMachine, { type: entityType }); + const entityLayer = EntityMachine.layer(entity, { + initializeState: () => TransformState.Active({ count: 1 }), + persistence: { strategy }, + }); + const provideLayer = entityLayer.pipe( + Layer.provide(ActorSystemDefault), + Layer.provide(adapterLayer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const makeClient = yield* Entity.makeTestClient(entity, provideLayer); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = (yield* makeClient("transform-1")) as any; + const state = yield* client.Send({ event: TransformEvent.Add({ amount: 2 }) }); + expect(state.count).toBe(3); + }), + ).pipe(Effect.provide(TestShardingConfig)); + + const store = yield* Ref.get(storeRef); + const entry = store.get(`${entityType}/transform-1`); + expect(entry?.snapshot?.state).toEqual({ _tag: "Active", count: "3" }); + + if (strategy === "journal") { + expect(entry?.events[0]?.event).toEqual({ _tag: "Add", amount: "2" }); + if (entry !== undefined) entry.snapshot = undefined; + } + + yield* Effect.scoped( + Effect.gen(function* () { + const makeClient = yield* Entity.makeTestClient(entity, provideLayer); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = (yield* makeClient("transform-1")) as any; + const state = yield* client.GetState(); + expect(state._tag).toBe("Active"); + expect(state.count).toBe(3); + const typedCount: number = state.count; + expect(typedCount).toBe(3); + }), + ).pipe(Effect.provide(TestShardingConfig)); + }) as Effect.Effect, + ); + // ============================================================================= // Tests // ============================================================================= describe("Entity Persistence", () => { + test("snapshot: transforming state codec round-trips encoded storage", async () => { + await runTransformingPersistenceTest("snapshot"); + }); + + test("journal: transforming event codec replays encoded storage", async () => { + await runTransformingPersistenceTest("journal"); + }); + // --------------------------------------------------------------------------- // 1. Snapshot strategy — state survives deactivation // --------------------------------------------------------------------------- diff --git a/test/integration/cluster.test.ts b/test/integration/cluster.test.ts index 3902c46..58ae1db 100644 --- a/test/integration/cluster.test.ts +++ b/test/integration/cluster.test.ts @@ -22,7 +22,6 @@ import { simulate, State, Event, - Slot, } from "../../src/index.js"; import { toEntity, EntityMachine, makeEntityActorRef } from "../../src/cluster/index.js"; @@ -238,23 +237,13 @@ describe("Entity.makeTestClient with machine handler", () => { }); type CounterEvent = typeof CounterEvent.Type; - const CounterSlots = Slot.define({ - underLimit: Slot.fn({}, Schema.Boolean), - }); - const counterMachine = Machine.make({ state: CounterState, event: CounterEvent, - slots: CounterSlots, initial: CounterState.Counting({ count: 0 }), }) - .on(CounterState.Counting, CounterEvent.Increment, ({ state, slots }) => - Effect.gen(function* () { - if (yield* slots.underLimit()) { - return CounterState.Counting({ count: state.count + 1 }); - } - return state; - }), + .on(CounterState.Counting, CounterEvent.Increment, ({ state }) => + state.count < 3 ? CounterState.Counting({ count: state.count + 1 }) : state, ) .on(CounterState.Counting, CounterEvent.Finish, ({ state }) => CounterState.Done({ count: state.count }), @@ -322,27 +311,13 @@ describe("Entity.makeTestClient with machine handler", () => { test("guards work with simulate", async () => { await Effect.runPromise( Effect.gen(function* () { - const result = yield* simulate( - counterMachine, - [ - CounterEvent.Increment, - CounterEvent.Increment, - CounterEvent.Increment, - CounterEvent.Increment, // blocked by guard - CounterEvent.Finish, - ], - { - slots: { - underLimit: () => - Effect.gen(function* () { - const ctx = yield* counterMachine.Context; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const state = ctx.state as any; - return state._tag === "Counting" && state.count < 3; - }), - }, - }, - ); + const result = yield* simulate(counterMachine, [ + CounterEvent.Increment, + CounterEvent.Increment, + CounterEvent.Increment, + CounterEvent.Increment, // blocked by guard + CounterEvent.Finish, + ]); expect(result.finalState._tag).toBe("Done"); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test assertion @@ -448,6 +423,42 @@ describe("EntityMachine.layer", () => { ); }); + test("deferred transforming ask decodes once via EntityMachine.layer", async () => { + const AskState = State({ + Idle: {}, + Replying: {}, + }); + const AskEvent = Event({ + GetCount: Event.reply({}, Schema.NumberFromString), + }); + const askMachine = Machine.make({ + state: AskState, + event: AskEvent, + initial: AskState.Idle, + }) + .on(AskState.Idle, AskEvent.GetCount, () => Machine.deferReply(AskState.Replying)) + .spawn(AskState.Replying, ({ self }) => + Effect.sleep("10 millis").pipe(Effect.andThen(self.reply(42)), Effect.asVoid), + ); + const entity = toEntity(askMachine, { type: "DeferredTransformAsk" }); + const entityLayer = EntityMachine.layer(entity, { + initializeState: () => AskState.Idle, + }); + + await Effect.runPromise( + Effect.gen(function* () { + const makeClient = yield* Entity.makeTestClient( + entity, + entityLayer.pipe(Layer.provide(ActorSystemDefault)), + ); + const client = yield* makeClient("deferred-ask-1"); + const ref = makeEntityActorRef(entity, client, "deferred-ask-1"); + const reply: number = yield* ref.ask(AskEvent.GetCount).pipe(Effect.timeout("2 seconds")); + expect(reply).toBe(42); + }).pipe(Effect.scoped, Effect.provide(TestShardingConfig)) as Effect.Effect, + ); + }); + // --------------------------------------------------------------------------- // Test 3: Background effects run // BUG: entity-machine never iterates machine.backgroundEffects diff --git a/test/internal/runtime.test.ts b/test/internal/runtime.test.ts new file mode 100644 index 0000000..bf0c79d --- /dev/null +++ b/test/internal/runtime.test.ts @@ -0,0 +1,59 @@ +// @effect-diagnostics strictEffectProvide:off - tests are entry points +import type { Scope } from "effect"; +import { Cause, Effect, Exit } from "effect"; +import { expect } from "bun:test"; +import { describe, it } from "effect-bun-test"; + +import { Event, Machine, State } from "../../src/index.js"; + +const TestState = State({ + Idle: {}, +}); + +const TestEvent = Event({ + Ping: {}, +}); + +describe("actor start gate", () => { + it.scopedLive("preserves the original defect for concurrent and repeated callers", () => + Effect.gen(function* () { + const defect = new Error("startup exploded"); + const machine = Machine.make({ + state: TestState, + event: TestEvent, + initial: TestState.Idle, + }).spawn(TestState.Idle, (): Effect.Effect => { + // Deliberately violate the handler contract before returning an Effect to exercise + // startup-gate defect propagation rather than an asynchronously forked defect. + throw defect; + }); + const actor = yield* Machine.spawn(machine, { id: "start-gate" }); + + const infallibleStart: Effect.Effect = actor.start; + const concurrent = yield* Effect.all( + [ + infallibleStart.pipe(Effect.exit), + infallibleStart.pipe(Effect.exit), + infallibleStart.pipe(Effect.exit), + ], + { concurrency: "unbounded" }, + ); + const repeated = yield* infallibleStart.pipe(Effect.exit); + + let originalCause: Cause.Cause | undefined; + for (const exit of [...concurrent, repeated]) { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBe(defect); + if (originalCause === undefined) { + originalCause = exit.cause; + } else { + expect(exit.cause).toEqual(originalCause); + } + } + } + + yield* actor.stop; + }), + ); +}); diff --git a/test/internal/transition.test.ts b/test/internal/transition.test.ts index d7e8df1..bd74b0a 100644 --- a/test/internal/transition.test.ts +++ b/test/internal/transition.test.ts @@ -6,9 +6,9 @@ * transition index used for state/event matching. */ import { describe, expect, test } from "bun:test"; -import { Effect, Schema } from "effect"; +import { Schema } from "effect"; -import { Event, Machine, Slot, State } from "../../src/index.js"; +import { Event, Machine, State } from "../../src/index.js"; // Test state machine types const TestState = State({ @@ -61,33 +61,16 @@ describe("Transition Index", () => { expect(noTransitions.length).toBe(0); }); - test("findTransitions returns single transition (guards now in handler)", () => { - // With the new API, guards are checked inside handlers - // So multiple transitions for same state/event just means multiple registrations - const TestSlots = Slot.define({ - isSpecial: Slot.fn({}, Schema.Boolean), - isNormal: Slot.fn({}, Schema.Boolean), - }); - + test("findTransitions returns a single transition with in-handler conditions", () => { const machine = Machine.make({ state: TestState, event: TestEvent, - slots: TestSlots, initial: TestState.Idle, - }).on(TestState.Idle, TestEvent.Start, ({ event, slots }) => - Effect.gen(function* () { - if (yield* slots.isSpecial()) { - return TestState.Loading({ id: event.id }); - } - if (yield* slots.isNormal()) { - return TestState.Loading({ id: event.id }); - } - return TestState.Loading({ id: event.id }); - }), + }).on(TestState.Idle, TestEvent.Start, ({ event }) => + TestState.Loading({ id: event.id.startsWith("special-") ? event.id : `normal-${event.id}` }), ); const transitions = Machine.findTransitions(machine, "Idle", "Start"); - // Now there's just one transition with guards inside the handler expect(transitions.length).toBe(1); }); diff --git a/test/machine.test.ts b/test/machine.test.ts index 704ab9a..15a1875 100644 --- a/test/machine.test.ts +++ b/test/machine.test.ts @@ -2,8 +2,7 @@ import { Effect, Schema } from "effect"; import { describe, expect, test } from "bun:test"; -import { Machine, simulate, State, Event, Slot } from "../src/index.js"; -import { materializeMachine } from "../src/machine.js"; +import { Machine, simulate, State, Event } from "../src/index.js"; const CounterState = State({ Idle: { count: Schema.Number }, @@ -87,58 +86,6 @@ describe("Machine", () => { ); }); - test("supports slots via Slot.define", async () => { - const CounterSlots = Slot.define({ - belowLimit: Slot.fn({ limit: Schema.Number }, Schema.Boolean), - }); - - await Effect.runPromise( - Effect.gen(function* () { - const machine = Machine.make({ - state: CounterState, - event: CounterEvent, - slots: CounterSlots, - initial: CounterState.Counting({ count: 0 }), - }) - .on(CounterState.Counting, CounterEvent.Increment, ({ state, slots }) => - Effect.gen(function* () { - if (yield* slots.belowLimit({ limit: 3 })) { - return CounterState.Counting({ count: state.count + 1 }); - } - return state; - }), - ) - .on(CounterState.Counting, CounterEvent.Stop, ({ state }) => - CounterState.Done({ count: state.count }), - ) - .final(CounterState.Done); - - const result = yield* simulate( - machine, - [ - CounterEvent.Increment, - CounterEvent.Increment, - CounterEvent.Increment, - CounterEvent.Increment, // blocked - CounterEvent.Stop, - ], - { - slots: { - belowLimit: ({ limit }: { limit: number }) => - Effect.gen(function* () { - const ctx = yield* machine.Context; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (ctx.state as any).count < limit; - }), - }, - }, - ); - - expect(result.finalState.count).toBe(3); - }), - ); - }); - test("supports effects in handler via Effect", async () => { await Effect.runPromise( Effect.gen(function* () { @@ -377,55 +324,3 @@ describe(".from()", () => { expect(approved.finalState._tag).toBe("Approved"); }); }); - -// ============================================================================ -// (F7) -// ============================================================================ - -describe("materializeMachine", () => { - test("throws ProvisionValidationError when slots missing", () => { - const TestSlots = Slot.define({ - check: Slot.fn({}, Schema.Boolean), - notify: Slot.fn({}), - }); - - const machine = Machine.make({ - state: CounterState, - event: CounterEvent, - slots: TestSlots, - initial: CounterState.Idle({ count: 0 }), - }); - - expect(() => materializeMachine(machine, {})).toThrow(); - }); - - test("succeeds when all handlers provided", () => { - const TestSlots = Slot.define({ - check: Slot.fn({}, Schema.Boolean), - }); - - const machine = Machine.make({ - state: CounterState, - event: CounterEvent, - slots: TestSlots, - initial: CounterState.Idle({ count: 0 }), - }); - - const materialized = materializeMachine(machine, { - check: () => true, - }); - - expect(materialized.initial._tag).toBe("Idle"); - }); - - test("no-arg materialize works on slotless machine", () => { - const machine = Machine.make({ - state: CounterState, - event: CounterEvent, - initial: CounterState.Idle({ count: 0 }), - }); - - const materialized = materializeMachine(machine); - expect(materialized.initial._tag).toBe("Idle"); - }); -}); diff --git a/test/patterns/menu-navigation.test.ts b/test/patterns/menu-navigation.test.ts index 9fdb993..1b23fc2 100644 --- a/test/patterns/menu-navigation.test.ts +++ b/test/patterns/menu-navigation.test.ts @@ -7,7 +7,6 @@ import { Event, Machine, simulate, - Slot, State, } from "../../src/index.js"; @@ -65,40 +64,31 @@ describe("Menu Navigation Pattern", () => { const cart: string[] = []; - const MenuSlots = Slot.define({ - canNavigateToPage: Slot.fn({}, Schema.Boolean), - canScrollToSection: Slot.fn({}, Schema.Boolean), - }); - const menuMachine = Machine.make({ state: MenuState, event: MenuEvent, - slots: MenuSlots, initial: MenuState.Browsing({ pageId: "food", sectionIndex: 0, itemIndex: null }), }) // Browsing handlers // Navigate to different page (reset section) - .on(MenuState.Browsing, MenuEvent.NavigateToPage, ({ state, event, slots }) => - Effect.gen(function* () { - if (yield* slots.canNavigateToPage()) { - return MenuState.Browsing({ pageId: event.pageId, sectionIndex: 0, itemIndex: null }); - } - return state; - }), + .on(MenuState.Browsing, MenuEvent.NavigateToPage, ({ state, event }) => + state.pageId !== event.pageId && pages.some((page) => page.id === event.pageId) + ? MenuState.Browsing({ pageId: event.pageId, sectionIndex: 0, itemIndex: null }) + : state, ) // Scroll to section - .on(MenuState.Browsing, MenuEvent.ScrollToSection, ({ state, event, slots }) => - Effect.gen(function* () { - if (yield* slots.canScrollToSection()) { - return MenuState.Browsing({ + .on(MenuState.Browsing, MenuEvent.ScrollToSection, ({ state, event }) => { + const page = pages.find((candidate) => candidate.id === state.pageId); + return page !== undefined && + event.sectionIndex >= 0 && + event.sectionIndex < page.sections.length + ? MenuState.Browsing({ ...state, sectionIndex: event.sectionIndex, itemIndex: null, - }); - } - return state; - }), - ) + }) + : state; + }) // Select item .on(MenuState.Browsing, MenuEvent.SelectItem, ({ state, event }) => MenuState.ItemSelected({ @@ -133,36 +123,12 @@ describe("Menu Navigation Pattern", () => { .on(MenuState.Checkout, MenuEvent.Close, () => MenuState.Closed) .final(MenuState.Closed); - const menuSlots = { - canNavigateToPage: () => - Effect.gen(function* () { - const ctx = yield* menuMachine.Context; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = ctx.state as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = ctx.event as any; - return s.pageId !== e.pageId && pages.some((p: Page) => p.id === e.pageId); - }), - canScrollToSection: () => - Effect.gen(function* () { - const ctx = yield* menuMachine.Context; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = ctx.state as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = ctx.event as any; - const page = pages.find((p: Page) => p.id === s.pageId); - return page !== undefined && e.sectionIndex >= 0 && e.sectionIndex < page.sections.length; - }), - }; - test("page navigation with valid page", async () => { await Effect.runPromise( Effect.gen(function* () { - const result = yield* simulate( - menuMachine, - [MenuEvent.NavigateToPage({ pageId: "drinks" })], - { slots: menuSlots }, - ); + const result = yield* simulate(menuMachine, [ + MenuEvent.NavigateToPage({ pageId: "drinks" }), + ]); expect(result.finalState._tag).toBe("Browsing"); expect((result.finalState as BrowsingState).pageId).toBe("drinks"); @@ -174,14 +140,10 @@ describe("Menu Navigation Pattern", () => { test("page navigation to same page is no-op", async () => { await Effect.runPromise( Effect.gen(function* () { - const result = yield* simulate( - menuMachine, - [ - MenuEvent.ScrollToSection({ sectionIndex: 1 }), - MenuEvent.NavigateToPage({ pageId: "food" }), // Same page - ], - { slots: menuSlots }, - ); + const result = yield* simulate(menuMachine, [ + MenuEvent.ScrollToSection({ sectionIndex: 1 }), + MenuEvent.NavigateToPage({ pageId: "food" }), // Same page + ]); // Section should still be 1 (internal transition preserved state) expect((result.finalState as BrowsingState).sectionIndex).toBe(1); @@ -192,11 +154,9 @@ describe("Menu Navigation Pattern", () => { test("page navigation to invalid page blocked", async () => { await Effect.runPromise( Effect.gen(function* () { - const result = yield* simulate( - menuMachine, - [MenuEvent.NavigateToPage({ pageId: "nonexistent" })], - { slots: menuSlots }, - ); + const result = yield* simulate(menuMachine, [ + MenuEvent.NavigateToPage({ pageId: "nonexistent" }), + ]); // Should stay on food (initial page) expect((result.finalState as BrowsingState).pageId).toBe("food"); @@ -207,11 +167,9 @@ describe("Menu Navigation Pattern", () => { test("section scrolling with valid index", async () => { await Effect.runPromise( Effect.gen(function* () { - const result = yield* simulate( - menuMachine, - [MenuEvent.ScrollToSection({ sectionIndex: 1 })], - { slots: menuSlots }, - ); + const result = yield* simulate(menuMachine, [ + MenuEvent.ScrollToSection({ sectionIndex: 1 }), + ]); expect((result.finalState as BrowsingState).sectionIndex).toBe(1); }), @@ -221,13 +179,9 @@ describe("Menu Navigation Pattern", () => { test("section scrolling with invalid index blocked", async () => { await Effect.runPromise( Effect.gen(function* () { - const result = yield* simulate( - menuMachine, - [ - MenuEvent.ScrollToSection({ sectionIndex: 99 }), // Invalid - ], - { slots: menuSlots }, - ); + const result = yield* simulate(menuMachine, [ + MenuEvent.ScrollToSection({ sectionIndex: 99 }), // Invalid + ]); expect((result.finalState as BrowsingState).sectionIndex).toBe(0); }), @@ -240,7 +194,6 @@ describe("Menu Navigation Pattern", () => { menuMachine, [MenuEvent.SelectItem({ itemId: "burger" }), MenuEvent.AddToCart], ["Browsing", "ItemSelected", "Browsing"], - { slots: menuSlots }, ), ); }); @@ -248,15 +201,11 @@ describe("Menu Navigation Pattern", () => { test("cancel selection returns to browsing", async () => { await Effect.runPromise( Effect.gen(function* () { - const result = yield* simulate( - menuMachine, - [ - MenuEvent.ScrollToSection({ sectionIndex: 1 }), - MenuEvent.SelectItem({ itemId: "burger" }), - MenuEvent.Close, - ], - { slots: menuSlots }, - ); + const result = yield* simulate(menuMachine, [ + MenuEvent.ScrollToSection({ sectionIndex: 1 }), + MenuEvent.SelectItem({ itemId: "burger" }), + MenuEvent.Close, + ]); expect(result.finalState._tag).toBe("Browsing"); // Preserves section from before selection @@ -271,15 +220,12 @@ describe("Menu Navigation Pattern", () => { menuMachine, [MenuEvent.SelectItem({ itemId: "fries" }), MenuEvent.AddToCart, MenuEvent.GoToCheckout], ["Browsing", "ItemSelected", "Browsing", "Checkout"], - { slots: menuSlots }, ), ); }); test("close menu from browsing", async () => { - await Effect.runPromise( - assertPath(menuMachine, [MenuEvent.Close], ["Browsing", "Closed"], { slots: menuSlots }), - ); + await Effect.runPromise(assertPath(menuMachine, [MenuEvent.Close], ["Browsing", "Closed"])); }); test("navigation never reaches checkout without explicit action", async () => { @@ -292,7 +238,6 @@ describe("Menu Navigation Pattern", () => { MenuEvent.NavigateToPage({ pageId: "food" }), ], "Checkout", - { slots: menuSlots }, ), ); }); @@ -300,20 +245,16 @@ describe("Menu Navigation Pattern", () => { test("complex navigation flow", async () => { await Effect.runPromise( Effect.gen(function* () { - const result = yield* simulate( - menuMachine, - [ - MenuEvent.NavigateToPage({ pageId: "drinks" }), - MenuEvent.ScrollToSection({ sectionIndex: 1 }), - MenuEvent.SelectItem({ itemId: "beer" }), - MenuEvent.Close, // Cancel, back to browsing - MenuEvent.NavigateToPage({ pageId: "food" }), - MenuEvent.SelectItem({ itemId: "burger" }), - MenuEvent.AddToCart, - MenuEvent.GoToCheckout, - ], - { slots: menuSlots }, - ); + const result = yield* simulate(menuMachine, [ + MenuEvent.NavigateToPage({ pageId: "drinks" }), + MenuEvent.ScrollToSection({ sectionIndex: 1 }), + MenuEvent.SelectItem({ itemId: "beer" }), + MenuEvent.Close, // Cancel, back to browsing + MenuEvent.NavigateToPage({ pageId: "food" }), + MenuEvent.SelectItem({ itemId: "burger" }), + MenuEvent.AddToCart, + MenuEvent.GoToCheckout, + ]); expect(result.finalState._tag).toBe("Checkout"); }), diff --git a/test/patterns/payment-flow.test.ts b/test/patterns/payment-flow.test.ts index a0896c5..121aca7 100644 --- a/test/patterns/payment-flow.test.ts +++ b/test/patterns/payment-flow.test.ts @@ -8,7 +8,6 @@ import { assertPath, Event, Machine, - Slot, State, } from "../../src/index.js"; import { describe, expect, it, yieldFibers } from "effect-bun-test"; @@ -50,16 +49,9 @@ describe("Payment Flow Pattern", () => { }); type PaymentEvent = typeof PaymentEvent.Type; - const PaymentSlots = Slot.define({ - canRetry: Slot.fn({}, Schema.Boolean), - scheduleBridgeTimeout: Slot.fn({}), - scheduleAutoDismiss: Slot.fn({}), - }); - const paymentMachine = Machine.make({ state: PaymentState, event: PaymentEvent, - slots: PaymentSlots, initial: PaymentState.Idle, }) .on(PaymentState.Idle, PaymentEvent.StartCheckout, ({ event }) => @@ -100,17 +92,14 @@ describe("Payment Flow Pattern", () => { }), ) // Error handling - retry with guard - .on(PaymentState.PaymentError, PaymentEvent.Retry, ({ state, slots }) => - Effect.gen(function* () { - if (yield* slots.canRetry()) { - return PaymentState.ProcessingPayment({ + .on(PaymentState.PaymentError, PaymentEvent.Retry, ({ state }) => + state.canRetry && state.attempts < 3 + ? PaymentState.ProcessingPayment({ method: "card", amount: state.amount, attempts: state.attempts + 1, - }); - } - return state; - }), + }) + : state, ) // Auto-dismiss goes back to idle (only for non-retryable errors) .on(PaymentState.PaymentError, PaymentEvent.AutoDismissError, ({ state }) => { @@ -121,12 +110,12 @@ describe("Payment Flow Pattern", () => { return state; // Stay in error state }) // Timeout tasks - .task(PaymentState.AwaitingBridgeConfirm, ({ slots }) => slots.scheduleBridgeTimeout(), { + .task(PaymentState.AwaitingBridgeConfirm, () => Effect.sleep("30 seconds"), { onSuccess: () => PaymentEvent.BridgeTimeout, }) // Delay timer only fires for non-retryable errors // This works because the timer still fires, but the transition handler can check state - .task(PaymentState.PaymentError, ({ slots }) => slots.scheduleAutoDismiss(), { + .task(PaymentState.PaymentError, () => Effect.sleep("5 seconds"), { onSuccess: () => PaymentEvent.AutoDismissError, }) // Cancel from multiple states @@ -141,18 +130,6 @@ describe("Payment Flow Pattern", () => { .final(PaymentState.PaymentSuccess) .final(PaymentState.PaymentCancelled); - const paymentSlots = { - canRetry: () => - Effect.gen(function* () { - const ctx = yield* paymentMachine.Context; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = ctx.state as any; - return s.canRetry === true && s.attempts < 3; - }), - scheduleBridgeTimeout: () => Effect.sleep("30 seconds"), - scheduleAutoDismiss: () => Effect.sleep("5 seconds"), - }; - it.live("card payment happy path", () => assertPath( paymentMachine, @@ -162,7 +139,6 @@ describe("Payment Flow Pattern", () => { PaymentEvent.PaymentSucceeded({ receiptId: "rcpt-123" }), ], ["Idle", "SelectingMethod", "ProcessingPayment", "PaymentSuccess"], - { slots: paymentSlots }, ), ); @@ -175,7 +151,6 @@ describe("Payment Flow Pattern", () => { PaymentEvent.BridgeConfirmed({ transactionId: "tx-456" }), ], ["Idle", "SelectingMethod", "AwaitingBridgeConfirm", "PaymentSuccess"], - { slots: paymentSlots }, ), ); @@ -197,16 +172,12 @@ describe("Payment Flow Pattern", () => { "ProcessingPayment", "PaymentSuccess", ], - { slots: paymentSlots }, ), ); it.scopedLive("retry blocked after max attempts", () => Effect.gen(function* () { - const actor = yield* Machine.spawn(paymentMachine, { - id: "payment", - slots: paymentSlots, - }); + const actor = yield* Machine.spawn(paymentMachine, { id: "payment" }); yield* actor.start; yield* actor.send(PaymentEvent.StartCheckout({ amount: 50 })); @@ -242,7 +213,6 @@ describe("Payment Flow Pattern", () => { PaymentEvent.Cancel, ], ["Idle", "SelectingMethod", "ProcessingPayment", "PaymentCancelled"], - { slots: paymentSlots }, ), ); @@ -251,16 +221,12 @@ describe("Payment Flow Pattern", () => { paymentMachine, [PaymentEvent.StartCheckout({ amount: 100 }), PaymentEvent.Cancel], "PaymentSuccess", - { slots: paymentSlots }, ), ); it.scoped("bridge timeout triggers error", () => Effect.gen(function* () { - const actor = yield* Machine.spawn(paymentMachine, { - id: "payment", - slots: paymentSlots, - }); + const actor = yield* Machine.spawn(paymentMachine, { id: "payment" }); yield* actor.start; yield* actor.send(PaymentEvent.StartCheckout({ amount: 100 })); @@ -281,10 +247,7 @@ describe("Payment Flow Pattern", () => { it.scoped("non-retryable error auto-dismisses", () => Effect.gen(function* () { - const actor = yield* Machine.spawn(paymentMachine, { - id: "payment", - slots: paymentSlots, - }); + const actor = yield* Machine.spawn(paymentMachine, { id: "payment" }); yield* actor.start; yield* actor.send(PaymentEvent.StartCheckout({ amount: 100 })); diff --git a/test/patterns/session-lifecycle.test.ts b/test/patterns/session-lifecycle.test.ts index 1cb762b..40637c8 100644 --- a/test/patterns/session-lifecycle.test.ts +++ b/test/patterns/session-lifecycle.test.ts @@ -2,7 +2,7 @@ import { Clock, Effect, Schema, SubscriptionRef } from "effect"; import { TestClock } from "effect/testing"; -import { ActorSystemDefault, assertPath, Event, Machine, Slot, State } from "../../src/index.js"; +import { ActorSystemDefault, assertPath, Event, Machine, State } from "../../src/index.js"; import { describe, expect, it, yieldFibers } from "effect-bun-test"; /** @@ -31,10 +31,6 @@ describe("Session Lifecycle Pattern", () => { Logout: {}, }); - const SessionSlots = Slot.define({ - scheduleTimeout: Slot.fn({}), - }); - // Helper to compute initial state based on token const makeSessionMachine = (token: string | null) => { // Initial state computed inline - no need for .always() @@ -46,7 +42,6 @@ describe("Session Lifecycle Pattern", () => { return Machine.make({ state: SessionState, event: SessionEvent, - slots: SessionSlots, initial, }) .on(SessionState.Guest, SessionEvent.Login, ({ event }) => @@ -62,7 +57,7 @@ describe("Session Lifecycle Pattern", () => { }), ) .on(SessionState.Active, SessionEvent.SessionTimeout, () => SessionState.SessionExpired) - .task(SessionState.Active, ({ slots }) => slots.scheduleTimeout(), { + .task(SessionState.Active, () => Effect.sleep("30 minutes"), { onSuccess: () => SessionEvent.SessionTimeout, }) .on(SessionState.Maintenance, SessionEvent.MaintenanceEnded, ({ state }) => @@ -75,14 +70,10 @@ describe("Session Lifecycle Pattern", () => { .final(SessionState.LoggedOut); }; - const sessionSlots = { - scheduleTimeout: () => Effect.sleep("30 minutes"), - }; - it.live("null token starts as Guest", () => Effect.gen(function* () { const machine = makeSessionMachine(null); - const result = yield* assertPath(machine, [], ["Guest"], { slots: sessionSlots }); + const result = yield* assertPath(machine, [], ["Guest"]); expect(result.finalState._tag).toBe("Guest"); }), ); @@ -90,7 +81,7 @@ describe("Session Lifecycle Pattern", () => { it.live("valid token starts as Active", () => Effect.gen(function* () { const machine = makeSessionMachine("valid-token"); - const result = yield* assertPath(machine, [], ["Active"], { slots: sessionSlots }); + const result = yield* assertPath(machine, [], ["Active"]); expect(result.finalState._tag).toBe("Active"); }), ); @@ -102,7 +93,6 @@ describe("Session Lifecycle Pattern", () => { machine, [SessionEvent.Login({ userId: "user-123", role: "user" })], ["Guest", "Active"], - { slots: sessionSlots }, ); expect(result.finalState._tag).toBe("Active"); }), @@ -139,7 +129,6 @@ describe("Session Lifecycle Pattern", () => { const activeMachine = Machine.make({ state: SessionState, event: SessionEvent, - slots: SessionSlots, initial: SessionState.Active({ userId: "user-1", role: "user", @@ -147,15 +136,12 @@ describe("Session Lifecycle Pattern", () => { }), }) .on(SessionState.Active, SessionEvent.SessionTimeout, () => SessionState.SessionExpired) - .task(SessionState.Active, ({ slots }) => slots.scheduleTimeout(), { + .task(SessionState.Active, () => Effect.sleep("30 minutes"), { onSuccess: () => SessionEvent.SessionTimeout, }) .final(SessionState.SessionExpired); - const actor = yield* Machine.spawn(activeMachine, { - id: "session", - slots: sessionSlots, - }); + const actor = yield* Machine.spawn(activeMachine, { id: "session" }); yield* actor.start; let state = yield* SubscriptionRef.get(actor.state); @@ -185,14 +171,13 @@ describe("Session Lifecycle Pattern", () => { const activeMachine = Machine.make({ state: SessionState, event: SessionEvent, - slots: SessionSlots, initial: SessionState.Active({ userId: "user-1", role: "user", lastActivity: now, }), }) - .task(SessionState.Active, ({ slots }) => slots.scheduleTimeout(), { + .task(SessionState.Active, () => Effect.sleep("30 minutes"), { onSuccess: () => SessionEvent.SessionTimeout, }) .on(SessionState.Active, SessionEvent.SessionTimeout, () => SessionState.SessionExpired) @@ -202,10 +187,7 @@ describe("Session Lifecycle Pattern", () => { ) .final(SessionState.SessionExpired); - const actor = yield* Machine.spawn(activeMachine, { - id: "session", - slots: sessionSlots, - }); + const actor = yield* Machine.spawn(activeMachine, { id: "session" }); yield* actor.start; // Activity after 20 minutes diff --git a/test/reenter.test.ts b/test/reenter.test.ts index 6911c58..f03b16c 100644 --- a/test/reenter.test.ts +++ b/test/reenter.test.ts @@ -2,14 +2,7 @@ import { Effect, Schema, SubscriptionRef } from "effect"; import { TestClock } from "effect/testing"; -import { - ActorSystemDefault, - ActorSystemService, - Event, - Machine, - Slot, - State, -} from "../src/index.js"; +import { ActorSystemDefault, ActorSystemService, Event, Machine, State } from "../src/index.js"; import { describe, expect, it, yieldFibers } from "effect-bun-test"; describe("Same-state Transitions", () => { @@ -121,10 +114,6 @@ describe("Reenter Transitions", () => { Finish: {}, }); - const PollSlots = Slot.define({ - runPollingEffect: Slot.fn({}), - }); - it.scopedLive("reenter runs exit/enter for same state tag", () => Effect.gen(function* () { const effects: string[] = []; @@ -185,23 +174,17 @@ describe("Reenter Transitions", () => { const machine = Machine.make({ state: PollState, event: PollEvent, - slots: PollSlots, initial: PollState.Polling({ attempts: 0 }), }) .on(PollState.Polling, PollEvent.Poll, () => PollState.Done) .reenter(PollState.Polling, PollEvent.Reset, ({ state }) => PollState.Polling.with(state, { attempts: state.attempts + 1 }), ) - .task(PollState.Polling, ({ slots }) => slots.runPollingEffect(), { + .task(PollState.Polling, () => Effect.sleep("5 seconds"), { onSuccess: () => PollEvent.Poll, }); - const actor = yield* Machine.spawn(machine, { - id: "poller", - slots: { - runPollingEffect: () => Effect.sleep("5 seconds"), - }, - }); + const actor = yield* Machine.spawn(machine, { id: "poller" }); yield* actor.start; // Advance 3 seconds diff --git a/test/slot.test.ts b/test/slot.test.ts deleted file mode 100644 index 9ecab19..0000000 --- a/test/slot.test.ts +++ /dev/null @@ -1,466 +0,0 @@ -// @effect-diagnostics strictEffectProvide:off - tests are entry points -// @effect-diagnostics anyUnknownInErrorContext:off - validation tests use `as any` casts -// @effect-diagnostics missingEffectContext:off -// @effect-diagnostics missingEffectError:off -import { Effect, Schema } from "effect"; -import { describe, expect, test } from "bun:test"; - -import { Event, Machine, simulate, State, Slot } from "../src/index.js"; - -describe("Parameterized Slots (via Slot.define)", () => { - const TestState = State({ - Ready: { canPrint: Schema.Boolean }, - Printing: {}, - Done: {}, - }); - - const TestEvent = Event({ - Print: {}, - Finish: {}, - }); - - const TestSlots = Slot.define({ - canPrint: Slot.fn({}, Schema.Boolean), - }); - - test("slot blocks transition when handler returns false", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const machine = Machine.make({ - state: TestState, - event: TestEvent, - slots: TestSlots, - initial: TestState.Ready({ canPrint: false }), - }).on(TestState.Ready, TestEvent.Print, ({ state, slots }) => - Effect.gen(function* () { - if (yield* slots.canPrint()) { - return TestState.Printing; - } - return state; - }), - ); - - const result = yield* simulate(machine, [TestEvent.Print], { - slots: { - canPrint: () => false, - }, - }); - expect(result.finalState._tag).toBe("Ready"); - }), - ); - }); - - test("slot allows transition when handler returns true", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const machine = Machine.make({ - state: TestState, - event: TestEvent, - slots: TestSlots, - initial: TestState.Ready({ canPrint: true }), - }).on(TestState.Ready, TestEvent.Print, ({ state, slots }) => - Effect.gen(function* () { - if (yield* slots.canPrint()) { - return TestState.Printing; - } - return state; - }), - ); - - const result = yield* simulate(machine, [TestEvent.Print], { - slots: { - canPrint: () => true, - }, - }); - expect(result.finalState._tag).toBe("Printing"); - }), - ); - }); -}); - -describe("Parameterized Slots with Parameters", () => { - const AuthState = State({ - Idle: { role: Schema.String, age: Schema.Number }, - Allowed: {}, - Denied: {}, - }); - - const AuthEvent = Event({ - Access: {}, - }); - - const AuthSlots = Slot.define({ - isAdmin: Slot.fn({}, Schema.Boolean), - isAdult: Slot.fn({ minAge: Schema.Number }, Schema.Boolean), - isModerator: Slot.fn({}, Schema.Boolean), - }); - - test("slot with parameters: isAdult({ minAge: 18 })", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const machine = Machine.make({ - state: AuthState, - event: AuthEvent, - slots: AuthSlots, - initial: AuthState.Idle({ role: "admin", age: 25 }), - }) - .on(AuthState.Idle, AuthEvent.Access, ({ slots }) => - Effect.gen(function* () { - const isAdmin = yield* slots.isAdmin(); - const isAdult = yield* slots.isAdult({ minAge: 18 }); - if (isAdmin && isAdult) { - return AuthState.Allowed; - } - return AuthState.Denied; - }), - ) - .final(AuthState.Allowed) - .final(AuthState.Denied); - - const authSlots = { - isAdmin: () => true, - isAdult: ({ minAge }: { minAge: number }) => minAge <= 25, - isModerator: () => false, - }; - - const result = yield* simulate(machine, [AuthEvent.Access], { slots: authSlots }); - expect(result.finalState._tag).toBe("Allowed"); - }), - ); - }); - - test("combined slot logic with && / ||", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const machine = Machine.make({ - state: AuthState, - event: AuthEvent, - slots: AuthSlots, - initial: AuthState.Idle({ role: "moderator", age: 25 }), - }) - .on(AuthState.Idle, AuthEvent.Access, ({ slots }) => - Effect.gen(function* () { - // (admin OR moderator) AND adult - const isAdmin = yield* slots.isAdmin(); - const isMod = yield* slots.isModerator(); - const isAdult = yield* slots.isAdult({ minAge: 18 }); - if ((isAdmin || isMod) && isAdult) { - return AuthState.Allowed; - } - return AuthState.Denied; - }), - ) - .final(AuthState.Allowed) - .final(AuthState.Denied); - - const authSlots = { - isAdmin: () => false, - isAdult: ({ minAge }: { minAge: number }) => minAge <= 25, - isModerator: () => true, - }; - - const result = yield* simulate(machine, [AuthEvent.Access], { slots: authSlots }); - expect(result.finalState._tag).toBe("Allowed"); - }), - ); - }); - - test("NOT logic with !", async () => { - const LockedSlots = Slot.define({ - isGuest: Slot.fn({}, Schema.Boolean), - }); - - await Effect.runPromise( - Effect.gen(function* () { - const machine = Machine.make({ - state: AuthState, - event: AuthEvent, - slots: LockedSlots, - initial: AuthState.Idle({ role: "user", age: 20 }), - }) - .on(AuthState.Idle, AuthEvent.Access, ({ slots }) => - Effect.gen(function* () { - const isGuest = yield* slots.isGuest(); - // NOT guest = allowed - if (!isGuest) { - return AuthState.Allowed; - } - return AuthState.Denied; - }), - ) - .final(AuthState.Allowed) - .final(AuthState.Denied); - - const result = yield* simulate(machine, [AuthEvent.Access], { - slots: { - isGuest: () => false, - }, - }); - expect(result.finalState._tag).toBe("Allowed"); - }), - ); - }); -}); - -// ============================================================================ -// Slot Schema Tests -// ============================================================================ - -describe("Slot schemas", () => { - const MySlots = Slot.define({ - canRetry: Slot.fn({ max: Schema.Number }, Schema.Boolean), - fetchData: Slot.fn({ url: Schema.String }), - computeValue: Slot.fn({ input: Schema.Number }, Schema.Number), - }); - - test("SlotFnDef has inputSchema and outputSchema", () => { - const canRetryDef = MySlots.definitions.canRetry; - expect(canRetryDef.inputSchema).toBeDefined(); - expect(canRetryDef.outputSchema).toBeDefined(); - - // Input schema decodes correctly - const params = Schema.decodeUnknownSync(canRetryDef.inputSchema)({ max: 3 }); - expect(params).toEqual({ max: 3 }); - - // Output schema decodes correctly - const result = Schema.decodeUnknownSync(canRetryDef.outputSchema)(true); - expect(result).toBe(true); - }); - - test("void-returning slot has Schema.Void as outputSchema", () => { - const fetchDef = MySlots.definitions.fetchData; - expect(fetchDef.returnSchema).toBeUndefined(); - // outputSchema is Schema.Void - const result = Schema.decodeUnknownSync(fetchDef.outputSchema)(undefined); - expect(result).toBeUndefined(); - }); - - test("empty-fields slot has Schema.Void as inputSchema", () => { - const EmptySlots = Slot.define({ - ping: Slot.fn({}, Schema.Boolean), - }); - const result = Schema.decodeUnknownSync(EmptySlots.definitions.ping.inputSchema)(undefined); - expect(result).toBeUndefined(); - }); - - test("input schema rejects invalid data", () => { - const canRetryDef = MySlots.definitions.canRetry; - expect(() => - Schema.decodeUnknownSync(canRetryDef.inputSchema)({ max: "not a number" }), - ).toThrow(); - expect(() => Schema.decodeUnknownSync(canRetryDef.inputSchema)({})).toThrow(); - }); - - test("output schema rejects invalid data", () => { - const canRetryDef = MySlots.definitions.canRetry; - expect(() => Schema.decodeUnknownSync(canRetryDef.outputSchema)("not a boolean")).toThrow(); - }); - - test("invocationSchema decodes slot invocations", () => { - const decoded = Schema.decodeUnknownSync(MySlots.invocationSchema)({ - _tag: "SlotInvocation", - name: "canRetry", - params: { max: 3 }, - result: true, - }); - expect(decoded).toEqual({ - _tag: "SlotInvocation", - name: "canRetry", - params: { max: 3 }, - result: true, - }); - }); - - test("invocationSchema rejects unknown slot names", () => { - expect(() => - Schema.decodeUnknownSync(MySlots.invocationSchema)({ - _tag: "SlotInvocation", - name: "unknown", - params: {}, - result: null, - }), - ).toThrow(); - }); - - test("requestSchema decodes slot requests", () => { - const decoded = Schema.decodeUnknownSync(MySlots.requestSchema)({ - _tag: "SlotRequest", - name: "canRetry", - params: { max: 3 }, - }); - expect(decoded).toEqual({ _tag: "SlotRequest", name: "canRetry", params: { max: 3 } }); - }); - - test("resultSchema decodes slot results", () => { - const decoded = Schema.decodeUnknownSync(MySlots.resultSchema)({ - _tag: "SlotResult", - name: "computeValue", - result: 42, - }); - expect(decoded).toEqual({ _tag: "SlotResult", name: "computeValue", result: 42 }); - }); - - test("requestSchema rejects unknown slot names", () => { - expect(() => - Schema.decodeUnknownSync(MySlots.requestSchema)({ - _tag: "SlotRequest", - name: "unknown", - params: {}, - }), - ).toThrow(); - }); - - test("invocationSchema encodes slot invocations", () => { - const encoded = Schema.encodeSync(MySlots.invocationSchema)({ - _tag: "SlotInvocation", - name: "computeValue", - params: { input: 42 }, - result: 84, - }); - expect(encoded).toEqual({ - _tag: "SlotInvocation", - name: "computeValue", - params: { input: 42 }, - result: 84, - }); - }); -}); - -// ============================================================================ -// Slot Runtime Validation Tests -// ============================================================================ - -describe("Slot runtime validation", () => { - const ValState = State({ - Idle: {}, - Done: { result: Schema.Number }, - }); - - const ValEvent = Event({ - Go: {}, - }); - - const ValSlots = Slot.define({ - compute: Slot.fn({ input: Schema.Number }, Schema.Number), - }); - - test("validates output — rejects wrong return type (defect)", async () => { - const machine = Machine.make({ - state: ValState, - event: ValEvent, - slots: ValSlots, - initial: ValState.Idle, - }).on(ValState.Idle, ValEvent.Go, ({ slots }) => - slots.compute({ input: 5 }).pipe(Effect.map((result) => ValState.Done({ result }))), - ); - - const result = await Effect.runPromise( - simulate(machine, [ValEvent.Go], { - // Handler returns string instead of number — output validation catches it - // eslint-disable-next-line @typescript-eslint/no-explicit-any - slots: { compute: () => "not a number" } as any, - }).pipe(Effect.exit), - ); - // Should be a defect (die) due to SlotCodecError on output phase - expect(result._tag).toBe("Failure"); - }); - - test("validates input — rejects wrong param type (defect)", async () => { - // Use a slot where the handler itself triggers input validation - // by being called with wrong types at runtime - const InputSlots = Slot.define({ - lookup: Slot.fn({ id: Schema.Number }, Schema.String), - }); - const InputState = State({ Idle: {}, Done: { name: Schema.String } }); - const InputEvent = Event({ Go: { id: Schema.Number } }); - - const machine = Machine.make({ - state: InputState, - event: InputEvent, - slots: InputSlots, - initial: InputState.Idle, - }).on(InputState.Idle, InputEvent.Go, ({ event, slots }) => - slots.lookup({ id: event.id }).pipe(Effect.map((name) => InputState.Done({ name }))), - ); - - // Slot handler receives pre-validated params; to test input validation - // we provide a handler and check it receives correct types - const result = await Effect.runPromise( - simulate(machine, [InputEvent.Go({ id: 42 })], { - slots: { lookup: ({ id }: { id: number }) => `user-${id}` }, - }), - ); - expect(result.finalState._tag).toBe("Done"); - expect((result.finalState as { name: string }).name).toBe("user-42"); - }); - - test("valid input/output passes through", async () => { - const machine = Machine.make({ - state: ValState, - event: ValEvent, - slots: ValSlots, - initial: ValState.Idle, - }).on(ValState.Idle, ValEvent.Go, ({ slots }) => - slots.compute({ input: 5 }).pipe(Effect.map((result) => ValState.Done({ result }))), - ); - - const result = await Effect.runPromise( - simulate(machine, [ValEvent.Go], { - slots: { compute: ({ input }: { input: number }) => input * 2 }, - }), - ); - expect(result.finalState._tag).toBe("Done"); - expect((result.finalState as { result: number }).result).toBe(10); - }); - - test("slotValidation: false disables validation", async () => { - const machine = Machine.make({ - state: ValState, - event: ValEvent, - slots: ValSlots, - initial: ValState.Idle, - slotValidation: false, - }).on(ValState.Idle, ValEvent.Go, ({ slots }) => - slots.compute({ input: 5 }).pipe(Effect.map((result) => ValState.Done({ result }))), - ); - - // With validation off, wrong return type goes through unchecked - const result = await Effect.runPromise( - simulate(machine, [ValEvent.Go], { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - slots: { compute: () => "not a number" } as any, - }), - ); - expect(result.finalState._tag).toBe("Done"); - // The string went through unchecked - expect((result.finalState as { result: unknown }).result).toBe("not a number"); - }); - - test("plain object return works (not treated as Effect)", async () => { - const ObjSlots = Slot.define({ - getData: Slot.fn({}, Schema.Struct({ value: Schema.Number })), - }); - - const ObjState = State({ Idle: {}, Done: { value: Schema.Number } }); - const ObjEvent = Event({ Go: {} }); - - const machine = Machine.make({ - state: ObjState, - event: ObjEvent, - slots: ObjSlots, - initial: ObjState.Idle, - }).on(ObjState.Idle, ObjEvent.Go, ({ slots }) => - slots - .getData(undefined as void) - .pipe(Effect.map((data) => ObjState.Done({ value: data.value }))), - ); - - const result = await Effect.runPromise( - simulate(machine, [ObjEvent.Go], { - slots: { getData: () => ({ value: 42 }) }, - }), - ); - expect(result.finalState._tag).toBe("Done"); - expect((result.finalState as { value: number }).value).toBe(42); - }); -}); diff --git a/test/spawn-slots.test.ts b/test/spawn-slots.test.ts deleted file mode 100644 index 5522ef6..0000000 --- a/test/spawn-slots.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -// @effect-diagnostics strictEffectProvide:off - tests are entry points -import { Effect, Schema } from "effect"; - -import { Machine, State, Event, Slot, simulate, createTestHarness } from "../src/index.js"; -import { materializeMachine } from "../src/machine.js"; -import { describe, expect, it, yieldFibers } from "effect-bun-test"; -import { test } from "bun:test"; - -// ============================================================================ -// Test Fixtures -// ============================================================================ - -const SimpleState = State({ - Idle: {}, - Active: { count: Schema.Number }, - Done: {}, -}); -type SimpleState = typeof SimpleState.Type; - -const SimpleEvent = Event({ - Start: { count: Schema.Number }, - Increment: {}, - Finish: {}, -}); -type SimpleEvent = typeof SimpleEvent.Type; - -const TestSlots = Slot.define({ - canStart: Slot.fn({}, Schema.Boolean), - onStart: Slot.fn({}), -}); - -const createSimpleMachine = () => - Machine.make({ - state: SimpleState, - event: SimpleEvent, - initial: SimpleState.Idle, - }) - .on(SimpleState.Idle, SimpleEvent.Start, ({ event }) => - SimpleState.Active({ count: event.count }), - ) - .on(SimpleState.Active, SimpleEvent.Increment, ({ state }) => - SimpleState.Active({ count: state.count + 1 }), - ) - .on(SimpleState.Active, SimpleEvent.Finish, () => SimpleState.Done) - .final(SimpleState.Done); - -const createSlotMachine = () => - Machine.make({ - state: SimpleState, - event: SimpleEvent, - slots: TestSlots, - initial: SimpleState.Idle, - }) - .on(SimpleState.Idle, SimpleEvent.Start, ({ event, slots }) => - Effect.gen(function* () { - if (yield* slots.canStart()) { - yield* slots.onStart(); - return SimpleState.Active({ count: event.count }); - } - return SimpleState.Idle; - }), - ) - .on(SimpleState.Active, SimpleEvent.Finish, () => SimpleState.Done) - .final(SimpleState.Done); - -// ============================================================================ -// Machine.spawn with slots (spawn-time materialization) -// ============================================================================ - -describe("Machine.spawn with slots", () => { - it.scopedLive("spawns with slots at spawn time", () => - Effect.gen(function* () { - const machine = createSlotMachine(); - const actor = yield* Machine.spawn(machine, { - slots: { - canStart: () => true, - onStart: () => Effect.void, - }, - }); - yield* actor.start; - - yield* actor.send(SimpleEvent.Start({ count: 42 })); - yield* Effect.yieldNow; - yield* yieldFibers; - - const state = yield* actor.snapshot; - expect(state._tag).toBe("Active"); - }), - ); - - it.scopedLive("no-slot machine spawns without slots option", () => - Effect.gen(function* () { - const machine = createSimpleMachine(); - const actor = yield* Machine.spawn(machine); - yield* actor.start; - - yield* actor.send(SimpleEvent.Start({ count: 1 })); - yield* Effect.yieldNow; - yield* yieldFibers; - - const state = yield* actor.snapshot; - expect(state._tag).toBe("Active"); - }), - ); - - it.scopedLive("backward compat: spawn with slots works", () => - Effect.gen(function* () { - const machine = createSlotMachine(); - const actor = yield* Machine.spawn(machine, { - slots: { - canStart: () => true, - onStart: () => Effect.void, - }, - }); - yield* actor.start; - - yield* actor.send(SimpleEvent.Start({ count: 5 })); - yield* Effect.yieldNow; - yield* yieldFibers; - - const state = yield* actor.snapshot; - expect(state._tag).toBe("Active"); - }), - ); -}); - -// ============================================================================ -// materializeMachine validation -// ============================================================================ - -describe("materializeMachine", () => { - test("throws ProvisionValidationError for slotful machine without handlers", () => { - const machine = createSlotMachine(); - expect(() => materializeMachine(machine)).toThrow(); - }); - - test("throws ProvisionValidationError for missing slot handlers", () => { - const machine = createSlotMachine(); - expect(() => materializeMachine(machine, { canStart: () => true })).toThrow(); - }); - - test("throws ProvisionValidationError for extra slot handlers", () => { - const machine = createSlotMachine(); - expect(() => - materializeMachine(machine, { - canStart: () => true, - onStart: () => Effect.void, - extra: () => true, - }), - ).toThrow(); - }); - - test("returns machine as-is for no-slot machine", () => { - const machine = createSimpleMachine(); - const result = materializeMachine(machine); - expect(result).toBe(machine); - }); - - test("returns fresh copy for slotful machine with handlers", () => { - const machine = createSlotMachine(); - const result = materializeMachine(machine, { - canStart: () => true, - onStart: () => Effect.void, - }); - expect(result).not.toBe(machine); - expect(result.initial).toEqual(machine.initial); - }); -}); - -// ============================================================================ -// Machine.replay with slots -// ============================================================================ - -describe("Machine.replay with slots", () => { - it.scopedLive("replays with slots at replay time", () => - Effect.gen(function* () { - const machine = createSlotMachine(); - const state = yield* Machine.replay(machine, [SimpleEvent.Start({ count: 10 })], { - slots: { - canStart: () => true, - onStart: () => Effect.void, - }, - }); - - expect(state._tag).toBe("Active"); - if (state._tag === "Active") { - expect(state.count).toBe(10); - } - }), - ); -}); - -// ============================================================================ -// simulate with slots -// ============================================================================ - -describe("simulate with slots", () => { - it.scopedLive("simulates with slots option", () => - Effect.gen(function* () { - const machine = createSlotMachine(); - const result = yield* simulate(machine, [SimpleEvent.Start({ count: 7 })], { - slots: { - canStart: () => true, - onStart: () => Effect.void, - }, - }); - - expect(result.finalState._tag).toBe("Active"); - }), - ); -}); - -// ============================================================================ -// createTestHarness with slots -// ============================================================================ - -describe("createTestHarness with slots", () => { - it.scopedLive("harness with slots option", () => - Effect.gen(function* () { - const machine = createSlotMachine(); - const harness = yield* createTestHarness(machine, { - slots: { - canStart: () => true, - onStart: () => Effect.void, - }, - }); - - yield* harness.send(SimpleEvent.Start({ count: 3 })); - const state = yield* harness.getState; - expect(state._tag).toBe("Active"); - }), - ); -}); diff --git a/test/timeouts.task.test.ts b/test/timeouts.task.test.ts index c055d43..c6f88cc 100644 --- a/test/timeouts.task.test.ts +++ b/test/timeouts.task.test.ts @@ -2,8 +2,7 @@ import { Duration, Effect, Schema, SubscriptionRef } from "effect"; import { TestClock } from "effect/testing"; -import { ActorSystemDefault, Event, Machine, Slot, State } from "../src/index.js"; -import { MachineContextTag } from "../src/slot.js"; +import { ActorSystemDefault, Event, Machine, State } from "../src/index.js"; import { describe, expect, it, yieldFibers } from "effect-bun-test"; describe("Timeout Transitions via Task", () => { @@ -17,30 +16,20 @@ describe("Timeout Transitions via Task", () => { Dismiss: {}, }); - const NotifSlots = Slot.define({ - scheduleAutoDismiss: Slot.fn({}), - }); - it.scoped("schedules event after duration with TestClock", () => Effect.gen(function* () { const machine = Machine.make({ state: NotifState, event: NotifEvent, - slots: NotifSlots, initial: NotifState.Showing({ message: "Hello" }), }) .on(NotifState.Showing, NotifEvent.Dismiss, () => NotifState.Dismissed) - .task(NotifState.Showing, ({ slots }) => slots.scheduleAutoDismiss(), { + .task(NotifState.Showing, () => Effect.sleep("3 seconds"), { onSuccess: () => NotifEvent.Dismiss, }) .final(NotifState.Dismissed); - const actor = yield* Machine.spawn(machine, { - id: "notification", - slots: { - scheduleAutoDismiss: () => Effect.sleep("3 seconds"), - }, - }); + const actor = yield* Machine.spawn(machine, { id: "notification" }); yield* actor.start; // Initial state @@ -64,21 +53,15 @@ describe("Timeout Transitions via Task", () => { const machine = Machine.make({ state: NotifState, event: NotifEvent, - slots: NotifSlots, initial: NotifState.Showing({ message: "Hello" }), }) .on(NotifState.Showing, NotifEvent.Dismiss, () => NotifState.Dismissed) - .task(NotifState.Showing, ({ slots }) => slots.scheduleAutoDismiss(), { + .task(NotifState.Showing, () => Effect.sleep("3 seconds"), { onSuccess: () => NotifEvent.Dismiss, }) .final(NotifState.Dismissed); - const actor = yield* Machine.spawn(machine, { - id: "notification", - slots: { - scheduleAutoDismiss: () => Effect.sleep("3 seconds"), - }, - }); + const actor = yield* Machine.spawn(machine, { id: "notification" }); yield* actor.start; // Manual dismiss before timer @@ -109,36 +92,20 @@ describe("Dynamic Timeout Duration via Task", () => { Timeout: {}, }); - const WaitSlots = Slot.define({ - scheduleTimeout: Slot.fn({}), - }); - it.scoped("dynamic duration computed from state", () => Effect.gen(function* () { const machine = Machine.make({ state: WaitState, event: WaitEvent, - slots: WaitSlots, initial: WaitState.Waiting({ timeout: 5 }), }) .on(WaitState.Waiting, WaitEvent.Timeout, () => WaitState.TimedOut) - .task(WaitState.Waiting, ({ slots }) => slots.scheduleTimeout(), { + .task(WaitState.Waiting, ({ state }) => Effect.sleep(Duration.seconds(state.timeout)), { onSuccess: () => WaitEvent.Timeout, }) .final(WaitState.TimedOut); - const actor = yield* Machine.spawn(machine, { - id: "waiter", - slots: { - scheduleTimeout: () => - Effect.gen(function* () { - const ctx = yield* MachineContextTag; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = ctx.state; - yield* Effect.sleep(Duration.seconds(s.timeout)); - }), - }, - }); + const actor = yield* Machine.spawn(machine, { id: "waiter" }); yield* actor.start; // Initial state @@ -175,14 +142,9 @@ describe("Dynamic Timeout Duration via Task", () => { GiveUp: {}, }); - const RetrySlots = Slot.define({ - scheduleGiveUp: Slot.fn({}), - }); - const machine = Machine.make({ state: RetryState, event: RetryEvent, - slots: RetrySlots, initial: RetryState.Retrying({ attempt: 1, backoff: 1 }), }) .reenter(RetryState.Retrying, RetryEvent.Retry, ({ state }) => @@ -190,23 +152,12 @@ describe("Dynamic Timeout Duration via Task", () => { ) .on(RetryState.Retrying, RetryEvent.GiveUp, () => RetryState.Failed) // Exponential backoff based on state - .task(RetryState.Retrying, ({ slots }) => slots.scheduleGiveUp(), { + .task(RetryState.Retrying, ({ state }) => Effect.sleep(Duration.seconds(state.backoff)), { onSuccess: () => RetryEvent.GiveUp, }) .final(RetryState.Failed); - const actor = yield* Machine.spawn(machine, { - id: "retry", - slots: { - scheduleGiveUp: () => - Effect.gen(function* () { - const ctx = yield* MachineContextTag; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = ctx.state; - yield* Effect.sleep(Duration.seconds(s.backoff)); - }), - }, - }); + const actor = yield* Machine.spawn(machine, { id: "retry" }); yield* actor.start; // Initial state should be Retrying with attempt=1, backoff=1 diff --git a/test/type-constraints.test.ts b/test/type-constraints.test.ts index ec06025..f91fb9b 100644 --- a/test/type-constraints.test.ts +++ b/test/type-constraints.test.ts @@ -13,9 +13,8 @@ * All "bad" tests use @ts-expect-error on the handler return expression. */ import { Effect, Schema, Context } from "effect"; -import { Machine, State, Event, Slot } from "../src/index.js"; +import { Machine, State, Event } from "../src/index.js"; import type { ActorHandle } from "../src/index.js"; -import type { ProvideSlots } from "../src/slot.js"; const MyState = State({ Idle: {}, @@ -207,94 +206,5 @@ const _test9bPayload: Parameters[0] = { id: "t const _test9b = PayloadReplyEvent.GetById(_test9bPayload); const _test9bId: string = _test9b.id; -// ============================================================================ -// Slot Type Safety Regression Tests -// ============================================================================ - -const MySlots = Slot.define({ - canRetry: Slot.fn({ max: Schema.Number }, Schema.Boolean), - fetchData: Slot.fn({ url: Schema.String }), - computeValue: Slot.fn({ input: Schema.Number }, Schema.Number), -}); -type MySlotsDef = typeof MySlots.definitions; - -// Test 10: Slots are accessible via `slots` in handler context -const _test10 = Machine.make({ - state: MyState, - event: MyEvent, - slots: MySlots, - initial: MyState.Idle, -}).on(MyState.Idle, MyEvent.Start, ({ slots }) => - Effect.gen(function* () { - const canRetry = yield* slots.canRetry({ max: 3 }); - if (canRetry) { - yield* slots.fetchData({ url: "/" }); - } - return MyState.Loading({ url: "/" }); - }), -); - -// Test 11: Slot call with wrong param type is rejected -const _test11 = Machine.make({ - state: MyState, - event: MyEvent, - slots: MySlots, - initial: MyState.Idle, -}).on(MyState.Idle, MyEvent.Start, ({ slots }) => - Effect.gen(function* () { - // @ts-expect-error - max should be number, not string - yield* slots.canRetry({ max: "not a number" }); - return MyState.Loading({ url: "/" }); - }), -); - -// Test 12: Slot return type is enforced -const _test12 = Machine.make({ - state: MyState, - event: MyEvent, - slots: MySlots, - initial: MyState.Idle, -}).on(MyState.Idle, MyEvent.Start, ({ slots }) => - Effect.gen(function* () { - // computeValue returns number, assigning to string should fail - // @ts-expect-error - computeValue returns number, not string - const _v: string = yield* slots.computeValue({ input: 42 }); - return MyState.Loading({ url: "/" }); - }), -); - -// Test 13: ProvideSlots requires all slots to be implemented -// @ts-expect-error - missing 'computeValue' property -const _test13: ProvideSlots = { - canRetry: ({ max }) => max > 0, - fetchData: ({ url }) => Effect.log(url), -}; - -// Test 14: ProvideSlots rejects wrong handler param types -const _test14: ProvideSlots = { - // @ts-expect-error - max should be number, handler expects string - canRetry: ({ max }: { max: string }) => max.length > 0, - fetchData: ({ url }) => Effect.log(url), - computeValue: ({ input }) => input * 2, -}; - -// Test 15: ProvideSlots accepts valid implementations (should compile) -const _test15: ProvideSlots = { - canRetry: ({ max }) => max > 0, - fetchData: ({ url }) => Effect.log(url), - computeValue: ({ input }) => input * 2, -}; - -// Test 16: Machine without slots — handler context has empty slots -const _test16 = Machine.make({ - state: MyState, - event: MyEvent, - initial: MyState.Idle, -}).on(MyState.Idle, MyEvent.Start, ({ slots }) => { - // @ts-expect-error - no slots defined, canRetry doesn't exist - const _x = slots.canRetry; - return MyState.Loading({ url: "/" }); -}); - // This file should compile with all @ts-expect-error comments being valid export {}; diff --git a/tsconfig.json b/tsconfig.json index 9126614..38fe96a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -121,14 +121,6 @@ "strictEffectProvide": "off" } } - }, - { - "include": ["src/slot.ts"], - "options": { - "diagnosticSeverity": { - "anyUnknownInErrorContext": "off" - } - } } ] } From c5b22837853be2b5f7906fc3ab8f532f36f572b9 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 31 Aug 2026 11:00:40 -0700 Subject: [PATCH 4/4] fix: satisfy Effect diagnostics in CI HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a04eba-6fce-73bb-ae5f-a529a8fa351f --- src/actor.ts | 43 ++++++++++++++------------- src/machine.ts | 1 + test/cluster-type-constraints.test.ts | 2 +- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/actor.ts b/src/actor.ts index 4e67a3b..81f2c67 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -450,29 +450,30 @@ export const buildActorRefCore = < return result; }); - const ask = >(event: ReplyEvent) => - Effect.gen(function* () { - const registeredSchema = machine.replySchemas.get(event._tag); - if (registeredSchema === undefined) { - return yield* new NoReplyError({ actorId: id, eventTag: event._tag }); - } + const ask = Effect.fn("effect-machine.actor.ask")(function* < + ReplyEvent extends E & ReplyTypeBrand, + >(event: ReplyEvent) { + const registeredSchema = machine.replySchemas.get(event._tag); + if (registeredSchema === undefined) { + return yield* new NoReplyError({ actorId: id, eventTag: event._tag }); + } - const stopped = yield* Ref.get(stoppedRef); - if (stopped) { - return yield* new ActorStoppedError({ actorId: id }); - } + const stopped = yield* Ref.get(stoppedRef); + if (stopped) { + return yield* new ActorStoppedError({ actorId: id }); + } - const reply = yield* Deferred.make(); - const pending = pendingReply(reply); - pendingReplies.add(pending); - const q = yield* Ref.get(eventQueueRef); - yield* Queue.offer(q, { _tag: "ask", event, reply }); - const input: unknown = yield* Deferred.await(reply).pipe( - Effect.ensuring(Effect.sync(() => pendingReplies.delete(pending))), - ); - const decoder = Schema.make>>(registeredSchema.ast); - return yield* Schema.decodeUnknownEffect(decoder)(input).pipe(Effect.orDie); - }).pipe(Effect.withSpan("effect-machine.actor.ask")); + const reply = yield* Deferred.make(); + const pending = pendingReply(reply); + pendingReplies.add(pending); + const q = yield* Ref.get(eventQueueRef); + yield* Queue.offer(q, { _tag: "ask", event, reply }); + const input: unknown = yield* Deferred.await(reply).pipe( + Effect.ensuring(Effect.sync(() => pendingReplies.delete(pending))), + ); + const decoder = Schema.make>>(registeredSchema.ast); + return yield* Schema.decodeUnknownEffect(decoder)(input).pipe(Effect.orDie); + }); const snapshot = SubscriptionRef.get(stateRef).pipe( Effect.withSpan("effect-machine.actor.snapshot"), diff --git a/src/machine.ts b/src/machine.ts index cdd9eec..c9e93ef 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -684,6 +684,7 @@ export class Machine< phase: "start", }); + // @effect-diagnostics anyUnknownInErrorContext:off -- the public task overloads preserve concrete error and requirement channels at this implementation boundary const exit = yield* Effect.exit(run(ctx)); if (Exit.isSuccess(exit)) { diff --git a/test/cluster-type-constraints.test.ts b/test/cluster-type-constraints.test.ts index 9c0ec7c..53cddc6 100644 --- a/test/cluster-type-constraints.test.ts +++ b/test/cluster-type-constraints.test.ts @@ -21,7 +21,7 @@ const ClusterEvent = Event({ class ClusterService extends Context.Service< ClusterService, { readonly run: Effect.Effect } ->()("@test/ClusterService") {} +>()("@humanlayer/effect-machine/test/cluster-type-constraints.test/ClusterService") {} const clusterMachine = Machine.make({ state: ClusterState,