From c788955b916528b4709dc88094dbb03a722e1742 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 10 Aug 2026 13:09:00 +0200 Subject: [PATCH] Decompose machine semantic internals --- .changeset/calm-machines-divide.md | 5 + scripts/check-architecture.mjs | 79 +- scripts/check-architecture.test.mjs | 10 +- src/Machine.ts | 12 +- src/internal/machine/atom.ts | 8 +- src/internal/machine/command.ts | 58 + src/internal/machine/commandRuntime.ts | 43 + .../machine/{model.ts => configuration.ts} | 1266 +---------------- src/internal/machine/executionPlan.ts | 925 ++++++++++++ src/internal/machine/machine.ts | 55 +- src/internal/machine/planner.ts | 1030 +------------- src/internal/machine/process.ts | 99 +- src/internal/machine/protocol.ts | 322 +++++ src/internal/machine/serialization.ts | 499 +++++++ src/internal/machine/topology.ts | 479 +++++++ src/unstable/reactivity/AtomMachine.ts | 1 - test/internal/machine/protocol.test.ts | 2 +- .../machine/strategyDifferential.test.ts | 8 +- .../machine/support/strategyDifferential.ts | 14 +- 19 files changed, 2579 insertions(+), 2336 deletions(-) create mode 100644 .changeset/calm-machines-divide.md create mode 100644 src/internal/machine/command.ts create mode 100644 src/internal/machine/commandRuntime.ts rename src/internal/machine/{model.ts => configuration.ts} (52%) create mode 100644 src/internal/machine/executionPlan.ts create mode 100644 src/internal/machine/protocol.ts create mode 100644 src/internal/machine/serialization.ts create mode 100644 src/internal/machine/topology.ts diff --git a/.changeset/calm-machines-divide.md b/.changeset/calm-machines-divide.md new file mode 100644 index 0000000..4e688b4 --- /dev/null +++ b/.changeset/calm-machines-divide.md @@ -0,0 +1,5 @@ +--- +"@typeonce/effect-machine": patch +--- + +Decompose machine topology, schema protocols, configurations, snapshot serialization, command execution, semantic planning, and compiled execution plans into explicit internal modules without changing runtime behavior or public types. diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index 08483f8..ed4d8f4 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -7,6 +7,7 @@ const ruleDescriptions = { ARCH002: "Public modules may only reach internals through their designated implementation seam", ARCH003: "Core internals may only refer back to Machine through type-only imports", ARCH004: "The planner may not depend on process or runtime execution", + ARCH005: "Machine semantic layers may only depend inward", ARCH006: "The runtime may not depend on machine semantics or process orchestration", ARCH007: "Production modules may not depend on testing internals", ARCH008: "Black-box tests may not depend on implementation internals", @@ -219,6 +220,65 @@ export const checkArchitecture = ({ ["src/unstable/reactivity/AtomMachine.ts", "src/internal/machine/atom.ts"], ["src/unstable/cluster/ClusterMachine.ts", "src/internal/machine/cluster.ts"] ]) + const forbiddenSemanticDependencies = new Map([ + ["src/internal/machine/topology.ts", new Set([ + "src/internal/machine/protocol.ts", + "src/internal/machine/configuration.ts", + "src/internal/machine/serialization.ts", + "src/internal/machine/planner.ts", + "src/internal/machine/command.ts", + "src/internal/machine/executionPlan.ts", + "src/internal/machine/commandRuntime.ts", + "src/internal/machine/process.ts", + "src/internal/machine/runtime.ts" + ])], + ["src/internal/machine/protocol.ts", new Set([ + "src/internal/machine/configuration.ts", + "src/internal/machine/serialization.ts", + "src/internal/machine/planner.ts", + "src/internal/machine/command.ts", + "src/internal/machine/executionPlan.ts", + "src/internal/machine/commandRuntime.ts", + "src/internal/machine/process.ts", + "src/internal/machine/runtime.ts" + ])], + ["src/internal/machine/configuration.ts", new Set([ + "src/internal/machine/serialization.ts", + "src/internal/machine/planner.ts", + "src/internal/machine/command.ts", + "src/internal/machine/executionPlan.ts", + "src/internal/machine/commandRuntime.ts", + "src/internal/machine/process.ts", + "src/internal/machine/runtime.ts" + ])], + ["src/internal/machine/serialization.ts", new Set([ + "src/internal/machine/planner.ts", + "src/internal/machine/command.ts", + "src/internal/machine/executionPlan.ts", + "src/internal/machine/commandRuntime.ts", + "src/internal/machine/process.ts", + "src/internal/machine/runtime.ts" + ])], + ["src/internal/machine/command.ts", new Set([ + "src/internal/machine/executionPlan.ts", + "src/internal/machine/commandRuntime.ts", + "src/internal/machine/process.ts", + "src/internal/machine/runtime.ts" + ])], + ["src/internal/machine/planner.ts", new Set([ + "src/internal/machine/serialization.ts", + "src/internal/machine/executionPlan.ts", + "src/internal/machine/commandRuntime.ts", + "src/internal/machine/process.ts", + "src/internal/machine/runtime.ts" + ])], + ["src/internal/machine/executionPlan.ts", new Set([ + "src/internal/machine/serialization.ts", + "src/internal/machine/commandRuntime.ts", + "src/internal/machine/process.ts", + "src/internal/machine/runtime.ts" + ])] + ]) for (const edge of edges) { if (entrypoints.has(edge.source) && edge.target.includes("/internal/")) { @@ -261,7 +321,12 @@ export const checkArchitecture = ({ if ( edge.source === "src/internal/machine/planner.ts" && !edge.typeOnly && - (edge.target === "src/internal/machine/process.ts" || edge.target === "src/internal/machine/runtime.ts") + [ + "src/internal/machine/commandRuntime.ts", + "src/internal/machine/executionPlan.ts", + "src/internal/machine/process.ts", + "src/internal/machine/runtime.ts" + ].includes(edge.target) ) { diagnostics.push(diagnostic( "ARCH004", @@ -271,10 +336,20 @@ export const checkArchitecture = ({ `Planner has a runtime dependency on ${edge.target}` )) } + if (forbiddenSemanticDependencies.get(edge.source)?.has(edge.target)) { + diagnostics.push(diagnostic( + "ARCH005", + edge.sourceFile, + edge.node, + edge.source, + `Semantic layer depends outward on ${edge.target}` + )) + } if ( edge.source === "src/internal/machine/runtime.ts" && [ - "src/internal/machine/model.ts", + "src/internal/machine/configuration.ts", + "src/internal/machine/executionPlan.ts", "src/internal/machine/planner.ts", "src/internal/machine/process.ts" ].includes(edge.target) diff --git a/scripts/check-architecture.test.mjs b/scripts/check-architecture.test.mjs index d9b057b..e37dae5 100644 --- a/scripts/check-architecture.test.mjs +++ b/scripts/check-architecture.test.mjs @@ -80,7 +80,15 @@ test("rejects value back-edges and execution-layer inversions", () => { "src/internal/testing/machine/arbitrary.ts": "export const arbitrary = 1", "src/consumer.ts": 'import { arbitrary } from "./internal/testing/machine/arbitrary.js"\nvoid arbitrary' }) - assert.deepEqual(rules(root), ["ARCH007", "ARCH003", "ARCH004", "ARCH006"]) + assert.deepEqual(rules(root), ["ARCH007", "ARCH003", "ARCH004", "ARCH005", "ARCH006"]) +}) + +test("rejects outward machine semantic dependencies", () => { + const root = makeProject({ + "src/internal/machine/topology.ts": 'import { decode } from "./protocol.js"\nexport const topology = decode', + "src/internal/machine/protocol.ts": "export const decode = 1" + }) + assert.deepEqual(rules(root), ["ARCH005"]) }) test("detects runtime cycles while permitting type-only cycles", () => { diff --git a/src/Machine.ts b/src/Machine.ts index ea0480d..d84d27d 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -26,10 +26,10 @@ import type { } from "./internal/machine/machine.js" import * as internal from "./internal/machine/machine.js" import { InitialEventTypeId } from "./internal/machine/machine.js" -import type * as Model from "./internal/machine/model.js" import type { EnsureExecutable } from "./internal/machine/readiness.js" import type * as internalRuntime from "./internal/machine/runtime.js" import type * as StateDefinition from "./internal/machine/stateDefinition.js" +import type * as Topology from "./internal/machine/topology.js" /** * String literal type used as the runtime type identifier for `Machine` @@ -3394,7 +3394,7 @@ export declare namespace Machine { * @since 4.0.0 */ export interface StateConstruction { - readonly [Model.StateConstructionTypeId]: Result + readonly [Topology.StateConstructionTypeId]: Result } /** @@ -3407,8 +3407,8 @@ export declare namespace Machine { States extends StateSchemas, StateId extends StateIdentifier > { - readonly [Model.TargetTypeId]: typeof Model.TargetTypeId - readonly [Model.TargetSnapshotTypeId]?: SnapshotByIdentifier + readonly [Topology.TargetTypeId]: typeof Topology.TargetTypeId + readonly [Topology.TargetSnapshotTypeId]?: SnapshotByIdentifier readonly path: StateId readonly value: StateByIdentifier readonly values?: Partial< @@ -3432,14 +3432,14 @@ export declare namespace Machine { States extends StateSchemas, HistoryId extends HistoryIdentifier > { - readonly [Model.HistoryTargetTypeId]: typeof Model.HistoryTargetTypeId + readonly [Topology.HistoryTargetTypeId]: typeof Topology.HistoryTargetTypeId readonly path: HistoryId readonly parent: Extract, StateIdentifier> } /** Branded transient target instruction used while constructing initial states. */ export interface ChoiceTargetInstruction { - readonly [Model.ChoiceTargetTypeId]: typeof Model.ChoiceTargetTypeId + readonly [Topology.ChoiceTargetTypeId]: typeof Topology.ChoiceTargetTypeId readonly path: ChoiceId readonly parent: ParentPath readonly values?: Readonly> diff --git a/src/internal/machine/atom.ts b/src/internal/machine/atom.ts index 60ef0f9..a4eb331 100644 --- a/src/internal/machine/atom.ts +++ b/src/internal/machine/atom.ts @@ -15,8 +15,8 @@ import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity import type * as Machine from "../../Machine.js" import type { Bound, ChildMachineAtom, ChildOf, MachineAtom } from "../../unstable/reactivity/AtomMachine.js" import * as internalMachine from "./machine.js" -import * as Model from "./model.js" import type { EnsureExecutable } from "./readiness.js" +import * as Topology from "./topology.js" export class NotReadyError extends Data.TaggedError("NotReadyError") {} @@ -448,7 +448,7 @@ const selectSnapshot = < snapshot: State, path: Path ): Option.Option> => - Model.getSnapshotByPath(snapshot, path).pipe( + Topology.getSnapshotByPath(snapshot, path).pipe( Option.map((snapshot) => snapshot.value) ) as Option.Option> @@ -498,7 +498,7 @@ export const matches = < self: MachineAtom, path: Path ): Atom.Atom> => - Atom.mapResult(self.result, (snapshot) => Option.isSome(Model.getSnapshotByPath(snapshot, path))).pipe( + Atom.mapResult(self.result, (snapshot) => Option.isSome(Topology.getSnapshotByPath(snapshot, path))).pipe( Atom.withEquality(Equal.equals) ) @@ -514,7 +514,7 @@ export const matchesChild = < > => Atom.mapResult( self.result, - Option.exists((snapshot) => Option.isSome(Model.getSnapshotByPath(snapshot, path))) + Option.exists((snapshot) => Option.isSome(Topology.getSnapshotByPath(snapshot, path))) ).pipe(Atom.withEquality(Equal.equals)) const BoundRequirementsTypeId = "~effect/reactivity/AtomMachine/BoundRequirements" diff --git a/src/internal/machine/command.ts b/src/internal/machine/command.ts new file mode 100644 index 0000000..1f011a0 --- /dev/null +++ b/src/internal/machine/command.ts @@ -0,0 +1,58 @@ +/** + * Internal machine command collection. + * + * @since 4.0.0 + */ + +import type { Command, Enqueue, Machine } from "../../Machine.js" +import { decodeEmitSync, decodeEventSync } from "./protocol.js" + +export type RuntimeCommand = Command + +export interface Collected { + readonly enqueue: Enqueue + readonly commands: Array + readonly raisedEvents: Array + readonly emittedEvents: Array +} + +const targetBuilderCache = new WeakMap>() + +export const getTargetBuilder = (machine: Machine.Any, path: string): any => { + let byPath = targetBuilderCache.get(machine) + if (byPath === undefined) { + byPath = new Map() + targetBuilderCache.set(machine, byPath) + } + if (byPath.has(path)) { + return byPath.get(path) + } + const builder = machine.makeTargetBuilder(path as any) + byPath.set(path, builder) + return builder +} + +export const makeCollector = (machine: Machine.Any): Collected => { + const commands: Array = [] + const raisedEvents: Array = [] + const emittedEvents: Array = [] + return { + commands, + raisedEvents, + emittedEvents, + enqueue: { + raise: (event) => { + raisedEvents.push(decodeEventSync(machine, event) as Event) + }, + emit: (event) => { + emittedEvents.push(decodeEmitSync(machine, event)) + }, + sendTo: (child: unknown, event: unknown) => { + commands.push({ _tag: "SendTo", child: child as any, event }) + }, + stop: (child: unknown) => { + commands.push({ _tag: "Stop", child: child as any }) + } + } + } +} diff --git a/src/internal/machine/commandRuntime.ts b/src/internal/machine/commandRuntime.ts new file mode 100644 index 0000000..0e2ac21 --- /dev/null +++ b/src/internal/machine/commandRuntime.ts @@ -0,0 +1,43 @@ +/** + * Internal process-side machine command execution. + * + * @since 4.0.0 + */ + +import * as Effect from "effect/Effect" +import type { Machine, Runtime } from "../../Machine.js" +import type { RuntimeCommand } from "./command.js" +import { decodeEmit, decodeEvent } from "./protocol.js" +import type { ProcessScope } from "./runtime.js" + +export const makeLiveRuntime = ( + machine: Machine.Any, + scope: ProcessScope +): Runtime => ({ + raise: (event) => + decodeEvent(machine, event).pipe( + Effect.flatMap((event) => scope.self.send(event as Events)) + ), + sendParent: (event) => + decodeEmit(machine, event).pipe( + Effect.flatMap((event) => scope.sendParent(event)) + ) +}) + +export const runCommands = ( + commands: Iterable, + scope: ProcessScope +) => + Effect.forEach(commands, (command) => + command._tag === "SendTo" + ? scope.sendTo(command.child as never, command.event) + : scope.stopChild(command.child as never), { discard: true }) + +export const runEmittedEvents = ( + events: Iterable, + runtime: Runtime +) => + Effect.all( + Array.from(events, (event) => runtime.sendParent(event)), + { discard: true } + ) diff --git a/src/internal/machine/model.ts b/src/internal/machine/configuration.ts similarity index 52% rename from src/internal/machine/model.ts rename to src/internal/machine/configuration.ts index 7c957e5..1e83213 100644 --- a/src/internal/machine/model.ts +++ b/src/internal/machine/configuration.ts @@ -1,5 +1,5 @@ /** - * Internal machine representation helpers. + * Internal active-configuration, history, and completion helpers. * * @since 4.0.0 */ @@ -8,38 +8,27 @@ import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" import * as Option from "effect/Option" import { hasProperty } from "effect/Predicate" -import * as Result from "effect/Result" -import * as Schema from "effect/Schema" import type { Machine } from "../../Machine.js" -import { MachineSchemaDecodeError, MachineSchemaEncodeError } from "./errors.js" - -export const TargetTypeId = "~effect/Machine/Target" -export const TargetSnapshotTypeId: unique symbol = Symbol("effect/Machine/TargetSnapshot") -export const StateInputTypeId: unique symbol = Symbol("effect/Machine/StateInput") -export const StateConstructionTypeId: unique symbol = Symbol("effect/Machine/StateConstruction") -export const HistoryTargetTypeId: unique symbol = Symbol("effect/Machine/HistoryTarget") -export const ChoiceTargetTypeId: unique symbol = Symbol("effect/Machine/ChoiceTarget") - -interface StateInput { - readonly [StateInputTypeId]: typeof StateInputTypeId - readonly input: unknown -} - -/** Internal target produced by the history target builder. History nodes are - * routing instructions and are never part of an active configuration. */ -export interface HistoryTarget { - readonly [HistoryTargetTypeId]: typeof HistoryTargetTypeId - readonly path: string - readonly parent: string -} - -/** Internal target produced by a choice target builder. */ -export interface ChoiceTarget { - readonly [ChoiceTargetTypeId]: typeof ChoiceTargetTypeId - readonly path: string - readonly parent: string - readonly values?: Readonly> -} +import { MachineSchemaDecodeError } from "./errors.js" +import { + decodeBoundary, + decodeOutputValue, + decodeOutputValueSync, + decodeStateValue, + decodeStateValueSync +} from "./protocol.js" +import { + ChoiceTargetTypeId, + getNode, + getStateNodeDefinition, + getStateNodeSchema, + HistoryTargetTypeId, + isChoiceTarget, + isHistoryTarget, + isSnapshot, + isTarget, + TargetSnapshotTypeId +} from "./topology.js" export interface HistoryRecord { readonly mode: "shallow" | "deep" @@ -48,7 +37,7 @@ export interface HistoryRecord { readonly values: ReadonlyMap } -const validateHistoryRecordControl = (machine: Machine.Any, record: HistoryRecord): void => { +export const validateHistoryRecordControl = (machine: Machine.Any, record: HistoryRecord): void => { const ancestry = new Set(getPathToRoot(machine, record.parent)) const visit = (path: string): void => { const node = getNode(machine, path) @@ -240,427 +229,6 @@ const historyToSnapshot = ( return entries } -export const makeHistoryTarget = (path: string, parent: string): HistoryTarget => ({ - [HistoryTargetTypeId]: HistoryTargetTypeId, - path, - parent -}) - -export const isHistoryTarget = (u: unknown): u is HistoryTarget => hasProperty(u, HistoryTargetTypeId) - -export const makeChoiceTarget = ( - path: string, - parent: string, - values?: Readonly> -): ChoiceTarget => ({ - [ChoiceTargetTypeId]: ChoiceTargetTypeId, - path, - parent, - ...(values === undefined ? {} : { values }) -}) - -export const isChoiceTarget = (u: unknown): u is ChoiceTarget => hasProperty(u, ChoiceTargetTypeId) - -interface NormalizedStateNodeDefinitionBase { - readonly annotations: Readonly | undefined -} - -type NormalizedStateNodeDefinition = - | (NormalizedStateNodeDefinitionBase & { - readonly type: "atomic" - readonly schema: Machine.TaggedSchema - readonly output: undefined - readonly history: undefined - readonly initial: undefined - readonly states: undefined - }) - | (NormalizedStateNodeDefinitionBase & { - readonly type: "compound" - readonly schema: Machine.TaggedSchema - readonly output: undefined - readonly history: undefined - readonly initial: string - readonly states: Machine.StateTree - }) - | (NormalizedStateNodeDefinitionBase & { - readonly type: "parallel" - readonly schema: Machine.TaggedSchema - readonly output: Schema.Top | undefined - readonly history: undefined - readonly initial: undefined - readonly states: Machine.StateTree - }) - | (NormalizedStateNodeDefinitionBase & { - readonly type: "final" - readonly schema: Machine.TaggedSchema - readonly output: Schema.Top | undefined - readonly history: undefined - readonly initial: undefined - readonly states: undefined - }) - | (NormalizedStateNodeDefinitionBase & { - readonly type: "history" - readonly schema: undefined - readonly output: undefined - readonly history: "shallow" | "deep" - readonly initial: undefined - readonly states: undefined - }) - | (NormalizedStateNodeDefinitionBase & { - readonly type: "choice" - readonly schema: undefined - readonly output: undefined - readonly history: undefined - readonly initial: undefined - readonly states: undefined - }) - -export const getStateNodeDefinition = ( - path: string, - definition: Machine.TaggedSchema | Machine.StateNodeConfig -): NormalizedStateNodeDefinition => { - if (!Schema.isSchema(definition) && (definition as any).type === "history") { - const history = definition as Machine.HistoryStateNodeConfig - return { - schema: undefined, - output: undefined, - annotations: history.annotations, - type: "history", - history: history.history === "deep" ? "deep" : "shallow", - initial: undefined, - states: undefined - } - } - if (!Schema.isSchema(definition) && (definition as any).type === "choice") { - return { - schema: undefined, - output: undefined, - annotations: (definition as Machine.ChoiceStateNodeConfig).annotations, - type: "choice", - history: undefined, - initial: undefined, - states: undefined - } - } - if (Schema.isSchema(definition)) { - return { - schema: definition as Machine.TaggedSchema, - output: undefined, - annotations: Schema.resolveAnnotations(definition), - type: "atomic", - history: undefined, - initial: undefined, - states: undefined - } - } - if (!hasProperty(definition, "schema") || !Schema.isSchema(definition.schema)) { - throw new Error(`Machine.make expected state "${path}" to be a tagged schema or state node config`) - } - if ((definition as any).type === "parallel" && !hasProperty(definition, "states")) { - throw new Error(`Machine.make expected parallel state "${path}" to declare child regions`) - } - if (hasProperty(definition, "states")) { - if ((definition as any).type === "final") { - throw new Error(`Machine.make expected compound state "${path}" to be active`) - } - if ((definition as any).type === "parallel") { - return { - schema: definition.schema as Machine.TaggedSchema, - output: Schema.isSchema((definition as any).output) ? (definition as any).output as Schema.Top : undefined, - annotations: Schema.resolveAnnotations(definition.schema), - type: "parallel", - history: undefined, - initial: undefined, - states: (definition as any).states as Machine.StateTree - } - } - if (typeof (definition as any).initial !== "string") { - throw new Error(`Machine.make expected compound state "${path}" to declare an initial child`) - } - return { - schema: definition.schema as Machine.TaggedSchema, - output: undefined, - annotations: Schema.resolveAnnotations(definition.schema), - type: "compound", - history: undefined, - initial: (definition as any).initial, - states: (definition as any).states as Machine.StateTree - } - } - const output = Schema.isSchema((definition as any).output) ? (definition as any).output as Schema.Top : undefined - return definition.type === "final" - ? { - schema: definition.schema as Machine.TaggedSchema, - output, - annotations: Schema.resolveAnnotations(definition.schema), - type: "final", - history: undefined, - initial: undefined, - states: undefined - } - : { - schema: definition.schema as Machine.TaggedSchema, - output: undefined, - annotations: Schema.resolveAnnotations(definition.schema), - type: "atomic", - history: undefined, - initial: undefined, - states: undefined - } -} - -export const compileStateNodes = (states: Machine.StateSchemas): Machine.StateNodes => { - const byPath = new Map() - let order = 0 - - const compile = (tree: Machine.StateTree, parent: string | undefined): ReadonlyArray => { - const paths: Array = [] - for (const key of Object.keys(tree)) { - if (key.includes(".")) { - throw new Error(`Machine state keys cannot contain ".": "${key}"`) - } - const path = parent === undefined ? key : `${parent}.${key}` - const definition = getStateNodeDefinition(path, tree[key]) - let node: Machine.StateNode - let childStates: Machine.StateTree | undefined - const base = { path, key, annotations: definition.annotations, order } - switch (definition.type) { - case "atomic": - node = { - ...base, - type: "atomic", - schema: definition.schema, - output: undefined, - history: undefined, - parent, - children: [], - initial: undefined - } - break - case "compound": - node = { - ...base, - type: "compound", - schema: definition.schema, - output: undefined, - history: undefined, - parent, - children: [], - initial: `${path}.${definition.initial}` - } - childStates = definition.states - break - case "parallel": - node = { - ...base, - type: "parallel", - schema: definition.schema, - output: definition.output, - history: undefined, - parent, - children: [], - initial: undefined - } - childStates = definition.states - break - case "final": - node = { - ...base, - type: "final", - schema: definition.schema, - output: definition.output, - history: undefined, - parent, - children: [], - initial: undefined - } - break - case "history": - if (parent === undefined) { - throw new Error(`Machine history state "${path}" must belong to a parent state`) - } - node = { - ...base, - type: "history", - schema: undefined, - output: undefined, - history: definition.history, - parent, - children: [], - initial: undefined - } - break - case "choice": - if (parent === undefined) { - throw new Error(`Machine choice state "${path}" must belong to a parent state`) - } - node = { - ...base, - type: "choice", - schema: undefined, - output: undefined, - history: undefined, - parent, - children: [], - initial: undefined - } - break - } - byPath.set(path, node) - order += 1 - if (definition.type === "history" || definition.type === "choice") { - continue - } - paths.push(path) - if (childStates !== undefined) { - const children = compile(childStates, path) - if (node.type === "compound") { - if (!children.includes(node.initial) && byPath.get(node.initial)?.type !== "choice") { - throw new Error(`Machine.make expected compound state "${path}" initial child to exist`) - } - node = { ...node, children } - } else if (node.type === "parallel") { - node = { ...node, children } - } else { - throw new Error(`Machine state "${path}" cannot declare child states`) - } - byPath.set(path, node) - } - } - return paths - } - - return { - byPath, - roots: compile(states, undefined) - } as Machine.StateNodes -} - -const dynamicTransitionTargets = { type: "dynamic" } as const - -const transitionTargets = (handler: unknown): Machine.TransitionTargets => - typeof handler === "object" && handler !== null && "targets" in handler && handler.targets !== undefined - ? { type: "declared", paths: Array.from(handler.targets as ReadonlyArray) } - : dynamicTransitionTargets - -export const transitionDefinitions = ( - machine: Machine.Any -): ReadonlyArray => { - const definitions: Array = [] - for (const node of machine.stateNodes.byPath.values()) { - const config = machine.handlers[node.path] as Machine.AnyStateConfig | undefined - if (config === undefined) { - continue - } - if (node.type === "choice") { - const choice = (config as any).choice - if (choice !== undefined) { - definitions.push({ - source: node.path, - trigger: { type: "choice" }, - reenter: false, - targets: transitionTargets(choice) - }) - } - continue - } - for (const event of Reflect.ownKeys(config.on ?? {})) { - const handler = config.on?.[event] - definitions.push({ - source: node.path, - trigger: { type: "event", event }, - reenter: typeof handler === "object" && handler !== null && handler.reenter === true, - targets: transitionTargets(handler) - }) - } - if (config.always !== undefined) { - definitions.push({ - source: node.path, - trigger: { type: "always" }, - reenter: false, - targets: transitionTargets(config.always) - }) - } - if (config.onDone !== undefined) { - definitions.push({ - source: node.path, - trigger: { type: "done" }, - reenter: false, - targets: transitionTargets(config.onDone) - }) - } - } - return definitions -} - -export const makeTarget = < - const States extends Machine.StateSchemas, - const StateId extends Machine.StateIdentifier ->( - path: StateId, - value: Machine.StateByIdentifier, - options?: { - readonly snapshot?: Machine.SnapshotByIdentifier - readonly values?: Partial< - { - readonly [AncestorStateId in Machine.StateIdentifier]: Machine.StateByIdentifier< - States, - AncestorStateId - > - } - > - } -): Machine.Target => - ({ - [TargetTypeId]: TargetTypeId, - [TargetSnapshotTypeId]: options?.snapshot, - path, - value, - values: options?.values - }) as Machine.Target - -export const isTarget = (u: unknown): u is Machine.Target => hasProperty(u, TargetTypeId) - -export const makeStateInput = (input: unknown): StateInput => ({ - [StateInputTypeId]: StateInputTypeId, - input -}) - -const isStateInput = (u: unknown): u is StateInput => hasProperty(u, StateInputTypeId) - -export const isSnapshot = (u: unknown): u is Machine.AtomicSnapshot => - hasProperty(u, "path") && hasProperty(u, "value") - -export const getSnapshotByPath = ( - snapshot: Machine.AtomicSnapshot, - path: string, - parents?: Record -): Option.Option> => { - if (snapshot.path === path) { - return Option.some(snapshot) - } - if (!path.startsWith(`${snapshot.path}.`)) { - return Option.none() - } - if (parents !== undefined) { - parents[snapshot.path] = snapshot.value - } - if (hasProperty(snapshot, "state") && isSnapshot(snapshot.state)) { - return getSnapshotByPath(snapshot.state, path, parents) - } - if (hasProperty(snapshot, "states") && typeof snapshot.states === "object" && snapshot.states !== null) { - for (const child of Object.values(snapshot.states)) { - if (isSnapshot(child)) { - const result = getSnapshotByPath(child, path, parents) - if (Option.isSome(result)) { - return result - } - } - } - } - return Option.none() -} - export interface ActiveConfiguration { readonly active: ReadonlySet readonly values: ReadonlyMap @@ -673,332 +241,12 @@ export interface FinalCompletion { readonly output: unknown } -export interface DecodeBoundaryOptions { - readonly boundary: "input" | "event" | "emit" | "state" | "output" | "history" | "configuration" - readonly state?: string - readonly event?: string -} - interface CompletionResult extends FinalCompletion { readonly isNew: boolean } -interface MachineProtocolSchemas { - readonly event: Schema.Top - readonly emit: Schema.Top - readonly eventConstructors: ReadonlySet - readonly trustedEvents: WeakSet -} - -type BoundaryDecoder = (value: unknown) => Effect.Effect -type BoundaryResultDecoder = (value: unknown) => Result.Result - -const boundaryDecoderCache = new WeakMap() -const boundaryResultDecoderCache = new WeakMap() const pathToRootCache = new WeakMap>>() -const getBoundaryDecoder = (schema: Schema.Top): BoundaryDecoder => { - const key = schema as object - const cached = boundaryDecoderCache.get(key) - if (cached !== undefined) { - return cached - } - const decoder = Schema.decodeUnknownEffect(Schema.toType(schema)) as BoundaryDecoder - boundaryDecoderCache.set(key, decoder) - return decoder -} - -const getBoundaryResultDecoder = (schema: Schema.Top): BoundaryResultDecoder => { - const key = schema as object - const cached = boundaryResultDecoderCache.get(key) - if (cached !== undefined) { - return cached - } - const decoder = Schema.decodeUnknownResult(Schema.toType(schema)) as BoundaryResultDecoder - boundaryResultDecoderCache.set(key, decoder) - return decoder -} - -const MachineProtocolTypeId = Symbol.for("effect/Machine/protocol") - -const getProtocolSchemas = (machine: Machine.Any): MachineProtocolSchemas => { - const protocol = (machine as any)[MachineProtocolTypeId] as MachineProtocolSchemas | undefined - if (protocol === undefined) { - throw new Error("Machine protocol is unavailable") - } - return protocol -} - -const setProtocolSchemas = (machine: Machine.Any, protocol: MachineProtocolSchemas): void => { - Object.defineProperty(machine, MachineProtocolTypeId, { - value: protocol, - enumerable: false - }) -} - -const collectEventConstructors = ( - schemas: ReadonlyArray -): ReadonlySet => { - const constructors = new Set() - const add = (schema: Machine.TaggedSchema): void => { - const key = schema as object - if (constructors.has(key)) return - constructors.add(key) - if (!hasProperty(schema, "cases") || typeof schema.cases !== "object" || schema.cases === null) return - for (const candidate of Object.values(schema.cases)) { - if ( - ((typeof candidate === "object" && candidate !== null) || typeof candidate === "function") && - hasProperty(candidate, "make") - ) { - add(candidate as Machine.TaggedSchema) - } - } - } - for (const schema of schemas) add(schema) - return constructors -} - -export const setProtocol = (machine: Machine.Any): void => { - const events = [...machine.events, ...machine.internalEvents] - setProtocolSchemas(machine, { - event: Schema.Union(events), - emit: Schema.Union(machine.emits), - eventConstructors: collectEventConstructors(events), - trustedEvents: new WeakSet() - }) -} - -export const copyProtocol = (source: Machine.Any, target: Machine.Any): void => - setProtocolSchemas(target, getProtocolSchemas(source)) - -export const getEventName = (event: unknown): string | undefined => - hasProperty(event, "_tag") ? String(event._tag) : undefined - -export const decodeBoundary = ( - machine: Machine.Any, - schema: Schema.Top, - value: unknown, - options: DecodeBoundaryOptions -): Effect.Effect => - getBoundaryDecoder(schema)(value).pipe( - Effect.mapError((cause) => - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: options.boundary, - cause, - ...(options.state === undefined ? {} : { state: options.state }), - ...(options.event === undefined ? {} : { event: options.event }) - }) - ) - ) as Effect.Effect - -export const decodeBoundarySync = ( - machine: Machine.Any, - schema: Schema.Top, - value: unknown, - options: DecodeBoundaryOptions -): A => { - const decoded = getBoundaryResultDecoder(schema)(value) - if (Result.isFailure(decoded)) { - throw new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: options.boundary, - cause: decoded.failure, - ...(options.state === undefined ? {} : { state: options.state }), - ...(options.event === undefined ? {} : { event: options.event }) - }) - } - return decoded.success as A -} - -const makeBoundarySync = ( - machine: Machine.Any, - schema: Schema.Top, - input: unknown, - options: DecodeBoundaryOptions -): A => { - try { - return schema.make(input as never) as A - } catch (cause) { - const issue = cause instanceof Error ? cause.cause : undefined - throw new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: options.boundary, - cause: Schema.isSchemaError(cause) - ? cause - : hasProperty(issue, "~effect/SchemaIssue/Issue") - ? new Schema.SchemaError(issue as any) - : Cause.die(cause), - ...(options.state === undefined ? {} : { state: options.state }), - ...(options.event === undefined ? {} : { event: options.event }) - }) - } -} - -/** Constructs an event through one of the machine protocol's own schemas and - * records the decoded value as trusted by that protocol. Machine clones share - * the protocol record, while unrelated machines retain independent trust. */ -export const makeEvent = ( - machine: Machine.Any, - schema: Schema, - input: unknown -): Schema["Type"] => { - const protocol = getProtocolSchemas(machine) - if (!protocol.eventConstructors.has(schema as object)) { - throw new Error("Machine.event expected a schema from the machine event protocol") - } - const inputName = getEventName(input) - const event = makeBoundarySync( - machine, - schema, - input, - inputName === undefined ? { boundary: "event" } : { boundary: "event", event: inputName } - ) - protocol.trustedEvents.add(event as object) - return event -} - -const isTrustedEvent = (protocol: MachineProtocolSchemas, event: unknown): boolean => - typeof event === "object" && event !== null && protocol.trustedEvents.has(event) - -export const decodeInput = ( - machine: Machine.Any, - schema: Input, - value: unknown -): Effect.Effect => - decodeBoundary(machine, schema, value, { boundary: "input" }) - -export const decodeEvent = >( - machine: Machine.Any, - event: unknown -): Effect.Effect, MachineSchemaDecodeError> => { - const protocol = getProtocolSchemas(machine) - if (isTrustedEvent(protocol, event)) { - return Effect.succeed(event as Machine.EventOf) - } - const eventName = getEventName(event) - return decodeBoundary>( - machine, - protocol.event, - event, - eventName === undefined ? { boundary: "event" } : { boundary: "event", event: eventName } - ) -} - -export const decodeEventSync = >( - machine: Machine.Any, - event: unknown -): Machine.EventOf => { - const protocol = getProtocolSchemas(machine) - if (isTrustedEvent(protocol, event)) { - return event as Machine.EventOf - } - const eventName = getEventName(event) - return decodeBoundarySync>( - machine, - protocol.event, - event, - eventName === undefined ? { boundary: "event" } : { boundary: "event", event: eventName } - ) -} - -export const decodeEmit = >( - machine: Machine.Any, - event: unknown -): Effect.Effect, MachineSchemaDecodeError> => { - const eventName = getEventName(event) - return decodeBoundary>( - machine, - getProtocolSchemas(machine).emit, - event, - eventName === undefined ? { boundary: "emit" } : { boundary: "emit", event: eventName } - ) -} - -export const decodeEmitSync = >( - machine: Machine.Any, - event: unknown -): Machine.EmitOf => { - const eventName = getEventName(event) - return decodeBoundarySync>( - machine, - getProtocolSchemas(machine).emit, - event, - eventName === undefined ? { boundary: "emit" } : { boundary: "emit", event: eventName } - ) -} - -export const decodeInputSync = ( - machine: Machine.Any, - schema: Input, - value: unknown -): Input["Type"] => decodeBoundarySync(machine, schema, value, { boundary: "input" }) - -export const decodeStateValue = ( - machine: Machine.Any, - node: Machine.StateNode, - value: unknown -): Effect.Effect => - isStateInput(value) - ? getStateNodeSchema(node).makeEffect(value.input).pipe( - Effect.mapError((cause) => - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: "state", - state: node.path, - cause: new Schema.SchemaError(cause) - }) - ) - ) - : decodeBoundary(machine, getStateNodeSchema(node), value, { boundary: "state", state: node.path }) - -export const decodeStateValueSync = ( - machine: Machine.Any, - node: Machine.StateNode, - value: unknown -): unknown => { - if (!isStateInput(value)) { - return decodeBoundarySync(machine, getStateNodeSchema(node), value, { boundary: "state", state: node.path }) - } - return makeBoundarySync(machine, getStateNodeSchema(node), value.input, { - boundary: "state", - state: node.path - }) -} - -export const decodeOutputValue = ( - machine: Machine.Any, - node: Machine.StateNode, - value: unknown -): Effect.Effect => - node.output === undefined - ? Effect.succeed(value) - : decodeBoundary(machine, node.output, value, { boundary: "output", state: node.path }) - -export const decodeOutputValueSync = ( - machine: Machine.Any, - node: Machine.StateNode, - value: unknown -): unknown => - node.output === undefined - ? value - : decodeBoundarySync(machine, node.output, value, { boundary: "output", state: node.path }) - -export const getNode = (machine: Machine.Any, path: string): Machine.StateNode => { - const node = machine.stateNodes.byPath.get(path) - if (node === undefined) { - throw new Error(`Machine expected state path "${path}" to exist`) - } - return node -} - -export const getStateNodeSchema = (node: Machine.StateNode): Machine.TaggedSchema => { - if (node.schema === undefined) { - throw new Error(`Machine pseudo-state "${node.path}" has no active value schema`) - } - return node.schema -} - export const hasOwn = (u: object, key: string): boolean => Object.prototype.hasOwnProperty.call(u, key) export const isDescendantOf = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`) @@ -2092,473 +1340,3 @@ export const completeConfigurationSync = => - Schema.encodeUnknownEffect(schema)(value).pipe( - Effect.mapError((cause) => - new MachineSchemaEncodeError({ - machineId: machine.id, - boundary: options.boundary, - state: options.state, - cause - }) - ) - ) - -const decodeEncodedBoundary = ( - machine: Machine.Any, - schema: Schema.Top, - value: unknown, - options: { - readonly boundary: "state" | "output" | "history" - readonly state: string - } -): Effect.Effect => - Schema.decodeUnknownEffect(schema)(value).pipe( - Effect.mapError((cause) => - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: options.boundary, - state: options.state, - cause - }) - ) - ) - -const getCompletionSchema = ( - machine: Machine.Any, - configuration: ActiveConfiguration, - path: string -): Schema.Top => { - const node = getNode(machine, path) - if (node.type === "compound") { - const child = getActiveChildPath(machine, configuration, path) - if (child === undefined) { - throw new Error(`Machine expected completed state "${path}" to have an active child`) - } - return getCompletionSchema(machine, configuration, child) - } - return node.output ?? Schema.Void -} - -/** Defensively validates and normalizes an in-memory logical snapshot. Unlike - * the transport decoder this consumes decoded schema values. */ -export const normalizeSnapshotEffect = ( - machine: Machine.Any, - snapshot: Machine.Snapshot -): Effect.Effect, MachineSchemaDecodeError> => - Effect.gen(function*() { - const configuration = yield* normalizeConfigurationEffect(machine, snapshot) - const outputs = new Map() - const completionPaths = new Set() - const completions = snapshot.completed ?? [] - if (!Array.isArray(completions)) { - throw new Error("Machine snapshot completion metadata must be an array") - } - for (const completion of completions) { - if ( - typeof completion !== "object" || completion === null || - typeof (completion as { readonly path?: unknown }).path !== "string" - ) { - throw new Error("Machine snapshot contains malformed completion metadata") - } - const path = completion.path - if (completionPaths.has(path)) { - throw new Error(`Machine snapshot contains duplicate completion "${path}"`) - } - if (!configuration.active.has(path) || !isActiveFinalNode(machine, configuration, path)) { - throw new Error(`Machine snapshot contains invalid completion "${path}"`) - } - completionPaths.add(path) - outputs.set( - path, - yield* decodeBoundary(machine, getCompletionSchema(machine, configuration, path), completion.output, { - boundary: "output", - state: path - }) - ) - } - return snapshotFromConfiguration(machine, { ...configuration, outputs }) - }).pipe(Effect.catchCause((cause) => failDecodeCause(machine, cause))) - -const validateEncodedConfiguration = ( - machine: Machine.Any, - configuration: ActiveConfiguration -): Machine.Snapshot => { - const snapshot = snapshotFromConfiguration(machine, configuration) - const normalized = configurationFromSnapshot(machine, snapshot) - if ( - normalized.active.size !== configuration.active.size || - Array.from(configuration.active).some((path) => !normalized.active.has(path)) - ) { - throw new Error("Machine encoded snapshot contains states outside its active configuration") - } - return snapshot -} - -const failEncodeCause = ( - machine: Machine.Any, - cause: Cause.Cause -): Effect.Effect => { - const error = Cause.findErrorOption(cause) - return Option.isSome(error) && error.value instanceof MachineSchemaEncodeError - ? Effect.fail(error.value) - : Effect.fail( - new MachineSchemaEncodeError({ - machineId: machine.id, - boundary: "configuration", - cause - }) - ) -} - -const failDecodeCause = ( - machine: Machine.Any, - cause: Cause.Cause -): Effect.Effect => { - const error = Cause.findErrorOption(cause) - return Option.isSome(error) && error.value instanceof MachineSchemaDecodeError - ? Effect.fail(error.value) - : Effect.fail( - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: "configuration", - cause - }) - ) -} - -export const encodeSnapshot = ( - machine: Machine.Any, - snapshot: Machine.Snapshot -): Effect.Effect => - Effect.gen(function*() { - const configuration = yield* normalizeConfigurationEffect(machine, snapshot).pipe( - Effect.mapError((error) => - new MachineSchemaEncodeError({ - machineId: machine.id, - boundary: error.boundary === "state" || error.boundary === "history" ? error.boundary : "configuration", - ...(error.state === undefined ? {} : { state: error.state }), - cause: error.cause - }) - ) - ) - const completionPaths = new Set() - for (const completion of snapshot.completed ?? []) { - if (completionPaths.has(completion.path)) { - throw new Error(`Machine snapshot contains duplicate completion "${completion.path}"`) - } - if (!configuration.active.has(completion.path) || !isActiveFinalNode(machine, configuration, completion.path)) { - throw new Error(`Machine snapshot contains invalid completion "${completion.path}"`) - } - completionPaths.add(completion.path) - } - const active: Array = [] - for ( - const path of Array.from(configuration.active).sort((left, right) => compareDocumentOrder(machine, left, right)) - ) { - const node = getNode(machine, path) - active.push({ - path, - value: yield* encodeBoundary(machine, getStateNodeSchema(node), getActiveValue(configuration, path), { - boundary: "state", - state: path - }) - }) - } - - const completed: Array = [] - for ( - const [path, output] of Array.from(configuration.outputs).sort(([left], [right]) => - compareDocumentOrder(machine, left, right) - ) - ) { - if (!configuration.active.has(path) || !isActiveFinalNode(machine, configuration, path)) { - throw new Error(`Machine encoded snapshot contains invalid completion "${path}"`) - } - const encodedOutput = yield* encodeBoundary( - machine, - getCompletionSchema(machine, configuration, path), - output, - { - boundary: "output", - state: path - } - ) - completed.push({ - path, - ...(encodedOutput === undefined ? {} : { output: encodedOutput }) - }) - } - - const history: Record = {} - for ( - const [historyPath, record] of Array.from(configuration.history).sort(([left], [right]) => - left.localeCompare(right) - ) - ) { - const historyNode = machine.stateNodes.byPath.get(historyPath) - if ( - historyNode === undefined || historyNode.type !== "history" || historyNode.parent !== record.parent || - historyNode.history !== record.mode - ) { - return yield* Effect.fail( - new MachineSchemaEncodeError({ - machineId: machine.id, - boundary: "history", - state: historyPath, - cause: Cause.die(new Error(`Machine snapshot contains invalid history record "${historyPath}"`)) - }) - ) - } - try { - validateHistoryRecordControl(machine, record) - } catch (cause) { - return yield* Effect.fail( - new MachineSchemaEncodeError({ - machineId: machine.id, - boundary: "history", - state: historyPath, - cause: Cause.die(cause) - }) - ) - } - const encodedValues: Record = {} - for (const path of record.active) { - const stateNode = machine.stateNodes.byPath.get(path) - if ( - stateNode === undefined || stateNode.type === "history" || stateNode.type === "choice" || - !record.values.has(path) || - !(isPathInSubtree(path, record.parent) || getPathToRoot(machine, record.parent).includes(path)) - ) { - return yield* Effect.fail( - new MachineSchemaEncodeError({ - machineId: machine.id, - boundary: "history", - state: path, - cause: Cause.die(new Error(`Machine snapshot contains invalid remembered state "${path}"`)) - }) - ) - } - encodedValues[path] = yield* encodeBoundary( - machine, - getStateNodeSchema(stateNode), - record.values.get(path), - { boundary: "history", state: path } - ) - } - if (record.values.size !== record.active.size) { - return yield* Effect.fail( - new MachineSchemaEncodeError({ - machineId: machine.id, - boundary: "history", - state: historyPath, - cause: Cause.die(new Error(`Machine history record "${historyPath}" contains values outside its paths`)) - }) - ) - } - history[historyPath] = { - mode: record.mode, - active: Array.from(record.active).sort((left, right) => compareDocumentOrder(machine, left, right)), - values: encodedValues - } - } - - return { - _tag: "MachineSnapshot" as const, - active, - ...(completed.length === 0 ? {} : { completed }), - ...(Object.keys(history).length === 0 ? {} : { history }) - } - }).pipe(Effect.catchCause((cause) => failEncodeCause(machine, cause))) - -export const decodeSnapshot = ( - machine: Machine.Any, - encoded: unknown -): Effect.Effect, MachineSchemaDecodeError, unknown> => - Effect.gen(function*() { - const decoded = yield* Schema.decodeUnknownEffect(EncodedSnapshotSchema)(encoded).pipe( - Effect.mapError((cause) => - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: "configuration", - cause - }) - ) - ) - const active = new Set() - const values = new Map() - for (const entry of decoded.active) { - if (active.has(entry.path)) { - throw new Error(`Machine encoded snapshot contains duplicate state "${entry.path}"`) - } - const node = getNode(machine, entry.path) - active.add(entry.path) - values.set( - entry.path, - yield* decodeEncodedBoundary(machine, getStateNodeSchema(node), entry.value, { - boundary: "state", - state: entry.path - }) - ) - } - - const history = new Map() - for (const [historyPath, encodedRecord] of Object.entries(decoded.history ?? {})) { - const historyNode = machine.stateNodes.byPath.get(historyPath) - if ( - historyNode === undefined || historyNode.type !== "history" || historyNode.parent === undefined || - historyNode.history !== encodedRecord.mode - ) { - return yield* Effect.fail( - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: "history", - state: historyPath, - cause: Cause.die(new Error(`Machine encoded snapshot contains invalid history record "${historyPath}"`)) - }) - ) - } - const rememberedActive = new Set() - const rememberedValues = new Map() - for (const path of encodedRecord.active) { - if (rememberedActive.has(path)) { - return yield* Effect.fail( - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: "history", - state: path, - cause: Cause.die(new Error(`Machine encoded history contains duplicate state "${path}"`)) - }) - ) - } - const stateNode = machine.stateNodes.byPath.get(path) - if ( - stateNode === undefined || stateNode.type === "history" || stateNode.type === "choice" || - !Object.prototype.hasOwnProperty.call(encodedRecord.values, path) || - !(isPathInSubtree(path, historyNode.parent) || getPathToRoot(machine, historyNode.parent).includes(path)) - ) { - return yield* Effect.fail( - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: "history", - state: path, - cause: Cause.die(new Error(`Machine encoded snapshot contains invalid remembered state "${path}"`)) - }) - ) - } - rememberedActive.add(path) - rememberedValues.set( - path, - yield* decodeEncodedBoundary(machine, getStateNodeSchema(stateNode), encodedRecord.values[path], { - boundary: "history", - state: path - }) - ) - } - if (Object.keys(encodedRecord.values).length !== rememberedActive.size) { - return yield* Effect.fail( - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: "history", - state: historyPath, - cause: Cause.die(new Error(`Machine encoded history "${historyPath}" contains values outside its paths`)) - }) - ) - } - if (!rememberedActive.has(historyNode.parent)) { - return yield* Effect.fail( - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: "history", - state: historyPath, - cause: Cause.die(new Error(`Machine encoded history "${historyPath}" does not contain its parent state`)) - }) - ) - } - const record: HistoryRecord = { - mode: encodedRecord.mode, - parent: historyNode.parent, - active: rememberedActive, - values: rememberedValues - } - try { - validateHistoryRecordControl(machine, record) - } catch (cause) { - return yield* Effect.fail( - new MachineSchemaDecodeError({ - machineId: machine.id, - boundary: "history", - state: historyPath, - cause: Cause.die(cause) - }) - ) - } - history.set(historyPath, record) - } - - const configuration: ActiveConfiguration = { - active, - values, - outputs: new Map(), - history - } - const snapshot = validateEncodedConfiguration(machine, configuration) - const completions: Array = [] - const completionPaths = new Set() - for (const completion of decoded.completed ?? []) { - if (completionPaths.has(completion.path)) { - throw new Error(`Machine encoded snapshot contains duplicate completion "${completion.path}"`) - } - if (!active.has(completion.path) || !isActiveFinalNode(machine, configuration, completion.path)) { - throw new Error(`Machine encoded snapshot contains invalid completion "${completion.path}"`) - } - completionPaths.add(completion.path) - completions.push({ - path: completion.path, - output: yield* decodeEncodedBoundary( - machine, - getCompletionSchema(machine, configuration, completion.path), - completion.output, - { - boundary: "output", - state: completion.path - } - ) - }) - } - if (completions.length > 0) { - ;(snapshot as Machine.AtomicSnapshot & { - completed: ReadonlyArray - }).completed = completions - } - return snapshot - }).pipe(Effect.catchCause((cause) => failDecodeCause(machine, cause))) diff --git a/src/internal/machine/executionPlan.ts b/src/internal/machine/executionPlan.ts new file mode 100644 index 0000000..0e49aeb --- /dev/null +++ b/src/internal/machine/executionPlan.ts @@ -0,0 +1,925 @@ +/** + * Internal compiled machine execution plans. + * + * @since 4.0.0 + */ + +import type { Machine } from "../../Machine.js" +import { getTargetBuilder, type RuntimeCommand } from "./command.js" +import { + type ActiveConfiguration, + compareDocumentOrder, + completeConfigurationSync, + getInitialEntryPaths, + getPathToRoot, + getRootPath, + isActiveFinalConfiguration, + isDescendantOf, + normalizeConfigurationSync, + normalizeTargetConfigurationSync, + snapshotFromConfiguration, + validateInitialConfiguration +} from "./configuration.js" +import { InfiniteTransitionError } from "./errors.js" +import { + broadenTransitionBoundary, + type EvaluatedTransition, + getEntryPaths, + getExitPaths, + getLeastCommonAncestor, + getTargetNodePath, + InitialEvent, + type MacrostepPlan, + MaxMacrostepIterations, + type MicrostepPlan, + type MicrostepTransition, + normalizeTransition, + planConfiguration, + removeConflictingTransitions, + type SelectedTransition, + sortEntryPaths, + sortEvaluatedTransitions, + sortExitPaths, + type TransitionHandler, + validateDeclaredTransitionTarget +} from "./planner.js" +import { decodeEmitSync, decodeEventSync, decodeInputSync, decodeStateValueSync } from "./protocol.js" +import { getNode, isSnapshot, isTarget, TargetSnapshotTypeId } from "./topology.js" + +interface IndexedExecutionDescriptor { + readonly flat: boolean + readonly nodes: ReadonlyArray + readonly indexByPath: ReadonlyMap + readonly parentIndices: ReadonlyArray + readonly childIndices: ReadonlyArray> + readonly ancestorIndices: ReadonlyArray> + readonly rootIndices: ReadonlyArray + readonly leafIndices: ReadonlyArray + readonly finalIndices: ReadonlyArray + readonly dispatchByLeaf: ReadonlyMap< + number, + ReadonlyMap + }> + > +} + +const compileIndexedExecutionDescriptor = ( + machine: Machine.Any +): IndexedExecutionDescriptor | undefined => { + const nodes: Array = [] + const leafPaths: Array = [] + const finalPaths: Array = [] + const transitionsByPath = new Map< + PropertyKey, + ReadonlyMap> + >() + + for (const node of machine.stateNodes.byPath.values() as Iterable) { + nodes.push(node) + if (node.type === "choice" || node.type === "history") { + return undefined + } + if (node.type === "atomic" || node.type === "final") { + leafPaths.push(node.path) + } + if (node.type === "final") { + finalPaths.push(node.path) + } + + const config = machine.handlers[node.path] as Machine.AnyStateConfig | undefined + if ( + config?.entry !== undefined || config?.exit !== undefined || config?.always !== undefined || + config?.onDone !== undefined || (config as any)?.choice !== undefined || + (config as any)?.history !== undefined + ) { + return undefined + } + if (config?.on === undefined) { + continue + } + const byEvent = new Map>() + for (const tag of Reflect.ownKeys(config.on)) { + const transition = normalizeTransition(config.on[tag]) + if (transition !== undefined) { + byEvent.set(tag, transition) + } + } + if (byEvent.size > 0) { + transitionsByPath.set(node.path, byEvent) + } + } + + leafPaths.sort((left, right) => compareDocumentOrder(machine, left, right)) + finalPaths.sort((left, right) => compareDocumentOrder(machine, left, right)) + nodes.sort((left, right) => left.order - right.order) + const indexByPath = new Map(nodes.map((node, index) => [node.path, index])) + const indexOf = (path: string): number => { + const index = indexByPath.get(path) + if (index === undefined) { + throw new Error(`Machine expected compiled state path "${path}"`) + } + return index + } + const leafIndices = leafPaths.map(indexOf) + const parentIndices = nodes.map((node) => node.parent === undefined ? -1 : indexOf(node.parent)) + const childIndices = nodes.map((node) => node.children.map(indexOf)) + const ancestorIndices = nodes.map((node) => getPathToRoot(machine, node.path).slice(0, -1).map(indexOf)) + const transitionsByIndex = nodes.map((node) => transitionsByPath.get(node.path)) + const dispatchByLeaf = new Map< + number, + ReadonlyMap + }> + >() + for (const leafIndex of leafIndices) { + const dispatch = new Map + }>() + const candidates = [leafIndex, ...ancestorIndices[leafIndex]!.slice().reverse()] + for (const sourceIndex of candidates) { + for (const [tag, transition] of transitionsByIndex[sourceIndex] ?? []) { + if (!dispatch.has(tag)) dispatch.set(tag, { sourceIndex, transition }) + } + } + dispatchByLeaf.set(leafIndex, dispatch) + } + return { + flat: nodes.every((node) => node.parent === undefined && (node.type === "atomic" || node.type === "final")), + nodes, + indexByPath, + parentIndices, + childIndices, + ancestorIndices, + rootIndices: nodes.flatMap((node, index) => node.parent === undefined ? [index] : []), + leafIndices, + finalIndices: finalPaths.map(indexOf), + dispatchByLeaf + } +} + +interface IndexedConfiguration { + readonly active: Uint8Array + readonly activeLeaves: ReadonlyArray + // The compiled process fiber owns this slot table. Flat same-state updates + // may replace one value in place after eagerly detaching the handler's + // public snapshot; hierarchical microsteps retain immutable copies so + // simultaneous transition contexts continue to share their starting state. + readonly values: Array + readonly completed: Uint8Array + readonly outputs: ReadonlyArray + readonly completedOrder: ReadonlyArray +} + +const indexedConfigurationFromActive = ( + descriptor: IndexedExecutionDescriptor, + configuration: ActiveConfiguration +): IndexedConfiguration => { + const active = new Uint8Array(descriptor.nodes.length) + const values: Array = new Array(descriptor.nodes.length) + const completed = new Uint8Array(descriptor.nodes.length) + const outputs: Array = new Array(descriptor.nodes.length) + const completedOrder: Array = [] + for (const path of configuration.active) { + const index = descriptor.indexByPath.get(path) + if (index === undefined) throw new Error(`Machine expected indexed active path "${path}"`) + active[index] = 1 + values[index] = configuration.values.get(path) + } + for (const [path, output] of configuration.outputs) { + const index = descriptor.indexByPath.get(path) + if (index === undefined) throw new Error(`Machine expected indexed completed path "${path}"`) + completed[index] = 1 + outputs[index] = output + completedOrder.push(index) + } + return { + active, + activeLeaves: descriptor.leafIndices.filter((index) => active[index] === 1), + values, + completed, + outputs, + completedOrder + } +} + +const activeConfigurationFromIndexed = ( + descriptor: IndexedExecutionDescriptor, + configuration: IndexedConfiguration +): ActiveConfiguration => { + const active = new Set() + const values = new Map() + const outputs = new Map() + for (let index = 0; index < descriptor.nodes.length; index++) { + if (configuration.active[index] !== 1) continue + const path = descriptor.nodes[index]!.path + active.add(path) + values.set(path, configuration.values[index]) + } + for (const index of configuration.completedOrder) { + if (configuration.completed[index] === 1) { + outputs.set(descriptor.nodes[index]!.path, configuration.outputs[index]) + } + } + return { active, values, outputs, history: new Map() } +} + +const snapshotFromIndexedPath = ( + descriptor: IndexedExecutionDescriptor, + configuration: IndexedConfiguration, + index: number +): Machine.AtomicSnapshot => { + const node = descriptor.nodes[index]! + const snapshot: Record = { + path: node.path, + value: configuration.values[index] + } + if (node.type === "compound") { + const childIndex = descriptor.childIndices[index]!.find((childIndex) => configuration.active[childIndex] === 1) + if (childIndex === undefined) { + throw new Error(`Machine expected indexed compound state "${node.path}" to have an active child`) + } + snapshot.state = snapshotFromIndexedPath(descriptor, configuration, childIndex) + } else if (node.type === "parallel") { + const states: Record = {} + for (const childIndex of descriptor.childIndices[index]!) { + if (configuration.active[childIndex] !== 1) { + throw new Error( + `Machine expected indexed parallel state "${node.path}" to have active region "${ + descriptor.nodes[childIndex]!.path + }"` + ) + } + states[descriptor.nodes[childIndex]!.key] = snapshotFromIndexedPath(descriptor, configuration, childIndex) + } + snapshot.states = states + } + return snapshot as unknown as Machine.AtomicSnapshot +} + +const snapshotFromIndexed = ( + descriptor: IndexedExecutionDescriptor, + configuration: IndexedConfiguration +): Machine.Snapshot => { + const rootIndex = descriptor.rootIndices.find((index) => configuration.active[index] === 1) + if (rootIndex === undefined) throw new Error("Machine expected an active indexed root state") + const snapshot = snapshotFromIndexedPath(descriptor, configuration, rootIndex) as Machine.Snapshot + if (configuration.completedOrder.length > 0) { + ;(snapshot as Machine.AtomicSnapshot & { + completed: ReadonlyArray + }).completed = configuration.completedOrder.map((index) => ({ + path: descriptor.nodes[index]!.path, + output: configuration.outputs[index] + })) + } + return snapshot +} + +const makeIndexedTransitionContext = ( + machine: Machine.Any, + descriptor: IndexedExecutionDescriptor, + configuration: IndexedConfiguration, + sourceIndex: number, + event: any +): any => { + const source = descriptor.nodes[sourceIndex]! + const parentIndex = descriptor.parentIndices[sourceIndex]! + const parents: Record = {} + for (const ancestorIndex of descriptor.ancestorIndices[sourceIndex]!) { + parents[descriptor.nodes[ancestorIndex]!.path] = configuration.values[ancestorIndex] + } + return { + state: configuration.values[sourceIndex], + parent: parentIndex < 0 ? undefined : configuration.values[parentIndex], + parents, + event, + snapshot: snapshotFromIndexed(descriptor, configuration), + target: getTargetBuilder(machine, source.path) + } +} + +type IndexedSelectedTransition = SelectedTransition & { + readonly sourceIndex: number + readonly leafIndex: number +} + +type IndexedEvaluatedTransition = + & Omit< + EvaluatedTransition, + "selection" + > + & { + readonly selection: IndexedSelectedTransition + readonly next: IndexedConfiguration + } + +const emptyCompiledValues: ReadonlyArray = [] + +const collectIndexedTransition = ( + machine: Machine.Any, + transition: TransitionHandler, + context: any +) => { + let commands: Array | undefined + let raisedEvents: Array | undefined + let emittedEvents: Array | undefined + const state = transition(context, { + raise: (event: unknown) => { + ;(raisedEvents ??= []).push(decodeEventSync(machine, event)) + }, + emit: (event: unknown) => { + ;(emittedEvents ??= []).push(decodeEmitSync(machine, event)) + }, + sendTo: (child: unknown, event: unknown) => { + ;(commands ??= []).push({ _tag: "SendTo", child: child as any, event }) + }, + stop: (child: unknown) => { + ;(commands ??= []).push({ _tag: "Stop", child: child as any }) + } + }) + return { + state, + commands: commands ?? emptyCompiledValues, + raisedEvents: raisedEvents ?? emptyCompiledValues, + emittedEvents: emittedEvents ?? emptyCompiledValues + } +} + +const selectIndexedEventTransitions = ( + machine: Machine.Any, + descriptor: IndexedExecutionDescriptor, + configuration: IndexedConfiguration, + event: any +): ReadonlyArray => { + const selected: Array = [] + for (const leafIndex of configuration.activeLeaves) { + const dispatched = descriptor.dispatchByLeaf.get(leafIndex)!.get(event._tag) + if (dispatched !== undefined) { + const { sourceIndex, transition } = dispatched + if (!selected.some((selection) => selection.sourceIndex === sourceIndex)) { + const sourcePath = descriptor.nodes[sourceIndex]!.path + selected.push({ + sourceIndex, + leafIndex, + sourcePath, + leafPath: descriptor.nodes[leafIndex]!.path, + trigger: { type: "event", event: event._tag }, + transition, + context: makeIndexedTransitionContext( + machine, + descriptor, + configuration, + sourceIndex, + event + ) + }) + } + } + } + return selected +} + +const hasSameIndexedActive = (left: IndexedConfiguration, right: IndexedConfiguration): boolean => { + if (left.active === right.active) return true + for (let index = 0; index < left.active.length; index++) { + if (left.active[index] !== right.active[index]) return false + } + return true +} + +const normalizeIndexedTargetConfigurationSync = ( + machine: Machine.Any, + descriptor: IndexedExecutionDescriptor, + current: IndexedConfiguration, + target: Machine.Target | Machine.Snapshot, + activeLeafIndex: number +): IndexedConfiguration => { + const targetIndex = isTarget(target) ? descriptor.indexByPath.get(String(target.path)) : undefined + if ( + targetIndex === activeLeafIndex && current.active[activeLeafIndex] === 1 && isTarget(target) && + target[TargetSnapshotTypeId] === undefined && target.values === undefined && current.completedOrder.length === 0 + ) { + const values = current.values.slice() + values[activeLeafIndex] = decodeStateValueSync( + machine, + descriptor.nodes[activeLeafIndex]!, + target.value + ) + return { ...current, values } + } + return indexedConfigurationFromActive( + descriptor, + normalizeTargetConfigurationSync( + machine, + activeConfigurationFromIndexed(descriptor, current), + target + ) + ) +} + +const collectIndexedEvaluatedTransition = ( + machine: Machine.Any, + descriptor: IndexedExecutionDescriptor, + state: IndexedConfiguration, + selection: IndexedSelectedTransition +): IndexedEvaluatedTransition => { + const transitionResult = collectIndexedTransition(machine, selection.transition.transition, selection.context) + const target = transitionResult.state + validateDeclaredTransitionTarget( + selection.sourcePath, + selection.trigger, + selection.transition.targets, + target + ) + if (target !== undefined && !isTarget(target) && !isSnapshot(target)) { + throw new Error("Machine expected indexed transition target to be a snapshot or target builder result") + } + const next = target === undefined + ? state + : normalizeIndexedTargetConfigurationSync(machine, descriptor, state, target as any, selection.leafIndex) + const changed = selection.transition.reenter || !hasSameIndexedActive(state, next) + if (!changed) { + return { + selection, + unresolvedTarget: target as any, + target: target as any, + next, + commands: transitionResult.commands, + raisedEvents: transitionResult.raisedEvents, + emittedEvents: transitionResult.emittedEvents, + changed: false, + exitPaths: [], + entryPaths: [], + choiceTransitions: [] + } + } + + const targetPath = target === undefined ? undefined : getTargetNodePath(target as any) + const naturalBoundary = targetPath === undefined + ? descriptor.nodes[selection.sourceIndex]!.parent + : getLeastCommonAncestor(machine, selection.leafPath, targetPath) + const reentryBoundary = descriptor.nodes[selection.sourceIndex]!.parent + const boundary = selection.transition.reenter + ? broadenTransitionBoundary(naturalBoundary, reentryBoundary) + : naturalBoundary + return { + selection, + unresolvedTarget: target as any, + target: target as any, + next, + commands: transitionResult.commands, + raisedEvents: transitionResult.raisedEvents, + emittedEvents: transitionResult.emittedEvents, + changed: true, + exitPaths: getExitPaths(machine, activeConfigurationFromIndexed(descriptor, state), boundary), + entryPaths: getEntryPaths(machine, activeConfigurationFromIndexed(descriptor, next), boundary), + choiceTransitions: [] + } +} + +const indexedMicrostep = ( + machine: Machine.Any, + descriptor: IndexedExecutionDescriptor, + state: IndexedConfiguration, + event: any, + selections: ReadonlyArray +): MicrostepPlan => { + if (selections.length === 1) { + const transition = collectIndexedEvaluatedTransition(machine, descriptor, state, selections[0]!) + return { + next: transition.next, + event, + transitions: [], + commands: transition.commands, + raisedEvents: transition.raisedEvents, + emittedEvents: transition.emittedEvents, + exitPaths: transition.exitPaths, + entryPaths: transition.entryPaths, + changed: transition.changed + } + } + const activeSelections = selections.filter((selection) => + !selections.some((other) => + other.sourceIndex !== selection.sourceIndex && + isDescendantOf(other.sourcePath, selection.sourcePath) + ) + ) + const evaluated = activeSelections.map((selection) => + collectIndexedEvaluatedTransition(machine, descriptor, state, selection) + ) + const transitions = sortEvaluatedTransitions( + machine, + removeConflictingTransitions(machine, evaluated as any) + ) as ReadonlyArray + + let next = state + if (transitions.length === 1) { + next = transitions[0]!.next + } else { + const applicationOrder = [ + ...transitions.filter((transition) => !transition.changed), + ...transitions.filter((transition) => transition.changed) + ] + for (const transition of applicationOrder) { + if (transition.target !== undefined) { + next = normalizeIndexedTargetConfigurationSync( + machine, + descriptor, + next, + transition.target, + transition.selection.leafIndex + ) + } + } + } + + const commands = transitions.flatMap((transition) => transition.commands) + const raisedEvents = transitions.flatMap((transition) => transition.raisedEvents) + const emittedEvents = transitions.flatMap((transition) => transition.emittedEvents) + const changed = transitions.some((transition) => transition.changed) + return { + next, + event, + transitions: [], + commands, + raisedEvents, + emittedEvents, + exitPaths: changed ? sortExitPaths(machine, transitions.flatMap((transition) => transition.exitPaths)) : [], + entryPaths: changed ? sortEntryPaths(machine, transitions.flatMap((transition) => transition.entryPaths)) : [], + changed + } +} + +const planIndexedFlatConfiguration = ( + machine: Machine.Any, + descriptor: IndexedExecutionDescriptor, + configuration: IndexedConfiguration, + decoded: { readonly _tag: PropertyKey } +): MacrostepPlan => { + let current = configuration + let event: any = decoded + let commands: Array | undefined + let raisedEvents: Array | undefined + let emittedEvents: Array | undefined + let microsteps: Array> | undefined + let raisedIndex = 0 + let iterations = 0 + + while (true) { + iterations += 1 + if (iterations > MaxMacrostepIterations) { + throw new InfiniteTransitionError({ + machineId: machine.id, + state: descriptor.nodes[current.activeLeaves[0]!]!.path, + maxIterations: MaxMacrostepIterations + }) + } + + const sourceIndex = current.activeLeaves[0] + if (sourceIndex === undefined) { + throw new Error("Machine expected an active indexed root state") + } + if (descriptor.nodes[sourceIndex]!.type === "final") { + const completed = completeConfigurationSync( + machine, + activeConfigurationFromIndexed(descriptor, current), + event + ).configuration + const root = getRootPath(machine, completed) + if (!completed.outputs.has(root)) { + throw new Error("Machine reached a terminal indexed configuration without a completed root output") + } + return { + next: indexedConfigurationFromActive(descriptor, completed), + commands: commands ?? emptyCompiledValues, + emittedEvents: emittedEvents ?? emptyCompiledValues, + microsteps: microsteps ?? emptyCompiledValues, + done: true, + output: completed.outputs.get(root) + } + } + + const sourcePath = descriptor.nodes[sourceIndex]!.path + const transition = normalizeTransition(machine.handlers[sourcePath]?.on?.[event._tag]) + if (transition !== undefined) { + const transitionResult = collectIndexedTransition( + machine, + transition.transition, + { + state: current.values[sourceIndex], + parent: undefined, + parents: {}, + event, + snapshot: snapshotFromIndexed(descriptor, current), + target: getTargetBuilder(machine, sourcePath) + } + ) + const target = transitionResult.state + validateDeclaredTransitionTarget( + sourcePath, + { type: "event", event: event._tag }, + transition.targets, + target + ) + if (target !== undefined && !isTarget(target) && !isSnapshot(target)) { + throw new Error("Machine expected indexed transition target to be a snapshot or target builder result") + } + + let next = current + if (target !== undefined) { + const targetIndex = descriptor.indexByPath.get(String(target.path)) + const isSimpleTarget = isTarget(target) + ? target[TargetSnapshotTypeId] === undefined && target.values === undefined + : !("state" in target) && !("states" in target) && !("completed" in target) && !("history" in target) + if ( + targetIndex === sourceIndex && isSimpleTarget && current.completedOrder.length === 0 + ) { + current.values[sourceIndex] = decodeStateValueSync( + machine, + descriptor.nodes[sourceIndex]!, + target.value + ) + } else { + next = normalizeIndexedTargetConfigurationSync(machine, descriptor, current, target as any, sourceIndex) + } + } + const changed = transition.reenter || !hasSameIndexedActive(current, next) + const nextIndex = next.activeLeaves[0] + if (nextIndex === undefined) { + throw new Error("Machine expected an active indexed transition target") + } + const step: MicrostepPlan = { + next, + event, + transitions: emptyCompiledValues, + commands: transitionResult.commands, + raisedEvents: transitionResult.raisedEvents, + emittedEvents: transitionResult.emittedEvents, + exitPaths: changed ? [sourcePath] : emptyCompiledValues, + entryPaths: changed ? [descriptor.nodes[nextIndex]!.path] : emptyCompiledValues, + changed + } + current = next + ;(microsteps ??= []).push(step) + if (transitionResult.commands.length > 0) { + ;(commands ??= []).push(...transitionResult.commands) + } + if (transitionResult.raisedEvents.length > 0) { + ;(raisedEvents ??= []).push(...transitionResult.raisedEvents) + } + if (transitionResult.emittedEvents.length > 0) { + ;(emittedEvents ??= []).push(...transitionResult.emittedEvents) + } + } + + if (descriptor.nodes[current.activeLeaves[0]!]!.type === "final") { + continue + } + const raised = raisedEvents?.[raisedIndex] + if (raised === undefined) { + return { + next: current, + commands: commands ?? emptyCompiledValues, + emittedEvents: emittedEvents ?? emptyCompiledValues, + microsteps: microsteps ?? emptyCompiledValues, + done: false, + output: undefined + } + } + raisedIndex += 1 + event = raised + } +} + +const planIndexedConfiguration = ( + machine: Machine.Any, + descriptor: IndexedExecutionDescriptor, + configuration: IndexedConfiguration, + input: unknown +): MacrostepPlan => { + const decoded = decodeEventSync(machine, input) as { readonly _tag: PropertyKey } + if (descriptor.flat) { + return planIndexedFlatConfiguration(machine, descriptor, configuration, decoded) + } + if (descriptor.finalIndices.some((index) => configuration.active[index] === 1)) { + const active = activeConfigurationFromIndexed(descriptor, configuration) + if (isActiveFinalConfiguration(machine, active)) { + const completed = completeConfigurationSync(machine, active, decoded).configuration + const root = getRootPath(machine, completed) + if (!completed.outputs.has(root)) { + throw new Error("Machine reached a terminal indexed configuration without a completed root output") + } + return { + next: indexedConfigurationFromActive(descriptor, completed), + commands: [], + emittedEvents: [], + microsteps: [], + done: true, + output: completed.outputs.get(root) + } + } + } + + const selections = selectIndexedEventTransitions(machine, descriptor, configuration, decoded) + if (selections.length === 0) { + return { + next: configuration, + commands: [], + emittedEvents: [], + microsteps: [], + done: false, + output: undefined + } + } + + const first = indexedMicrostep(machine, descriptor, configuration, decoded, selections) + let current = first.next + let currentEvent: any = decoded + const commands = [...first.commands] + const raisedEvents = [...first.raisedEvents] + const emittedEvents = [...first.emittedEvents] + const microsteps = [first] + let raisedIndex = 0 + let iterations = 0 + + while (true) { + iterations += 1 + if (iterations > MaxMacrostepIterations) { + throw new InfiniteTransitionError({ + machineId: machine.id, + state: descriptor.nodes[descriptor.leafIndices.find((index) => current.active[index] === 1)!]!.path, + maxIterations: MaxMacrostepIterations + }) + } + + if (descriptor.finalIndices.some((index) => current.active[index] === 1)) { + const completed = completeConfigurationSync( + machine, + activeConfigurationFromIndexed(descriptor, current), + currentEvent + ).configuration + current = indexedConfigurationFromActive(descriptor, completed) + if (isActiveFinalConfiguration(machine, completed)) { + const root = getRootPath(machine, completed) + if (!completed.outputs.has(root)) { + throw new Error("Machine reached a terminal indexed configuration without a completed root output") + } + return { + next: current, + commands, + emittedEvents, + microsteps, + done: true, + output: completed.outputs.get(root) + } + } + } + + const raised = raisedEvents[raisedIndex] + if (raised === undefined) { + return { + next: current, + commands, + emittedEvents, + microsteps, + done: false, + output: undefined + } + } + raisedIndex += 1 + currentEvent = raised + const raisedSelections = selectIndexedEventTransitions(machine, descriptor, current, raised) + if (raisedSelections.length === 0) continue + const step = indexedMicrostep(machine, descriptor, current, raised, raisedSelections) + current = step.next + commands.push(...step.commands) + raisedEvents.push(...step.raisedEvents) + emittedEvents.push(...step.emittedEvents) + microsteps.push(step) + } +} + +export interface CompiledExecutionPlan { + readonly fromConfiguration: (configuration: ActiveConfiguration) => unknown + readonly toConfiguration: (state: unknown) => ActiveConfiguration + readonly snapshot: (state: unknown) => Machine.Snapshot + readonly plan: ( + state: unknown, + event: unknown + ) => MacrostepPlan + readonly initial?: ( + args: ReadonlyArray + ) => { + readonly state: Machine.Snapshot + readonly configuration: unknown + readonly activeConfiguration: ActiveConfiguration + readonly initialEntryPaths: ReadonlyArray + readonly done: boolean + readonly output: unknown + } +} + +const executionPlanCache = new WeakMap() + +const makeActiveExecutionPlan = (machine: Machine.Any): CompiledExecutionPlan => ({ + fromConfiguration: (configuration) => configuration, + toConfiguration: (state) => state as ActiveConfiguration, + snapshot: (state) => snapshotFromConfiguration(machine, state as ActiveConfiguration), + plan: (state, event) => planConfiguration(machine as any, state as ActiveConfiguration, event as any) +}) + +const makeIndexedExecutionPlan = ( + machine: Machine.Any, + indexed: IndexedExecutionDescriptor +): CompiledExecutionPlan => ({ + fromConfiguration: (configuration) => indexedConfigurationFromActive(indexed, configuration), + toConfiguration: (state) => activeConfigurationFromIndexed(indexed, state as IndexedConfiguration), + snapshot: (state) => snapshotFromIndexed(indexed, state as IndexedConfiguration), + plan: (state, event) => planIndexedConfiguration(machine, indexed, state as IndexedConfiguration, event), + initial: (args) => { + const inputArgs = machine.input === undefined + ? args + : args.length === 0 + ? (decodeInputSync(machine, machine.input, undefined), args) + : [decodeInputSync(machine, machine.input, args[0])] + const initial = machine.initial(...inputArgs as any) + const active = normalizeConfigurationSync(machine, initial as Machine.Snapshot) + validateInitialConfiguration(machine, active) + const completed = completeConfigurationSync(machine, active, InitialEvent).configuration + const configuration = indexedConfigurationFromActive(indexed, completed) + const state = snapshotFromIndexed(indexed, configuration) + const done = isActiveFinalConfiguration(machine, completed) + if (!done) { + return { + state, + configuration, + activeConfiguration: completed, + initialEntryPaths: getInitialEntryPaths(machine, completed), + done: false, + output: undefined + } + } + const root = getRootPath(machine, completed) + if (!completed.outputs.has(root)) { + throw new Error("Machine reached a terminal configuration without a completed root output") + } + return { + state, + configuration, + activeConfiguration: completed, + initialEntryPaths: getInitialEntryPaths(machine, completed), + done: true, + output: completed.outputs.get(root) + } + } +}) + +export type ExecutionPlanStrategy = "generic" | "indexed-flat" | "indexed-hierarchical" | "auto" + +export interface SelectedExecutionPlan { + readonly strategy: Exclude + readonly plan: CompiledExecutionPlan +} + +const selectExecutionPlan = ( + machine: Machine.Any, + strategy: ExecutionPlanStrategy +): SelectedExecutionPlan => { + if (strategy === "generic") { + return { strategy, plan: makeActiveExecutionPlan(machine) } + } + const indexed = compileIndexedExecutionDescriptor(machine) + if (indexed === undefined) { + if (strategy === "auto") { + return { strategy: "generic", plan: makeActiveExecutionPlan(machine) } + } + throw new Error(`Machine cannot compile the requested ${strategy} execution plan`) + } + const selected = indexed.flat ? "indexed-flat" : "indexed-hierarchical" + if (strategy !== "auto" && strategy !== selected) { + throw new Error(`Machine compiled ${selected}, not the requested ${strategy} execution plan`) + } + return { strategy: selected, plan: makeIndexedExecutionPlan(machine, indexed) } +} + +/** @internal Test-only uncached strategy selection. */ +export const selectExecutionPlanForTesting = ( + machine: Machine.Any, + strategy: ExecutionPlanStrategy +): SelectedExecutionPlan => selectExecutionPlan(machine, strategy) + +export const compileExecutionPlan = (machine: Machine.Any): CompiledExecutionPlan => { + const cached = executionPlanCache.get(machine) + if (cached !== undefined) { + return cached + } + const indexed = compileIndexedExecutionDescriptor(machine) + const compiled = indexed === undefined + ? makeActiveExecutionPlan(machine) + : makeIndexedExecutionPlan(machine, indexed) + executionPlanCache.set(machine, compiled) + return compiled +} diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index 68c0fe0..b77ea83 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -25,13 +25,16 @@ import type { StoppedError } from "../../Machine.js" import * as Activities from "./activities.js" +import * as Configuration from "./configuration.js" import type { ChildAlreadyExistsError, InfiniteTransitionError, StartupError } from "./errors.js" -import * as Model from "./model.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 * as internalRuntime from "./runtime.js" +import * as Serialization from "./serialization.js" import * as StateDefinition from "./stateDefinition.js" +import * as Topology from "./topology.js" export { ChildAlreadyExistsError, @@ -117,7 +120,7 @@ const cloneWithHandlers = ( machine.makeTargetBuilder = self.makeTargetBuilder machine.handlers = handlers machine.handle = makeHandle(machine) - Model.copyProtocol(self, machine) + Protocol.copyProtocol(self, machine) return machine } @@ -183,7 +186,7 @@ const flattenHandlers = ( } handlers[path] = stateConfig as Machine.AnyStateConfig if (childConfig !== undefined) { - const node = Model.getStateNodeDefinition(path, states[key]) + const node = Topology.getStateNodeDefinition(path, states[key]) if (node.states === undefined) { throw new Error(`Machine expected state "${path}" to declare child states`) } @@ -258,7 +261,7 @@ const withFrom = ) = const omitted = args.length === 0 || (kind === "nested" && args.length === 1 && typeof args[0] === "function") const input = omitted ? {} : args[0] const rest = omitted ? args : args.slice(1) - return method(Model.makeStateInput(input), ...rest) + return method(Topology.makeStateInput(input), ...rest) }, enumerable: false }) @@ -277,10 +280,10 @@ const makeSnapshotBuilder = ( } const path = options.prefix === "" ? key : `${options.prefix}.${key}` if (pseudoType === "choice") { - builder[key] = () => Model.makeChoiceTarget(path, getParentPathRuntime(path)) + builder[key] = () => Topology.makeChoiceTarget(path, getParentPathRuntime(path)) continue } - const node = Model.getStateNodeDefinition(path, states[key]) + const node = Topology.getStateNodeDefinition(path, states[key]) builder[key] = withFrom( (value: unknown, selector?: (builder: unknown) => unknown) => makeSnapshotForNode(states[key], key, value, selector, options), @@ -309,7 +312,7 @@ const makeParallelSnapshotBuilder = ( continue } const path = options.prefix === "" ? key : `${options.prefix}.${key}` - const node = Model.getStateNodeDefinition(path, states[key]) + const node = Topology.getStateNodeDefinition(path, states[key]) builder[key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { const nextRegions: Record = {} for (const regionKey of Object.keys(regions)) { @@ -353,7 +356,7 @@ const makeSnapshotForNode = ( options: SnapshotBuilderOptions ): Record => { const path = options.prefix === "" ? key : `${options.prefix}.${key}` - const node = Model.getStateNodeDefinition(path, definition) + const node = Topology.getStateNodeDefinition(path, definition) const snapshot: Record = { path, value @@ -417,8 +420,8 @@ const makeTargetWithValues = ( values: Readonly> | undefined ): Machine.Target => hasTargetValues(values) - ? Model.makeTarget(path as any, value as any, { values: values as any }) - : Model.makeTarget(path as any, value as any) + ? Topology.makeTarget(path as any, value as any, { values: values as any }) + : Topology.makeTarget(path as any, value as any) const getTargetBuilderDefinition = ( states: Machine.StateTree, @@ -433,7 +436,7 @@ const getTargetBuilderDefinition = ( } definition = children[key] path = path === "" ? key : `${path}.${key}` - const node = Model.getStateNodeDefinition(path, definition) + const node = Topology.getStateNodeDefinition(path, definition) children = node.states ?? {} } return definition! @@ -456,7 +459,7 @@ const makeParallelTarget = ( selector, { mode: "full", prefix: node.parent ?? "" } ) - return Model.makeTarget(node.path as any, value as any, { + return Topology.makeTarget(node.path as any, value as any, { snapshot: snapshot as any, values: values as any }) @@ -493,7 +496,7 @@ const makeLocalTargetChildBuilder = ( ) { const child = getTargetBuilderNode(stateNodes, childPath) if (child.type === "choice") { - builder[child.key] = () => Model.makeChoiceTarget(child.path, parent.path, values) + builder[child.key] = () => Topology.makeChoiceTarget(child.path, parent.path, values) continue } builder[child.key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { @@ -565,7 +568,7 @@ const addBranchTargetChildren = ( ) { const child = getTargetBuilderNode(stateNodes, childPath) if (child.type === "choice") { - builder[child.key] = () => Model.makeChoiceTarget(child.path, parent.path, values) + builder[child.key] = () => Topology.makeChoiceTarget(child.path, parent.path, values) continue } builder[child.key] = makeBranchTargetNodeBuilder(states, stateNodes, child.path, values, source) @@ -644,7 +647,7 @@ const makeHistoryTargetBuilder = ( const definition = states[key] if ((definition as { readonly type?: unknown }).type === "history") { const parent = getParentPathRuntime(path) - builder[key] = () => Model.makeHistoryTarget(path, parent) + builder[key] = () => Topology.makeHistoryTarget(path, parent) continue } if (typeof definition === "object" && definition !== null && hasProperty(definition, "states")) { @@ -685,17 +688,17 @@ export const defineStates: DefineStates = (, get: ((snapshot, path) => - Model.getSnapshotByPath(snapshot, path).pipe( + Topology.getSnapshotByPath(snapshot, path).pipe( Option.map((snapshot) => snapshot.value) )) as Machine.DefinedStates["get"], getWithParents: ((snapshot, path) => { const parents: Record = {} - return Model.getSnapshotByPath(snapshot, path, parents).pipe( + return Topology.getSnapshotByPath(snapshot, path, parents).pipe( Option.map((snapshot) => ({ value: snapshot.value, parents })) ) }) as Machine.DefinedStates["getWithParents"], - getSnapshot: Model.getSnapshotByPath as unknown as Machine.DefinedStates["getSnapshot"], - matches: (snapshot, path) => Option.isSome(Model.getSnapshotByPath(snapshot, path)) + getSnapshot: Topology.getSnapshotByPath as unknown as Machine.DefinedStates["getSnapshot"], + matches: (snapshot, path) => Option.isSome(Topology.getSnapshotByPath(snapshot, path)) } }) as DefineStates @@ -802,11 +805,11 @@ export const make: Make = (< self.input = config.input self.id = config.id self.initial = config.initial - self.stateNodes = Model.compileStateNodes(config.states) + self.stateNodes = Topology.compileStateNodes(config.states) self.makeTargetBuilder = makeTargetBuilder(config.states, self.stateNodes) self.handlers = Object.create(null) self.handle = makeHandle(self) - Model.setProtocol(self) + Protocol.setProtocol(self) return self }) as Make @@ -821,7 +824,7 @@ export const event = < machine: M, schema: EventSchema & ([EventSchema["Type"]] extends [Machine.Event] ? unknown : never), ...args: EventConstructorArgs -): EventSchema["Type"] => Model.makeEvent(machine, schema, args.length === 0 ? {} : args[0]) +): EventSchema["Type"] => Protocol.makeEvent(machine, schema, args.length === 0 ? {} : args[0]) export const encodeSnapshot: < const States extends Machine.StateSchemas, @@ -858,7 +861,7 @@ export const encodeSnapshot: < Machine.EncodedSnapshot, MachineSchemaEncodeError, Machine.SnapshotEncodingServices -> = Model.encodeSnapshot as any +> = Serialization.encodeSnapshot as any export const decodeSnapshot: < const States extends Machine.StateSchemas, @@ -895,7 +898,7 @@ export const decodeSnapshot: < Machine.Snapshot, MachineSchemaDecodeError, Machine.SnapshotDecodingServices -> = Model.decodeSnapshot as any +> = Serialization.decodeSnapshot as any export const invoke = < ChildState, @@ -1367,7 +1370,7 @@ export const transitionDefinitions = ( Machine.StateNodeIdentifier> > > => - Model.transitionDefinitions(machine) as ReadonlyArray< + Topology.transitionDefinitions(machine) as ReadonlyArray< Machine.TransitionDefinition< Machine.StateNodeIdentifier>, Machine.TagOf[number]>, @@ -1391,7 +1394,7 @@ export const configuration = ( Machine.ChoiceIdentifier> > > => { - const active = Model.normalizeConfiguration(machine, state).active + const active = Configuration.normalizeConfiguration(machine, state).active return stateNodes(machine).filter( (node): node is Machine.ActiveStateNode< Machine.StateIdentifier>, diff --git a/src/internal/machine/planner.ts b/src/internal/machine/planner.ts index ae7a2c1..70b3fb0 100644 --- a/src/internal/machine/planner.ts +++ b/src/internal/machine/planner.ts @@ -9,7 +9,7 @@ import * as Effect from "effect/Effect" import * as Option from "effect/Option" import type * as Schema from "effect/Schema" import type { Command, Enqueue, InitialEvent as MachineInitialEvent, Machine, Runtime } from "../../Machine.js" -import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError } from "./errors.js" +import { getTargetBuilder, makeCollector, type RuntimeCommand } from "./command.js" import { type ActiveConfiguration, captureHistory, @@ -17,34 +17,19 @@ import { completeConfigurationEffect, completeConfigurationSync, configurationFromHistoryRecord, - decodeEmit, - decodeEmitSync, - decodeEvent, - decodeEventSync, - decodeInput, - decodeInputSync, - decodeStateValue, - decodeStateValueSync, getActiveLeafPathFrom, getActiveLeafPaths, getActiveValue, getHistoryRecord, getInitialEntryPaths, getLeafPath, - getNode, getParentValue, getParentValues, getPathToRoot, getRootPath, isActiveFinalConfiguration, - isChoiceTarget, isDescendantOf, - isHistoryTarget, isPathInSubtree, - isSnapshot, - isTarget, - makeChoiceTarget, - makeTarget, normalizeConfiguration, normalizeConfigurationEffect, normalizeConfigurationSync, @@ -53,93 +38,30 @@ import { pathDepth, snapshotFromConfiguration, snapshotFromConfigurationAtPath, - TargetSnapshotTypeId, validateInitialConfiguration -} from "./model.js" -import type { ProcessScope } from "./runtime.js" +} from "./configuration.js" +import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError } from "./errors.js" +import { + decodeEmit, + decodeEmitSync, + decodeEvent, + decodeEventSync, + decodeInput, + decodeInputSync, + decodeStateValue, + decodeStateValueSync +} from "./protocol.js" import { InitialEventTypeId } from "./symbols.js" - -export type RuntimeCommand = Command - -interface Collected { - readonly enqueue: Enqueue - readonly commands: Array - readonly raisedEvents: Array - readonly emittedEvents: Array -} - -const targetBuilderCache = new WeakMap>() - -const getTargetBuilder = (machine: Machine.Any, path: string): any => { - let byPath = targetBuilderCache.get(machine) - if (byPath === undefined) { - byPath = new Map() - targetBuilderCache.set(machine, byPath) - } - if (byPath.has(path)) { - return byPath.get(path) - } - const builder = machine.makeTargetBuilder(path as any) - byPath.set(path, builder) - return builder -} - -const makeCollector = (machine: Machine.Any): Collected => { - const commands: Array = [] - const raisedEvents: Array = [] - const emittedEvents: Array = [] - return { - commands, - raisedEvents, - emittedEvents, - enqueue: { - raise: (event) => { - raisedEvents.push(decodeEventSync(machine, event) as Event) - }, - emit: (event) => { - emittedEvents.push(decodeEmitSync(machine, event)) - }, - sendTo: (child: unknown, event: unknown) => { - commands.push({ _tag: "SendTo", child: child as any, event }) - }, - stop: (child: unknown) => { - commands.push({ _tag: "Stop", child: child as any }) - } - } - } -} - -export const makeLiveRuntime = ( - machine: Machine.Any, - scope: ProcessScope -): Runtime => ({ - raise: (event) => - decodeEvent(machine, event).pipe( - Effect.flatMap((event) => scope.self.send(event as Events)) - ), - sendParent: (event) => - decodeEmit(machine, event).pipe( - Effect.flatMap((event) => scope.sendParent(event)) - ) -}) - -export const runCommands = ( - commands: Iterable, - scope: ProcessScope -) => - Effect.forEach(commands, (command) => - command._tag === "SendTo" - ? scope.sendTo(command.child as never, command.event) - : scope.stopChild(command.child as never), { discard: true }) - -export const runEmittedEvents = ( - events: Iterable, - runtime: Runtime -) => - Effect.all( - Array.from(events, (event) => runtime.sendParent(event)), - { discard: true } - ) +import { + getNode, + isChoiceTarget, + isHistoryTarget, + isSnapshot, + isTarget, + makeChoiceTarget, + makeTarget, + TargetSnapshotTypeId +} from "./topology.js" export type MicrostepPlan = { readonly next: State @@ -177,7 +99,7 @@ export type MacrostepPlan = } ) -type TransitionHandler = ( +export type TransitionHandler = ( context: Context, enqueue: Enqueue ) => Machine.HandlerResult @@ -190,13 +112,13 @@ type EventTransition = readonly transition: TransitionHandler } -type MicrostepTransition = { +export type MicrostepTransition = { readonly reenter: boolean readonly targets: ReadonlyArray | undefined readonly transition: TransitionHandler } -const normalizeTransition = ( +export const normalizeTransition = ( transition: EventTransition | undefined ): MicrostepTransition | undefined => { if (transition === undefined) { @@ -462,7 +384,7 @@ const resolveHistoryTarget = ( } } -type SelectedTransition = { +export type SelectedTransition = { readonly sourcePath: string readonly leafPath: string readonly trigger: Machine.TransitionTrigger @@ -470,7 +392,7 @@ type SelectedTransition = { readonly context: Context } -type EvaluatedTransition = { +export type EvaluatedTransition = { readonly selection: SelectedTransition readonly unresolvedTarget: | Machine.Snapshot @@ -507,7 +429,7 @@ const getCandidatePaths = (machine: Machine.Any, configuration: ActiveConfigurat const getLeafCandidatePaths = (machine: Machine.Any, leaf: string): ReadonlyArray => [...getPathToRoot(machine, leaf)].reverse() -const getLeastCommonAncestor = ( +export const getLeastCommonAncestor = ( machine: Machine.Any, left: string, right: string @@ -525,7 +447,7 @@ const getLeastCommonAncestor = ( return ancestor } -const broadenTransitionBoundary = ( +export const broadenTransitionBoundary = ( naturalBoundary: string | undefined, reentryBoundary: string | undefined ): string | undefined => { @@ -535,7 +457,7 @@ const broadenTransitionBoundary = ( : naturalBoundary } -const getExitPaths = ( +export const getExitPaths = ( machine: Machine.Any, configuration: ActiveConfiguration, boundary: string | undefined @@ -546,7 +468,7 @@ const getExitPaths = ( .filter((path) => boundary === undefined || isDescendantOf(path, boundary)) ) -const getEntryPaths = ( +export const getEntryPaths = ( machine: Machine.Any, configuration: ActiveConfiguration, boundary: string | undefined @@ -878,7 +800,7 @@ const selectEventTransitions = < return selected } -const getTargetNodePath = ( +export const getTargetNodePath = ( target: | Machine.Snapshot | Machine.Target> @@ -897,7 +819,7 @@ const getTargetNodePath = ( throw new Error("Machine expected transition target to be a snapshot or target builder result") } -const validateDeclaredTransitionTarget = ( +export const validateDeclaredTransitionTarget = ( sourcePath: string, trigger: Machine.TransitionTrigger, declaredTargets: ReadonlyArray | undefined, @@ -928,7 +850,7 @@ const hasPathIntersection = (left: ReadonlyArray, right: ReadonlyArray } -const MaxMacrostepIterations = 1000 +export const MaxMacrostepIterations = 1000 export const InitialEvent: MachineInitialEvent = { _tag: InitialEventTypeId } const catchStartup = ( @@ -1946,884 +1868,6 @@ const macrostepConfiguration = < return settle(machine, step.next, decodedEvent, commands, raisedEvents, emittedEvents, microsteps) } -interface IndexedExecutionDescriptor { - readonly flat: boolean - readonly nodes: ReadonlyArray - readonly indexByPath: ReadonlyMap - readonly parentIndices: ReadonlyArray - readonly childIndices: ReadonlyArray> - readonly ancestorIndices: ReadonlyArray> - readonly rootIndices: ReadonlyArray - readonly leafIndices: ReadonlyArray - readonly finalIndices: ReadonlyArray - readonly dispatchByLeaf: ReadonlyMap< - number, - ReadonlyMap - }> - > -} - -const compileIndexedExecutionDescriptor = ( - machine: Machine.Any -): IndexedExecutionDescriptor | undefined => { - const nodes: Array = [] - const leafPaths: Array = [] - const finalPaths: Array = [] - const transitionsByPath = new Map< - PropertyKey, - ReadonlyMap> - >() - - for (const node of machine.stateNodes.byPath.values() as Iterable) { - nodes.push(node) - if (node.type === "choice" || node.type === "history") { - return undefined - } - if (node.type === "atomic" || node.type === "final") { - leafPaths.push(node.path) - } - if (node.type === "final") { - finalPaths.push(node.path) - } - - const config = machine.handlers[node.path] as Machine.AnyStateConfig | undefined - if ( - config?.entry !== undefined || config?.exit !== undefined || config?.always !== undefined || - config?.onDone !== undefined || (config as any)?.choice !== undefined || - (config as any)?.history !== undefined - ) { - return undefined - } - if (config?.on === undefined) { - continue - } - const byEvent = new Map>() - for (const tag of Reflect.ownKeys(config.on)) { - const transition = normalizeTransition(config.on[tag]) - if (transition !== undefined) { - byEvent.set(tag, transition) - } - } - if (byEvent.size > 0) { - transitionsByPath.set(node.path, byEvent) - } - } - - leafPaths.sort((left, right) => compareDocumentOrder(machine, left, right)) - finalPaths.sort((left, right) => compareDocumentOrder(machine, left, right)) - nodes.sort((left, right) => left.order - right.order) - const indexByPath = new Map(nodes.map((node, index) => [node.path, index])) - const indexOf = (path: string): number => { - const index = indexByPath.get(path) - if (index === undefined) { - throw new Error(`Machine expected compiled state path "${path}"`) - } - return index - } - const leafIndices = leafPaths.map(indexOf) - const parentIndices = nodes.map((node) => node.parent === undefined ? -1 : indexOf(node.parent)) - const childIndices = nodes.map((node) => node.children.map(indexOf)) - const ancestorIndices = nodes.map((node) => getPathToRoot(machine, node.path).slice(0, -1).map(indexOf)) - const transitionsByIndex = nodes.map((node) => transitionsByPath.get(node.path)) - const dispatchByLeaf = new Map< - number, - ReadonlyMap - }> - >() - for (const leafIndex of leafIndices) { - const dispatch = new Map - }>() - const candidates = [leafIndex, ...ancestorIndices[leafIndex]!.slice().reverse()] - for (const sourceIndex of candidates) { - for (const [tag, transition] of transitionsByIndex[sourceIndex] ?? []) { - if (!dispatch.has(tag)) dispatch.set(tag, { sourceIndex, transition }) - } - } - dispatchByLeaf.set(leafIndex, dispatch) - } - return { - flat: nodes.every((node) => node.parent === undefined && (node.type === "atomic" || node.type === "final")), - nodes, - indexByPath, - parentIndices, - childIndices, - ancestorIndices, - rootIndices: nodes.flatMap((node, index) => node.parent === undefined ? [index] : []), - leafIndices, - finalIndices: finalPaths.map(indexOf), - dispatchByLeaf - } -} - -interface IndexedConfiguration { - readonly active: Uint8Array - readonly activeLeaves: ReadonlyArray - // The compiled process fiber owns this slot table. Flat same-state updates - // may replace one value in place after eagerly detaching the handler's - // public snapshot; hierarchical microsteps retain immutable copies so - // simultaneous transition contexts continue to share their starting state. - readonly values: Array - readonly completed: Uint8Array - readonly outputs: ReadonlyArray - readonly completedOrder: ReadonlyArray -} - -const indexedConfigurationFromActive = ( - descriptor: IndexedExecutionDescriptor, - configuration: ActiveConfiguration -): IndexedConfiguration => { - const active = new Uint8Array(descriptor.nodes.length) - const values: Array = new Array(descriptor.nodes.length) - const completed = new Uint8Array(descriptor.nodes.length) - const outputs: Array = new Array(descriptor.nodes.length) - const completedOrder: Array = [] - for (const path of configuration.active) { - const index = descriptor.indexByPath.get(path) - if (index === undefined) throw new Error(`Machine expected indexed active path "${path}"`) - active[index] = 1 - values[index] = configuration.values.get(path) - } - for (const [path, output] of configuration.outputs) { - const index = descriptor.indexByPath.get(path) - if (index === undefined) throw new Error(`Machine expected indexed completed path "${path}"`) - completed[index] = 1 - outputs[index] = output - completedOrder.push(index) - } - return { - active, - activeLeaves: descriptor.leafIndices.filter((index) => active[index] === 1), - values, - completed, - outputs, - completedOrder - } -} - -const activeConfigurationFromIndexed = ( - descriptor: IndexedExecutionDescriptor, - configuration: IndexedConfiguration -): ActiveConfiguration => { - const active = new Set() - const values = new Map() - const outputs = new Map() - for (let index = 0; index < descriptor.nodes.length; index++) { - if (configuration.active[index] !== 1) continue - const path = descriptor.nodes[index]!.path - active.add(path) - values.set(path, configuration.values[index]) - } - for (const index of configuration.completedOrder) { - if (configuration.completed[index] === 1) { - outputs.set(descriptor.nodes[index]!.path, configuration.outputs[index]) - } - } - return { active, values, outputs, history: new Map() } -} - -const snapshotFromIndexedPath = ( - descriptor: IndexedExecutionDescriptor, - configuration: IndexedConfiguration, - index: number -): Machine.AtomicSnapshot => { - const node = descriptor.nodes[index]! - const snapshot: Record = { - path: node.path, - value: configuration.values[index] - } - if (node.type === "compound") { - const childIndex = descriptor.childIndices[index]!.find((childIndex) => configuration.active[childIndex] === 1) - if (childIndex === undefined) { - throw new Error(`Machine expected indexed compound state "${node.path}" to have an active child`) - } - snapshot.state = snapshotFromIndexedPath(descriptor, configuration, childIndex) - } else if (node.type === "parallel") { - const states: Record = {} - for (const childIndex of descriptor.childIndices[index]!) { - if (configuration.active[childIndex] !== 1) { - throw new Error( - `Machine expected indexed parallel state "${node.path}" to have active region "${ - descriptor.nodes[childIndex]!.path - }"` - ) - } - states[descriptor.nodes[childIndex]!.key] = snapshotFromIndexedPath(descriptor, configuration, childIndex) - } - snapshot.states = states - } - return snapshot as unknown as Machine.AtomicSnapshot -} - -const snapshotFromIndexed = ( - descriptor: IndexedExecutionDescriptor, - configuration: IndexedConfiguration -): Machine.Snapshot => { - const rootIndex = descriptor.rootIndices.find((index) => configuration.active[index] === 1) - if (rootIndex === undefined) throw new Error("Machine expected an active indexed root state") - const snapshot = snapshotFromIndexedPath(descriptor, configuration, rootIndex) as Machine.Snapshot - if (configuration.completedOrder.length > 0) { - ;(snapshot as Machine.AtomicSnapshot & { - completed: ReadonlyArray - }).completed = configuration.completedOrder.map((index) => ({ - path: descriptor.nodes[index]!.path, - output: configuration.outputs[index] - })) - } - return snapshot -} - -const makeIndexedTransitionContext = ( - machine: Machine.Any, - descriptor: IndexedExecutionDescriptor, - configuration: IndexedConfiguration, - sourceIndex: number, - event: any -): any => { - const source = descriptor.nodes[sourceIndex]! - const parentIndex = descriptor.parentIndices[sourceIndex]! - const parents: Record = {} - for (const ancestorIndex of descriptor.ancestorIndices[sourceIndex]!) { - parents[descriptor.nodes[ancestorIndex]!.path] = configuration.values[ancestorIndex] - } - return { - state: configuration.values[sourceIndex], - parent: parentIndex < 0 ? undefined : configuration.values[parentIndex], - parents, - event, - snapshot: snapshotFromIndexed(descriptor, configuration), - target: getTargetBuilder(machine, source.path) - } -} - -type IndexedSelectedTransition = SelectedTransition & { - readonly sourceIndex: number - readonly leafIndex: number -} - -type IndexedEvaluatedTransition = - & Omit< - EvaluatedTransition, - "selection" - > - & { - readonly selection: IndexedSelectedTransition - readonly next: IndexedConfiguration - } - -const emptyCompiledValues: ReadonlyArray = [] - -const collectIndexedTransition = ( - machine: Machine.Any, - transition: TransitionHandler, - context: any -) => { - let commands: Array | undefined - let raisedEvents: Array | undefined - let emittedEvents: Array | undefined - const state = transition(context, { - raise: (event: unknown) => { - ;(raisedEvents ??= []).push(decodeEventSync(machine, event)) - }, - emit: (event: unknown) => { - ;(emittedEvents ??= []).push(decodeEmitSync(machine, event)) - }, - sendTo: (child: unknown, event: unknown) => { - ;(commands ??= []).push({ _tag: "SendTo", child: child as any, event }) - }, - stop: (child: unknown) => { - ;(commands ??= []).push({ _tag: "Stop", child: child as any }) - } - }) - return { - state, - commands: commands ?? emptyCompiledValues, - raisedEvents: raisedEvents ?? emptyCompiledValues, - emittedEvents: emittedEvents ?? emptyCompiledValues - } -} - -const selectIndexedEventTransitions = ( - machine: Machine.Any, - descriptor: IndexedExecutionDescriptor, - configuration: IndexedConfiguration, - event: any -): ReadonlyArray => { - const selected: Array = [] - for (const leafIndex of configuration.activeLeaves) { - const dispatched = descriptor.dispatchByLeaf.get(leafIndex)!.get(event._tag) - if (dispatched !== undefined) { - const { sourceIndex, transition } = dispatched - if (!selected.some((selection) => selection.sourceIndex === sourceIndex)) { - const sourcePath = descriptor.nodes[sourceIndex]!.path - selected.push({ - sourceIndex, - leafIndex, - sourcePath, - leafPath: descriptor.nodes[leafIndex]!.path, - trigger: { type: "event", event: event._tag }, - transition, - context: makeIndexedTransitionContext( - machine, - descriptor, - configuration, - sourceIndex, - event - ) - }) - } - } - } - return selected -} - -const hasSameIndexedActive = (left: IndexedConfiguration, right: IndexedConfiguration): boolean => { - if (left.active === right.active) return true - for (let index = 0; index < left.active.length; index++) { - if (left.active[index] !== right.active[index]) return false - } - return true -} - -const normalizeIndexedTargetConfigurationSync = ( - machine: Machine.Any, - descriptor: IndexedExecutionDescriptor, - current: IndexedConfiguration, - target: Machine.Target | Machine.Snapshot, - activeLeafIndex: number -): IndexedConfiguration => { - const targetIndex = isTarget(target) ? descriptor.indexByPath.get(String(target.path)) : undefined - if ( - targetIndex === activeLeafIndex && current.active[activeLeafIndex] === 1 && isTarget(target) && - target[TargetSnapshotTypeId] === undefined && target.values === undefined && current.completedOrder.length === 0 - ) { - const values = current.values.slice() - values[activeLeafIndex] = decodeStateValueSync( - machine, - descriptor.nodes[activeLeafIndex]!, - target.value - ) - return { ...current, values } - } - return indexedConfigurationFromActive( - descriptor, - normalizeTargetConfigurationSync( - machine, - activeConfigurationFromIndexed(descriptor, current), - target - ) - ) -} - -const collectIndexedEvaluatedTransition = ( - machine: Machine.Any, - descriptor: IndexedExecutionDescriptor, - state: IndexedConfiguration, - selection: IndexedSelectedTransition -): IndexedEvaluatedTransition => { - const transitionResult = collectIndexedTransition(machine, selection.transition.transition, selection.context) - const target = transitionResult.state - validateDeclaredTransitionTarget( - selection.sourcePath, - selection.trigger, - selection.transition.targets, - target - ) - if (target !== undefined && !isTarget(target) && !isSnapshot(target)) { - throw new Error("Machine expected indexed transition target to be a snapshot or target builder result") - } - const next = target === undefined - ? state - : normalizeIndexedTargetConfigurationSync(machine, descriptor, state, target as any, selection.leafIndex) - const changed = selection.transition.reenter || !hasSameIndexedActive(state, next) - if (!changed) { - return { - selection, - unresolvedTarget: target as any, - target: target as any, - next, - commands: transitionResult.commands, - raisedEvents: transitionResult.raisedEvents, - emittedEvents: transitionResult.emittedEvents, - changed: false, - exitPaths: [], - entryPaths: [], - choiceTransitions: [] - } - } - - const targetPath = target === undefined ? undefined : getTargetNodePath(target as any) - const naturalBoundary = targetPath === undefined - ? descriptor.nodes[selection.sourceIndex]!.parent - : getLeastCommonAncestor(machine, selection.leafPath, targetPath) - const reentryBoundary = descriptor.nodes[selection.sourceIndex]!.parent - const boundary = selection.transition.reenter - ? broadenTransitionBoundary(naturalBoundary, reentryBoundary) - : naturalBoundary - return { - selection, - unresolvedTarget: target as any, - target: target as any, - next, - commands: transitionResult.commands, - raisedEvents: transitionResult.raisedEvents, - emittedEvents: transitionResult.emittedEvents, - changed: true, - exitPaths: getExitPaths(machine, activeConfigurationFromIndexed(descriptor, state), boundary), - entryPaths: getEntryPaths(machine, activeConfigurationFromIndexed(descriptor, next), boundary), - choiceTransitions: [] - } -} - -const indexedMicrostep = ( - machine: Machine.Any, - descriptor: IndexedExecutionDescriptor, - state: IndexedConfiguration, - event: any, - selections: ReadonlyArray -): MicrostepPlan => { - if (selections.length === 1) { - const transition = collectIndexedEvaluatedTransition(machine, descriptor, state, selections[0]!) - return { - next: transition.next, - event, - transitions: [], - commands: transition.commands, - raisedEvents: transition.raisedEvents, - emittedEvents: transition.emittedEvents, - exitPaths: transition.exitPaths, - entryPaths: transition.entryPaths, - changed: transition.changed - } - } - const activeSelections = selections.filter((selection) => - !selections.some((other) => - other.sourceIndex !== selection.sourceIndex && - isDescendantOf(other.sourcePath, selection.sourcePath) - ) - ) - const evaluated = activeSelections.map((selection) => - collectIndexedEvaluatedTransition(machine, descriptor, state, selection) - ) - const transitions = sortEvaluatedTransitions( - machine, - removeConflictingTransitions(machine, evaluated as any) - ) as ReadonlyArray - - let next = state - if (transitions.length === 1) { - next = transitions[0]!.next - } else { - const applicationOrder = [ - ...transitions.filter((transition) => !transition.changed), - ...transitions.filter((transition) => transition.changed) - ] - for (const transition of applicationOrder) { - if (transition.target !== undefined) { - next = normalizeIndexedTargetConfigurationSync( - machine, - descriptor, - next, - transition.target, - transition.selection.leafIndex - ) - } - } - } - - const commands = transitions.flatMap((transition) => transition.commands) - const raisedEvents = transitions.flatMap((transition) => transition.raisedEvents) - const emittedEvents = transitions.flatMap((transition) => transition.emittedEvents) - const changed = transitions.some((transition) => transition.changed) - return { - next, - event, - transitions: [], - commands, - raisedEvents, - emittedEvents, - exitPaths: changed ? sortExitPaths(machine, transitions.flatMap((transition) => transition.exitPaths)) : [], - entryPaths: changed ? sortEntryPaths(machine, transitions.flatMap((transition) => transition.entryPaths)) : [], - changed - } -} - -const planIndexedFlatConfiguration = ( - machine: Machine.Any, - descriptor: IndexedExecutionDescriptor, - configuration: IndexedConfiguration, - decoded: { readonly _tag: PropertyKey } -): MacrostepPlan => { - let current = configuration - let event: any = decoded - let commands: Array | undefined - let raisedEvents: Array | undefined - let emittedEvents: Array | undefined - let microsteps: Array> | undefined - let raisedIndex = 0 - let iterations = 0 - - while (true) { - iterations += 1 - if (iterations > MaxMacrostepIterations) { - throw new InfiniteTransitionError({ - machineId: machine.id, - state: descriptor.nodes[current.activeLeaves[0]!]!.path, - maxIterations: MaxMacrostepIterations - }) - } - - const sourceIndex = current.activeLeaves[0] - if (sourceIndex === undefined) { - throw new Error("Machine expected an active indexed root state") - } - if (descriptor.nodes[sourceIndex]!.type === "final") { - const completed = completeConfigurationSync( - machine, - activeConfigurationFromIndexed(descriptor, current), - event - ).configuration - const root = getRootPath(machine, completed) - if (!completed.outputs.has(root)) { - throw new Error("Machine reached a terminal indexed configuration without a completed root output") - } - return { - next: indexedConfigurationFromActive(descriptor, completed), - commands: commands ?? emptyCompiledValues, - emittedEvents: emittedEvents ?? emptyCompiledValues, - microsteps: microsteps ?? emptyCompiledValues, - done: true, - output: completed.outputs.get(root) - } - } - - const sourcePath = descriptor.nodes[sourceIndex]!.path - const transition = normalizeTransition(machine.handlers[sourcePath]?.on?.[event._tag]) - if (transition !== undefined) { - const transitionResult = collectIndexedTransition( - machine, - transition.transition, - { - state: current.values[sourceIndex], - parent: undefined, - parents: {}, - event, - snapshot: snapshotFromIndexed(descriptor, current), - target: getTargetBuilder(machine, sourcePath) - } - ) - const target = transitionResult.state - validateDeclaredTransitionTarget( - sourcePath, - { type: "event", event: event._tag }, - transition.targets, - target - ) - if (target !== undefined && !isTarget(target) && !isSnapshot(target)) { - throw new Error("Machine expected indexed transition target to be a snapshot or target builder result") - } - - let next = current - if (target !== undefined) { - const targetIndex = descriptor.indexByPath.get(String(target.path)) - const isSimpleTarget = isTarget(target) - ? target[TargetSnapshotTypeId] === undefined && target.values === undefined - : !("state" in target) && !("states" in target) && !("completed" in target) && !("history" in target) - if ( - targetIndex === sourceIndex && isSimpleTarget && current.completedOrder.length === 0 - ) { - current.values[sourceIndex] = decodeStateValueSync( - machine, - descriptor.nodes[sourceIndex]!, - target.value - ) - } else { - next = normalizeIndexedTargetConfigurationSync(machine, descriptor, current, target as any, sourceIndex) - } - } - const changed = transition.reenter || !hasSameIndexedActive(current, next) - const nextIndex = next.activeLeaves[0] - if (nextIndex === undefined) { - throw new Error("Machine expected an active indexed transition target") - } - const step: MicrostepPlan = { - next, - event, - transitions: emptyCompiledValues, - commands: transitionResult.commands, - raisedEvents: transitionResult.raisedEvents, - emittedEvents: transitionResult.emittedEvents, - exitPaths: changed ? [sourcePath] : emptyCompiledValues, - entryPaths: changed ? [descriptor.nodes[nextIndex]!.path] : emptyCompiledValues, - changed - } - current = next - ;(microsteps ??= []).push(step) - if (transitionResult.commands.length > 0) { - ;(commands ??= []).push(...transitionResult.commands) - } - if (transitionResult.raisedEvents.length > 0) { - ;(raisedEvents ??= []).push(...transitionResult.raisedEvents) - } - if (transitionResult.emittedEvents.length > 0) { - ;(emittedEvents ??= []).push(...transitionResult.emittedEvents) - } - } - - if (descriptor.nodes[current.activeLeaves[0]!]!.type === "final") { - continue - } - const raised = raisedEvents?.[raisedIndex] - if (raised === undefined) { - return { - next: current, - commands: commands ?? emptyCompiledValues, - emittedEvents: emittedEvents ?? emptyCompiledValues, - microsteps: microsteps ?? emptyCompiledValues, - done: false, - output: undefined - } - } - raisedIndex += 1 - event = raised - } -} - -const planIndexedConfiguration = ( - machine: Machine.Any, - descriptor: IndexedExecutionDescriptor, - configuration: IndexedConfiguration, - input: unknown -): MacrostepPlan => { - const decoded = decodeEventSync(machine, input) as { readonly _tag: PropertyKey } - if (descriptor.flat) { - return planIndexedFlatConfiguration(machine, descriptor, configuration, decoded) - } - if (descriptor.finalIndices.some((index) => configuration.active[index] === 1)) { - const active = activeConfigurationFromIndexed(descriptor, configuration) - if (isActiveFinalConfiguration(machine, active)) { - const completed = completeConfigurationSync(machine, active, decoded).configuration - const root = getRootPath(machine, completed) - if (!completed.outputs.has(root)) { - throw new Error("Machine reached a terminal indexed configuration without a completed root output") - } - return { - next: indexedConfigurationFromActive(descriptor, completed), - commands: [], - emittedEvents: [], - microsteps: [], - done: true, - output: completed.outputs.get(root) - } - } - } - - const selections = selectIndexedEventTransitions(machine, descriptor, configuration, decoded) - if (selections.length === 0) { - return { - next: configuration, - commands: [], - emittedEvents: [], - microsteps: [], - done: false, - output: undefined - } - } - - const first = indexedMicrostep(machine, descriptor, configuration, decoded, selections) - let current = first.next - let currentEvent: any = decoded - const commands = [...first.commands] - const raisedEvents = [...first.raisedEvents] - const emittedEvents = [...first.emittedEvents] - const microsteps = [first] - let raisedIndex = 0 - let iterations = 0 - - while (true) { - iterations += 1 - if (iterations > MaxMacrostepIterations) { - throw new InfiniteTransitionError({ - machineId: machine.id, - state: descriptor.nodes[descriptor.leafIndices.find((index) => current.active[index] === 1)!]!.path, - maxIterations: MaxMacrostepIterations - }) - } - - if (descriptor.finalIndices.some((index) => current.active[index] === 1)) { - const completed = completeConfigurationSync( - machine, - activeConfigurationFromIndexed(descriptor, current), - currentEvent - ).configuration - current = indexedConfigurationFromActive(descriptor, completed) - if (isActiveFinalConfiguration(machine, completed)) { - const root = getRootPath(machine, completed) - if (!completed.outputs.has(root)) { - throw new Error("Machine reached a terminal indexed configuration without a completed root output") - } - return { - next: current, - commands, - emittedEvents, - microsteps, - done: true, - output: completed.outputs.get(root) - } - } - } - - const raised = raisedEvents[raisedIndex] - if (raised === undefined) { - return { - next: current, - commands, - emittedEvents, - microsteps, - done: false, - output: undefined - } - } - raisedIndex += 1 - currentEvent = raised - const raisedSelections = selectIndexedEventTransitions(machine, descriptor, current, raised) - if (raisedSelections.length === 0) continue - const step = indexedMicrostep(machine, descriptor, current, raised, raisedSelections) - current = step.next - commands.push(...step.commands) - raisedEvents.push(...step.raisedEvents) - emittedEvents.push(...step.emittedEvents) - microsteps.push(step) - } -} - -export interface CompiledExecutionPlan { - readonly fromConfiguration: (configuration: ActiveConfiguration) => unknown - readonly toConfiguration: (state: unknown) => ActiveConfiguration - readonly snapshot: (state: unknown) => Machine.Snapshot - readonly plan: ( - state: unknown, - event: unknown - ) => MacrostepPlan - readonly initial?: ( - args: ReadonlyArray - ) => { - readonly state: Machine.Snapshot - readonly configuration: unknown - readonly activeConfiguration: ActiveConfiguration - readonly initialEntryPaths: ReadonlyArray - readonly done: boolean - readonly output: unknown - } -} - -const executionPlanCache = new WeakMap() - -const makeActiveExecutionPlan = (machine: Machine.Any): CompiledExecutionPlan => ({ - fromConfiguration: (configuration) => configuration, - toConfiguration: (state) => state as ActiveConfiguration, - snapshot: (state) => snapshotFromConfiguration(machine, state as ActiveConfiguration), - plan: (state, event) => planConfiguration(machine as any, state as ActiveConfiguration, event as any) -}) - -const makeIndexedExecutionPlan = ( - machine: Machine.Any, - indexed: IndexedExecutionDescriptor -): CompiledExecutionPlan => ({ - fromConfiguration: (configuration) => indexedConfigurationFromActive(indexed, configuration), - toConfiguration: (state) => activeConfigurationFromIndexed(indexed, state as IndexedConfiguration), - snapshot: (state) => snapshotFromIndexed(indexed, state as IndexedConfiguration), - plan: (state, event) => planIndexedConfiguration(machine, indexed, state as IndexedConfiguration, event), - initial: (args) => { - const inputArgs = machine.input === undefined - ? args - : args.length === 0 - ? (decodeInputSync(machine, machine.input, undefined), args) - : [decodeInputSync(machine, machine.input, args[0])] - const initial = machine.initial(...inputArgs as any) - const active = normalizeConfigurationSync(machine, initial as Machine.Snapshot) - validateInitialConfiguration(machine, active) - const completed = completeConfigurationSync(machine, active, InitialEvent).configuration - const configuration = indexedConfigurationFromActive(indexed, completed) - const state = snapshotFromIndexed(indexed, configuration) - const done = isActiveFinalConfiguration(machine, completed) - if (!done) { - return { - state, - configuration, - activeConfiguration: completed, - initialEntryPaths: getInitialEntryPaths(machine, completed), - done: false, - output: undefined - } - } - const root = getRootPath(machine, completed) - if (!completed.outputs.has(root)) { - throw new Error("Machine reached a terminal configuration without a completed root output") - } - return { - state, - configuration, - activeConfiguration: completed, - initialEntryPaths: getInitialEntryPaths(machine, completed), - done: true, - output: completed.outputs.get(root) - } - } -}) - -export type ExecutionPlanStrategy = "generic" | "indexed-flat" | "indexed-hierarchical" | "auto" - -export interface SelectedExecutionPlan { - readonly strategy: Exclude - readonly plan: CompiledExecutionPlan -} - -const selectExecutionPlan = ( - machine: Machine.Any, - strategy: ExecutionPlanStrategy -): SelectedExecutionPlan => { - if (strategy === "generic") { - return { strategy, plan: makeActiveExecutionPlan(machine) } - } - const indexed = compileIndexedExecutionDescriptor(machine) - if (indexed === undefined) { - if (strategy === "auto") { - return { strategy: "generic", plan: makeActiveExecutionPlan(machine) } - } - throw new Error(`Machine cannot compile the requested ${strategy} execution plan`) - } - const selected = indexed.flat ? "indexed-flat" : "indexed-hierarchical" - if (strategy !== "auto" && strategy !== selected) { - throw new Error(`Machine compiled ${selected}, not the requested ${strategy} execution plan`) - } - return { strategy: selected, plan: makeIndexedExecutionPlan(machine, indexed) } -} - -/** @internal Test-only uncached strategy selection. */ -export const selectExecutionPlanForTesting = ( - machine: Machine.Any, - strategy: ExecutionPlanStrategy -): SelectedExecutionPlan => selectExecutionPlan(machine, strategy) - -export const compileExecutionPlan = (machine: Machine.Any): CompiledExecutionPlan => { - const cached = executionPlanCache.get(machine) - if (cached !== undefined) { - return cached - } - const indexed = compileIndexedExecutionDescriptor(machine) - const compiled = indexed === undefined - ? makeActiveExecutionPlan(machine) - : makeIndexedExecutionPlan(machine, indexed) - executionPlanCache.set(machine, compiled) - return compiled -} - const snapshotMacrostep = < const States extends Machine.StateSchemas, Event, diff --git a/src/internal/machine/process.ts b/src/internal/machine/process.ts index 05c9b41..3b31345 100644 --- a/src/internal/machine/process.ts +++ b/src/internal/machine/process.ts @@ -11,11 +11,14 @@ import * as Option from "effect/Option" import * as Queue from "effect/Queue" import type * as Schema from "effect/Schema" import type { ActionError, ExecutionServices, Machine, Runtime } from "../../Machine.js" +import * as CommandRuntime from "./commandRuntime.js" +import * as Configuration from "./configuration.js" import { ChildAlreadyExistsError, InfiniteTransitionError, MachineSchemaDecodeError, StartupError } from "./errors.js" import type { StoppedError } from "./errors.js" -import * as Model from "./model.js" +import * as ExecutionPlan from "./executionPlan.js" import * as internalPlanner from "./planner.js" import * as internalRuntime from "./runtime.js" +import * as Serialization from "./serialization.js" type IsAny = 0 extends (1 & A) ? true : false @@ -167,7 +170,7 @@ const makeChildlessCompiledDrain = ( ): ( context: internalRuntime.CompiledProcessContext ) => Effect.Effect, any, any> => { - const executionPlan = internalPlanner.compileExecutionPlan(machine) + const executionPlan = ExecutionPlan.compileExecutionPlan(machine) return (context) => { let current = context.state() if (checkInitialFinal && internalPlanner.isFinalState(machine, current)) { @@ -191,7 +194,7 @@ const makeChildlessCompiledDrain = ( let planned try { planned = executionPlan.plan( - configuration ?? executionPlan.fromConfiguration(Model.normalizeConfigurationSync(machine, current)), + configuration ?? executionPlan.fromConfiguration(Configuration.normalizeConfigurationSync(machine, current)), pending.value ) } catch (error) { @@ -208,12 +211,12 @@ const makeChildlessCompiledDrain = ( const next = executionPlan.snapshot(planned.next) const beforeCommit = planned.commands.length === 0 ? undefined - : internalPlanner.runCommands(planned.commands, context.scope) + : CommandRuntime.runCommands(planned.commands, context.scope) const afterCommit = planned.emittedEvents.length === 0 ? undefined - : internalPlanner.runEmittedEvents( + : CommandRuntime.runEmittedEvents( planned.emittedEvents, - liveRuntime ??= internalPlanner.makeLiveRuntime(machine, context.scope) + liveRuntime ??= CommandRuntime.makeLiveRuntime(machine, context.scope) ) const commit = (): Effect.Effect | undefined => { const notification = context.commit(next) @@ -250,14 +253,14 @@ class InvokeExecutionKernel { initial: | { readonly configuration: unknown - readonly activeConfiguration: Model.ActiveConfiguration + readonly activeConfiguration: Configuration.ActiveConfiguration readonly entryPaths: ReadonlyArray } | undefined constructor(initial?: { readonly configuration: unknown - readonly activeConfiguration: Model.ActiveConfiguration + readonly activeConfiguration: Configuration.ActiveConfiguration readonly entryPaths: ReadonlyArray }) { this.initial = initial @@ -318,17 +321,17 @@ class InvokeExecutionKernel { startAll( machine: Machine.Any, context: internalRuntime.CompiledProcessContext, - configuration: Model.ActiveConfiguration, + configuration: Configuration.ActiveConfiguration, paths: ReadonlyArray, event: Machine.LifecycleEvent ): Effect.Effect | undefined { const effects = internalPlanner.sortEntryPaths(machine, paths) .filter((path) => configuration.active.has(path)) .flatMap((path) => - getInvokes(Model.getStateConfigByPath(machine, path), { - state: Model.getActiveValue(configuration, path), - parent: Model.getParentValue(machine, configuration, path), - parents: Model.getParentValues(machine, configuration, path), + getInvokes(Configuration.getStateConfigByPath(machine, path), { + state: Configuration.getActiveValue(configuration, path), + parent: Configuration.getParentValue(machine, configuration, path), + parents: Configuration.getParentValues(machine, configuration, path), event }).map((config) => this.start(context, path, config)) ) @@ -342,7 +345,7 @@ const makeInvokingCompiledDrain = ( ): ( context: internalRuntime.CompiledProcessContext ) => Effect.Effect, any, any> => { - const executionPlan = internalPlanner.compileExecutionPlan(machine) + const executionPlan = ExecutionPlan.compileExecutionPlan(machine) return (context) => { let current = context.state() if (checkInitialFinal && internalPlanner.isFinalState(machine, current)) { @@ -374,7 +377,7 @@ const makeInvokingCompiledDrain = ( try { planned = executionPlan.plan( configuration ?? - executionPlan.fromConfiguration(Model.normalizeConfigurationSync(machine, current)), + executionPlan.fromConfiguration(Configuration.normalizeConfigurationSync(machine, current)), pending.value ) } catch (error) { @@ -404,7 +407,7 @@ const makeInvokingCompiledDrain = ( const next = executionPlan.snapshot(planned.next) const beforeCommit: Array> = [] if (planned.commands.length > 0) { - beforeCommit.push(internalPlanner.runCommands(planned.commands, scope)) + beforeCommit.push(CommandRuntime.runCommands(planned.commands, scope)) } if (changed) { const stopping = context.ownedChildren.stopPaths(exitPaths) @@ -413,9 +416,9 @@ const makeInvokingCompiledDrain = ( const afterCommit: Array> = [] if (planned.emittedEvents.length > 0) { afterCommit.push( - internalPlanner.runEmittedEvents( + CommandRuntime.runEmittedEvents( planned.emittedEvents, - liveRuntime ??= internalPlanner.makeLiveRuntime(machine, scope) + liveRuntime ??= CommandRuntime.makeLiveRuntime(machine, scope) ) ) } @@ -461,13 +464,14 @@ const makeInvokingCompiledDrain = ( return loop } const seeded = execution.initial - const initialConfiguration = seeded?.activeConfiguration ?? Model.normalizeConfigurationSync(machine, current) + const initialConfiguration = seeded?.activeConfiguration ?? + Configuration.normalizeConfigurationSync(machine, current) configuration = seeded?.configuration ?? executionPlan.fromConfiguration(initialConfiguration) const starting = execution.startAll( machine, context, initialConfiguration, - seeded?.entryPaths ?? Model.getInitialEntryPaths(machine, initialConfiguration), + seeded?.entryPaths ?? Configuration.getInitialEntryPaths(machine, initialConfiguration), internalPlanner.InitialEvent ) execution.initial = undefined @@ -527,7 +531,7 @@ const makeProcessLogic: < entry: ProcessEntry ) => { const hasInvokes = hasInvokeCapability(machine) - const executionPlan = internalPlanner.compileExecutionPlan(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 : () => { @@ -563,12 +567,12 @@ const makeProcessLogic: < Effect.flatMap((planned) => { const commands = planned.commands.length === 0 ? undefined - : internalPlanner.runCommands(planned.commands, scope) + : CommandRuntime.runCommands(planned.commands, scope) const emitted = planned.emittedEvents.length === 0 ? undefined - : internalPlanner.runEmittedEvents( + : CommandRuntime.runEmittedEvents( planned.emittedEvents, - internalPlanner.makeLiveRuntime, Machine.EmitOf>(machine, scope) + CommandRuntime.makeLiveRuntime, Machine.EmitOf>(machine, scope) ) const result = Effect.succeed({ state: planned.state, @@ -595,7 +599,7 @@ const makeProcessLogic: < : makeChildlessCompiledDrain(machine, entry._tag === "Resume"), initial: (scope) => entry._tag === "Resume" - ? internalRuntime.provideMachineRuntime(Model.normalizeSnapshotEffect(machine, entry.snapshot), scope) + ? internalRuntime.provideMachineRuntime(Serialization.normalizeSnapshotEffect(machine, entry.snapshot), scope) : makeInitial(scope).pipe(Effect.map((initialized) => initialized.state)), run: (context) => internalRuntime.provideMachineRuntime( @@ -620,7 +624,7 @@ const makeProcessLogic: < // Keeping the loop in this generator avoids a suspended generator // per iteration; every iteration still crosses Effect boundaries, // so the Effect scheduler remains responsible for cooperative yield. - let configuration: Model.ActiveConfiguration | undefined + let configuration: Configuration.ActiveConfiguration | undefined let pendingEvent: Option.Option> = Option.none() let pollEvent: Effect.Effect>> | undefined let liveRuntime: Runtime, Machine.EmitOf> | undefined @@ -631,7 +635,7 @@ const makeProcessLogic: < try { planned = internalPlanner.planConfiguration( machine, - configuration ?? Model.normalizeConfigurationSync(machine, current), + configuration ?? Configuration.normalizeConfigurationSync(machine, current), event ) } catch (error) { @@ -643,14 +647,14 @@ const makeProcessLogic: < configuration = planned.next if (planned.microsteps.length > 0) { - const next = Model.snapshotFromConfiguration(machine, planned.next) - yield* internalPlanner.runCommands(planned.commands, context) + const next = Configuration.snapshotFromConfiguration(machine, planned.next) + yield* CommandRuntime.runCommands(planned.commands, context) yield* setState(next) current = next if (planned.emittedEvents.length > 0) { - yield* internalPlanner.runEmittedEvents( + yield* CommandRuntime.runEmittedEvents( planned.emittedEvents as ReadonlyArray>, - liveRuntime ??= internalPlanner.makeLiveRuntime(machine, context) + liveRuntime ??= CommandRuntime.makeLiveRuntime(machine, context) ) } @@ -762,11 +766,11 @@ const makeProcessLogic: < ) }) const startInvokes: ( - configuration: Model.ActiveConfiguration, + configuration: Configuration.ActiveConfiguration, paths: ReadonlyArray, event: Machine.LifecycleEvent ) => Effect.Effect = Effect.fnUntraced(function*( - configuration: Model.ActiveConfiguration, + configuration: Configuration.ActiveConfiguration, paths: ReadonlyArray, event: Machine.LifecycleEvent ) { @@ -774,10 +778,10 @@ const makeProcessLogic: < internalPlanner.sortEntryPaths(machine, paths) .filter((path) => configuration.active.has(path)) .flatMap((path) => - getInvokes(Model.getStateConfigByPath(machine, path), { - state: Model.getActiveValue(configuration, path), - parent: Model.getParentValue(machine, configuration, path), - parents: Model.getParentValues(machine, configuration, path), + getInvokes(Configuration.getStateConfigByPath(machine, path), { + state: Configuration.getActiveValue(configuration, path), + parent: Configuration.getParentValue(machine, configuration, path), + parents: Configuration.getParentValues(machine, configuration, path), event }).map((config) => startInvoke( @@ -800,13 +804,14 @@ const makeProcessLogic: < ) return yield* Effect.gen(function*() { - let configuration: Model.ActiveConfiguration | undefined = yield* Model.normalizeConfigurationEffect( - machine, - current - ) + let configuration: Configuration.ActiveConfiguration | undefined = yield* Configuration + .normalizeConfigurationEffect( + machine, + current + ) yield* startInvokes( configuration, - Model.getInitialEntryPaths(machine, configuration), + Configuration.getInitialEntryPaths(machine, configuration), internalPlanner.InitialEvent ) // As above, keep the normalized configuration only while this @@ -825,7 +830,7 @@ const makeProcessLogic: < try { planned = internalPlanner.planConfiguration( machine, - configuration ?? Model.normalizeConfigurationSync(machine, current), + configuration ?? Configuration.normalizeConfigurationSync(machine, current), event ) } catch (error) { @@ -847,17 +852,17 @@ const makeProcessLogic: < } } - const next = Model.snapshotFromConfiguration(machine, planned.next) - yield* internalPlanner.runCommands(planned.commands, context) + const next = Configuration.snapshotFromConfiguration(machine, planned.next) + yield* CommandRuntime.runCommands(planned.commands, context) if (changed) { yield* stopInvokes(exitPaths) } yield* setState(next) current = next if (planned.emittedEvents.length > 0) { - yield* internalPlanner.runEmittedEvents( + yield* CommandRuntime.runEmittedEvents( planned.emittedEvents as ReadonlyArray>, - liveRuntime ??= internalPlanner.makeLiveRuntime(machine, context) + liveRuntime ??= CommandRuntime.makeLiveRuntime(machine, context) ) } diff --git a/src/internal/machine/protocol.ts b/src/internal/machine/protocol.ts new file mode 100644 index 0000000..f0cad1f --- /dev/null +++ b/src/internal/machine/protocol.ts @@ -0,0 +1,322 @@ +/** + * Internal machine schema protocol and boundary decoders. + * + * @since 4.0.0 + */ + +import * as Cause from "effect/Cause" +import * as Effect from "effect/Effect" +import { hasProperty } from "effect/Predicate" +import * as Result from "effect/Result" +import * as Schema from "effect/Schema" +import type { Machine } from "../../Machine.js" +import { MachineSchemaDecodeError } from "./errors.js" +import { getStateNodeSchema, isStateInput } from "./topology.js" + +export interface DecodeBoundaryOptions { + readonly boundary: "input" | "event" | "emit" | "state" | "output" | "history" | "configuration" + readonly state?: string + readonly event?: string +} + +interface MachineProtocolSchemas { + readonly event: Schema.Top + readonly emit: Schema.Top + readonly eventConstructors: ReadonlySet + readonly trustedEvents: WeakSet +} + +type BoundaryDecoder = (value: unknown) => Effect.Effect + +type BoundaryResultDecoder = (value: unknown) => Result.Result + +const boundaryDecoderCache = new WeakMap() + +const boundaryResultDecoderCache = new WeakMap() + +const getBoundaryDecoder = (schema: Schema.Top): BoundaryDecoder => { + const key = schema as object + const cached = boundaryDecoderCache.get(key) + if (cached !== undefined) { + return cached + } + const decoder = Schema.decodeUnknownEffect(Schema.toType(schema)) as BoundaryDecoder + boundaryDecoderCache.set(key, decoder) + return decoder +} + +const getBoundaryResultDecoder = (schema: Schema.Top): BoundaryResultDecoder => { + const key = schema as object + const cached = boundaryResultDecoderCache.get(key) + if (cached !== undefined) { + return cached + } + const decoder = Schema.decodeUnknownResult(Schema.toType(schema)) as BoundaryResultDecoder + boundaryResultDecoderCache.set(key, decoder) + return decoder +} + +const MachineProtocolTypeId = Symbol.for("effect/Machine/protocol") + +const getProtocolSchemas = (machine: Machine.Any): MachineProtocolSchemas => { + const protocol = (machine as any)[MachineProtocolTypeId] as MachineProtocolSchemas | undefined + if (protocol === undefined) { + throw new Error("Machine protocol is unavailable") + } + return protocol +} + +const setProtocolSchemas = (machine: Machine.Any, protocol: MachineProtocolSchemas): void => { + Object.defineProperty(machine, MachineProtocolTypeId, { + value: protocol, + enumerable: false + }) +} + +const collectEventConstructors = ( + schemas: ReadonlyArray +): ReadonlySet => { + const constructors = new Set() + const add = (schema: Machine.TaggedSchema): void => { + const key = schema as object + if (constructors.has(key)) return + constructors.add(key) + if (!hasProperty(schema, "cases") || typeof schema.cases !== "object" || schema.cases === null) return + for (const candidate of Object.values(schema.cases)) { + if ( + ((typeof candidate === "object" && candidate !== null) || typeof candidate === "function") && + hasProperty(candidate, "make") + ) { + add(candidate as Machine.TaggedSchema) + } + } + } + for (const schema of schemas) add(schema) + return constructors +} + +export const setProtocol = (machine: Machine.Any): void => { + const events = [...machine.events, ...machine.internalEvents] + setProtocolSchemas(machine, { + event: Schema.Union(events), + emit: Schema.Union(machine.emits), + eventConstructors: collectEventConstructors(events), + trustedEvents: new WeakSet() + }) +} + +export const copyProtocol = (source: Machine.Any, target: Machine.Any): void => + setProtocolSchemas(target, getProtocolSchemas(source)) + +export const getEventName = (event: unknown): string | undefined => + hasProperty(event, "_tag") ? String(event._tag) : undefined + +export const decodeBoundary = ( + machine: Machine.Any, + schema: Schema.Top, + value: unknown, + options: DecodeBoundaryOptions +): Effect.Effect => + getBoundaryDecoder(schema)(value).pipe( + Effect.mapError((cause) => + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: options.boundary, + cause, + ...(options.state === undefined ? {} : { state: options.state }), + ...(options.event === undefined ? {} : { event: options.event }) + }) + ) + ) as Effect.Effect + +export const decodeBoundarySync = ( + machine: Machine.Any, + schema: Schema.Top, + value: unknown, + options: DecodeBoundaryOptions +): A => { + const decoded = getBoundaryResultDecoder(schema)(value) + if (Result.isFailure(decoded)) { + throw new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: options.boundary, + cause: decoded.failure, + ...(options.state === undefined ? {} : { state: options.state }), + ...(options.event === undefined ? {} : { event: options.event }) + }) + } + return decoded.success as A +} + +const makeBoundarySync = ( + machine: Machine.Any, + schema: Schema.Top, + input: unknown, + options: DecodeBoundaryOptions +): A => { + try { + return schema.make(input as never) as A + } catch (cause) { + const issue = cause instanceof Error ? cause.cause : undefined + throw new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: options.boundary, + cause: Schema.isSchemaError(cause) + ? cause + : hasProperty(issue, "~effect/SchemaIssue/Issue") + ? new Schema.SchemaError(issue as any) + : Cause.die(cause), + ...(options.state === undefined ? {} : { state: options.state }), + ...(options.event === undefined ? {} : { event: options.event }) + }) + } +} + +/** Constructs an event through one of the machine protocol's own schemas and + * records the decoded value as trusted by that protocol. Machine clones share + * the protocol record, while unrelated machines retain independent trust. */ +export const makeEvent = ( + machine: Machine.Any, + schema: Schema, + input: unknown +): Schema["Type"] => { + const protocol = getProtocolSchemas(machine) + if (!protocol.eventConstructors.has(schema as object)) { + throw new Error("Machine.event expected a schema from the machine event protocol") + } + const inputName = getEventName(input) + const event = makeBoundarySync( + machine, + schema, + input, + inputName === undefined ? { boundary: "event" } : { boundary: "event", event: inputName } + ) + protocol.trustedEvents.add(event as object) + return event +} + +const isTrustedEvent = (protocol: MachineProtocolSchemas, event: unknown): boolean => + typeof event === "object" && event !== null && protocol.trustedEvents.has(event) + +export const decodeInput = ( + machine: Machine.Any, + schema: Input, + value: unknown +): Effect.Effect => + decodeBoundary(machine, schema, value, { boundary: "input" }) + +export const decodeEvent = >( + machine: Machine.Any, + event: unknown +): Effect.Effect, MachineSchemaDecodeError> => { + const protocol = getProtocolSchemas(machine) + if (isTrustedEvent(protocol, event)) { + return Effect.succeed(event as Machine.EventOf) + } + const eventName = getEventName(event) + return decodeBoundary>( + machine, + protocol.event, + event, + eventName === undefined ? { boundary: "event" } : { boundary: "event", event: eventName } + ) +} + +export const decodeEventSync = >( + machine: Machine.Any, + event: unknown +): Machine.EventOf => { + const protocol = getProtocolSchemas(machine) + if (isTrustedEvent(protocol, event)) { + return event as Machine.EventOf + } + const eventName = getEventName(event) + return decodeBoundarySync>( + machine, + protocol.event, + event, + eventName === undefined ? { boundary: "event" } : { boundary: "event", event: eventName } + ) +} + +export const decodeEmit = >( + machine: Machine.Any, + event: unknown +): Effect.Effect, MachineSchemaDecodeError> => { + const eventName = getEventName(event) + return decodeBoundary>( + machine, + getProtocolSchemas(machine).emit, + event, + eventName === undefined ? { boundary: "emit" } : { boundary: "emit", event: eventName } + ) +} + +export const decodeEmitSync = >( + machine: Machine.Any, + event: unknown +): Machine.EmitOf => { + const eventName = getEventName(event) + return decodeBoundarySync>( + machine, + getProtocolSchemas(machine).emit, + event, + eventName === undefined ? { boundary: "emit" } : { boundary: "emit", event: eventName } + ) +} + +export const decodeInputSync = ( + machine: Machine.Any, + schema: Input, + value: unknown +): Input["Type"] => decodeBoundarySync(machine, schema, value, { boundary: "input" }) + +export const decodeStateValue = ( + machine: Machine.Any, + node: Machine.StateNode, + value: unknown +): Effect.Effect => + isStateInput(value) + ? getStateNodeSchema(node).makeEffect(value.input).pipe( + Effect.mapError((cause) => + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "state", + state: node.path, + cause: new Schema.SchemaError(cause) + }) + ) + ) + : decodeBoundary(machine, getStateNodeSchema(node), value, { boundary: "state", state: node.path }) + +export const decodeStateValueSync = ( + machine: Machine.Any, + node: Machine.StateNode, + value: unknown +): unknown => { + if (!isStateInput(value)) { + return decodeBoundarySync(machine, getStateNodeSchema(node), value, { boundary: "state", state: node.path }) + } + return makeBoundarySync(machine, getStateNodeSchema(node), value.input, { + boundary: "state", + state: node.path + }) +} + +export const decodeOutputValue = ( + machine: Machine.Any, + node: Machine.StateNode, + value: unknown +): Effect.Effect => + node.output === undefined + ? Effect.succeed(value) + : decodeBoundary(machine, node.output, value, { boundary: "output", state: node.path }) + +export const decodeOutputValueSync = ( + machine: Machine.Any, + node: Machine.StateNode, + value: unknown +): unknown => + node.output === undefined + ? value + : decodeBoundarySync(machine, node.output, value, { boundary: "output", state: node.path }) diff --git a/src/internal/machine/serialization.ts b/src/internal/machine/serialization.ts new file mode 100644 index 0000000..867a45d --- /dev/null +++ b/src/internal/machine/serialization.ts @@ -0,0 +1,499 @@ +/** + * Internal encoded snapshot serialization. + * + * @since 4.0.0 + */ + +import * as Cause from "effect/Cause" +import * as Effect from "effect/Effect" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import type { Machine } from "../../Machine.js" +import { + type ActiveConfiguration, + compareDocumentOrder, + configurationFromSnapshot, + configurationFromSnapshotEffect, + getActiveChildPath, + getActiveValue, + getPathToRoot, + type HistoryRecord, + isActiveFinalNode, + isPathInSubtree, + normalizeConfigurationEffect, + snapshotFromConfiguration, + validateHistoryRecordControl +} from "./configuration.js" +import { MachineSchemaDecodeError, MachineSchemaEncodeError } from "./errors.js" +import { decodeBoundary, decodeOutputValue, getEventName } from "./protocol.js" +import { getNode, getStateNodeSchema } from "./topology.js" + +const EncodedSnapshotSchema = Schema.Struct({ + _tag: Schema.Literal("MachineSnapshot"), + active: Schema.Array(Schema.Struct({ + path: Schema.String, + value: Schema.Unknown + })), + completed: Schema.optional(Schema.Array(Schema.Struct({ + path: Schema.String, + output: Schema.optional(Schema.Unknown) + }))), + history: Schema.optional(Schema.Record( + Schema.String, + Schema.Struct({ + mode: Schema.Literals(["shallow", "deep"]), + active: Schema.Array(Schema.String), + values: Schema.Record(Schema.String, Schema.Unknown) + }) + )) +}) + +const encodeBoundary = ( + machine: Machine.Any, + schema: Schema.Top, + value: unknown, + options: { + readonly boundary: "state" | "output" | "history" + readonly state: string + } +): Effect.Effect => + Schema.encodeUnknownEffect(schema)(value).pipe( + Effect.mapError((cause) => + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: options.boundary, + state: options.state, + cause + }) + ) + ) + +const decodeEncodedBoundary = ( + machine: Machine.Any, + schema: Schema.Top, + value: unknown, + options: { + readonly boundary: "state" | "output" | "history" + readonly state: string + } +): Effect.Effect => + Schema.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError((cause) => + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: options.boundary, + state: options.state, + cause + }) + ) + ) + +const getCompletionSchema = ( + machine: Machine.Any, + configuration: ActiveConfiguration, + path: string +): Schema.Top => { + const node = getNode(machine, path) + if (node.type === "compound") { + const child = getActiveChildPath(machine, configuration, path) + if (child === undefined) { + throw new Error(`Machine expected completed state "${path}" to have an active child`) + } + return getCompletionSchema(machine, configuration, child) + } + return node.output ?? Schema.Void +} + +/** Defensively validates and normalizes an in-memory logical snapshot. Unlike + * the transport decoder this consumes decoded schema values. */ +export const normalizeSnapshotEffect = ( + machine: Machine.Any, + snapshot: Machine.Snapshot +): Effect.Effect, MachineSchemaDecodeError> => + Effect.gen(function*() { + const configuration = yield* normalizeConfigurationEffect(machine, snapshot) + const outputs = new Map() + const completionPaths = new Set() + const completions = snapshot.completed ?? [] + if (!Array.isArray(completions)) { + throw new Error("Machine snapshot completion metadata must be an array") + } + for (const completion of completions) { + if ( + typeof completion !== "object" || completion === null || + typeof (completion as { readonly path?: unknown }).path !== "string" + ) { + throw new Error("Machine snapshot contains malformed completion metadata") + } + const path = completion.path + if (completionPaths.has(path)) { + throw new Error(`Machine snapshot contains duplicate completion "${path}"`) + } + if (!configuration.active.has(path) || !isActiveFinalNode(machine, configuration, path)) { + throw new Error(`Machine snapshot contains invalid completion "${path}"`) + } + completionPaths.add(path) + outputs.set( + path, + yield* decodeBoundary(machine, getCompletionSchema(machine, configuration, path), completion.output, { + boundary: "output", + state: path + }) + ) + } + return snapshotFromConfiguration(machine, { ...configuration, outputs }) + }).pipe(Effect.catchCause((cause) => failDecodeCause(machine, cause))) + +const validateEncodedConfiguration = ( + machine: Machine.Any, + configuration: ActiveConfiguration +): Machine.Snapshot => { + const snapshot = snapshotFromConfiguration(machine, configuration) + const normalized = configurationFromSnapshot(machine, snapshot) + if ( + normalized.active.size !== configuration.active.size || + Array.from(configuration.active).some((path) => !normalized.active.has(path)) + ) { + throw new Error("Machine encoded snapshot contains states outside its active configuration") + } + return snapshot +} + +const failEncodeCause = ( + machine: Machine.Any, + cause: Cause.Cause +): Effect.Effect => { + const error = Cause.findErrorOption(cause) + return Option.isSome(error) && error.value instanceof MachineSchemaEncodeError + ? Effect.fail(error.value) + : Effect.fail( + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: "configuration", + cause + }) + ) +} + +const failDecodeCause = ( + machine: Machine.Any, + cause: Cause.Cause +): Effect.Effect => { + const error = Cause.findErrorOption(cause) + return Option.isSome(error) && error.value instanceof MachineSchemaDecodeError + ? Effect.fail(error.value) + : Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "configuration", + cause + }) + ) +} + +export const encodeSnapshot = ( + machine: Machine.Any, + snapshot: Machine.Snapshot +): Effect.Effect => + Effect.gen(function*() { + const configuration = yield* normalizeConfigurationEffect(machine, snapshot).pipe( + Effect.mapError((error) => + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: error.boundary === "state" || error.boundary === "history" ? error.boundary : "configuration", + ...(error.state === undefined ? {} : { state: error.state }), + cause: error.cause + }) + ) + ) + const completionPaths = new Set() + for (const completion of snapshot.completed ?? []) { + if (completionPaths.has(completion.path)) { + throw new Error(`Machine snapshot contains duplicate completion "${completion.path}"`) + } + if (!configuration.active.has(completion.path) || !isActiveFinalNode(machine, configuration, completion.path)) { + throw new Error(`Machine snapshot contains invalid completion "${completion.path}"`) + } + completionPaths.add(completion.path) + } + const active: Array = [] + for ( + const path of Array.from(configuration.active).sort((left, right) => compareDocumentOrder(machine, left, right)) + ) { + const node = getNode(machine, path) + active.push({ + path, + value: yield* encodeBoundary(machine, getStateNodeSchema(node), getActiveValue(configuration, path), { + boundary: "state", + state: path + }) + }) + } + + const completed: Array = [] + for ( + const [path, output] of Array.from(configuration.outputs).sort(([left], [right]) => + compareDocumentOrder(machine, left, right) + ) + ) { + if (!configuration.active.has(path) || !isActiveFinalNode(machine, configuration, path)) { + throw new Error(`Machine encoded snapshot contains invalid completion "${path}"`) + } + const encodedOutput = yield* encodeBoundary( + machine, + getCompletionSchema(machine, configuration, path), + output, + { + boundary: "output", + state: path + } + ) + completed.push({ + path, + ...(encodedOutput === undefined ? {} : { output: encodedOutput }) + }) + } + + const history: Record = {} + for ( + const [historyPath, record] of Array.from(configuration.history).sort(([left], [right]) => + left.localeCompare(right) + ) + ) { + const historyNode = machine.stateNodes.byPath.get(historyPath) + if ( + historyNode === undefined || historyNode.type !== "history" || historyNode.parent !== record.parent || + historyNode.history !== record.mode + ) { + return yield* Effect.fail( + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine snapshot contains invalid history record "${historyPath}"`)) + }) + ) + } + try { + validateHistoryRecordControl(machine, record) + } catch (cause) { + return yield* Effect.fail( + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(cause) + }) + ) + } + const encodedValues: Record = {} + for (const path of record.active) { + const stateNode = machine.stateNodes.byPath.get(path) + if ( + stateNode === undefined || stateNode.type === "history" || stateNode.type === "choice" || + !record.values.has(path) || + !(isPathInSubtree(path, record.parent) || getPathToRoot(machine, record.parent).includes(path)) + ) { + return yield* Effect.fail( + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: "history", + state: path, + cause: Cause.die(new Error(`Machine snapshot contains invalid remembered state "${path}"`)) + }) + ) + } + encodedValues[path] = yield* encodeBoundary( + machine, + getStateNodeSchema(stateNode), + record.values.get(path), + { boundary: "history", state: path } + ) + } + if (record.values.size !== record.active.size) { + return yield* Effect.fail( + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine history record "${historyPath}" contains values outside its paths`)) + }) + ) + } + history[historyPath] = { + mode: record.mode, + active: Array.from(record.active).sort((left, right) => compareDocumentOrder(machine, left, right)), + values: encodedValues + } + } + + return { + _tag: "MachineSnapshot" as const, + active, + ...(completed.length === 0 ? {} : { completed }), + ...(Object.keys(history).length === 0 ? {} : { history }) + } + }).pipe(Effect.catchCause((cause) => failEncodeCause(machine, cause))) + +export const decodeSnapshot = ( + machine: Machine.Any, + encoded: unknown +): Effect.Effect, MachineSchemaDecodeError, unknown> => + Effect.gen(function*() { + const decoded = yield* Schema.decodeUnknownEffect(EncodedSnapshotSchema)(encoded).pipe( + Effect.mapError((cause) => + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "configuration", + cause + }) + ) + ) + const active = new Set() + const values = new Map() + for (const entry of decoded.active) { + if (active.has(entry.path)) { + throw new Error(`Machine encoded snapshot contains duplicate state "${entry.path}"`) + } + const node = getNode(machine, entry.path) + active.add(entry.path) + values.set( + entry.path, + yield* decodeEncodedBoundary(machine, getStateNodeSchema(node), entry.value, { + boundary: "state", + state: entry.path + }) + ) + } + + const history = new Map() + for (const [historyPath, encodedRecord] of Object.entries(decoded.history ?? {})) { + const historyNode = machine.stateNodes.byPath.get(historyPath) + if ( + historyNode === undefined || historyNode.type !== "history" || historyNode.parent === undefined || + historyNode.history !== encodedRecord.mode + ) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine encoded snapshot contains invalid history record "${historyPath}"`)) + }) + ) + } + const rememberedActive = new Set() + const rememberedValues = new Map() + for (const path of encodedRecord.active) { + if (rememberedActive.has(path)) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: path, + cause: Cause.die(new Error(`Machine encoded history contains duplicate state "${path}"`)) + }) + ) + } + const stateNode = machine.stateNodes.byPath.get(path) + if ( + stateNode === undefined || stateNode.type === "history" || stateNode.type === "choice" || + !Object.prototype.hasOwnProperty.call(encodedRecord.values, path) || + !(isPathInSubtree(path, historyNode.parent) || getPathToRoot(machine, historyNode.parent).includes(path)) + ) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: path, + cause: Cause.die(new Error(`Machine encoded snapshot contains invalid remembered state "${path}"`)) + }) + ) + } + rememberedActive.add(path) + rememberedValues.set( + path, + yield* decodeEncodedBoundary(machine, getStateNodeSchema(stateNode), encodedRecord.values[path], { + boundary: "history", + state: path + }) + ) + } + if (Object.keys(encodedRecord.values).length !== rememberedActive.size) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine encoded history "${historyPath}" contains values outside its paths`)) + }) + ) + } + if (!rememberedActive.has(historyNode.parent)) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine encoded history "${historyPath}" does not contain its parent state`)) + }) + ) + } + const record: HistoryRecord = { + mode: encodedRecord.mode, + parent: historyNode.parent, + active: rememberedActive, + values: rememberedValues + } + try { + validateHistoryRecordControl(machine, record) + } catch (cause) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(cause) + }) + ) + } + history.set(historyPath, record) + } + + const configuration: ActiveConfiguration = { + active, + values, + outputs: new Map(), + history + } + const snapshot = validateEncodedConfiguration(machine, configuration) + const completions: Array = [] + const completionPaths = new Set() + for (const completion of decoded.completed ?? []) { + if (completionPaths.has(completion.path)) { + throw new Error(`Machine encoded snapshot contains duplicate completion "${completion.path}"`) + } + if (!active.has(completion.path) || !isActiveFinalNode(machine, configuration, completion.path)) { + throw new Error(`Machine encoded snapshot contains invalid completion "${completion.path}"`) + } + completionPaths.add(completion.path) + completions.push({ + path: completion.path, + output: yield* decodeEncodedBoundary( + machine, + getCompletionSchema(machine, configuration, completion.path), + completion.output, + { + boundary: "output", + state: completion.path + } + ) + }) + } + if (completions.length > 0) { + ;(snapshot as Machine.AtomicSnapshot & { + completed: ReadonlyArray + }).completed = completions + } + return snapshot + }).pipe(Effect.catchCause((cause) => failDecodeCause(machine, cause))) diff --git a/src/internal/machine/topology.ts b/src/internal/machine/topology.ts new file mode 100644 index 0000000..c5541f1 --- /dev/null +++ b/src/internal/machine/topology.ts @@ -0,0 +1,479 @@ +/** + * Internal machine topology and target helpers. + * + * @since 4.0.0 + */ + +import * as Option from "effect/Option" +import { hasProperty } from "effect/Predicate" +import * as Schema from "effect/Schema" +import type { Machine } from "../../Machine.js" + +export const TargetTypeId = "~effect/Machine/Target" + +export const TargetSnapshotTypeId: unique symbol = Symbol("effect/Machine/TargetSnapshot") + +export const StateInputTypeId: unique symbol = Symbol("effect/Machine/StateInput") + +export const StateConstructionTypeId: unique symbol = Symbol("effect/Machine/StateConstruction") + +export const HistoryTargetTypeId: unique symbol = Symbol("effect/Machine/HistoryTarget") + +export const ChoiceTargetTypeId: unique symbol = Symbol("effect/Machine/ChoiceTarget") + +interface StateInput { + readonly [StateInputTypeId]: typeof StateInputTypeId + readonly input: unknown +} + +/** Internal target produced by the history target builder. History nodes are + * routing instructions and are never part of an active configuration. */ +export interface HistoryTarget { + readonly [HistoryTargetTypeId]: typeof HistoryTargetTypeId + readonly path: string + readonly parent: string +} + +/** Internal target produced by a choice target builder. */ +export interface ChoiceTarget { + readonly [ChoiceTargetTypeId]: typeof ChoiceTargetTypeId + readonly path: string + readonly parent: string + readonly values?: Readonly> +} + +export const makeHistoryTarget = (path: string, parent: string): HistoryTarget => ({ + [HistoryTargetTypeId]: HistoryTargetTypeId, + path, + parent +}) + +export const isHistoryTarget = (u: unknown): u is HistoryTarget => hasProperty(u, HistoryTargetTypeId) + +export const makeChoiceTarget = ( + path: string, + parent: string, + values?: Readonly> +): ChoiceTarget => ({ + [ChoiceTargetTypeId]: ChoiceTargetTypeId, + path, + parent, + ...(values === undefined ? {} : { values }) +}) + +export const isChoiceTarget = (u: unknown): u is ChoiceTarget => hasProperty(u, ChoiceTargetTypeId) + +interface NormalizedStateNodeDefinitionBase { + readonly annotations: Readonly | undefined +} + +type NormalizedStateNodeDefinition = + | (NormalizedStateNodeDefinitionBase & { + readonly type: "atomic" + readonly schema: Machine.TaggedSchema + readonly output: undefined + readonly history: undefined + readonly initial: undefined + readonly states: undefined + }) + | (NormalizedStateNodeDefinitionBase & { + readonly type: "compound" + readonly schema: Machine.TaggedSchema + readonly output: undefined + readonly history: undefined + readonly initial: string + readonly states: Machine.StateTree + }) + | (NormalizedStateNodeDefinitionBase & { + readonly type: "parallel" + readonly schema: Machine.TaggedSchema + readonly output: Schema.Top | undefined + readonly history: undefined + readonly initial: undefined + readonly states: Machine.StateTree + }) + | (NormalizedStateNodeDefinitionBase & { + readonly type: "final" + readonly schema: Machine.TaggedSchema + readonly output: Schema.Top | undefined + readonly history: undefined + readonly initial: undefined + readonly states: undefined + }) + | (NormalizedStateNodeDefinitionBase & { + readonly type: "history" + readonly schema: undefined + readonly output: undefined + readonly history: "shallow" | "deep" + readonly initial: undefined + readonly states: undefined + }) + | (NormalizedStateNodeDefinitionBase & { + readonly type: "choice" + readonly schema: undefined + readonly output: undefined + readonly history: undefined + readonly initial: undefined + readonly states: undefined + }) + +export const getStateNodeDefinition = ( + path: string, + definition: Machine.TaggedSchema | Machine.StateNodeConfig +): NormalizedStateNodeDefinition => { + if (!Schema.isSchema(definition) && (definition as any).type === "history") { + const history = definition as Machine.HistoryStateNodeConfig + return { + schema: undefined, + output: undefined, + annotations: history.annotations, + type: "history", + history: history.history === "deep" ? "deep" : "shallow", + initial: undefined, + states: undefined + } + } + if (!Schema.isSchema(definition) && (definition as any).type === "choice") { + return { + schema: undefined, + output: undefined, + annotations: (definition as Machine.ChoiceStateNodeConfig).annotations, + type: "choice", + history: undefined, + initial: undefined, + states: undefined + } + } + if (Schema.isSchema(definition)) { + return { + schema: definition as Machine.TaggedSchema, + output: undefined, + annotations: Schema.resolveAnnotations(definition), + type: "atomic", + history: undefined, + initial: undefined, + states: undefined + } + } + if (!hasProperty(definition, "schema") || !Schema.isSchema(definition.schema)) { + throw new Error(`Machine.make expected state "${path}" to be a tagged schema or state node config`) + } + if ((definition as any).type === "parallel" && !hasProperty(definition, "states")) { + throw new Error(`Machine.make expected parallel state "${path}" to declare child regions`) + } + if (hasProperty(definition, "states")) { + if ((definition as any).type === "final") { + throw new Error(`Machine.make expected compound state "${path}" to be active`) + } + if ((definition as any).type === "parallel") { + return { + schema: definition.schema as Machine.TaggedSchema, + output: Schema.isSchema((definition as any).output) ? (definition as any).output as Schema.Top : undefined, + annotations: Schema.resolveAnnotations(definition.schema), + type: "parallel", + history: undefined, + initial: undefined, + states: (definition as any).states as Machine.StateTree + } + } + if (typeof (definition as any).initial !== "string") { + throw new Error(`Machine.make expected compound state "${path}" to declare an initial child`) + } + return { + schema: definition.schema as Machine.TaggedSchema, + output: undefined, + annotations: Schema.resolveAnnotations(definition.schema), + type: "compound", + history: undefined, + initial: (definition as any).initial, + states: (definition as any).states as Machine.StateTree + } + } + const output = Schema.isSchema((definition as any).output) ? (definition as any).output as Schema.Top : undefined + return definition.type === "final" + ? { + schema: definition.schema as Machine.TaggedSchema, + output, + annotations: Schema.resolveAnnotations(definition.schema), + type: "final", + history: undefined, + initial: undefined, + states: undefined + } + : { + schema: definition.schema as Machine.TaggedSchema, + output: undefined, + annotations: Schema.resolveAnnotations(definition.schema), + type: "atomic", + history: undefined, + initial: undefined, + states: undefined + } +} + +export const compileStateNodes = (states: Machine.StateSchemas): Machine.StateNodes => { + const byPath = new Map() + let order = 0 + + const compile = (tree: Machine.StateTree, parent: string | undefined): ReadonlyArray => { + const paths: Array = [] + for (const key of Object.keys(tree)) { + if (key.includes(".")) { + throw new Error(`Machine state keys cannot contain ".": "${key}"`) + } + const path = parent === undefined ? key : `${parent}.${key}` + const definition = getStateNodeDefinition(path, tree[key]) + let node: Machine.StateNode + let childStates: Machine.StateTree | undefined + const base = { path, key, annotations: definition.annotations, order } + switch (definition.type) { + case "atomic": + node = { + ...base, + type: "atomic", + schema: definition.schema, + output: undefined, + history: undefined, + parent, + children: [], + initial: undefined + } + break + case "compound": + node = { + ...base, + type: "compound", + schema: definition.schema, + output: undefined, + history: undefined, + parent, + children: [], + initial: `${path}.${definition.initial}` + } + childStates = definition.states + break + case "parallel": + node = { + ...base, + type: "parallel", + schema: definition.schema, + output: definition.output, + history: undefined, + parent, + children: [], + initial: undefined + } + childStates = definition.states + break + case "final": + node = { + ...base, + type: "final", + schema: definition.schema, + output: definition.output, + history: undefined, + parent, + children: [], + initial: undefined + } + break + case "history": + if (parent === undefined) { + throw new Error(`Machine history state "${path}" must belong to a parent state`) + } + node = { + ...base, + type: "history", + schema: undefined, + output: undefined, + history: definition.history, + parent, + children: [], + initial: undefined + } + break + case "choice": + if (parent === undefined) { + throw new Error(`Machine choice state "${path}" must belong to a parent state`) + } + node = { + ...base, + type: "choice", + schema: undefined, + output: undefined, + history: undefined, + parent, + children: [], + initial: undefined + } + break + } + byPath.set(path, node) + order += 1 + if (definition.type === "history" || definition.type === "choice") { + continue + } + paths.push(path) + if (childStates !== undefined) { + const children = compile(childStates, path) + if (node.type === "compound") { + if (!children.includes(node.initial) && byPath.get(node.initial)?.type !== "choice") { + throw new Error(`Machine.make expected compound state "${path}" initial child to exist`) + } + node = { ...node, children } + } else if (node.type === "parallel") { + node = { ...node, children } + } else { + throw new Error(`Machine state "${path}" cannot declare child states`) + } + byPath.set(path, node) + } + } + return paths + } + + return { + byPath, + roots: compile(states, undefined) + } as Machine.StateNodes +} + +const dynamicTransitionTargets = { type: "dynamic" } as const + +const transitionTargets = (handler: unknown): Machine.TransitionTargets => + typeof handler === "object" && handler !== null && "targets" in handler && handler.targets !== undefined + ? { type: "declared", paths: Array.from(handler.targets as ReadonlyArray) } + : dynamicTransitionTargets + +export const transitionDefinitions = ( + machine: Machine.Any +): ReadonlyArray => { + const definitions: Array = [] + for (const node of machine.stateNodes.byPath.values()) { + const config = machine.handlers[node.path] as Machine.AnyStateConfig | undefined + if (config === undefined) { + continue + } + if (node.type === "choice") { + const choice = (config as any).choice + if (choice !== undefined) { + definitions.push({ + source: node.path, + trigger: { type: "choice" }, + reenter: false, + targets: transitionTargets(choice) + }) + } + continue + } + for (const event of Reflect.ownKeys(config.on ?? {})) { + const handler = config.on?.[event] + definitions.push({ + source: node.path, + trigger: { type: "event", event }, + reenter: typeof handler === "object" && handler !== null && handler.reenter === true, + targets: transitionTargets(handler) + }) + } + if (config.always !== undefined) { + definitions.push({ + source: node.path, + trigger: { type: "always" }, + reenter: false, + targets: transitionTargets(config.always) + }) + } + if (config.onDone !== undefined) { + definitions.push({ + source: node.path, + trigger: { type: "done" }, + reenter: false, + targets: transitionTargets(config.onDone) + }) + } + } + return definitions +} + +export const makeTarget = < + const States extends Machine.StateSchemas, + const StateId extends Machine.StateIdentifier +>( + path: StateId, + value: Machine.StateByIdentifier, + options?: { + readonly snapshot?: Machine.SnapshotByIdentifier + readonly values?: Partial< + { + readonly [AncestorStateId in Machine.StateIdentifier]: Machine.StateByIdentifier< + States, + AncestorStateId + > + } + > + } +): Machine.Target => + ({ + [TargetTypeId]: TargetTypeId, + [TargetSnapshotTypeId]: options?.snapshot, + path, + value, + values: options?.values + }) as Machine.Target + +export const isTarget = (u: unknown): u is Machine.Target => hasProperty(u, TargetTypeId) + +export const makeStateInput = (input: unknown): StateInput => ({ + [StateInputTypeId]: StateInputTypeId, + input +}) + +export const isStateInput = (u: unknown): u is StateInput => hasProperty(u, StateInputTypeId) + +export const isSnapshot = (u: unknown): u is Machine.AtomicSnapshot => + hasProperty(u, "path") && hasProperty(u, "value") + +export const getSnapshotByPath = ( + snapshot: Machine.AtomicSnapshot, + path: string, + parents?: Record +): Option.Option> => { + if (snapshot.path === path) { + return Option.some(snapshot) + } + if (!path.startsWith(`${snapshot.path}.`)) { + return Option.none() + } + if (parents !== undefined) { + parents[snapshot.path] = snapshot.value + } + if (hasProperty(snapshot, "state") && isSnapshot(snapshot.state)) { + return getSnapshotByPath(snapshot.state, path, parents) + } + if (hasProperty(snapshot, "states") && typeof snapshot.states === "object" && snapshot.states !== null) { + for (const child of Object.values(snapshot.states)) { + if (isSnapshot(child)) { + const result = getSnapshotByPath(child, path, parents) + if (Option.isSome(result)) { + return result + } + } + } + } + return Option.none() +} + +export const getNode = (machine: Machine.Any, path: string): Machine.StateNode => { + const node = machine.stateNodes.byPath.get(path) + if (node === undefined) { + throw new Error(`Machine expected state path "${path}" to exist`) + } + return node +} + +export const getStateNodeSchema = (node: Machine.StateNode): Machine.TaggedSchema => { + if (node.schema === undefined) { + throw new Error(`Machine pseudo-state "${node.path}" has no active value schema`) + } + return node.schema +} diff --git a/src/unstable/reactivity/AtomMachine.ts b/src/unstable/reactivity/AtomMachine.ts index f4f0346..0217870 100644 --- a/src/unstable/reactivity/AtomMachine.ts +++ b/src/unstable/reactivity/AtomMachine.ts @@ -12,7 +12,6 @@ import type * as Stream from "effect/Stream" import type { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity" import * as internal from "../../internal/machine/atom.js" import type { ChildNotActiveError, NotReadyError } from "../../internal/machine/atom.js" -import type * as Model from "../../internal/machine/model.js" import type { EnsureExecutable } from "../../internal/machine/readiness.js" import type * as Machine from "../../Machine.js" diff --git a/test/internal/machine/protocol.test.ts b/test/internal/machine/protocol.test.ts index 0aa945f..129ebf5 100644 --- a/test/internal/machine/protocol.test.ts +++ b/test/internal/machine/protocol.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Schema } from "effect" import { Machine } from "../../../src/index.js" -import { decodeEvent } from "../../../src/internal/machine/model.js" +import { decodeEvent } from "../../../src/internal/machine/protocol.js" class ProtocolIdle extends Schema.TaggedClass("ProtocolIdle")("ProtocolIdle", {}) {} diff --git a/test/internal/machine/strategyDifferential.test.ts b/test/internal/machine/strategyDifferential.test.ts index 63225b5..5076e0c 100644 --- a/test/internal/machine/strategyDifferential.test.ts +++ b/test/internal/machine/strategyDifferential.test.ts @@ -2,7 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Fiber, Schema, Stream } from "effect" import { FastCheck } from "effect/testing" import { Machine } from "../../../src/index.js" -import * as Planner from "../../../src/internal/machine/planner.js" +import * as ExecutionPlan from "../../../src/internal/machine/executionPlan.js" import { MachineTest } from "../../../src/testing/index.js" import type { DifferentialStep } from "../../machine/support/runtimeDifferential.js" import { verifyManagedExecution } from "../../machine/support/runtimeDifferential.js" @@ -113,7 +113,7 @@ describe("machine planner and runtime strategies", () => { Ready: {} }) - assert.strictEqual(Planner.selectExecutionPlanForTesting(machine, "auto").strategy, "generic") + assert.strictEqual(ExecutionPlan.selectExecutionPlanForTesting(machine, "auto").strategy, "generic") yield* verifyPlannerStrategies({ machine, events: [], @@ -148,7 +148,7 @@ describe("machine planner and runtime strategies", () => { label: "initially final startup" }) - const compiledInitial = Planner.selectExecutionPlanForTesting(machine, "indexed-flat").plan.initial! + const compiledInitial = ExecutionPlan.selectExecutionPlanForTesting(machine, "indexed-flat").plan.initial! assert.throws( () => compiledInitial([{ value: "invalid" }]), Machine.MachineSchemaDecodeError @@ -271,7 +271,7 @@ describe("machine planner and runtime strategies", () => { for (let index = 0; index < samples.length && compared < 24; index++) { const model = samples[index]! const machine = MachineTest.compileModel(model) - const selected = Planner.selectExecutionPlanForTesting(machine, "auto").strategy + const selected = ExecutionPlan.selectExecutionPlanForTesting(machine, "auto").strategy if (selected === "generic") continue const events = Array.from({ length: 6 }, (_, eventIndex) => ({ _tag: model.events[(index + eventIndex) % model.events.length]! diff --git a/test/internal/machine/support/strategyDifferential.ts b/test/internal/machine/support/strategyDifferential.ts index 3166af2..6087bf5 100644 --- a/test/internal/machine/support/strategyDifferential.ts +++ b/test/internal/machine/support/strategyDifferential.ts @@ -1,8 +1,8 @@ import { assert } from "@effect/vitest" import { Effect } from "effect" import { Machine } from "../../../../src/index.js" -import * as Model from "../../../../src/internal/machine/model.js" -import * as Planner from "../../../../src/internal/machine/planner.js" +import * as Configuration from "../../../../src/internal/machine/configuration.js" +import * as ExecutionPlan from "../../../../src/internal/machine/executionPlan.js" import * as Process from "../../../../src/internal/machine/process.js" const eventTag = (event: unknown): PropertyKey | undefined => @@ -20,8 +20,8 @@ const encodeState = ( const canonicalMacrostep = Effect.fn(function*( machine: Machine.Machine.Any, - executionPlan: Planner.CompiledExecutionPlan, - planned: ReturnType + executionPlan: ExecutionPlan.CompiledExecutionPlan, + planned: ReturnType ) { return { next: yield* encodeState(machine, executionPlan.snapshot(planned.next)), @@ -59,8 +59,8 @@ const verifyPlannerStrategiesEffect = Effect.fn(function*(options: { unknown, never > - const generic = Planner.selectExecutionPlanForTesting(options.machine, "generic") - const selected = Planner.selectExecutionPlanForTesting(options.machine, "auto") + const generic = ExecutionPlan.selectExecutionPlanForTesting(options.machine, "generic") + const selected = ExecutionPlan.selectExecutionPlanForTesting(options.machine, "auto") if (options.expected !== undefined) { assert.strictEqual(selected.strategy, options.expected, `${options.label} selected strategy`) } @@ -77,7 +77,7 @@ const verifyPlannerStrategiesEffect = Effect.fn(function*(options: { assert.deepStrictEqual(compiledInitial.output, initial.output) } - const active = Model.normalizeConfigurationSync(options.machine, initial.state) + const active = Configuration.normalizeConfigurationSync(options.machine, initial.state) let genericState = generic.plan.fromConfiguration(active) let selectedState = selected.plan.fromConfiguration(active) for (let index = 0; index < options.events.length; index++) {