From dcc0f6da0fa9bc465523b5835a9f2f04749f3115 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 27 Sep 2026 17:06:38 +0900 Subject: [PATCH] Check public Machine operations against erased implementations Remove the unchecked casts between the public Machine module and its implementations. Each operation now has one generic signature (the public one) and one erased internal implementation, so the public declaration is checked structurally instead of being asserted. This removes three layers of drifted duplicate signatures (which had lost the ParentEvents parameter). Route synchronous planning throws through a shared classifier: planning failures stay typed, startup throws become StartupError, and every other throw is a defect, for both generic and compiled runtimes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/typed-operation-boundary.md | 7 + packages/effect-machine/src/Machine.ts | 41 +- .../src/internal/machine/atom.ts | 11 +- .../src/internal/machine/declaration.ts | 6 +- .../src/internal/machine/errors.ts | 23 +- .../internal/machine/initialDeclaration.ts | 4 +- .../src/internal/machine/machine.ts | 519 +++--------------- .../src/internal/machine/planner.ts | 274 +++------ .../src/internal/machine/process.ts | 359 +++--------- .../src/internal/machine/targetReference.ts | 6 +- .../internal/machine/planningFailures.test.ts | 122 ++++ 11 files changed, 413 insertions(+), 959 deletions(-) create mode 100644 .changeset/typed-operation-boundary.md create mode 100644 packages/effect-machine/test/internal/machine/planningFailures.test.ts diff --git a/.changeset/typed-operation-boundary.md b/.changeset/typed-operation-boundary.md new file mode 100644 index 00000000..dbb835e8 --- /dev/null +++ b/.changeset/typed-operation-boundary.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": patch +--- + +Check the public `Machine` operations against their implementations at compile time. + +`Machine.start`, `resume`, `plan`, `planInitial`, `can`, `enabled`, `isFinal`, `encodeSnapshot`, `decodeSnapshot`, `make`, `targets`, and the event protocol builders no longer rely on unchecked casts, so their documented signatures now stay in sync with runtime behavior. Planning failures are classified consistently across runtime strategies: non-stabilization and schema failures stay typed, startup throws become `StartupError`, and other handler throws remain defects. diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index 86649b8a..7ac24cbd 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -2613,7 +2613,6 @@ export declare namespace Machine { /** @internal */ readonly stateNodes: StateNodes /** @internal */ - /** @internal */ readonly handlers: any /** @internal */ readonly initial: any @@ -7737,7 +7736,7 @@ export const isFinal: < ParentEvents >, state: Machine.Snapshot -) => state is Machine.SnapshotContainingFinal = internal.isFinal as any +) => state is Machine.SnapshotContainingFinal = internal.isFinal /** * Defines root or nested state schemas while preserving the exact child topology. @@ -7835,10 +7834,8 @@ export interface Targets { * @category constructors * @since 0.34.0 */ -export const targets: (root: State) => Targets = internal - .targets as unknown as ( - root: State - ) => Targets +export const targets: (root: State) => Targets = + internal.targets type UniqueSourceNames = [ | Extract @@ -8066,7 +8063,7 @@ interface Make { * @category constructors * @since 0.4.0 */ -export const make: Make = internal.make as unknown as Make +export const make: Make = internal.make /** * Extracts the decoded event union carried by an event protocol descriptor. @@ -8131,7 +8128,7 @@ type ValidateEventFields = { */ export const events: >>( cases: Cases & ValidateEventFields> & ValidateEventProtocolBuilder<"public", EventFieldsSchemas> -) => Machine.EventProtocol<"public", EventFieldsSchemas> = internal.eventsFromFields as any +) => Machine.EventProtocol<"public", EventFieldsSchemas> = internal.eventsFromFields /** Imports existing schemas and protocols without rebuilding their contracts. * @category constructors @@ -8141,7 +8138,7 @@ export const eventsFromSchemas: { >>( ...inputs: Inputs & ValidateEventProtocolBuilder<"public", Inputs> ): Machine.EventProtocol<"public", Machine.EventProtocolInputSchemasOf<"public", Inputs>> -} = internal.events as any +} = internal.events /** * Requires the machine to run as an owned child whose parent accepts the @@ -8201,7 +8198,7 @@ export const internalEvents: > & ValidateEventProtocolBuilder<"internal", EventFieldsSchemas> -) => Machine.EventProtocol<"internal", EventFieldsSchemas> = internal.internalEventsFromFields as any +) => Machine.EventProtocol<"internal", EventFieldsSchemas> = internal.internalEventsFromFields /** Imports existing schemas and protocols without rebuilding their contracts. * @category constructors @@ -8211,7 +8208,7 @@ export const internalEventsFromSchemas: { >>( ...inputs: Inputs & ValidateEventProtocolBuilder<"internal", Inputs> ): Machine.EventProtocol<"internal", Machine.EventProtocolInputSchemasOf<"internal", Inputs>> -} = internal.internalEvents as any +} = internal.internalEvents /** * Defines the ephemeral notifications a machine may publish to external @@ -8238,7 +8235,7 @@ export const emittedEvents: > & ValidateEventProtocolBuilder<"emitted", EventFieldsSchemas> -) => Machine.EventProtocol<"emitted", EventFieldsSchemas> = internal.emittedEventsFromFields as any +) => Machine.EventProtocol<"emitted", EventFieldsSchemas> = internal.emittedEventsFromFields /** Imports existing schemas and protocols without rebuilding their contracts. * @category constructors @@ -8248,7 +8245,7 @@ export const emittedEventsFromSchemas: { >>( ...inputs: Inputs & ValidateEventProtocolBuilder<"emitted", Inputs> ): Machine.EventProtocol<"emitted", Machine.EventProtocolInputSchemasOf<"emitted", Inputs>> -} = internal.emittedEvents as any +} = internal.emittedEvents /** * Encodes a decoded machine snapshot into a normalized data representation. @@ -8342,7 +8339,7 @@ export const encodeSnapshot: < Machine.EncodedSnapshot, MachineSchemaEncodeError, Machine.SnapshotEncodingServices -> = internal.encodeSnapshot as any +> = internal.encodeSnapshot /** * Decodes a normalized data representation into a validated machine snapshot. @@ -8430,7 +8427,7 @@ export const decodeSnapshot: < Machine.Snapshot, MachineSchemaDecodeError, Machine.SnapshotDecodingServices -> = internal.decodeSnapshot as any +> = internal.decodeSnapshot type TransitionBranchRecordError = { readonly "~effect/Machine/TransitionBranchRecordError": Message @@ -8578,7 +8575,7 @@ export const planInitial: < ), InitialE | E | InfiniteTransitionError | MachineSchemaDecodeError | StartupError, never -> = internal.planInitial as any +> = internal.planInitial /** * Returns every compiled state node in definition order. @@ -8738,7 +8735,7 @@ export const enabled: < ParentEvents >, state: Machine.Snapshot -) => ReadonlyArray> = internal.enabled as any +) => ReadonlyArray> = internal.enabled /** * Tests whether a concrete event would select at least one transition from a @@ -8870,7 +8867,7 @@ export const can: { state: Machine.Snapshot, event: Machine.EventInputOf ): Effect.Effect -} = internal.can as any +} = internal.can /** * Returns an Effect that plans the next state snapshot without running command effects. @@ -9004,7 +9001,7 @@ export const plan: < ), E | InfiniteTransitionError | MachineSchemaDecodeError, never -> = internal.plan as any +> = internal.plan /** * Creates advanced stateful process logic from explicit initialization and @@ -9315,7 +9312,7 @@ export const prepare: < Machine.EmittedEventOf > > -> = internal.prepare as any +> = internal.prepare /** * Starts a machine. @@ -9429,7 +9426,7 @@ export const start: < Machine.EventOf, Machine.EmittedEventOf > -> = internal.start as any +> = internal.start /** * Starts a fresh managed runtime from a decoded logical snapshot. @@ -9541,4 +9538,4 @@ export const resume: < Machine.EventOf, Machine.EmittedEventOf > -> = internal.resume as any +> = internal.resume diff --git a/packages/effect-machine/src/internal/machine/atom.ts b/packages/effect-machine/src/internal/machine/atom.ts index 352b2e1f..77792938 100644 --- a/packages/effect-machine/src/internal/machine/atom.ts +++ b/packages/effect-machine/src/internal/machine/atom.ts @@ -140,7 +140,7 @@ const resumeMachineAtomEffect = ( get: Atom.AtomContext, machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot -) => runMachineAtomEffect(get, internalMachine.resume(machine as any, snapshot as any)) +) => runMachineAtomEffect(get, internalMachine.resume(machine, snapshot)) type RefState = Ref extends Machine.MachineRef ? State : never type RefError = Ref extends Machine.MachineRef ? Error : never @@ -723,10 +723,7 @@ export const can = (event: unknown) => { event: unknown ) => Effect.Effect = machine === undefined ? () => Effect.die(new Error("AtomMachine.can requires a machine atom created by AtomMachine")) - : internalMachine.can(machine) as ( - state: Machine.Machine.Snapshot, - event: unknown - ) => Effect.Effect + : internalMachine.can(machine) const result = Atom.readable((get): AsyncResult.AsyncResult => { const current = get(self.snapshot) @@ -840,7 +837,7 @@ export const make: { Machine.Machine.EmittedEventOf > } = ((machine: Machine.Machine.Any, ...args: ReadonlyArray) => { - const prepared = Atom.make(() => internalMachine.prepare(machine as any, ...(args as []))) + const prepared = Atom.make(() => internalMachine.prepare(machine, ...args)) const ref = Atom.make((get) => startPreparedMachineAtomEffect(get, prepared as any)) const result = makeFromRefAtom(ref as any, machine) preparedByMachineAtom.set(result, prepared as any) @@ -869,7 +866,7 @@ const makeWithRuntime = ( machine: Machine.Machine.Any, args: ReadonlyArray ): MachineAtom => { - const prepared = runtime.atom(() => internalMachine.prepare(machine as any, ...(args as []))) + const prepared = runtime.atom(() => internalMachine.prepare(machine, ...args)) const ref = runtime.atom((get) => startPreparedMachineAtomEffect(get, prepared as any)) const result = makeFromRefAtom(ref as any, machine) preparedByMachineAtom.set(result, prepared as any) diff --git a/packages/effect-machine/src/internal/machine/declaration.ts b/packages/effect-machine/src/internal/machine/declaration.ts index 843e7702..006f4466 100644 --- a/packages/effect-machine/src/internal/machine/declaration.ts +++ b/packages/effect-machine/src/internal/machine/declaration.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect" import { hasProperty } from "effect/Predicate" import * as Stream from "effect/Stream" -import type { Machine, State } from "../../Machine.js" +import type { Machine } from "../../Machine.js" import * as Reference from "./targetReference.js" import * as Topology from "./topology.js" @@ -13,7 +13,7 @@ interface Source { } export interface Declaration { readonly initialize?: (input: unknown) => unknown - readonly root: State + readonly root: Machine.Any["root"] readonly sources: ReadonlyMap readonly branches: ReadonlyMap>>>> } @@ -22,7 +22,7 @@ const record = (value: unknown, message: string): Record => { return value as Record } export const capture = ( - root: State, + root: Machine.Any["root"], config: Readonly> ): Declaration => { const sources = new Map() diff --git a/packages/effect-machine/src/internal/machine/errors.ts b/packages/effect-machine/src/internal/machine/errors.ts index 1b0c0416..6a272425 100644 --- a/packages/effect-machine/src/internal/machine/errors.ts +++ b/packages/effect-machine/src/internal/machine/errors.ts @@ -1,5 +1,6 @@ -import type * as Cause from "effect/Cause" +import * as Cause from "effect/Cause" import * as Data from "effect/Data" +import * as Effect from "effect/Effect" import type * as Schema from "effect/Schema" /** @@ -85,3 +86,23 @@ export class ProcessLocalError extends Data.TaggedError("ProcessLocalError")<{ * @since 0.4.0 */ export class StoppedError extends Data.TaggedError("StoppedError") {} + +/** + * Failures that synchronous planning reports through the typed error channel. + * Every other planning throw is a defect. + */ +export type PlanningError = InfiniteTransitionError | MachineSchemaDecodeError + +export const isPlanningError = (error: unknown): error is PlanningError => + error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError + +/** Converts a synchronous planning throw into a typed failure or a defect. */ +export const failPlanning = (error: unknown): Effect.Effect => + isPlanningError(error) ? Effect.fail(error) : Effect.die(error) + +/** + * Classifies a synchronous initial-planning throw. Planning failures stay + * typed; any other throw is preserved as the defect cause of a `StartupError`. + */ +export const toStartupFailure = (error: unknown): PlanningError | StartupError => + isPlanningError(error) || error instanceof StartupError ? error : new StartupError({ cause: Cause.die(error) }) diff --git a/packages/effect-machine/src/internal/machine/initialDeclaration.ts b/packages/effect-machine/src/internal/machine/initialDeclaration.ts index 25a06e8f..a0a7426d 100644 --- a/packages/effect-machine/src/internal/machine/initialDeclaration.ts +++ b/packages/effect-machine/src/internal/machine/initialDeclaration.ts @@ -1,7 +1,7 @@ /** Captures handler-owned initial edges before compiling executable topology. */ import { hasProperty } from "effect/Predicate" import * as Schema from "effect/Schema" -import type { Machine, State } from "../../Machine.js" +import type { Machine } from "../../Machine.js" import { SnapshotBuilderStateTypeId } from "./symbols.js" import * as Reference from "./targetReference.js" import * as Topology from "./topology.js" @@ -52,7 +52,7 @@ interface Captured { readonly handlers: Record } export const capture = ( - root: State, + root: Machine.Any["root"], handler: Readonly> ): Captured => { const visit = ( diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index 2569a766..e3185c33 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -7,13 +7,9 @@ import * as Schema from "effect/Schema" import type * as Scope from "effect/Scope" import type * as Stream from "effect/Stream" import type { - ActionError, ChildAddress, ChildMachine, - Command, Definition, - ExecutionServices, - InitialEvent as InitialEventModel, Logic, Machine, MachineRef, @@ -30,7 +26,7 @@ import * as Activities from "./activities.js" import * as Configuration from "./configuration.js" import * as Construction from "./construction.js" import * as Declaration from "./declaration.js" -import type { ChildAlreadyExistsError, InfiniteTransitionError, StartupError } from "./errors.js" +import type { ChildAlreadyExistsError, PlanningError, StartupError } from "./errors.js" import type { CapturedStateConfig } from "./implementation.js" import * as InitialDeclaration from "./initialDeclaration.js" import * as InvocationDefinition from "./invocationDefinition.js" @@ -38,8 +34,6 @@ import * as Observation from "./observation.js" import * as internalPlanner from "./planner.js" import * as internalProcess from "./process.js" import * as Protocol from "./protocol.js" -import type { EnsureExecutable } from "./readiness.js" -import type { ExcludeCompatibleRuntime } from "./requirements.js" import * as internalRuntime from "./runtimeProtocol.js" import * as Serialization from "./serialization.js" import * as StateDefinition from "./stateDefinition.js" @@ -60,8 +54,7 @@ export { ChildMachineLogicTypeId, InitialEventTypeId, SnapshotBuilderStateTypeId /** Internal seam for the public target-reference factory and its opaque brand. */ export const TargetReferenceTypeId: typeof TargetReference.TypeId = TargetReference.TypeId -export const targets: (root: State) => { readonly root: TargetReference.Reference } = - TargetReference.make +export const targets: (root: Machine.Any["root"]) => any = TargetReference.make const TypeId = "~effect/Machine" const ParentTypeId = "~effect/Machine/Parent" @@ -742,38 +735,11 @@ export const isMachine = ( u: unknown ): u is Machine.Any => hasProperty(u, TypeId) && u[TypeId] === TypeId -export const isFinal = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray, - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events ->( - machine: Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - >, - state: Machine.Snapshot -): state is Machine.SnapshotContainingFinal => internalPlanner.isFinal(machine as any, state) +// Erased machine operations. `any` marks the positions whose types the public +// `Machine` module computes from the machine's generic parameters; the public +// module specializes each operation without a cast. + +export const isFinal: (machine: Machine.Any, state: any) => state is any = internalPlanner.isFinal export const state = (node: unknown): State => { const captured = StateDefinition.captureRoot(node) as Machine.StateNodeConfig @@ -806,54 +772,23 @@ const makeStateHelpers = (): Machine.StateAccessors<{ readonly "": Machine.State } } -type MakeResult< - States extends Machine.StateSchemas, - InputEvents extends ReadonlyArray, - Emits extends ReadonlyArray, - Input extends Schema.Top, - InitialE, - InitialR, - InternalEvents extends ReadonlyArray, - ParentDeclaration extends Parent.Any | undefined -> = Definition< - States, - readonly [...InputEvents, ...InternalEvents], - Input, - InitialE, - InitialR, - Machine.FinalStateFromDefinition, - Machine.TerminalOutput, - Emits, - InputEvents, - Machine.ParentEventsOf -> - -export const make = < - const States extends Machine.StateSchemas, - const InputEvents extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - InitialE = never, - InitialR = never, - const InternalEvents extends ReadonlyArray = readonly [], - const ParentDeclaration extends Parent.Any | undefined = undefined ->( +export const make = ( config: { readonly id?: string - readonly root: State + readonly root: Machine.Any["root"] readonly effects?: unknown readonly streams?: unknown readonly timers?: unknown readonly logic?: unknown readonly children?: unknown readonly branches?: unknown - readonly events: Machine.EventProtocol<"public", InputEvents> - readonly internalEvents?: Machine.EventProtocol<"internal", InternalEvents> - readonly emittedEvents?: Machine.EventProtocol<"emitted", Emits> - readonly parent?: ParentDeclaration - readonly input?: Input + readonly events: Machine.EventProtocol<"public", any> + readonly internalEvents?: Machine.EventProtocol<"internal", any> + readonly emittedEvents?: Machine.EventProtocol<"emitted", any> + readonly parent?: Parent.Any | undefined + readonly input?: Schema.Top } -): MakeResult => { +): any => { if (Object.hasOwn(config, "initial") || Object.hasOwn(config, "initialConfiguration")) { throw new Error("Machine initial edges and root data belong in handle") } @@ -900,12 +835,15 @@ const eventFieldSchemas = ( return Object.keys(cases).length === 0 ? [] : [Schema.TaggedUnion(cases)] } -export const eventsFromFields = (cases: Readonly>) => - Protocol.makeEventProtocol("public", eventFieldSchemas(cases)) -export const internalEventsFromFields = (cases: Readonly>) => - Protocol.makeEventProtocol("internal", eventFieldSchemas(cases)) -export const emittedEventsFromFields = (cases: Readonly>) => - Protocol.makeEventProtocol("emitted", eventFieldSchemas(cases)) +export const eventsFromFields = ( + cases: Readonly> +): Machine.EventProtocol<"public", any> => Protocol.makeEventProtocol("public", eventFieldSchemas(cases)) +export const internalEventsFromFields = ( + cases: Readonly> +): Machine.EventProtocol<"internal", any> => Protocol.makeEventProtocol("internal", eventFieldSchemas(cases)) +export const emittedEventsFromFields = ( + cases: Readonly> +): Machine.EventProtocol<"emitted", any> => Protocol.makeEventProtocol("emitted", eventFieldSchemas(cases)) const makeParent = < const Mode extends ParentMode, @@ -944,151 +882,20 @@ export const emittedEvents = > -export const encodeSnapshot: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events ->( - machine: Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - >, - snapshot: Machine.Snapshot -) => Effect.Effect< - Machine.EncodedSnapshot, - MachineSchemaEncodeError, - Machine.SnapshotEncodingServices -> = Serialization.encodeSnapshot as any - -export const decodeSnapshot: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events ->( - machine: Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - >, +export const encodeSnapshot: ( + machine: Machine.Any, + snapshot: any +) => Effect.Effect = Serialization.encodeSnapshot + +export const decodeSnapshot: ( + machine: Machine.Any, encoded: unknown -) => Effect.Effect< - Machine.Snapshot, - MachineSchemaDecodeError, - Machine.SnapshotDecodingServices -> = Serialization.decodeSnapshot as any - -export const planInitial: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events ->( - machine: - & Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - > - & EnsureExecutable, - ...args: [...Machine.InputArgs] -) => Effect.Effect< - & { - readonly startingState: Machine.Snapshot - readonly initialEntryPaths: ReadonlyArray> - readonly state: Machine.Snapshot - readonly commands: ReadonlyArray - readonly emittedEvents: ReadonlyArray> - readonly microsteps: ReadonlyArray<{ - readonly next: Machine.Snapshot - readonly event: Machine.EventOf | InitialEventModel - readonly transitions: ReadonlyArray< - Machine.RetainedTransition< - Machine.StateNodeIdentifier, - Machine.TagOf, - Machine.StateNodeIdentifier - > - > - readonly commands: ReadonlyArray - readonly raisedEvents: ReadonlyArray> - readonly emittedEvents: ReadonlyArray> - readonly exitPaths: ReadonlyArray - readonly entryPaths: ReadonlyArray - readonly changed: boolean - }> - } - & ( - | { - readonly done: true - readonly output: Output - } - | { - readonly done: false - readonly output: undefined - } - ), - InitialE | E | InfiniteTransitionError | MachineSchemaDecodeError | StartupError, - never -> = internalPlanner.planInitial as any +) => Effect.Effect = Serialization.decodeSnapshot + +export const planInitial: ( + machine: Machine.Any, + ...args: ReadonlyArray +) => Effect.Effect = internalPlanner.planInitial export const stateNodes = ( machine: M @@ -1162,111 +969,18 @@ export const configuration = ( ) } -export const enabled = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray, - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events ->( - machine: Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - >, - state: Machine.Snapshot -): ReadonlyArray> => internalPlanner.enabled(machine as any, state) - -export const can = internalPlanner.can - -export const plan: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events ->( - machine: - & Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - > - & EnsureExecutable, - state: Machine.Snapshot, - event: Machine.EventInputOf -) => Effect.Effect< - & { - readonly next: Machine.Snapshot - readonly commands: ReadonlyArray - readonly emittedEvents: ReadonlyArray> - readonly microsteps: ReadonlyArray<{ - readonly next: Machine.Snapshot - readonly event: Machine.EventOf | InitialEventModel - readonly transitions: ReadonlyArray< - Machine.RetainedTransition< - Machine.StateNodeIdentifier, - Machine.TagOf, - Machine.StateNodeIdentifier - > - > - readonly commands: ReadonlyArray - readonly raisedEvents: ReadonlyArray> - readonly emittedEvents: ReadonlyArray> - readonly exitPaths: ReadonlyArray - readonly entryPaths: ReadonlyArray - readonly changed: boolean - }> - } - & ( - | { - readonly done: true - readonly output: Output - } - | { - readonly done: false - readonly output: undefined - } - ), - E | InfiniteTransitionError | MachineSchemaDecodeError, - never -> = internalPlanner.plan as any +export const enabled: (machine: Machine.Any, state: any) => ReadonlyArray = internalPlanner.enabled + +export const can: { + (machine: Machine.Any): (state: any, event: any) => Effect.Effect + (machine: Machine.Any, state: any, event: any): Effect.Effect +} = internalPlanner.can + +export const plan: ( + machine: Machine.Any, + state: any, + event: any +) => Effect.Effect = internalPlanner.plan export const logic = < State, @@ -1316,8 +1030,8 @@ export const child = ( ): ChildMachine => makeChild(id, machine, (input) => machine.input === undefined - ? (internalProcess.toProcessLogic as any)(machine) - : (internalProcess.toProcessLogic as any)(machine, input)) + ? internalProcess.toProcessLogic(machine) + : internalProcess.toProcessLogic(machine, input)) const makeChild = ( id: Id, @@ -1333,8 +1047,8 @@ const makeChild = ( export const childFamily = (machine: M): ChildMachine.Family => { const makeLogic = (input?: unknown): Logic => machine.input === undefined - ? (internalProcess.toProcessLogic as any)(machine) - : (internalProcess.toProcessLogic as any)(machine, input) + ? internalProcess.toProcessLogic(machine) + : internalProcess.toProcessLogic(machine, input) return (id) => makeChild(id, machine, makeLogic) } @@ -1414,116 +1128,17 @@ export const watch = ( export const waitFor = Observation.waitFor -export const prepare = internalProcess.prepare - -export const start: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events ->( - machine: - & Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - > - & EnsureExecutable, - ...args: [...Machine.InputArgs] -) => Effect.Effect< - MachineRef< - Machine.Snapshot, - Machine.EventInputOf, - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StoppedError, - Output, - Machine.EmittedEventOf - >, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError, - ExcludeCompatibleRuntime< - ExecutionServices, - Machine.EventOf, - Machine.EmittedEventOf - > -> = internalProcess.start as any - -export const resume: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events ->( - machine: - & Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - > - & EnsureExecutable, - snapshot: Machine.Snapshot -) => Effect.Effect< - MachineRef< - Machine.Snapshot, - Machine.EventInputOf, - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StoppedError, - Output, - Machine.EmittedEventOf - >, - MachineSchemaDecodeError, - ExcludeCompatibleRuntime< - ExecutionServices, - Machine.EventOf, - Machine.EmittedEventOf - > -> = internalProcess.resume as any +export const prepare: ( + machine: Machine.Any, + ...args: ReadonlyArray +) => Effect.Effect> = internalProcess.prepare + +export const start: ( + machine: Machine.Any, + ...args: ReadonlyArray +) => Effect.Effect, any, any> = internalProcess.start + +export const resume: ( + machine: Machine.Any, + snapshot: any +) => Effect.Effect, MachineSchemaDecodeError, any> = internalProcess.resume diff --git a/packages/effect-machine/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts index e79f52c8..1f161cb8 100644 --- a/packages/effect-machine/src/internal/machine/planner.ts +++ b/packages/effect-machine/src/internal/machine/planner.ts @@ -4,9 +4,7 @@ * @since 0.4.0 */ -import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" -import type * as Schema from "effect/Schema" import type { Enqueue, InitialEvent as MachineInitialEvent, Machine, MachineTarget } from "../../Machine.js" import { makeCollector, type RuntimeCommand } from "./command.js" import { @@ -38,7 +36,9 @@ import { snapshotFromConfigurationAtPath } from "./configuration.js" import * as Construction from "./construction.js" -import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError, StoppedError } from "./errors.js" +import { failPlanning, InfiniteTransitionError, MachineSchemaDecodeError, toStartupFailure } from "./errors.js" +import { StoppedError } from "./errors.js" +import type { PlanningError, StartupError } from "./errors.js" import { type CapturedStateConfig, toImpl } from "./implementation.js" import { isDataInitializer } from "./initialDeclaration.js" import { getStateInitializeValues, makeStateInitializeBuilder } from "./initialization.js" @@ -770,7 +770,7 @@ const makeTransitionContext = < StateId extends Machine.StateIdentifier, EventTag extends Machine.TagOf >( - machine: Machine, + machine: Machine.Any, configuration: ActiveConfiguration, path: string, event: Machine.EventByTag, @@ -1728,58 +1728,20 @@ export const getFinalOutputEffect = < }) ) -export const isFinal = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray, - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never ->( - machine: Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates - >, - state: Machine.Snapshot -): state is Machine.SnapshotContainingFinal => isFinalState(machine, state) - -export const planInitialSync = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, - ...args: [...Machine.InputArgs] +export const isFinal = ( + machine: Machine.Any, + state: Machine.Snapshot +): state is Machine.SnapshotContainingFinal => isFinalState(machine, state) + +export const planInitialSync = ( + machine: Machine.Any, + ...args: ReadonlyArray ) => { const inputArgs = machine.input === undefined ? args : args.length === 0 ? (decodeInputSync(machine, machine.input, undefined), args) - : [decodeInputSync(machine, machine.input, args[0])] as [...Machine.InputArgs] + : [decodeInputSync(machine, machine.input, args[0])] const initial = machine.initial(...inputArgs) const emptyConfiguration: ActiveConfiguration = { active: new Set(), @@ -1789,12 +1751,12 @@ export const planInitialSync = < } const rootResolution = resolveInitialTarget(machine, emptyConfiguration, initial, InitialEvent) const configuration = normalizeTargetConfigurationSync(machine, emptyConfiguration, rootResolution.target) - const startingState = snapshotFromConfiguration(machine, configuration) + const startingState = snapshotFromConfiguration(machine, configuration) const initialEntryPaths = getInitialEntryPaths(machine, configuration) const commands = rootResolution.commands const raisedEvents = rootResolution.raisedEvents const emittedEvents = rootResolution.emittedEvents - const entry = collectStateActions( + const entry = collectStateActions( machine, configuration, initialEntryPaths, @@ -1806,7 +1768,7 @@ export const planInitialSync = < configuration, InitialEvent, [...entry.commands], - [...raisedEvents, ...entry.raisedEvents] as Array>, + [...raisedEvents, ...entry.raisedEvents], [...emittedEvents, ...entry.emittedEvents], rootResolution.transitions.length === 0 ? [] : @@ -1817,7 +1779,7 @@ export const planInitialSync = < commands, raisedEvents: [ ...raisedEvents - ] as ReadonlyArray>, + ], emittedEvents, exitPaths: [], entryPaths: [], @@ -1829,14 +1791,14 @@ export const planInitialSync = < const planned = { startingState, initialEntryPaths, - state: snapshotFromConfiguration(machine, settled.next), + state: snapshotFromConfiguration(machine, settled.next), commands: [ ...commands, ...settled.commands ], - emittedEvents: settled.emittedEvents as ReadonlyArray>, + emittedEvents: settled.emittedEvents, microsteps: settled.microsteps.map((step) => ({ - next: snapshotFromConfiguration(machine, step.next), + next: snapshotFromConfiguration(machine, step.next), event: step.event, transitions: step.transitions, commands: step.commands, @@ -1852,47 +1814,21 @@ export const planInitialSync = < : { ...planned, done: false as const, output: undefined } } -export const enabled = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray, - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - OutputStates extends Machine.StateIdentifier = never ->( - machine: Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates - >, - state: Machine.Snapshot -): ReadonlyArray> => { +export const enabled = ( + machine: Machine.Any, + state: Machine.Snapshot +): ReadonlyArray> => { if (isFinalState(machine, state)) { return [] } const configuration = normalizeConfiguration(machine, state) - const tags: Array> = [] + const tags: Array> = [] const seen = new Set() for (const path of getCandidatePaths(machine, configuration)) { for (const tag of Reflect.ownKeys(toImpl(machine).handlers[path]?.on ?? {})) { if (!seen.has(tag)) { seen.add(tag) - tags.push(tag as Machine.TagOf) + tags.push(tag) } } } @@ -1910,24 +1846,11 @@ const canSync = ( return selectEventTransitions(machine, configuration, decodedEvent as any).length > 0 } -const microstep = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - Context = never ->( - machine: Machine, +const microstep = ( + machine: Machine.Any, state: ActiveConfiguration, - event: Machine.LifecycleEvent, - selections: ReadonlyArray> + event: Machine.LifecycleEvent, + selections: ReadonlyArray> ) => { if (selections.length === 0) { return { @@ -1945,10 +1868,10 @@ const microstep = < } const activeSelections = removePreemptedAncestorSelections(selections) - const evaluatedTransitions: Array, E, R, Context>> = [] + const evaluatedTransitions: Array, any, any, any>> = [] for (const selection of activeSelections) { evaluatedTransitions.push( - collectEvaluatedTransition, E, R, Context>( + collectEvaluatedTransition( machine, state, selection @@ -1994,7 +1917,7 @@ const microstep = < ] for (const transition of targetApplicationOrder) { if (transition.target !== undefined) { - stateAfterTransition = normalizeTargetConfigurationSync( + stateAfterTransition = normalizeTargetConfigurationSync( machine, stateAfterTransition, transition.target @@ -2029,14 +1952,14 @@ const microstep = < const exitPaths = sortExitPaths(machine, sortedTransitions.flatMap((transition) => transition.exitPaths)) const entryPaths = sortEntryPaths(machine, sortedTransitions.flatMap((transition) => transition.entryPaths)) stateAfterTransition = captureHistory(machine, state, stateAfterTransition, exitPaths) - const exit = collectStateActions( + const exit = collectStateActions( machine, state, exitPaths, event, "exit" ) - const entry = collectStateActions( + const entry = collectStateActions( machine, stateAfterTransition, entryPaths, @@ -2050,7 +1973,7 @@ const microstep = < transitions: retainedTransitions, commands: [...exit.commands, ...transitionActions, ...entry.commands], raisedEvents: [...exit.raisedEvents, ...transitionRaisedEvents, ...entry.raisedEvents] as ReadonlyArray< - Machine.EventOf + Machine.EventOf >, emittedEvents: [...exit.emittedEvents, ...transitionEmittedEvents, ...entry.emittedEvents], exitPaths, @@ -2060,26 +1983,14 @@ const microstep = < } } -const settle = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, +const settle = ( + machine: Machine.Any, state: ActiveConfiguration, - event: Machine.LifecycleEvent, + event: Machine.LifecycleEvent, commands: Array, - raisedEvents: Array>, + raisedEvents: Array>, emittedEvents: Array, - microsteps: Array, E, R>> + microsteps: Array, any, any>> ) => { let currentState = state let currentEvent = event @@ -2087,7 +1998,7 @@ const settle = < let iterations = 0 let raisedEventIndex = 0 let completedTerminal = false - let finalOutput: Output | undefined = undefined + let finalOutput: any | undefined = undefined const pendingCompletions: Array<{ readonly path: string; readonly output: unknown }> = [] while (true) { @@ -2108,14 +2019,14 @@ const settle = < while (pendingCompletions.length > 0 && !currentState.active.has(pendingCompletions[0]!.path)) { pendingCompletions.shift() } - const done = selectDoneTransitions( + const done = selectDoneTransitions( machine, currentState, currentEvent, pendingCompletions.length === 0 ? [] : [pendingCompletions.shift()!] ) if (done.length > 0) { - const doneStep: SettlingMicrostep, E, R> = microstep( + const doneStep: SettlingMicrostep, any, any> = microstep( machine, currentState, currentEvent, @@ -2135,15 +2046,15 @@ const settle = < throw new Error("Machine reached a terminal configuration without a completed root output") } completedTerminal = true - finalOutput = currentState.outputs.get(root) as Output + finalOutput = currentState.outputs.get(root) as any break } const always = shouldRunAlways - ? selectAlwaysTransitions(machine, currentState, currentEvent) + ? selectAlwaysTransitions(machine, currentState, currentEvent) : [] if (always.length > 0) { - const alwaysStep: SettlingMicrostep, E, R> = microstep( + const alwaysStep: SettlingMicrostep, any, any> = microstep( machine, currentState, currentEvent, @@ -2168,10 +2079,10 @@ const settle = < // internal queue, so decoding it again here only repeats schema work. const raisedEvent = raisedEventValue currentEvent = raisedEvent - const raisedSelections = selectEventTransitions( + const raisedSelections = selectEventTransitions( machine, currentState, - raisedEvent as Machine.EventByTag> + raisedEvent ) if (raisedSelections.length === 0) { shouldRunAlways = true @@ -2198,28 +2109,16 @@ const settle = < microsteps } return completedTerminal - ? { ...result, done: true as const, output: finalOutput as Output } + ? { ...result, done: true as const, output: finalOutput as any } : { ...result, done: false as const, output: undefined } } -const macrostepConfiguration = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, +const macrostepConfiguration = ( + machine: Machine.Any, configuration: ActiveConfiguration, - event: Machine.EventOf | InvocationEvent.InvocationEvent + event: Machine.EventOf | InvocationEvent.InvocationEvent ) => { - const decodedEvent = InvocationEvent.isInvocationEvent(event) ? event : decodeEventSync(machine, event) + const decodedEvent = InvocationEvent.isInvocationEvent(event) ? event : decodeEventSync(machine, event) if (isActiveFinalConfiguration(machine, configuration)) { const completed = completeConfigurationSync(machine, configuration, decodedEvent as any) const root = getRootPath(machine, completed.configuration) @@ -2233,16 +2132,16 @@ const macrostepConfiguration = < emittedEvents: [], microsteps: [], done: true as const, - output: completed.configuration.outputs.get(root) as Output + output: completed.configuration.outputs.get(root) as any } } const selections = InvocationEvent.isInvocationEvent(decodedEvent) - ? selectInvocationTransition(machine, configuration, decodedEvent) - : selectEventTransitions( + ? selectInvocationTransition(machine, configuration, decodedEvent) + : selectEventTransitions( machine, configuration, - decodedEvent as Machine.EventByTag> + decodedEvent ) if (selections.length === 0) { return { @@ -2303,40 +2202,26 @@ const snapshotMacrostep = < : { ...planned, done: false, output: undefined } } -const macrostep = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, - state: Machine.Snapshot, - event: Machine.EventOf +const macrostep = ( + machine: Machine.Any, + state: Machine.Snapshot, + event: Machine.EventOf ) => { - const configuration = normalizeConfigurationSync(machine, state) + const configuration = normalizeConfigurationSync(machine, state) const settled = macrostepConfiguration(machine, configuration, event) - return snapshotMacrostep, E, R, Output>(machine, settled) + return snapshotMacrostep(machine, settled) } export const planSync = macrostep export const planConfiguration = macrostepConfiguration -const planningEffect = (thunk: () => A): Effect.Effect => +const planningEffect = (thunk: () => A): Effect.Effect => Effect.suspend(() => { try { return Effect.succeed(thunk()) } catch (error) { - return error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError - ? Effect.fail(error) - : Effect.die(error) + return failPlanning(error) } }) @@ -2349,25 +2234,34 @@ const schemaEffect = (thunk: () => A): Effect.Effect, unknown]) => { +export function can( + machine: Machine.Any +): (state: Machine.Snapshot, event: unknown) => Effect.Effect +export function can( + machine: Machine.Any, + state: Machine.Snapshot, + event: unknown +): Effect.Effect +export function can( + ...args: readonly [Machine.Any] | readonly [Machine.Any, Machine.Snapshot, unknown] +): + | ((state: Machine.Snapshot, event: unknown) => Effect.Effect) + | Effect.Effect +{ const query = (state: Machine.Snapshot, event: unknown) => schemaEffect(() => canSync(args[0], state, event)) return args.length === 1 ? query : query(args[1], args[2]) } export const plan = (machine: Machine.Any, state: Machine.Snapshot, event: unknown) => - planningEffect(() => planSync(machine as any, state, event as any)) + planningEffect(() => planSync(machine, state, event)) export const planInitial = ( machine: Machine.Any, ...args: ReadonlyArray -): Effect.Effect => +): Effect.Effect, PlanningError | StartupError> => Effect.try({ - try: () => (planInitialSync as any)(machine, ...args), - catch: (error) => { - return error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError - ? error - : new StartupError({ cause: Cause.die(error) }) - } + try: () => planInitialSync(machine, ...args), + catch: toStartupFailure }) // Captured constructors are private planner inputs. Public callbacks receive root data diff --git a/packages/effect-machine/src/internal/machine/process.ts b/packages/effect-machine/src/internal/machine/process.ts index 47c17eab..35536090 100644 --- a/packages/effect-machine/src/internal/machine/process.ts +++ b/packages/effect-machine/src/internal/machine/process.ts @@ -4,32 +4,28 @@ * @since 0.4.0 */ -import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" import * as Option from "effect/Option" -import type * as Schema from "effect/Schema" -import type { ActionError, ExecutionServices, Machine, Runtime } from "../../Machine.js" +import type { Machine, Runtime } from "../../Machine.js" import * as CommandRuntime from "./commandRuntime.js" import * as Configuration from "./configuration.js" -import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError } from "./errors.js" -import type { StoppedError } from "./errors.js" +import { failPlanning, toStartupFailure } from "./errors.js" import * as ExecutionPlan from "./executionPlan.js" import { type CapturedStateConfig, toImpl } from "./implementation.js" import * as Invocation from "./invocation.js" import * as internalPlanner from "./planner.js" -import type { ExcludeCompatibleRuntime } from "./requirements.js" import * as internalRuntime from "./runtime.js" import * as internalRuntimeProtocol from "./runtimeProtocol.js" import * as Serialization from "./serialization.js" -type ProcessEntry = +type ProcessEntry = | { readonly _tag: "Initial" - readonly args: [...Machine.InputArgs] + readonly args: ReadonlyArray } | { readonly _tag: "Resume" - readonly snapshot: Machine.Snapshot + readonly snapshot: Machine.Snapshot } const runSequentialDiscard = ( @@ -129,9 +125,7 @@ const makeChildlessCompiledDrain = ( context.scope ) } catch (error) { - return error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError - ? Effect.fail(error) - : Effect.die(error) + return failPlanning(error) } configuration = planned.next context.executionState = configuration @@ -254,9 +248,7 @@ const makeInvokingCompiledDrain = ( scope ) } catch (error) { - return error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError - ? Effect.fail(error) - : Effect.die(error) + return failPlanning(error) } configuration = planned.next if (planned.microsteps.length === 0) { @@ -370,69 +362,31 @@ const makeInvokingCompiledDrain = ( } } -const makeProcessLogic: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, - entry: ProcessEntry -) => internalRuntimeProtocol.ProcessLogic< - Machine.Snapshot, - Machine.EventOf, - E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, - ExcludeCompatibleRuntime< - Exclude, internalRuntimeProtocol.MachineRuntime>, - Machine.EventOf, - Machine.EmittedEventOf - >, - Output, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError -> = < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, - entry: ProcessEntry -) => { +// Planned initial states only carry an output once the machine is done. +const initialResult = ( + planned: { readonly state: Machine.Snapshot; readonly done: boolean; readonly output: unknown } +): internalRuntimeProtocol.CompiledProcessInitial, unknown> => + planned.done + ? { state: planned.state, done: true, output: planned.output } + : { state: planned.state, done: false, output: undefined } + +// Process logic executes erased machine definitions. The public `Machine` +// module owns the typed contract that specializes this boundary. +const makeProcessLogic = ( + machine: Machine.Any, + entry: ProcessEntry +): internalRuntimeProtocol.ProcessLogic => { const hasInvokes = hasInvokeCapability(machine) const executionPlan = ExecutionPlan.compileExecutionPlan(machine) const initialArgs = entry._tag === "Initial" ? entry.args : [] const compiledInitial = entry._tag === "Initial" ? executionPlan.initial : undefined const makeCompiledInitial = compiledInitial === undefined ? undefined : ( - scope: internalRuntimeProtocol.ProcessScope> + scope: internalRuntimeProtocol.ProcessScope> ) => { try { const planned = compiledInitial(initialArgs, scope) scope.inspectInitial(planned.initialEntryPaths) - const result = { - state: planned.state as Machine.Snapshot, - done: planned.done, - output: planned.output as Output | undefined - } + const result = initialResult(planned) return hasInvokes ? { ...result, @@ -444,13 +398,11 @@ const makeProcessLogic: < } : result } catch (error) { - throw error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError - ? error - : new StartupError({ cause: Cause.die(error) }) + throw toStartupFailure(error) } } const makeInitial = ( - scope: internalRuntimeProtocol.ProcessScope> + scope: internalRuntimeProtocol.ProcessScope> ) => compiledInitial === undefined ? internalRuntimeProtocol.provideMachineRuntime( @@ -464,13 +416,9 @@ const makeProcessLogic: < ? undefined : CommandRuntime.runEmittedEvents( planned.emittedEvents, - CommandRuntime.makeLiveRuntime, Machine.EmittedEventOf>(machine, scope) + CommandRuntime.makeLiveRuntime(machine, scope) ) - const result = Effect.succeed({ - state: planned.state, - done: planned.done, - output: planned.output - }) + const result = Effect.succeed(initialResult(planned)) return commands === undefined ? emitted === undefined ? result : emitted.pipe(Effect.andThen(result)) : emitted === undefined @@ -480,14 +428,14 @@ const makeProcessLogic: < ), scope ) - : Effect.try({ try: () => makeCompiledInitial!(scope), catch: (error) => error as any }) + : Effect.try({ try: () => makeCompiledInitial!(scope), catch: toStartupFailure }) return ({ inspection: { kind: "Machine", definition: machine }, execution: { _tag: "Compiled", childless: !hasInvokes, - initial: entry._tag === "Initial" ? makeInitial : undefined, - initialSync: makeCompiledInitial, + ...(entry._tag === "Initial" ? { initial: makeInitial } : {}), + ...(makeCompiledInitial === undefined ? {} : { initialSync: makeCompiledInitial }), drain: { _tag: "Owned", run: hasInvokes @@ -509,11 +457,11 @@ const makeProcessLogic: < if (completeMessage === undefined || pollMessage === undefined || receiveMessage === undefined) { return yield* Effect.die(new Error("Machine statechart started without acknowledged mailbox access")) } - let terminal: { readonly output: Output } | undefined + let terminal: { readonly output: any } | undefined let current = yield* state if (internalPlanner.isFinalState(machine, current)) { - return yield* internalPlanner.getFinalOutputEffect( + return yield* internalPlanner.getFinalOutputEffect( machine, current, internalPlanner.InitialEvent @@ -528,9 +476,9 @@ const makeProcessLogic: < // per iteration; every iteration still crosses Effect boundaries, // so the Effect scheduler remains responsible for cooperative yield. let configuration: Configuration.ActiveConfiguration | undefined - let pendingMessage: Option.Option>> = Option + let pendingMessage: Option.Option>> = Option .none() - let liveRuntime: Runtime, Machine.EmittedEventOf> | undefined + let liveRuntime: Runtime, Machine.EmittedEventOf> | undefined while (terminal === undefined) { const message = Option.isSome(pendingMessage) ? pendingMessage.value : yield* receiveMessage pendingMessage = Option.none() @@ -548,21 +496,18 @@ const makeProcessLogic: < event ) } catch (error) { - if (error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError) { - return yield* error - } - throw error + return yield* failPlanning(error) } configuration = planned.next if (planned.microsteps.length > 0) { - const next = Configuration.snapshotFromConfiguration(machine, planned.next) + const next = Configuration.snapshotFromConfiguration(machine, planned.next) yield* CommandRuntime.runCommands(planned.commands, context) yield* setState(next) current = next if (planned.emittedEvents.length > 0) { yield* CommandRuntime.runEmittedEvents( - planned.emittedEvents as ReadonlyArray>, + planned.emittedEvents, liveRuntime ??= CommandRuntime.makeLiveRuntime(machine, context) ) } @@ -610,16 +555,15 @@ const makeProcessLogic: < const startInvokes: ( configuration: Configuration.ActiveConfiguration, paths: ReadonlyArray, - event: Machine.LifecycleEvent - ) => Effect.Effect = (configuration, paths, event) => - (Invocation.startAll( - machine, - context, - ownedChildren, - configuration, - paths, - event - ) ?? Effect.void) as Effect.Effect + event: Machine.LifecycleEvent + ) => Effect.Effect = (configuration, paths, event) => (Invocation.startAll( + machine, + context, + ownedChildren, + configuration, + paths, + event + ) ?? Effect.void) const stopInvokes = (paths: ReadonlyArray): Effect.Effect => ownedChildren.stopPaths(paths) ?? Effect.void @@ -637,9 +581,9 @@ const makeProcessLogic: < // As above, keep the normalized configuration only while this // worker can continue draining an already queued batch. configuration = undefined - let pendingMessage: Option.Option>> = Option + let pendingMessage: Option.Option>> = Option .none() - let liveRuntime: Runtime, Machine.EmittedEventOf> | undefined + let liveRuntime: Runtime, Machine.EmittedEventOf> | undefined // Match the compact non-invoke loop while retaining state-scoped // child lifecycle work at the same ordered Effect boundaries. @@ -660,25 +604,22 @@ const makeProcessLogic: < event ) } catch (error) { - if (error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError) { - return yield* error - } - throw error + return yield* failPlanning(error) } configuration = planned.next if (planned.microsteps.length > 0) { const changed = planned.microsteps.some((step) => step.changed) const exitPaths = planned.microsteps.flatMap((step) => step.exitPaths) - const entryEvents = new Map>() + const entryEvents = new Map>() for (const step of planned.microsteps) { if (step.changed) { for (const path of step.entryPaths) { - entryEvents.set(path, step.event as Machine.LifecycleEvent) + entryEvents.set(path, step.event) } } } - const next = Configuration.snapshotFromConfiguration(machine, planned.next) + const next = Configuration.snapshotFromConfiguration(machine, planned.next) yield* CommandRuntime.runCommands(planned.commands, context) if (changed) { yield* stopInvokes(exitPaths) @@ -687,7 +628,7 @@ const makeProcessLogic: < current = next if (planned.emittedEvents.length > 0) { yield* CommandRuntime.runEmittedEvents( - planned.emittedEvents as ReadonlyArray>, + planned.emittedEvents, liveRuntime ??= CommandRuntime.makeLiveRuntime(machine, context) ) } @@ -734,24 +675,7 @@ const makeProcessLogic: < }), context ) - }) as internalRuntimeProtocol.ProcessLogic< - Machine.Snapshot, - Machine.EventOf, - E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, - ExcludeCompatibleRuntime< - Exclude, internalRuntimeProtocol.MachineRuntime>, - Machine.EventOf, - Machine.EmittedEventOf - >, - Output, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError - > + }) } const initialProcessLogicCache = new WeakMap< @@ -759,39 +683,10 @@ const initialProcessLogicCache = new WeakMap< internalRuntimeProtocol.ProcessLogic >() -export const toProcessLogic: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, - ...args: [...Machine.InputArgs] -) => internalRuntimeProtocol.ProcessLogic< - Machine.Snapshot, - Machine.EventOf, - E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, - ExcludeCompatibleRuntime< - Exclude, internalRuntimeProtocol.MachineRuntime>, - Machine.EventOf, - Machine.EmittedEventOf - >, - Output, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError -> = (machine, ...args) => { +export const toProcessLogic = ( + machine: Machine.Any, + ...args: ReadonlyArray +): internalRuntimeProtocol.ProcessLogic => { if (args.length > 0) { return makeProcessLogic(machine, { _tag: "Initial", args }) } @@ -813,7 +708,7 @@ const toResumedProcessLogic = ( machine: Machine.Any, snapshot: Machine.Snapshot ): internalRuntimeProtocol.ProcessLogic => - (makeProcessLogic as any)(machine, { _tag: "Resume", snapshot }) + makeProcessLogic(machine, { _tag: "Resume", snapshot }) /** @internal Test-only runtime strategy selection for a fresh machine. */ export const startWithRuntimeStrategyForTesting = ( @@ -822,7 +717,7 @@ export const startWithRuntimeStrategyForTesting = ( ...args: ReadonlyArray ): Effect.Effect, any, any> => internalRuntime.startProcessWithStrategyForTesting( - (toProcessLogic as any)(machine, ...args), + toProcessLogic(machine, ...args), strategy, machine.id === undefined ? undefined : { id: machine.id } ) @@ -834,7 +729,7 @@ export const prepareWithRuntimeStrategyForTesting = ( ...args: ReadonlyArray ): Effect.Effect, any, any> => internalRuntime.prepareProcessWithStrategyForTesting( - (toProcessLogic as any)(machine, ...args), + toProcessLogic(machine, ...args), strategy, machine.id === undefined ? undefined : { id: machine.id } ) @@ -851,125 +746,31 @@ export const resumeWithRuntimeStrategyForTesting = ( machine.id === undefined ? undefined : { id: machine.id } ) -export const start: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, - ...args: [...Machine.InputArgs] -) => Effect.Effect< - internalRuntimeProtocol.MachineRef< - Machine.Snapshot, - Machine.EventOf, - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StoppedError, - Output - >, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError, - ExcludeCompatibleRuntime< - Exclude, internalRuntimeProtocol.MachineRuntime>, - Machine.EventOf, - Machine.EmittedEventOf - > -> = (machine, ...args) => +// The public `Machine` module owns the typed start, prepare, and resume +// signatures. These implementations are the erased boundary they specialize. +export const start = ( + machine: Machine.Any, + ...args: ReadonlyArray +): Effect.Effect, any, any> => internalRuntime.startProcess( toProcessLogic(machine, ...args), machine.id === undefined ? undefined : { id: machine.id } - ) as any - -export const prepare: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, - ...args: [...Machine.InputArgs] -) => Effect.Effect< - internalRuntimeProtocol.PreparedProcess< - Machine.Snapshot, - Machine.EventOf, - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StoppedError, - Output, - Machine.EmittedEventOf, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError, - ExcludeCompatibleRuntime< - Exclude, internalRuntimeProtocol.MachineRuntime>, - Machine.EventOf, - Machine.EmittedEventOf - > - > -> = (machine, ...args) => + ) + +export const prepare = ( + machine: Machine.Any, + ...args: ReadonlyArray +): Effect.Effect> => internalRuntime.prepareProcess( toProcessLogic(machine, ...args), machine.id === undefined ? undefined : { id: machine.id } - ) as any - -export const resume: < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never ->( - machine: Machine, - snapshot: Machine.Snapshot -) => Effect.Effect< - internalRuntimeProtocol.MachineRef< - Machine.Snapshot, - Machine.EventOf, - E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, - Output - >, - MachineSchemaDecodeError, - ExcludeCompatibleRuntime< - Exclude, internalRuntimeProtocol.MachineRuntime>, - Machine.EventOf, - Machine.EmittedEventOf - > -> = (machine, snapshot) => + ) + +export const resume = ( + machine: Machine.Any, + snapshot: Machine.Snapshot +): Effect.Effect, any, any> => internalRuntime.startProcess( toResumedProcessLogic(machine, snapshot), machine.id === undefined ? undefined : { id: machine.id } - ) as any + ) diff --git a/packages/effect-machine/src/internal/machine/targetReference.ts b/packages/effect-machine/src/internal/machine/targetReference.ts index 4e2f68df..5a2bbcc0 100644 --- a/packages/effect-machine/src/internal/machine/targetReference.ts +++ b/packages/effect-machine/src/internal/machine/targetReference.ts @@ -1,19 +1,19 @@ import * as Schema from "effect/Schema" -import type { Machine, State } from "../../Machine.js" +import type { Machine } from "../../Machine.js" /** Reference metadata is separate from child names, including names such as `path`. */ export const TypeId: unique symbol = Symbol.for("effect/Machine/TargetReference") export interface Reference { readonly [TypeId]: { - readonly root: State + readonly root: Machine.Any["root"] readonly path: string readonly kind: "state" | "choice" | "history" } } /** Captures an immutable reference tree without allocating or executing a machine. */ -export const make = (root: State): { readonly root: Reference } => { +export const make = (root: Machine.Any["root"]): { readonly root: Reference } => { const nodes: Array<{ path: string; key: string; parent: string | undefined; type: string }> = [] const visit = ( node: Machine.StateNodeConfig | Machine.TaggedSchema, diff --git a/packages/effect-machine/test/internal/machine/planningFailures.test.ts b/packages/effect-machine/test/internal/machine/planningFailures.test.ts new file mode 100644 index 00000000..6bd71240 --- /dev/null +++ b/packages/effect-machine/test/internal/machine/planningFailures.test.ts @@ -0,0 +1,122 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause, Effect, Exit, Schema } from "effect" +import { Machine } from "../../../src/index.js" +import * as ExecutionPlan from "../../../src/internal/machine/executionPlan.js" +import { startWithRuntimeStrategyForTesting } from "../../../src/internal/machine/process.js" + +class Idle extends Schema.TaggedClass("PlanningFailureIdle")("Idle", {}) {} +class Busy extends Schema.TaggedClass("PlanningFailureBusy")("Busy", {}) {} +class Waiting extends Schema.TaggedClass("PlanningFailureWaiting")("Waiting", {}) {} +class Go extends Schema.TaggedClass("PlanningFailureGo")("Go", {}) {} +class Loop extends Schema.TaggedClass("PlanningFailureLoop")("Loop", {}) {} + +const defect = new Error("handler defect") + +// Uses only indexed-planner capabilities so the optimized initial and drain +// paths are exercised alongside the generic reference. +const makeDefectMachine = (options: { readonly failInitial: boolean }) => { + const root = Machine.state({ states: { Idle, Busy } }) + const targets = Machine.targets(root) + return Machine.make({ + root, + events: Machine.eventsFromSchemas(Go) + }).handle({ + initial: { + target: targets.root.Idle, + decoded: true, + data: () => { + if (options.failInitial) throw defect + return new Idle() + } + }, + states: { + Idle: { + on: { + Go: { + target: targets.root.Busy, + decoded: true, + data: () => { + throw defect + } + } + } + }, + Busy: {} + } + }) +} + +// Idle and Busy route to each other through `always`, so planning never +// stabilizes once either is entered. +const makeLoopMachine = (options: { readonly loopInitial: boolean }) => { + const root = Machine.state({ states: { Waiting, Idle, Busy } }) + const targets = Machine.targets(root) + const loop = { + Idle: { always: { target: targets.root.Busy, decoded: true, data: () => new Busy() } }, + Busy: { always: { target: targets.root.Idle, decoded: true, data: () => new Idle() } } + } as const + const machine = Machine.make({ + root, + events: Machine.eventsFromSchemas(Loop) + }) + return options.loopInitial + ? machine.handle({ + initial: { target: targets.root.Idle, decoded: true, data: new Idle() }, + states: { Waiting: {}, ...loop } + }) + : machine.handle({ + initial: { target: targets.root.Waiting, decoded: true, data: new Waiting() }, + states: { + Waiting: { on: { Loop: { target: targets.root.Idle, decoded: true, data: () => new Idle() } } }, + ...loop + } + }) +} + +describe("planning failure classification", () => { + it("compiles the defect fixture to an optimized planner", () => { + assert.notStrictEqual( + ExecutionPlan.selectExecutionPlanForTesting(makeDefectMachine({ failInitial: false }), "auto").strategy, + "generic" + ) + }) + + for (const strategy of ["generic", "compiled"] as const) { + it.effect(`${strategy}: wraps an initializer defect in StartupError`, () => + Effect.gen(function*() { + const error = yield* Effect.flip( + startWithRuntimeStrategyForTesting(makeDefectMachine({ failInitial: true }), strategy) + ) + assert.instanceOf(error, Machine.StartupError) + assert.strictEqual(Cause.squash(error.cause), defect) + })) + + it.effect(`${strategy}: reports a transition handler throw as a defect`, () => + Effect.gen(function*() { + const ref = yield* startWithRuntimeStrategyForTesting(makeDefectMachine({ failInitial: false }), strategy) + yield* ref.send(new Go()) + const exit = yield* Effect.exit(ref.join) + assert(Exit.isFailure(exit)) + assert(Cause.hasDies(exit.cause)) + assert.strictEqual(Cause.squash(exit.cause), defect) + })) + + it.effect(`${strategy}: keeps initial non-stabilization typed`, () => + Effect.gen(function*() { + const error = yield* Effect.flip( + startWithRuntimeStrategyForTesting(makeLoopMachine({ loopInitial: true }), strategy) + ) + assert.instanceOf(error, Machine.InfiniteTransitionError) + })) + + it.effect(`${strategy}: keeps event-time non-stabilization typed`, () => + Effect.gen(function*() { + const ref = yield* startWithRuntimeStrategyForTesting(makeLoopMachine({ loopInitial: false }), strategy) + yield* ref.send(new Loop()) + const exit = yield* Effect.exit(ref.join) + assert(Exit.isFailure(exit)) + assert.isFalse(Cause.hasDies(exit.cause)) + assert.instanceOf(Cause.squash(exit.cause), Machine.InfiniteTransitionError) + })) + } +})