diff --git a/.changeset/initial-entry-builders.md b/.changeset/initial-entry-builders.md new file mode 100644 index 0000000..34fa8a1 --- /dev/null +++ b/.changeset/initial-entry-builders.md @@ -0,0 +1,9 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add typed declared-initial entry to compound and parallel transition targets. + +Use `target.full.opened.initial()`, `initial(value)`, or `initial.from(input)` to enter the initial configuration declared by `Machine.defineStates`; the same operation is available through compatible `local` and `branch` target scopes. Schema-valued implicit children are constructed by `initialize: ({ builder }) => ...`, including fluent completion of every valued parallel region. Missing initializers are reported at `handle(...)`, and `.from` validation remains a typed `MachineSchemaDecodeError` during planning. + +State handler `initial` and its `StateInitial*` utility types have been replaced by `initialize` and `StateInitialize*`. Migrate compound initializers from `initial: () => new Child(...)` to `initialize: ({ builder }) => builder(new Child(...))`, and parallel initializers from returned value records to chained region builders. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index b3f8ebc..a06298f 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -254,6 +254,47 @@ const machine = Machine.make({ Do not repeat `type: "final"` in `handle`. Execution APIs reject a machine until every declared output schema has an implementation. +Enter a compound or parallel state through its declared initial configuration +with `.initial`. This is available on top-level state methods under +`target.full` and compatible nested state methods under `target.local` and +`target.branch`; atomic and final state methods do not expose it: + +```ts +Open: ({ target }) => target.full.opened.initial.from({ teamId: "team-1" }) +``` + +The selected state's own value is passed directly to `initial(value)` or +constructed inside planning with `initial.from(input)`. A structural selected +state uses `initial()`. + +When a declared initial child owns a schema, its parent implements +`initialize`. The context's `builder` is already bound to that child, so it +cannot accidentally select a state that differs from the definition: + +```ts +opened: { + initialize: ({ state, builder }) => + builder.from({ requestId: state.requestId }) +} +``` + +A parallel initializer supplies every schema-valued direct region with a +fluent completion builder. Structural regions are omitted: + +```ts +dashboard: { + initialize: ({ builder }) => + builder.filters.from({ query: "" }).results.from({ page: 1 }) +} +``` + +Default entry then continues recursively. Nested compound and parallel owners +provide their own `initialize` implementations. Missing implementations and +incomplete parallel builders are reported at `handle(...)`. Builder `.from` +inputs are decoded by the machine, so schema failures remain typed machine +failures. An explicit snapshot target that manually selects all children does +not use `initialize`. + Declare a history pseudo-state below the active parent whose configuration it should remember. It has no schema, is excluded from active state identifiers, and is addressed only through `target.history`: @@ -302,11 +343,12 @@ Resume: ({ target }) => target.history.checkout.exact() Deep history restores the complete remembered subtree and its decoded values. Shallow history restores only parent and direct-child values. If the remembered child is compound, its configured initial child needs a freshly constructed -value, so implement `initial` only on paths required by shallow history: +value, so implement `initialize` only on paths required by shallow history: ```ts payment: { - initial: ({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" }) + initialize: ({ state, builder }) => + builder.from({ cardNumber: `attempt-${state.attempt}` }) } ``` diff --git a/src/Machine.ts b/src/Machine.ts index 4a1f5b8..a48342f 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -841,6 +841,42 @@ type NodeMethod< > = Machine.NodeSchema extends never ? FromMethod : ((...args: Arguments) => Result) & FromMethod +type NodeMethodWithInitial< + Node, + Arguments extends ReadonlyArray, + Result, + FromArguments extends ReadonlyArray, + Path extends string +> = Machine.NodeSchema extends never ? { + readonly from: FromCallable> + readonly initial: InitialTargetFactory + } + : + & ((...args: Arguments) => Result) + & { + readonly from: FromCallable> + readonly initial: InitialTargetFactory + } + +interface InitialTargetMethod< + Node, + Path extends string +> { + /** Enters this state through its declared initial configuration. */ + readonly initial: InitialTargetFactory +} + +interface InitialTargetFactory< + Node, + Path extends string +> { + (...args: WithNodeValue): Machine.InitialTarget + readonly from: FromCallable< + WithNodeInput, + Machine.StateConstruction> + > +} + type NodeConstructionSelectorFromCallable = Machine.NodeSchema extends never ? { >( state: (builder: Builder) => Selected @@ -848,15 +884,19 @@ type NodeConstructionSelectorFromCallable = Machine.NodeS } : ConstructionSelectorFromCallable, Builder, Result> -type NestedTargetMethod = Machine.NodeSchema extends never ? { +type NestedTargetMethod = Machine.NodeSchema extends never ? { readonly from: NodeConstructionSelectorFromCallable + readonly initial: InitialTargetFactory } : & (>( value: NodeValue, state: (builder: Builder) => Selected ) => Selected) - & { readonly from: NodeConstructionSelectorFromCallable } + & { + readonly from: NodeConstructionSelectorFromCallable + readonly initial: InitialTargetFactory + } type ConstructionSelectorFromCallable = {} extends Input ? { >( @@ -1094,6 +1134,14 @@ type FullSnapshotResult< Path extends string = Machine.JoinPath > = Machine.SnapshotByIdentifierWithPath +type InitialEntryStateKey = { + readonly [Key in ActiveStateKey]: States[Key] extends { readonly states: Machine.StateSchemas } ? Key : never +}[ActiveStateKey] + +type FullInitialTargetBuilder = { + readonly [Key in InitialEntryStateKey]: InitialTargetMethod +} + type FullParallelBuilder< States extends Machine.StateSchemas, Prefix extends string, @@ -1384,9 +1432,10 @@ type LocalTargetMethod< Source extends Path | `${Path}.${string}` ? NestedTargetMethod< Node, LocalTargetBuilderWithPrefix, - LocalTargetResultWithPrefix + LocalTargetResultWithPrefix, + Path > - : NodeMethod< + : NodeMethodWithInitial< Node, WithNodeValue ) => SnapshotBuilderComplete, boolean> - ]> + ]>, + Path > : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? NestedTargetMethod< Node, LocalTargetBuilderWithPrefix, - LocalTargetResultWithPrefix + LocalTargetResultWithPrefix, + Path > : NodeMethod< Node, @@ -1498,10 +1549,11 @@ type BranchTargetMethod< & NestedTargetMethod< Node, BranchTargetBuilderWithPrefix, - BranchTargetResultWithPrefix + BranchTargetResultWithPrefix, + Path > & BranchTargetBuilderWithPrefix - : NodeMethod< + : NodeMethodWithInitial< Node, WithNodeValue ) => SnapshotBuilderComplete, boolean> - ]> + ]>, + Path > : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? & NestedTargetMethod< Node, BranchTargetBuilderWithPrefix, - BranchTargetResultWithPrefix + BranchTargetResultWithPrefix, + Path > & BranchTargetBuilderWithPrefix : NodeMethod< @@ -4033,6 +4087,23 @@ export declare namespace Machine { readonly parent: Extract, StateIdentifier> } + /** + * Transition instruction that enters a compound or parallel state through + * its declared initial configuration. + * + * @category models + * @since 0.13.0 + */ + export interface InitialTarget { + readonly [Topology.TargetTypeId]: typeof Topology.TargetTypeId + readonly [Topology.TargetSnapshotTypeId]?: never + readonly [Topology.InitialTargetTypeId]: typeof Topology.InitialTargetTypeId + readonly _tag: "InitialTarget" + readonly path: StateId + readonly value: never + readonly values?: never + } + /** Branded transient target instruction used while constructing initial states. */ export interface ChoiceTargetInstruction { readonly [Topology.ChoiceTargetTypeId]: typeof Topology.ChoiceTargetTypeId @@ -4063,7 +4134,9 @@ export declare namespace Machine { * @category utility types * @since 0.4.0 */ - export type FullTargetBuilder = FullSnapshotBuilderWithPrefix + export type FullTargetBuilder = [InitialEntryStateKey] extends [never] ? + FullSnapshotBuilderWithPrefix + : FullSnapshotBuilderWithPrefix & FullInitialTargetBuilder /** * Builder for a complete fallback configuration containing a history owner. @@ -4566,8 +4639,8 @@ export declare namespace Machine { ? NonNullable extends (...args: any) => infer Ret ? Ret : never : never /** Extracts the return value from an implicit initial child implementation. */ - export type StateInitialReturn = Config extends { readonly initial?: infer Initial } - ? NonNullable extends (...args: any) => infer Ret ? Ret : never + export type StateInitializeReturn = Config extends { readonly initialize?: infer Initialize } + ? NonNullable extends (...args: any) => infer Ret ? Ret : never : never /** Extracts the return values from a state's history defaults. */ export type HistoryDefaultReturn = Config extends { readonly history?: infer History } ? { @@ -4792,7 +4865,6 @@ export declare namespace Machine { | Effect.Services> | Effect.Services> | Effect.Services> - | Effect.Services> | Effect.Services> | Effect.Services> | InvokeRequirements @@ -4803,12 +4875,12 @@ export declare namespace Machine { Emits extends ReadonlyArray, Context > = - | ((context: Context, enqueue: Enqueue, EmitOf>) => HandlerResult) + | ((context: NoInfer, enqueue: Enqueue, EmitOf>) => HandlerResult) | { readonly reenter?: boolean readonly targets?: ReadonlyArray> readonly transition: ( - context: Context, + context: NoInfer, enqueue: Enqueue, EmitOf> ) => HandlerResult } @@ -5392,11 +5464,16 @@ export declare namespace Machine { ) => HandlerResult } } - readonly initial?: StateInitialHandler + readonly initialize?: StateInitializeHandler } & ActiveOutputHandlerConfig - /** Values supplied when a statechart implicitly enters a state's initial children. */ - export type StateInitialValue< + /** + * Values constructed for a state's schema-valued direct initial children. + * + * @category utility types + * @since 0.13.0 + */ + export type StateInitializeValue< States extends StateSchemas, StateId extends StateIdentifier > = NodeByIdentifier extends infer Node ? @@ -5407,23 +5484,92 @@ export declare namespace Machine { } : Node extends { readonly states: infer Children extends StateSchemas; readonly initial: infer Initial } ? Initial extends ActiveStateKey ? NodeSchema extends never ? never - : NodeValue + : { readonly [Key in Initial]: NodeValue } : never : never : never - /** Context passed to an implicit child-state initializer. */ - export type StateInitialContext< + type StateInitializeCompoundBuilder = Node extends { + readonly states: infer Children extends StateSchemas + } ? Initial extends ActiveStateKey ? NodeSchema extends never ? never + : NodeBuilderMethod< + Children[Initial], + readonly [value: NodeValue], + SnapshotBuilderComplete<{ readonly [Key in Initial]: NodeValue }>, + readonly [input: NodeMakeInput], + SnapshotBuilderComplete<{ readonly [Key in Initial]: NodeValue }, true> + > + : never + : never + + type StateInitializeParallelBuilder< + Children extends StateSchemas, + Remaining extends ActiveStateKey = { + readonly [Key in ActiveStateKey]: NodeSchema extends never ? never : Key + }[ActiveStateKey], + Values = {}, + Constructed extends boolean = false + > = + & SnapshotBuilderComplete + & { + readonly [Key in Remaining]: NodeBuilderMethod< + Children[Key], + readonly [value: NodeValue], + StateInitializeParallelBuilder< + Children, + Exclude, + Values & { readonly [Region in Key]: NodeValue }, + Constructed + >, + readonly [input: NodeMakeInput], + StateInitializeParallelBuilder< + Children, + Exclude, + Values & { readonly [Region in Key]: NodeValue }, + true + > + > + } + + /** + * Builder bound to the direct initial child or regions owned by a state. + * + * @category utility types + * @since 0.13.0 + */ + export type StateInitializeBuilder< + States extends StateSchemas, + StateId extends StateIdentifier, + Node = NodeByIdentifier + > = Node extends { readonly type: "parallel"; readonly states: infer Children extends StateSchemas } ? + StateInitializeParallelBuilder + : Node extends { readonly initial: infer Initial } ? StateInitializeCompoundBuilder + : never + + /** + * Context passed to an implicit child-state initializer. + * + * @category models + * @since 0.13.0 + */ + export type StateInitializeContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, StateId extends StateIdentifier, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > = StateActionContext + > = StateActionContext & { + readonly builder: StateInitializeBuilder + } - /** Initial child value implementation for a compound or parallel state. */ - export type StateInitialHandler< + /** + * Initial child value implementation for a compound or parallel state. + * + * @category models + * @since 0.13.0 + */ + export type StateInitializeHandler< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, @@ -5431,9 +5577,9 @@ export declare namespace Machine { InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] > = ( - context: StateInitialContext, + context: StateInitializeContext, enqueue: Enqueue, EmitOf> - ) => StateInitialValue + ) => SnapshotBuilderComplete, boolean> /** Context used only when a history node has no previously captured record. */ export interface HistoryDefaultContext< @@ -5503,7 +5649,7 @@ export declare namespace Machine { readonly on?: never readonly onDone?: never readonly output?: never - readonly initial?: never + readonly initialize?: never } /** @@ -5726,7 +5872,7 @@ export declare namespace Machine { | "entry" | "exit" | "history" - | "initial" + | "initialize" | "invoke" | "on" | "onDone" @@ -5839,6 +5985,75 @@ export declare namespace Machine { : Result extends { readonly path: infer Path extends string } ? Path : never + type TransitionResultInitialTargetPath = IsAny extends true ? never + : Result extends Effect.Effect ? TransitionResultInitialTargetPath + : Result extends StateConstruction ? TransitionResultInitialTargetPath + : Result extends { + readonly [Topology.InitialTargetTypeId]: typeof Topology.InitialTargetTypeId + readonly _tag: "InitialTarget" + } ? Result extends { readonly path: infer Path extends string } ? Path : never + : never + + type HandlerConfigInitialTargetPath = TransitionResultInitialTargetPath< + | EventHandlerReturn + | AlwaysReturn + | DoneReturn + | ChoiceReturn + | InvokeOutcomeReturn> + > + + type RequiredInitializersForTargetPath< + AllStates extends StateSchemas, + Path + > = string extends Path ? never : Path extends StateIdentifier ? InitializerClosureForNode< + AllStates, + NodeByIdentifier, + Path + > + : never + + type HandlerInitializeValidationAtPath = HandlerConfigAtPath extends + infer StateConfig ? [StateConfig] extends [never] ? HandlerValidationAtPath + }> + : "initialize" extends keyof StateConfig ? never + : HandlerValidationAtPath + }> + : never + + type HandlerInitialTargetValidationForPaths< + AllStates extends StateSchemas, + Config, + Required + > = Types.UnionToIntersection< + Required extends string ? HandlerInitializeValidationAtPath : unknown + > + + type HandlerTreeInitialTargetPath = string extends keyof Config ? never + : Config extends object ? { + readonly [Key in keyof Config]: + | HandlerConfigInitialTargetPath> + | (Config[Key] extends { readonly states: infer Children } ? HandlerTreeInitialTargetPath : never) + }[keyof Config] + : never + + type HandlerInitialTargetValidation< + AllStates extends StateSchemas, + Config + > = HandlerTreeInitialTargetPath extends infer TargetPath ? HandlerInitialTargetValidationForPaths< + AllStates, + Config, + RequiredInitializersForTargetPath + > + : unknown + type DeclaredTransitionTarget = Transition extends { readonly targets: infer Targets extends ReadonlyArray } ? Targets[number] @@ -5946,6 +6161,19 @@ export declare namespace Machine { : unknown : unknown) + type HandlerRequiredHistoryInitializeValidation< + AllStates extends StateSchemas, + StateId extends StateIdentifier, + Config + > = [HistoryIdentifier] extends [never] ? unknown + : StateId extends RequiredHistoryInitializers ? "initialize" extends keyof Config ? unknown : { + readonly initialize: HandlerValidationError< + "State requires initialize for shallow history restoration", + StateId + > + } + : unknown + type HandlerNodeValidation< AllStates extends StateSchemas, Node, @@ -5968,6 +6196,7 @@ export declare namespace Machine { & HandlerInvokeParentEventsValidation & HandlerChildrenValidation & HandlerOutputRequirementValidation + & HandlerRequiredHistoryInitializeValidation & HandlerRuntimeValidation : unknown @@ -6081,7 +6310,7 @@ export declare namespace Machine { AllStates extends StateSchemas, StateId extends StateIdentifier, Config - > = StateId extends RequiredHistoryInitializers ? "initial" extends keyof Config ? true : false : true + > = StateId extends RequiredHistoryInitializers ? "initialize" extends keyof Config ? true : false : true type HandlerHasRequiredHistoryDefaults = Node extends { readonly states: infer Children extends StateSchemas @@ -6121,7 +6350,6 @@ export declare namespace Machine { | Effect.Error> | Effect.Error> | Effect.Error> - | Effect.Error> | Effect.Error> | Effect.Error> | InvokeError @@ -6244,6 +6472,11 @@ export declare namespace Machine { StateIdentifier > > + & ([StateIdentifier] extends [UnhandledStates] ? HandlerInitialTargetValidation< + States, + NoInfer + > + : unknown) ): HandleTreeResult< States, Events, diff --git a/src/internal/machine/configuration.ts b/src/internal/machine/configuration.ts index 987488b..10b7f63 100644 --- a/src/internal/machine/configuration.ts +++ b/src/internal/machine/configuration.ts @@ -17,7 +17,14 @@ import { decodeStateValue, decodeStateValueSync } from "./protocol.js" -import { getNode, getStateNodeSchema, isSnapshot, isTarget, TargetSnapshotTypeId } from "./topology.js" +import { + getNode, + getStateNodeSchema, + type InitialTarget as InitialTargetInstruction, + isSnapshot, + isTarget, + TargetSnapshotTypeId +} from "./topology.js" export interface HistoryRecord { readonly mode: "shallow" | "deep" @@ -952,7 +959,8 @@ const configurationFromTargetPathSync = ( current: ActiveConfiguration, path: string, value: unknown, - providedValues: Readonly> | undefined + providedValues: Readonly> | undefined, + allowIncomplete = false ): ActiveConfiguration => { const node = getNode(machine, path) const active = new Set() @@ -998,12 +1006,29 @@ const configurationFromTargetPathSync = ( } } - if (node.type === "compound" || node.type === "parallel") { + if (!allowIncomplete && (node.type === "compound" || node.type === "parallel")) { throw new Error(`Machine target "${node.path}" must include an active child state`) } return { active, values, outputs, history: current.history } } +/** Builds the selected path and its ancestors without requiring nested active + * children. The planner completes those children through declared initial + * topology and `initialize` handlers before normalization continues. */ +export const configurationFromInitialTargetSync = ( + machine: Machine.Any, + current: ActiveConfiguration, + target: InitialTargetInstruction +): ActiveConfiguration => + configurationFromTargetPathSync( + machine, + current, + target.path, + target.value, + target.values as Readonly> | undefined, + true + ) + const configurationFromTargetSnapshotSync = ( machine: Machine.Any, current: ActiveConfiguration, diff --git a/src/internal/machine/executionPlan.ts b/src/internal/machine/executionPlan.ts index 9c30115..692eefa 100644 --- a/src/internal/machine/executionPlan.ts +++ b/src/internal/machine/executionPlan.ts @@ -38,6 +38,7 @@ import { normalizeTransition, planConfiguration, removeConflictingTransitions, + resolveInitialTarget, type SelectedTransition, sortEntryPaths, sortEvaluatedTransitions, @@ -46,7 +47,7 @@ import { validateDeclaredTransitionTarget } from "./planner.js" import { decodeEmitSync, decodeEventSync, decodeInputSync, decodeStateValueSync } from "./protocol.js" -import { isNoTarget, isSnapshot, isTarget, TargetSnapshotTypeId } from "./topology.js" +import { isInitialTarget, isNoTarget, isSnapshot, isTarget, TargetSnapshotTypeId } from "./topology.js" interface IndexedExecutionDescriptor { readonly flat: boolean @@ -91,7 +92,7 @@ const resolveMachineReferences = ( : { self: machineReferences.self, parent: machineReferences.parent } const indexedStateConfigKeys: ReadonlySet = new Set([ - "initial", + "initialize", "invoke", "on", "output" @@ -531,13 +532,22 @@ const collectIndexedEvaluatedTransition = ( selection: IndexedSelectedTransition ): IndexedEvaluatedTransition => { const transitionResult = collectIndexedTransition(machine, selection.transition.transition, selection.context) - const target = transitionResult.state + const unresolvedTarget = transitionResult.state validateDeclaredTransitionTarget( selection.sourcePath, selection.trigger, selection.transition.targets, - target + unresolvedTarget ) + const initialResolution = unresolvedTarget !== undefined && isInitialTarget(unresolvedTarget) + ? resolveInitialTarget( + machine, + activeConfigurationFromIndexedState(descriptor, state), + unresolvedTarget, + (selection.context as any).event + ) + : undefined + const target = initialResolution?.target ?? unresolvedTarget if (target !== undefined && !isTarget(target) && !isSnapshot(target)) { throw new Error("Machine expected indexed transition target to be a snapshot or target builder result") } @@ -548,16 +558,16 @@ const collectIndexedEvaluatedTransition = ( if (!changed) { return { selection, - unresolvedTarget: target as any, + unresolvedTarget: unresolvedTarget as any, target: target as any, next, - commands: transitionResult.commands, - raisedEvents: transitionResult.raisedEvents, - emittedEvents: transitionResult.emittedEvents, + commands: [...transitionResult.commands, ...(initialResolution?.commands ?? [])], + raisedEvents: [...transitionResult.raisedEvents, ...(initialResolution?.raisedEvents ?? [])], + emittedEvents: [...transitionResult.emittedEvents, ...(initialResolution?.emittedEvents ?? [])], changed: false, exitPaths: [], entryPaths: [], - choiceTransitions: [] + choiceTransitions: initialResolution?.transitions ?? [] } } @@ -571,16 +581,16 @@ const collectIndexedEvaluatedTransition = ( : naturalBoundary return { selection, - unresolvedTarget: target as any, + unresolvedTarget: unresolvedTarget as any, target: target as any, next, - commands: transitionResult.commands, - raisedEvents: transitionResult.raisedEvents, - emittedEvents: transitionResult.emittedEvents, + commands: [...transitionResult.commands, ...(initialResolution?.commands ?? [])], + raisedEvents: [...transitionResult.raisedEvents, ...(initialResolution?.raisedEvents ?? [])], + emittedEvents: [...transitionResult.emittedEvents, ...(initialResolution?.emittedEvents ?? [])], changed: true, exitPaths: getExitPaths(machine, activeConfigurationFromIndexedState(descriptor, state), boundary), entryPaths: getEntryPaths(machine, activeConfigurationFromIndexedState(descriptor, next), boundary), - choiceTransitions: [] + choiceTransitions: initialResolution?.transitions ?? [] } } diff --git a/src/internal/machine/initialization.ts b/src/internal/machine/initialization.ts new file mode 100644 index 0000000..02ab2a7 --- /dev/null +++ b/src/internal/machine/initialization.ts @@ -0,0 +1,73 @@ +/** Internal builders for schema-valued default state entry. */ + +import { hasProperty } from "effect/Predicate" +import type { Machine } from "../../Machine.js" +import { SnapshotBuilderStateTypeId } from "./symbols.js" +import * as Topology from "./topology.js" + +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 +} + +const withFrom = unknown>(method: Method) => { + Object.defineProperty(method, "from", { + value: (...args: ReadonlyArray) => method(Topology.makeStateInput(args.length === 0 ? {} : args[0])), + enumerable: false + }) + return method as Method & { readonly from: (...args: ReadonlyArray) => unknown } +} + +const makeCompletion = (values: Readonly>): object => { + const completion = {} + Object.defineProperty(completion, SnapshotBuilderStateTypeId, { + value: values, + enumerable: false + }) + return completion +} + +const makeParallelBuilder = ( + machine: Machine.Any, + node: Machine.StateNode, + values: Readonly> +): object => { + const builder = makeCompletion(values) as Record + for (const childPath of node.children) { + const child = getNode(machine, childPath) + if (child.schema === undefined || hasProperty(values, child.key)) continue + builder[child.key] = withFrom((value: unknown) => { + const nextValues: Record = Object.assign({}, values) + nextValues[child.key] = value + return makeParallelBuilder(machine, node, nextValues) + }) + } + return builder +} + +export const makeStateInitializeBuilder = (machine: Machine.Any, path: string): object => { + const node = getNode(machine, path) + if (node.type === "parallel") { + return makeParallelBuilder(machine, node, {}) + } + if (node.type !== "compound") { + throw new Error(`Machine state "${path}" cannot initialize child states`) + } + const child = getNode(machine, node.initial) + if (child.schema === undefined) { + throw new Error(`Machine state "${path}" has no schema-valued initial child`) + } + return withFrom((value: unknown) => makeCompletion({ [child.key]: value })) +} + +export const getStateInitializeValues = (path: string, result: unknown): Readonly> => { + if (typeof result !== "object" || result === null || !hasProperty(result, SnapshotBuilderStateTypeId)) { + throw new Error(`Machine initialize handler for "${path}" must return its builder result`) + } + return (result as { readonly [SnapshotBuilderStateTypeId]: Readonly> })[ + SnapshotBuilderStateTypeId + ] +} diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index 87386ee..4d3c8b8 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -33,7 +33,7 @@ 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 { ChildMachineLogicTypeId } from "./symbols.js" +import { ChildMachineLogicTypeId, SnapshotBuilderStateTypeId } from "./symbols.js" import * as Topology from "./topology.js" export { @@ -45,10 +45,9 @@ export { StartupError, StoppedError } from "./errors.js" -export { ChildMachineLogicTypeId, InitialEventTypeId } from "./symbols.js" +export { ChildMachineLogicTypeId, InitialEventTypeId, SnapshotBuilderStateTypeId } from "./symbols.js" const TypeId = "~effect/Machine" -export const SnapshotBuilderStateTypeId: unique symbol = Symbol("effect/Machine/SnapshotBuilderState") export const InvokeTypeId: unique symbol = Symbol.for("effect/Machine/Invoke") const ChildMachineTypeId = "~effect/Machine/ChildMachine" type IsAny = 0 extends 1 & A ? true : false @@ -300,6 +299,24 @@ const withFrom = ) = return method as Method & { readonly from: (...args: ReadonlyArray) => unknown } } +const withInitial = ( + builder: Builder, + path: string, + valued: boolean, + values?: Readonly> +): Builder => { + const initial = withFrom( + (value: unknown) => Topology.makeInitialTarget(path, value, values), + "leaf", + valued + ) + Object.defineProperty(builder, "initial", { + value: initial, + enumerable: false + }) + return builder +} + const makeSnapshotBuilder = ( states: Machine.StateTree, options: SnapshotBuilderOptions @@ -317,12 +334,15 @@ const makeSnapshotBuilder = ( continue } const node = Topology.getStateNodeDefinition(path, definition) - builder[key] = withFrom( + const method = withFrom( (value: unknown, selector?: (builder: unknown) => unknown) => makeSnapshotForNode(definition, key, value, selector, options), node.states === undefined ? "leaf" : "nested", node.schema !== undefined ) + builder[key] = node.states === undefined || options.mode !== "full" || options.prefix !== "" + ? method + : withInitial(method, path, node.schema !== undefined) } return builder } @@ -348,7 +368,7 @@ const makeParallelSnapshotBuilder = ( } const path = options.prefix === "" ? key : `${options.prefix}.${key}` const node = Topology.getStateNodeDefinition(path, definition) - builder[key] = withFrom( + const method = withFrom( (value: unknown, selector?: (builder: unknown) => unknown) => { const nextRegions: Record = {} for (const regionKey of Object.keys(regions)) { @@ -360,6 +380,7 @@ const makeParallelSnapshotBuilder = ( node.states === undefined ? "leaf" : "nested", node.schema !== undefined ) + builder[key] = method } return builder } @@ -538,7 +559,7 @@ const makeLocalTargetChildBuilder = ( builder[child.key] = () => Topology.makeChoiceTarget(child.path, parent.path, values) continue } - builder[child.key] = withFrom( + const method = withFrom( (value: unknown, selector?: (builder: unknown) => unknown) => { if (child.type === "atomic" || child.type === "final") { return makeTargetWithValues(child.path, value, values) @@ -572,6 +593,9 @@ const makeLocalTargetChildBuilder = ( child.type === "atomic" || child.type === "final" ? "leaf" : "nested", child.schema !== undefined ) + builder[child.key] = child.type === "atomic" || child.type === "final" + ? method + : withInitial(method, child.path, child.schema !== undefined, values) } return builder } @@ -677,6 +701,7 @@ const makeBranchTargetNodeBuilder = ( "nested", node.schema !== undefined ) as unknown as Record + withInitial(builder, node.path, node.schema !== undefined, values) if (node.type !== "parallel" || source === node.path || source.startsWith(`${node.path}.`)) { addBranchTargetChildren(builder, states, stateNodes, node.path, values, source) } diff --git a/src/internal/machine/planner.ts b/src/internal/machine/planner.ts index eb6c184..831bd89 100644 --- a/src/internal/machine/planner.ts +++ b/src/internal/machine/planner.ts @@ -22,6 +22,7 @@ import { completeConfigurationEffect, completeConfigurationSync, configurationFromHistoryRecord, + configurationFromInitialTargetSync, getActiveLeafPathFrom, getActiveLeafPaths, getHistoryRecord, @@ -44,13 +45,16 @@ import { validateInitialConfiguration } from "./configuration.js" import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError, StoppedError } from "./errors.js" +import { getStateInitializeValues, makeStateInitializeBuilder } from "./initialization.js" import * as InvocationEvent from "./invocationEvent.js" import { decodeEventSync, decodeInputSync, decodeStateValueSync } from "./protocol.js" import { InitialEventTypeId } from "./symbols.js" import { getNode, + type InitialTarget as InitialTargetInstruction, isChoiceTarget, isHistoryTarget, + isInitialTarget, isNoTarget, isSnapshot, isTarget, @@ -230,6 +234,7 @@ const completeHistoryConfiguration = ( const commands: Array = [] const raisedEvents: Array = [] const emittedEvents: Array = [] + const transitions: Array = [] let changed = true while (changed) { @@ -248,9 +253,47 @@ const completeHistoryConfiguration = ( history: configuration.history, machineReferences: configuration.machineReferences } as ActiveConfiguration + if (child.type === "choice") { + const choice = resolveChoiceTarget( + machine, + current, + makeChoiceTarget(child.path, path, Object.fromEntries(values)), + event + ) + let next = current + for (const routed of [choice.target, ...choice.additionalTargets]) { + if (routed === undefined || isHistoryTarget(routed)) { + throw new Error(`Machine initial choice "${child.path}" must resolve to an active state target`) + } + const resolved = isInitialTarget(routed) + ? resolveInitialTarget(machine, next, routed, event) + : undefined + next = normalizeTargetConfigurationSync( + machine, + next, + (resolved?.target ?? routed) as Machine.Snapshot | Machine.Target + ) + if (resolved !== undefined) { + commands.push(...resolved.commands) + raisedEvents.push(...resolved.raisedEvents) + emittedEvents.push(...resolved.emittedEvents) + transitions.push(...resolved.transitions) + } + } + active.clear() + values.clear() + for (const activePath of next.active) active.add(activePath) + for (const [valuePath, stateValue] of next.values) values.set(valuePath, stateValue) + commands.push(...choice.commands) + raisedEvents.push(...choice.raisedEvents) + emittedEvents.push(...choice.emittedEvents) + transitions.push(...choice.transitions) + changed = true + continue + } active.add(child.path) if (child.schema !== undefined) { - const initializer = machine.handlers[path]?.initial + const initializer = machine.handlers[path]?.initialize if (initializer === undefined) { throw new Error(`Machine shallow history requires an initial value implementation for state "${path}"`) } @@ -259,9 +302,11 @@ const completeHistoryConfiguration = ( state: current.values.get(path), containingState: getParentValue(machine, current, path), ancestors: getParentValues(machine, current, path), - event + event, + builder: makeStateInitializeBuilder(machine, path) }) - values.set(child.path, decodeStateValueSync(machine, child, initialized.value)) + const initializedValues = getStateInitializeValues(path, initialized.value) + values.set(child.path, decodeStateValueSync(machine, child, initializedValues[child.key])) commands.push(...initialized.commands) raisedEvents.push(...initialized.raisedEvents) emittedEvents.push(...initialized.emittedEvents) @@ -279,7 +324,7 @@ const completeHistoryConfiguration = ( history: configuration.history, machineReferences: configuration.machineReferences } as ActiveConfiguration - const initializer = valuedMissing.length === 0 ? undefined : machine.handlers[path]?.initial + const initializer = valuedMissing.length === 0 ? undefined : machine.handlers[path]?.initialize if (valuedMissing.length > 0 && initializer === undefined) { throw new Error(`Machine shallow history requires an initial value implementation for state "${path}"`) } @@ -288,24 +333,25 @@ const completeHistoryConfiguration = ( state: current.values.get(path), containingState: getParentValue(machine, current, path), ancestors: getParentValues(machine, current, path), - event + event, + builder: makeStateInitializeBuilder(machine, path) }) - if (initialized !== undefined && (typeof initialized.value !== "object" || initialized.value === null)) { - throw new Error(`Machine parallel state initializer for "${path}" must return its region values`) - } + const initializedValues = initialized === undefined + ? undefined + : getStateInitializeValues(path, initialized.value) for (const childPath of missing) { const child = getNode(machine, childPath) active.add(child.path) if (child.schema !== undefined) { if ( initialized === undefined || - !Object.prototype.hasOwnProperty.call(initialized.value as object, child.key) + initializedValues === undefined || !Object.prototype.hasOwnProperty.call(initializedValues, child.key) ) { throw new Error(`Machine parallel state initializer for "${path}" must return region "${child.key}"`) } values.set( child.path, - decodeStateValueSync(machine, child, (initialized.value as Record)[child.key]) + decodeStateValueSync(machine, child, initializedValues[child.key]) ) } } @@ -328,16 +374,38 @@ const completeHistoryConfiguration = ( } as ActiveConfiguration, commands, raisedEvents, - emittedEvents + emittedEvents, + transitions } } -const resolveHistoryTarget = ( +export function resolveInitialTarget( + machine: Machine.Any, + configuration: ActiveConfiguration, + target: InitialTargetInstruction, + event: unknown +) { + const partial = configurationFromInitialTargetSync(machine, configuration, target) + const completed = completeHistoryConfiguration(machine, partial, event) + const snapshot = snapshotFromConfigurationAtPath(machine, completed.configuration, target.path) + return { + target: makeTarget(target.path as any, snapshot.value as any, { + snapshot: snapshot as any, + values: Object.fromEntries(completed.configuration.values) as any + }), + commands: completed.commands, + raisedEvents: completed.raisedEvents, + emittedEvents: completed.emittedEvents, + transitions: completed.transitions + } +} + +function resolveHistoryTarget( machine: Machine.Any, configuration: ActiveConfiguration, target: { readonly path: string; readonly parent: string }, event: unknown -) => { +) { const node = getNode(machine, target.path) if (node.type !== "history" || node.parent !== target.parent) { throw new Error(`Machine expected history target "${target.path}" to resolve to its declared parent`) @@ -359,7 +427,7 @@ const resolveHistoryTarget = ( commands: completed.commands, raisedEvents: completed.raisedEvents, emittedEvents: completed.emittedEvents, - transitions: [] + transitions: completed.transitions } } @@ -1065,12 +1133,12 @@ interface ResolvedChoiceTransition { readonly resolvedTarget: string } -const resolveChoiceTarget = ( +function resolveChoiceTarget( machine: Machine.Any, configuration: ActiveConfiguration, initialTarget: unknown, event: unknown -) => { +) { const pending: Array = [initialTarget] const resolvedTargets: Array = [] const commands: Array = [] @@ -1141,7 +1209,7 @@ const resolveChoiceTarget = ( emittedEvents.push(...collected.emittedEvents) } - if (!isTarget(current) && !isSnapshot(current) && !isHistoryTarget(current)) { + if (!isTarget(current) && !isSnapshot(current) && !isHistoryTarget(current) && !isInitialTarget(current)) { throw new Error("Machine choice resolver must return a concrete typed target") } resolvedTargets.push(current) @@ -1196,6 +1264,10 @@ const collectEvaluatedTransition = < (selection.context as any).event ) const choiceResolvedTarget = choiceResolution?.target ?? unresolvedTarget + const initialResolution = choiceResolvedTarget !== undefined && isInitialTarget(choiceResolvedTarget) + ? resolveInitialTarget(machine, state, choiceResolvedTarget, (selection.context as any).event) + : undefined + const routedTarget = initialResolution?.target ?? choiceResolvedTarget let historyResolution: { readonly target: unknown readonly commands: ReadonlyArray @@ -1203,50 +1275,64 @@ const collectEvaluatedTransition = < readonly emittedEvents: ReadonlyArray readonly transitions: ReadonlyArray } | undefined - const reenteredHistoryTarget = isHistoryTarget(choiceResolvedTarget) && selection.transition.reenter && - state.active.has(choiceResolvedTarget.parent) - ? choiceResolvedTarget + const reenteredHistoryTarget = isHistoryTarget(routedTarget) && selection.transition.reenter && + state.active.has(routedTarget.parent) + ? routedTarget : undefined - if (choiceResolvedTarget !== undefined && isHistoryTarget(choiceResolvedTarget)) { + if (routedTarget !== undefined && isHistoryTarget(routedTarget)) { // A reentering transition may exit the history node's own parent. SCXML // history observes that same exit, so resolve against a provisional // capture rather than an older record (or the default). const provisionalBoundary = selection.transition.reenter ? getNode(machine, selection.sourcePath).parent - : getLeastCommonAncestor(machine, stateIdentifier, choiceResolvedTarget.parent) + : getLeastCommonAncestor(machine, stateIdentifier, routedTarget.parent) const provisionalExitPaths = reenteredHistoryTarget !== undefined ? sortExitPaths( machine, - Array.from(state.active).filter((path) => isPathInSubtree(path, choiceResolvedTarget.parent)) + Array.from(state.active).filter((path) => isPathInSubtree(path, routedTarget.parent)) ) : getExitPaths(machine, state, provisionalBoundary) - const stateAtHistoryResolution = provisionalExitPaths.includes(choiceResolvedTarget.parent) + const stateAtHistoryResolution = provisionalExitPaths.includes(routedTarget.parent) ? captureHistory(machine, state, state, provisionalExitPaths) : state historyResolution = resolveHistoryTarget( machine, stateAtHistoryResolution, - choiceResolvedTarget, + routedTarget, (selection.context as any).event ) } const target: Machine.Snapshot | Machine.Target> | undefined = historyResolution === undefined - ? choiceResolvedTarget as + ? routedTarget as | Machine.Snapshot | Machine.Target> | undefined : historyResolution.target as | Machine.Snapshot | Machine.Target> - const additionalHistoryActions: Array = [] - const additionalHistoryRaisedEvents: Array = [] - const additionalHistoryEmittedEvents: Array = [] - const additionalHistoryChoiceTransitions: Array = [] + const additionalTargetActions: Array = [] + const additionalTargetRaisedEvents: Array = [] + const additionalTargetEmittedEvents: Array = [] + const additionalTargetChoiceTransitions: Array = [] const additionalChoiceTargets: Array< Machine.Snapshot | Machine.Target> > = [] for (const additionalTarget of choiceResolution?.additionalTargets ?? []) { + if (isInitialTarget(additionalTarget)) { + const resolved = resolveInitialTarget( + machine, + state, + additionalTarget, + (selection.context as any).event + ) + additionalChoiceTargets.push(resolved.target as any) + additionalTargetActions.push(...resolved.commands) + additionalTargetRaisedEvents.push(...resolved.raisedEvents) + additionalTargetEmittedEvents.push(...resolved.emittedEvents) + additionalTargetChoiceTransitions.push(...resolved.transitions) + continue + } if (!isHistoryTarget(additionalTarget)) { additionalChoiceTargets.push(additionalTarget as any) continue @@ -1258,10 +1344,10 @@ const collectEvaluatedTransition = < (selection.context as any).event ) additionalChoiceTargets.push(resolved.target as any) - additionalHistoryActions.push(...resolved.commands) - additionalHistoryRaisedEvents.push(...resolved.raisedEvents) - additionalHistoryEmittedEvents.push(...resolved.emittedEvents) - additionalHistoryChoiceTransitions.push(...resolved.transitions) + additionalTargetActions.push(...resolved.commands) + additionalTargetRaisedEvents.push(...resolved.raisedEvents) + additionalTargetEmittedEvents.push(...resolved.emittedEvents) + additionalTargetChoiceTransitions.push(...resolved.transitions) } const targetPath = target === undefined ? undefined @@ -1288,28 +1374,32 @@ const collectEvaluatedTransition = < commands: [ ...transitionResult.commands, ...(choiceResolution?.commands ?? []), + ...(initialResolution?.commands ?? []), ...(historyResolution?.commands ?? []), - ...additionalHistoryActions + ...additionalTargetActions ], raisedEvents: [ ...transitionResult.raisedEvents, ...(choiceResolution?.raisedEvents ?? []), + ...(initialResolution?.raisedEvents ?? []), ...(historyResolution?.raisedEvents ?? []), - ...additionalHistoryRaisedEvents + ...additionalTargetRaisedEvents ], emittedEvents: [ ...transitionResult.emittedEvents, ...(choiceResolution?.emittedEvents ?? []), + ...(initialResolution?.emittedEvents ?? []), ...(historyResolution?.emittedEvents ?? []), - ...additionalHistoryEmittedEvents + ...additionalTargetEmittedEvents ], changed, exitPaths: [], entryPaths: [], choiceTransitions: [ ...(choiceResolution?.transitions ?? []), + ...(initialResolution?.transitions ?? []), ...(historyResolution?.transitions ?? []), - ...additionalHistoryChoiceTransitions + ...additionalTargetChoiceTransitions ] } as EvaluatedTransition } @@ -1329,20 +1419,23 @@ const collectEvaluatedTransition = < commands: [ ...transitionResult.commands, ...(choiceResolution?.commands ?? []), + ...(initialResolution?.commands ?? []), ...(historyResolution?.commands ?? []), - ...additionalHistoryActions + ...additionalTargetActions ], raisedEvents: [ ...transitionResult.raisedEvents, ...(choiceResolution?.raisedEvents ?? []), + ...(initialResolution?.raisedEvents ?? []), ...(historyResolution?.raisedEvents ?? []), - ...additionalHistoryRaisedEvents + ...additionalTargetRaisedEvents ], emittedEvents: [ ...transitionResult.emittedEvents, ...(choiceResolution?.emittedEvents ?? []), + ...(initialResolution?.emittedEvents ?? []), ...(historyResolution?.emittedEvents ?? []), - ...additionalHistoryEmittedEvents + ...additionalTargetEmittedEvents ], changed, exitPaths: reenteredHistoryTarget !== undefined @@ -1359,8 +1452,9 @@ const collectEvaluatedTransition = < : getEntryPaths(machine, stateAfterTransition, boundary), choiceTransitions: [ ...(choiceResolution?.transitions ?? []), + ...(initialResolution?.transitions ?? []), ...(historyResolution?.transitions ?? []), - ...additionalHistoryChoiceTransitions + ...additionalTargetChoiceTransitions ] } as EvaluatedTransition } diff --git a/src/internal/machine/symbols.ts b/src/internal/machine/symbols.ts index d2ca606..feb3a99 100644 --- a/src/internal/machine/symbols.ts +++ b/src/internal/machine/symbols.ts @@ -1,5 +1,8 @@ /** @internal */ export const InitialEventTypeId: unique symbol = Symbol("effect/Machine/InitialEvent") +/** @internal */ +export const SnapshotBuilderStateTypeId: unique symbol = Symbol("effect/Machine/SnapshotBuilderState") + /** @internal Returns process logic for a child descriptor and optional input. */ export const ChildMachineLogicTypeId: unique symbol = Symbol("effect/Machine/ChildMachineLogic") diff --git a/src/internal/machine/topology.ts b/src/internal/machine/topology.ts index dc05a2c..2a0947e 100644 --- a/src/internal/machine/topology.ts +++ b/src/internal/machine/topology.ts @@ -19,6 +19,8 @@ export const StateConstructionTypeId: unique symbol = Symbol("effect/Machine/Sta export const HistoryTargetTypeId: unique symbol = Symbol("effect/Machine/HistoryTarget") +export const InitialTargetTypeId: unique symbol = Symbol("effect/Machine/InitialTarget") + export const ChoiceTargetTypeId: unique symbol = Symbol("effect/Machine/ChoiceTarget") export const NoTargetTypeId: unique symbol = Symbol("effect/Machine/NoTarget") @@ -36,6 +38,17 @@ export interface HistoryTarget { readonly parent: string } +/** Internal instruction that enters the selected state's declared initial + * configuration. The planner supplies implicit child values through the + * owning states' `initialize` handlers. */ +export interface InitialTarget { + readonly [InitialTargetTypeId]: typeof InitialTargetTypeId + readonly _tag: "InitialTarget" + readonly path: string + readonly value: unknown + readonly values?: Readonly> +} + /** Internal target produced by a choice target builder. */ export interface ChoiceTarget { readonly [ChoiceTargetTypeId]: typeof ChoiceTargetTypeId @@ -65,6 +78,20 @@ export const makeHistoryTarget = (path: string, parent: string): HistoryTarget = export const isHistoryTarget = (u: unknown): u is HistoryTarget => hasProperty(u, HistoryTargetTypeId) +export const makeInitialTarget = ( + path: string, + value: unknown, + values?: Readonly> +): InitialTarget => ({ + [InitialTargetTypeId]: InitialTargetTypeId, + _tag: "InitialTarget", + path, + value, + ...(values === undefined ? {} : { values }) +}) + +export const isInitialTarget = (u: unknown): u is InitialTarget => hasProperty(u, InitialTargetTypeId) + export const makeChoiceTarget = ( path: string, parent: string, diff --git a/src/internal/testing/machine/finiteModel.ts b/src/internal/testing/machine/finiteModel.ts index a49d322..578f58b 100644 --- a/src/internal/testing/machine/finiteModel.ts +++ b/src/internal/testing/machine/finiteModel.ts @@ -1449,15 +1449,16 @@ const makeHandlers = ( ...(Object.keys(history).length === 0 ? {} : { history }), ...(node._tag === "Compound" ? { - initial: () => stateValue(byPath.get(`${path}.${node.initial}`)!) + initialize: ({ builder }: any) => builder(stateValue(byPath.get(`${path}.${node.initial}`)!)) } : { - initial: () => - Object.fromEntries( - node.states - .filter((child) => child._tag !== "History" && child._tag !== "Choice") - .map((child) => [child.key, stateValue(byPath.get(`${path}.${child.key}`)!)]) - ) + initialize: ({ builder }: any) => + node.states + .filter((child) => child._tag !== "History" && child._tag !== "Choice") + .reduce( + (current: any, child) => current[child.key](stateValue(byPath.get(`${path}.${child.key}`)!)), + builder + ) }), states: makeHandlers(node.states, path, byPath, transitions) } diff --git a/test/internal/machine/strategyDifferential.test.ts b/test/internal/machine/strategyDifferential.test.ts index 062ac1e..00944e4 100644 --- a/test/internal/machine/strategyDifferential.test.ts +++ b/test/internal/machine/strategyDifferential.test.ts @@ -138,6 +138,43 @@ describe("machine planner and runtime strategies", () => { }) })) + it.effect("matches generic and indexed-hierarchical planning for declared initial entry", () => + Effect.gen(function*() { + class Outside extends Schema.TaggedClass("StrategyInitialOutside")("Outside", {}) {} + class Opened extends Schema.TaggedClass("StrategyInitialOpened")("Opened", {}) {} + class Idle extends Schema.TaggedClass("StrategyInitialIdle")("Idle", { value: Schema.Number }) {} + class Enter extends Schema.TaggedClass("StrategyInitialEnter")("Enter", {}) {} + const states = Machine.defineStates({ + Outside, + Opened: { + schema: Opened, + initial: "Idle", + states: { Idle } + } + }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(Enter), + initial: () => states.initial.Outside(new Outside({})) + }).handle({ + Outside: { + on: { + Enter: ({ target }) => target.full.Opened.initial(new Opened({})) + } + }, + Opened: { + initialize: ({ builder }) => builder.from({ value: 1 }) + } + }) + + yield* verifyPlannerStrategies({ + machine, + events: [new Enter({})], + expected: "indexed-hierarchical", + label: "declared initial entry" + }) + })) + it.effect("preserves value-only updates beside control-changing simultaneous transitions", () => Effect.gen(function*() { const model: MachineTest.FiniteModel = { diff --git a/test/machine/History.test.ts b/test/machine/History.test.ts index e03f3ff..e3911f9 100644 --- a/test/machine/History.test.ts +++ b/test/machine/History.test.ts @@ -171,9 +171,9 @@ const makeCheckoutMachine = ( exit: () => { lifecycle?.push("exit:payment") }, - initial: ({ state }) => { + initialize: ({ state, builder }) => { onInitialize?.() - return new CardEntry({ cardNumber: `fresh-${state.attempt}` }) + return builder(new CardEntry({ cardNumber: `fresh-${state.attempt}` })) }, states: { verifying: { @@ -334,15 +334,15 @@ const makeWorkspaceMachine = (initialized: Array) => }, states: { editor: { - initial: ({ state }) => { + initialize: ({ state, builder }) => { initialized.push("editor") - return new Writing({ draft: `fresh:${state.documentId}` }) + return builder(new Writing({ draft: `fresh:${state.documentId}` })) } }, sidebar: { - initial: ({ state }) => { + initialize: ({ state, builder }) => { initialized.push("sidebar") - return new Files({ directory: `/fresh/${state.width}` }) + return builder(new Files({ directory: `/fresh/${state.width}` })) } } } diff --git a/test/machine/InitialEntry.test.ts b/test/machine/InitialEntry.test.ts new file mode 100644 index 0000000..539cf08 --- /dev/null +++ b/test/machine/InitialEntry.test.ts @@ -0,0 +1,294 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { Machine } from "../../src/index.js" + +class Closed extends Schema.TaggedClass("InitialEntryClosed")("Closed", {}) {} +class Opened extends Schema.TaggedClass("InitialEntryOpened")("Opened", { + id: Schema.NonEmptyString +}) {} +class Idle extends Schema.TaggedClass("InitialEntryIdle")("Idle", { + count: Schema.NumberFromString +}) {} +class Loading extends Schema.TaggedClass("InitialEntryLoading")("Loading", {}) {} +class Open extends Schema.TaggedClass("InitialEntryOpen")("Open", {}) {} +class OpenInvalid extends Schema.TaggedClass("InitialEntryOpenInvalid")("OpenInvalid", {}) {} + +class Outside extends Schema.TaggedClass("InitialEntryOutside")("Outside", {}) {} +class Dashboard extends Schema.TaggedClass("InitialEntryDashboard")("Dashboard", {}) {} +class Filters extends Schema.TaggedClass("InitialEntryFilters")("Filters", { id: Schema.String }) {} +class Ready extends Schema.TaggedClass("InitialEntryReady")("Ready", { enabled: Schema.Boolean }) {} +class Results extends Schema.TaggedClass("InitialEntryResults")("Results", { count: Schema.Number }) {} +class EnterDashboard extends Schema.TaggedClass("InitialEntryEnterDashboard")("EnterDashboard", {}) {} +class Flow extends Schema.TaggedClass("InitialEntryFlow")("Flow", {}) {} +class Approved extends Schema.TaggedClass("InitialEntryApproved")("Approved", {}) {} +class EnterFlow extends Schema.TaggedClass("InitialEntryEnterFlow")("EnterFlow", {}) {} +class OpenLocal extends Schema.TaggedClass("InitialEntryOpenLocal")("OpenLocal", {}) {} +class OpenBranch extends Schema.TaggedClass("InitialEntryOpenBranch")("OpenBranch", {}) {} + +const States = Machine.defineStates({ + closed: Closed, + opened: { + schema: Opened, + initial: "idle", + states: { + idle: Idle, + loading: Loading + } + } +}) + +const makeMachine = () => + Machine.make({ + states: States.states, + events: Machine.events(Open, OpenInvalid), + initial: () => States.initial.closed(new Closed({})) + }).handle({ + closed: { + on: { + Open: ({ target }) => target.full.opened.initial.from({ id: "team-1" }), + OpenInvalid: ({ target }) => target.full.opened.initial.from({ id: "" }) + } + }, + opened: { + initialize: ({ builder }) => builder.from({ count: 1 }) + } + }) + +const ParallelStates = Machine.defineStates({ + outside: Outside, + dashboard: { + schema: Dashboard, + type: "parallel", + states: { + filters: { + schema: Filters, + initial: "ready", + states: { ready: Ready } + }, + results: Results + } + } +}) + +const makeParallelMachine = () => + Machine.make({ + states: ParallelStates.states, + events: Machine.events(EnterDashboard), + initial: () => ParallelStates.initial.outside(new Outside({})) + }).handle({ + outside: { + on: { + EnterDashboard: ({ target }) => target.full.dashboard.initial(new Dashboard({})) + } + }, + dashboard: { + initialize: ({ builder }) => builder.filters.from({ id: "all" }).results.from({ count: 2 }), + states: { + filters: { + initialize: ({ builder }) => builder.from({ enabled: true }) + } + } + } + }) + +const ChoiceStates = Machine.defineStates({ + outside: Outside, + flow: { + schema: Flow, + initial: "routing", + states: { + routing: { type: "choice" }, + approved: Approved + } + } +}) + +const makeChoiceMachine = () => + Machine.make({ + states: ChoiceStates.states, + events: Machine.events(EnterFlow), + initial: () => ChoiceStates.initial.outside(new Outside({})) + }).handle({ + outside: { + on: { + EnterFlow: ({ target }) => target.full.flow.initial(new Flow({})) + } + }, + flow: { + states: { + routing: { + choice: { + targets: ["flow.approved"], + transition: ({ target }) => target.local.approved(new Approved({})) + } + } + } + } + }) + +const StructuralStates = Machine.defineStates({ + outside: Outside, + group: { + initial: "idle", + states: { idle: {} } + } +}) + +const makeStructuralMachine = () => + Machine.make({ + states: StructuralStates.states, + events: Machine.events(EnterFlow), + initial: () => StructuralStates.initial.outside(new Outside({})) + }).handle({ + outside: { + on: { + EnterFlow: ({ target }) => target.full.group.initial.from() + } + } + }) + +const NestedStates = Machine.defineStates({ + root: { + initial: "closed", + states: { + closed: Closed, + opened: { + schema: Opened, + initial: "idle", + states: { idle: Idle, loading: Loading } + } + } + } +}) + +const makeNestedMachine = () => + Machine.make({ + states: NestedStates.states, + events: Machine.events(OpenLocal, OpenBranch), + initial: () => NestedStates.initial.root.from((root) => root.closed(new Closed({}))) + }).handle({ + root: { + states: { + closed: { + on: { + OpenLocal: ({ target }) => target.local.opened.initial.from({ id: "local" }), + OpenBranch: ({ target }) => target.branch.root.opened.initial.from({ id: "branch" }) + } + }, + opened: { + initialize: ({ builder }) => builder.from({ count: 3 }) + } + } + } + }) + +describe("declared initial entry", () => { + it.effect("enters a compound state's declared initial child and decodes builder inputs", () => + Effect.gen(function*() { + const machine = makeMachine() + const initial = yield* Machine.planInitial(machine) + const planned = yield* Machine.plan(machine, initial.state, new Open({})) + + assert.deepStrictEqual( + planned.next, + States.initial.opened( + new Opened({ id: "team-1" }), + (opened) => opened.idle(new Idle({ count: 1 })) + ) + ) + })) + + it.effect("reports invalid initial target inputs as typed machine schema failures", () => + Effect.gen(function*() { + const machine = makeMachine() + const initial = yield* Machine.planInitial(machine) + const error = yield* Machine.plan(machine, initial.state, new OpenInvalid({})).pipe(Effect.flip) + + assert.instanceOf(error, Machine.MachineSchemaDecodeError) + assert.strictEqual(error.boundary, "state") + assert.strictEqual(error.state, "opened") + })) + + it.effect("initializes every parallel region fluently and recurses through nested defaults", () => + Effect.gen(function*() { + const machine = makeParallelMachine() + const initial = yield* Machine.planInitial(machine) + const planned = yield* Machine.plan(machine, initial.state, new EnterDashboard({})) + + assert.deepStrictEqual( + planned.next, + ParallelStates.initial.dashboard( + new Dashboard({}), + (dashboard) => + dashboard + .filters( + new Filters({ id: "all" }), + (filters) => filters.ready(new Ready({ enabled: true })) + ) + .results(new Results({ count: 2 })) + ) + ) + })) + + it.effect("routes a declared initial choice before activating the concrete child", () => + Effect.gen(function*() { + const machine = makeChoiceMachine() + const initial = yield* Machine.planInitial(machine) + const planned = yield* Machine.plan(machine, initial.state, new EnterFlow({})) + + assert.deepStrictEqual( + planned.next, + { + path: "flow", + value: new Flow({}), + state: { path: "flow.approved", value: new Approved({}) } + } + ) + assert.deepStrictEqual(planned.microsteps[0]?.transitions, [{ + source: "outside", + trigger: { type: "event", event: "EnterFlow" }, + reenter: false, + target: "flow", + resolvedTarget: "flow" + }, { + source: "flow.routing", + trigger: { type: "choice" }, + reenter: false, + target: "flow.approved", + resolvedTarget: "flow.approved" + }]) + })) + + it.effect("enters structural declared initial states without an initializer", () => + Effect.gen(function*() { + const machine = makeStructuralMachine() + const initial = yield* Machine.planInitial(machine) + const planned = yield* Machine.plan(machine, initial.state, new EnterFlow({})) + + assert.deepStrictEqual(planned.next, { + path: "group", + value: undefined, + state: { path: "group.idle", value: undefined } + }) + })) + + it.effect("supports declared initial entry through local and branch target scopes", () => + Effect.gen(function*() { + const machine = makeNestedMachine() + const initial = yield* Machine.planInitial(machine) + const local = yield* Machine.plan(machine, initial.state, new OpenLocal({})) + const branch = yield* Machine.plan(machine, initial.state, new OpenBranch({})) + + for (const [planned, id] of [[local, "local"], [branch, "branch"]] as const) { + assert.deepStrictEqual(planned.next, { + path: "root", + value: undefined, + state: { + path: "root.opened", + value: new Opened({ id }), + state: { path: "root.opened.idle", value: new Idle({ count: 3 }) } + } + }) + } + })) +}) diff --git a/test/machine/Visualization.test.ts b/test/machine/Visualization.test.ts index 088bd81..04810d0 100644 --- a/test/machine/Visualization.test.ts +++ b/test/machine/Visualization.test.ts @@ -98,7 +98,7 @@ const machine = Machine.make({ } }, running: { - initial: () => new Editing({}) + initialize: ({ builder }) => builder(new Editing({})) } } }, diff --git a/test/testing/Verification.test.ts b/test/testing/Verification.test.ts index d395627..826b8e2 100644 --- a/test/testing/Verification.test.ts +++ b/test/testing/Verification.test.ts @@ -189,7 +189,7 @@ const historyMachine = Machine.make({ }, states: { editor: { - initial: () => new Editing({ revision: 0 }) + initialize: ({ builder }) => builder(new Editing({ revision: 0 })) } } } diff --git a/typetest/machine/History.tst.ts b/typetest/machine/History.tst.ts index 8093e98..27e2884 100644 --- a/typetest/machine/History.tst.ts +++ b/typetest/machine/History.tst.ts @@ -225,11 +225,11 @@ describe("Machine history states", () => { }, states: { payment: { - initial: ({ state, containingState, ancestors }) => { + initialize: ({ state, containingState, ancestors, builder }) => { expect(state).type.toBe() expect(containingState).type.toBe() expect(ancestors).type.toBe<{ readonly checkout: Checkout }>() - return new CardEntry({ cardNumber: `attempt-${state.attempt}` }) + return builder(new CardEntry({ cardNumber: `attempt-${state.attempt}` })) } } } @@ -259,11 +259,14 @@ describe("Machine history states", () => { } }) - expect(machine.handle).type.not.toBeCallableWith({ + machine.handle({ checkout: { states: { payment: { - initial: () => new Verifying({ challengeId: "wrong-child" }) + initialize: ({ builder }) => { + expect(builder).type.not.toBeCallableWith(new Verifying({ challengeId: "wrong-child" })) + return builder(new CardEntry({ cardNumber: "" })) + } } } } @@ -461,7 +464,7 @@ describe("Machine history states", () => { checkout: { states: { payment: { - initial: ({ state }) => new CardEntry({ cardNumber: String(state.attempt) }) + initialize: ({ state, builder }) => builder(new CardEntry({ cardNumber: String(state.attempt) })) } } } @@ -572,12 +575,11 @@ describe("Machine history states", () => { }, states: { all: { - initial: ({ state }) => { + initialize: ({ state, builder }) => { expect(state).type.toBe() - return { - shipping: new Shipping({ address: `attempt-${state.attempt}` }), - card: new CardEntry({ cardNumber: "" }) - } + return builder + .shipping(new Shipping({ address: `attempt-${state.attempt}` })) + .card(new CardEntry({ cardNumber: "" })) } } } @@ -589,9 +591,12 @@ describe("Machine history states", () => { outer: { states: { all: { - initial: () => ({ - shipping: new Shipping({ address: "missing-card" }) - }) + initialize: ({ builder }: Machine.Machine.StateInitializeContext< + typeof ParallelStates.states, + readonly [typeof Resume], + readonly [], + "outer.all" + >) => builder.shipping(new Shipping({ address: "missing-card" })) } } } diff --git a/typetest/machine/InitialEntry.tst.ts b/typetest/machine/InitialEntry.tst.ts new file mode 100644 index 0000000..d9762df --- /dev/null +++ b/typetest/machine/InitialEntry.tst.ts @@ -0,0 +1,98 @@ +import { Schema } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" + +class Closed extends Schema.TaggedClass("InitialTypeClosed")("Closed", {}) {} +class Opened extends Schema.TaggedClass("InitialTypeOpened")("Opened", { id: Schema.String }) {} +class Idle extends Schema.TaggedClass("InitialTypeIdle")("Idle", { count: Schema.Number }) {} +class Loading extends Schema.TaggedClass("InitialTypeLoading")("Loading", {}) {} +class Open extends Schema.TaggedClass("InitialTypeOpen")("Open", {}) {} + +const States = Machine.defineStates({ + closed: Closed, + opened: { + schema: Opened, + initial: "idle", + states: { idle: Idle, loading: Loading } + } +}) + +const base = Machine.make({ + states: States.states, + events: Machine.events(Open), + initial: () => States.initial.closed(new Closed({})) +}) + +type Events = readonly [typeof Open] +type ClosedContext = Machine.Machine.HandlerContext< + typeof States.states, + Events, + readonly [], + "closed", + "Open", + never, + never +> +type OpenedInitializeContext = Machine.Machine.StateInitializeContext< + typeof States.states, + Events, + readonly [], + "opened" +> + +describe("declared initial entry types", () => { + it("requires initialize at the handle call that returns an initial target", () => { + expect(base.handle).type.not.toBeCallableWith({ + closed: { + on: { + Open: ({ target }: ClosedContext) => target.full.opened.initial(new Opened({ id: "team-1" })) + } + }, + opened: {} + }) + + expect(base.handle).type.toBeCallableWith({ + closed: { + on: { + Open: ({ target }: ClosedContext) => target.full.opened.initial.from({ id: "team-1" }) + } + }, + opened: { + initialize: ({ builder }: OpenedInitializeContext) => builder.from({ count: 0 }) + } + }) + + expect(base.handle).type.toBeCallableWith({ + closed: { + on: { + Open: ({ target }: ClosedContext) => + target.full.opened( + new Opened({ id: "team-1" }), + (opened) => opened.loading(new Loading({})) + ) + } + }, + opened: {} + }) + }) + + it("only exposes initial on compound and parallel state builders", () => { + base.handle({ + closed: { + on: { + Open: ({ target }) => { + expect(target.full.closed).type.not.toHaveProperty("initial") + expect(target.full.opened).type.toHaveProperty("initial") + expect(target.full.opened.initial).type.not.toBeCallableWith() + expect(target.full.opened.initial).type.toBeCallableWith(new Opened({ id: "team-1" })) + expect(target.full.opened.initial.from).type.toBeCallableWith({ id: "team-1" }) + return target.full.opened( + new Opened({ id: "team-1" }), + (opened) => opened.loading(new Loading({})) + ) + } + } + } + }) + }) +})