diff --git a/.changeset/update-into-choice-states.md b/.changeset/update-into-choice-states.md new file mode 100644 index 0000000..b5f9f7b --- /dev/null +++ b/.changeset/update-into-choice-states.md @@ -0,0 +1,13 @@ +--- +"@typeonce/effect-machine": minor +--- + +A transition that combines `target` and `update` can now enter a choice state. The owner is updated first, so the choice resolver reads the new value through `containingState`, `ancestors`, or `root` before the chosen branch is entered. With a choice target, `data` constructs only the owner value: + +```ts +Idle: { + on: { Bump: { target: "Route", update: "root", data: ({ root }) => ({ count: root.count + 1 }) } } +} +``` + +A declared branch group can declare the same pair and select it with `select.route({ update: { data } })`. Every branch the choice can reach, including through nested choices, must stay below the updated owner: an escaping branch is a type error such as `Choice 'Route' branch 'out' targets 'Outside' outside the updated owner 'Flow'`, and `handle` rejects it at construction. diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 268bdf4..5fc5bc2 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -277,6 +277,21 @@ Advanced construction declares both paths in a branch and uses A retained owner must be active for that source and remain active through the transition. A sibling region's value cannot be updated through this operation. +The destination can be a choice. The owner is updated first, so the choice +resolver reads the new value, and `data` constructs only the owner value: + +```ts +Bump: { + target: "Route", + update: "root", + data: ({ root }) => ({ count: root.count + 1 }) +} +``` + +Every branch the choice can reach, including through nested choices, must stay +below the owner. A declared branch selects it with +`select.route({ update: { data: ownerValues } })`. + The runtime transition API does not replace arbitrary complete root configurations. The root is not a destination. `initialize` restarts it: it accepts fresh machine input, or `true` for a machine without input, and follows diff --git a/packages/effect-machine/docs/agent-guide.md b/packages/effect-machine/docs/agent-guide.md index 90f2d18..5a68589 100644 --- a/packages/effect-machine/docs/agent-guide.md +++ b/packages/effect-machine/docs/agent-guide.md @@ -440,6 +440,13 @@ validated atomically, and destination entry sees the new owner value. A named branch can declare the same pair when a resolver needs commands or nested construction. Its selector requires `update: { data: ownerValue }` in the construction object. +The destination of `{ target, update, data }` can be a choice. The owner is +updated before the choice resolves, so its resolver reads the new value through +`containingState`, `ancestors`, or `root`, and `data` returns only the complete +owner input. Every branch the choice can reach, including through nested +choices, must stay below the owner; otherwise the definition does not compile +and `handle` throws while naming the choice, branch, and escaping target. + ## Test paths and invariants Test the statechart as a graph. Send domain events, inspect reached states, and diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index df5dd35..2a4e360 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -684,6 +684,16 @@ type InvocationDefinitionError< readonly field: Field } +type TransitionDefinitionError< + Message extends string, + Choice extends string, + Target extends string +> = { + readonly "~effect/Machine/TransitionDefinitionError": Message + readonly choice: Choice + readonly target: Target +} + type RootCompatibilityError = { readonly "~effect/Machine/RootCompatibilityError": Message } @@ -5084,7 +5094,7 @@ export declare namespace Machine { | ChoiceTarget> | StateUpdate> | CombinedTarget< - Target> | Snapshot, + Target> | Snapshot | ChoiceTarget>, States, ValuedStateIdentifier > @@ -6445,7 +6455,10 @@ export declare namespace Machine { | { /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target: ReferenceUnion> - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ + /** + * Replaces the complete value of a retained ancestor of the source and target without replacing its active + * children. A choice target resolves after the update, and every branch it can reach must stay below the owner. + */ readonly update?: ReferenceUnion> /** Enters a compound or parallel subtree through its declared initialization. */ readonly initial?: never @@ -6620,6 +6633,14 @@ export declare namespace Machine { P, "state", "branch" + > + : P extends ChoiceIdentifier ? TargetSelection< + (construction: { + readonly update: ConstructionData> + }) => CombinedTarget, S, Owner>, + P, + "choice", + "branch" > : never : never @@ -6737,6 +6758,7 @@ export declare namespace Machine { ) => { readonly target: NodeValue>; readonly update: StateByIdentifier } readonly from?: never }) + : P extends ChoiceIdentifier ? ObjectConstruction> : never type InlineCombined, C> = Src extends ChoiceIdentifier ? never : @@ -6747,7 +6769,11 @@ export declare namespace Machine { & { /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target: ReferenceAt - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ + /** + * Replaces the complete value of a retained ancestor of the source and target without replacing its active + * children. A choice target resolves after the update, and every branch it can reach must stay below the + * owner; `data` then constructs only the owner value. + */ readonly update: ReferenceAt /** Enters a compound or parallel subtree through its declared initialization. */ readonly initial?: never @@ -7479,37 +7505,115 @@ export declare namespace Machine { : Id extends LiteralInvokeIdAt, K>, R> ? Id : never : never - type ValidateInvokeEntry = I extends { + type ValidateInvokeEntry = I extends { /** Name of the source registered in make. */ readonly src: infer Src extends PropertyKey } ? { - readonly [K in keyof I]: [Duplicate] extends [never] ? ValidateInvokeField> + readonly [K in keyof I]: [Duplicate] extends [never] ? ValidateInvokeField, Root> : K extends ("id" extends keyof I ? "id" : "src") ? InvocationDefinitionError< `Invocation id '${Duplicate}' is declared more than once in this state`, Extract, K > - : ValidateInvokeField> + : ValidateInvokeField, Root> } : never - type ValidateInvokeField = K extends AllowedInvokeChannels - ? ValidateObjectTransition + type ValidateInvokeField = K extends + AllowedInvokeChannels ? ValidateObjectTransition & ValidateChoiceUpdate : K extends "src" | "id" | "address" | "input" ? unknown : InvokeFieldError - type ValidateObjectInvoke = I extends ReadonlyArray ? - number extends I["length"] ? { readonly [K in keyof I]: ValidateInvokeEntry } - : { readonly [K in keyof I]: ValidateInvokeEntry> } : - ValidateInvokeEntry + type ValidateObjectInvoke = I extends ReadonlyArray ? + number extends I["length"] ? { readonly [K in keyof I]: ValidateInvokeEntry } + : { readonly [K in keyof I]: ValidateInvokeEntry, Root> } : + ValidateInvokeEntry + // Handler config of a dotted state path, read from the root handler config. + type HandlerConfigAt = Config extends { readonly states: infer Children } ? + Path extends `${infer Head}.${infer Rest}` ? Head extends keyof Children ? HandlerConfigAt + : never + : Path extends keyof Children ? Children[Path] + : never + : never + type ChoiceConfigAt = HandlerConfigAt extends { + /** Implements this choice pseudo-state with one required synchronous transition. */ + readonly choice: infer Choice + } ? Choice + : never + type ChoiceBranchDeclaration = Choice extends { + /** Named groups of inspectable destinations used by transition resolvers. */ + readonly branches: infer G extends keyof Registered + } ? { + readonly [K in Extract[G], string>]: { + readonly label: `branch '${K}'` + readonly declaration: Registered[G][K] + } + }[Extract[G], string>] + : { readonly label: "transition"; readonly declaration: Choice } + type IsBelowOwner = Owner extends "" ? Path extends "" ? false : true + : Path extends `${Owner}.${string}` ? true + : false + type ChoiceBranchEscape = + IsBelowOwner extends true ? never : TransitionDefinitionError< + `Choice '${Choice}' ${Label} targets '${PathName}' outside the updated owner '${PathName}'`, + Choice, + PathName + > + // Every branch reachable from a choice entered with an owner update must stay below that owner. + type ChoiceUpdateEscape = + Choice extends Visited ? never + : ChoiceBranchDeclaration, R> extends infer Branch ? + Branch extends { readonly label: infer Label extends string; readonly declaration: infer D } ? D extends { + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ + readonly target: infer Ref extends string + } ? + [ChoiceConfigAt>] extends [never] ? ChoiceBranchEscape, Owner, Choice, Label> + : ChoiceUpdateEscape, Owner, Visited | Choice> + : D extends { + /** Restores the declared history reference, using its fallback on first entry. */ + readonly history: infer Ref extends string + } ? ChoiceBranchEscape, Owner, Choice, Label> + : D extends { + /** Reconstructs root and its initial children from fresh machine input. */ + readonly initialize: unknown + } ? ChoiceBranchEscape<"", Owner, Choice, Label> + : never + : never + : never + type ChoiceUpdateEscapeOf = D extends { + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ + readonly target: infer Ref extends string + /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ + readonly update: infer Owner extends string + } ? [ChoiceConfigAt>] extends [never] ? never + : ChoiceUpdateEscape, PathOf> + : never + type ValidateChoiceUpdate = T extends { + /** Named groups of inspectable destinations used by transition resolvers. */ + readonly branches: infer G extends keyof Registered + } ? { + readonly [K in keyof Registered[G]]: ChoiceUpdateEscapeOf[G][K], R, Root> + }[keyof Registered[G]] extends infer Escape ? [Escape] extends [never] ? unknown : { + /** Named groups of inspectable destinations used by transition resolvers. */ + readonly branches: Escape + } + : never + : ChoiceUpdateEscapeOf extends infer Escape ? [Escape] extends [never] ? unknown : { + /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ + readonly update: Escape + } + : never type ObjectDeclarations = | Extract | (C extends { readonly states: infer Children } ? { readonly [K in keyof Children]: ObjectDeclarations }[keyof Children] : never) - type ValidateObjectConfig = [ObjectDeclarations] extends [never] ? unknown : { - readonly [K in keyof C]: K extends "states" ? { readonly [P in keyof C[K]]: ValidateObjectConfig } - : K extends "on" ? { readonly [P in keyof C[K]]: ValidateObjectTransition } - : K extends "always" | "onDone" | "choice" ? ValidateObjectTransition - : K extends "invoke" ? ValidateObjectInvoke + type ValidateObjectConfig = [ObjectDeclarations] extends [never] ? unknown : { + readonly [K in keyof C]: K extends "states" ? { readonly [P in keyof C[K]]: ValidateObjectConfig } + : K extends "on" ? { + readonly [P in keyof C[K]]: ValidateObjectTransition & ValidateChoiceUpdate + } + : K extends "always" | "onDone" ? ValidateObjectTransition & ValidateChoiceUpdate + : K extends "choice" ? ValidateObjectTransition + : K extends "invoke" ? ValidateObjectInvoke : unknown } type NormalizeObjectInvoke = I extends ReadonlyArray ? diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index 429a1b3..b6a6d73 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -163,7 +163,8 @@ const captureDefinitionBranch = ( if (selection.updatePath !== undefined) { const owner = stateNodes.byPath.get(selection.updatePath) if ( - selection.kind !== "state" || (selection.scope !== "local" && selection.scope !== "branch") || + (selection.kind !== "state" && selection.kind !== "choice") || + (selection.scope !== "local" && selection.scope !== "branch") || selection.path === undefined || owner === undefined || owner.schema === undefined || (owner.type !== "compound" && owner.type !== "parallel") || !Configuration.isDescendantOf(path, owner.path) || @@ -296,6 +297,15 @@ const constructBranch = ( source?: string ): unknown => { const config = Construction.record(raw) + if (selection.kind === "choice" && selection.updatePath !== undefined) { + const { update, ...state } = config + if (Reflect.ownKeys(state).length > 0) throw new Error("Machine pseudo-state construction accepts no data") + const node = stateNodes.byPath.get(selection.path!)! + return Topology.makeCombinedTarget( + Topology.makeChoiceTarget(node.path, node.parent!), + Construction.update(stateNodes, selection.updatePath, update) + ) + } if (selection.kind === "none" || selection.kind === "history" || selection.kind === "choice") { if (Reflect.ownKeys(config).length > 0) throw new Error("Machine pseudo-state construction accepts no data") if (selection.kind === "none") return undefined @@ -475,6 +485,8 @@ const normalizeObjectTransition = ( ...(hasData ? { data: value } : {}), ...(config.decoded === undefined ? {} : { decoded: config.decoded }) } + : selection.kind === "choice" + ? { update: { data: value, decoded: config.decoded } } : { ...(value.target === undefined ? {} : { data: value.target, decoded: config.decoded }), update: { data: value.update, decoded: config.decoded } @@ -705,6 +717,54 @@ const flattenHandlers = ( } } +const publicPath = (path: string): string => path === "" ? Declaration.rootPathName : path + +// A choice entered with an owner update resolves after that update, so every +// branch it can reach, through nested choices, must keep the owner active. +const assertChoiceUpdatesRetainOwners = (machine: Machine.Any): void => { + let hasChoice = false + for (const node of machine.stateNodes.byPath.values()) { + if (node.type === "choice") { + hasChoice = true + break + } + } + if (!hasChoice) return + const definitions = Topology.transitionDefinitions(machine) + const choices = new Map() + for (const definition of definitions) { + if (definition.trigger.type === "choice") choices.set(definition.source, definition) + } + for (const definition of definitions) { + for (const branch of definition.branches) { + const owner = branch.updates[0] + const entered = branch.selection.path + if (branch.selection.kind !== "choice" || owner === undefined || entered === undefined) continue + const pending = [entered] + const visited = new Set() + while (pending.length > 0) { + const choice = pending.shift()! + if (visited.has(choice)) continue + visited.add(choice) + for (const choiceBranch of choices.get(choice)?.branches ?? []) { + const { kind, path: target } = choiceBranch.selection + // Targetless and update-only choice branches are rejected by choice validation itself. + if (target === undefined || kind === "none" || kind === "update") continue + if (kind === "choice") { + pending.push(target) + } else if (!Configuration.isDescendantOf(target, owner)) { + const label = choiceBranch.type === "branch" ? `branch "${choiceBranch.key}"` : "transition" + throw new Error( + `Machine transition from "${publicPath(definition.source)}" updates "${publicPath(owner)}" before ` + + `choice "${choice}", whose ${label} targets "${publicPath(target)}" outside that owner` + ) + } + } + } + } + } +} + const makeHandle = (self: Definition.Any, declaration: Declaration.Declaration): Definition.Any["handle"] => ((config: Record) => { const captured = InitialDeclaration.capture(declaration.root, config) @@ -734,7 +794,9 @@ const makeHandle = (self: Definition.Any, declaration: Declaration.Declaration): compiled.input === undefined ? undefined : Protocol.decodeInputSync(compiled, compiled.input, input) ) }, { "": captured.handlers }) - return makeWithHandlers(compiled, handlers) + const machine = makeWithHandlers(compiled, handlers) + assertChoiceUpdatesRetainOwners(machine) + return machine }) as Definition.Any["handle"] export const isMachine = ( diff --git a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts index 6a03255..1fdf06e 100644 --- a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts +++ b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts @@ -1492,6 +1492,96 @@ describe("machine planner and runtime strategies", () => { } assert.deepStrictEqual(results[1], results[0]) }) as Effect.Effect) + it.effect("fails closed to generic planning for an owner update that enters a choice", () => + Effect.gen(function*() { + const States = Machine.state({ + states: { + Flow: { + fields: { count: Schema.Number }, + states: { Idle: {}, First: { type: "choice" }, Second: { type: "choice" }, Even: {}, Odd: {} } + } + } + }) + const Events = Machine.events({ Bump: {}, Stay: {}, Reset: {} }) + const machine = Machine.make({ + root: States, + events: Events, + branches: { + first: { next: { target: "Flow.Second" } }, + second: { even: { target: "Flow.Even" }, odd: { target: "Flow.Odd" } } + } + }).handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + on: { Reset: { update: "Flow", data: () => ({ count: 0 }) } }, + initial: { target: "Flow.Idle" }, + states: { + Idle: { + on: { + Bump: { + target: "Flow.First", + update: "Flow", + data: ({ ancestors }) => ({ count: ancestors.Flow.count + 1 }) + } + } + }, + First: { choice: { branches: "first", resolve: ({ select }) => select.next() } }, + Second: { + choice: { + branches: "second", + resolve: ({ containingState, select }) => + containingState.count % 2 === 0 ? select.even() : select.odd() + } + }, + Even: { + on: { + Stay: { + target: "Flow.First", + update: "Flow", + reenter: true, + data: ({ ancestors }) => ({ count: ancestors.Flow.count + 2 }) + } + } + }, + Odd: { + on: { + Bump: { + target: "Flow.First", + update: "Flow", + data: ({ ancestors }) => ({ count: ancestors.Flow.count + 1 }) + } + } + } + } + } + } + }) + assert.strictEqual(ExecutionPlan.selectExecutionPlanForTesting(machine, "auto").strategy, "generic") + assert.throws( + () => ExecutionPlan.selectExecutionPlanForTesting(machine, "indexed-hierarchical"), + /cannot compile the requested indexed-hierarchical execution plan/ + ) + const events = [Events.Bump(), Events.Bump(), Events.Stay(), Events.Reset(), Events.Bump()] + yield* verifyPlannerStrategies({ machine, events, expected: "generic", label: "choice owner update" }) + const initial = yield* Machine.planInitial(machine) + const steps: Array = [] + let state = initial.state + for (const event of events) { + const plan = yield* Machine.plan(machine, state, event) + steps.push({ event, plan }) + state = plan.next + } + for (const strategy of ["generic", "compiled"] as const) { + yield* verifyManagedExecution({ + machine, + open: openWithRuntimeStrategy(machine, strategy), + initial: { state: initial.state, done: initial.done, output: initial.output }, + steps, + label: `${strategy} choice owner update runtime` + }) + } + }) as Effect.Effect) it.effect("compares generated eligible models across canonical and indexed planners", () => Effect.gen(function*() { const generated = MachineTest.finiteModels({ diff --git a/packages/effect-machine/test/machine/ChoiceUpdate.test.ts b/packages/effect-machine/test/machine/ChoiceUpdate.test.ts new file mode 100644 index 0000000..d97e120 --- /dev/null +++ b/packages/effect-machine/test/machine/ChoiceUpdate.test.ts @@ -0,0 +1,362 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { Machine } from "../../src/index.js" +import { MachineTest } from "../../src/testing/index.js" +import { makeMermaidRenderer } from "./visualization/mermaid.js" +import { makeTextRenderer } from "./visualization/text.js" + +const States = Machine.state({ + fields: { count: Schema.Number }, + states: { Idle: {}, Route: { type: "choice" }, Low: {}, High: {} } +}) +const Events = Machine.events({ Bump: {} }) +let observed: Array = [] +const machine = Machine.make({ + root: States, + events: Events, + branches: { route: { low: { target: "Low" }, high: { target: "High" } } } +}).handle({ + root: { count: 0 }, + initial: { target: "Idle" }, + states: { + Idle: { on: { Bump: { target: "Route", update: "root", data: ({ root }) => ({ count: root.count + 1 }) } } }, + Route: { + choice: { + branches: "route", + resolve: ({ containingState, ancestors, root, select }) => { + observed.push(containingState.count, ancestors[""].count, root.count) + return containingState.count > 0 ? select.high() : select.low() + } + } + }, + Low: {}, + High: {} + } +}) + +const NestedStates = Machine.state({ + states: { + Flow: { + fields: { count: Schema.Number }, + states: { Idle: {}, First: { type: "choice" }, Second: { type: "choice" }, Even: {}, Odd: {} } + }, + Outside: {} + } +}) +const NestedEvents = Machine.events({ Bump: {}, Stay: {}, Reset: {} }) +const makeNested = (second: Readonly>) => + Machine.make({ + root: NestedStates, + events: NestedEvents, + branches: { + first: { next: { target: "Flow.Second" } }, + second: second as { + readonly even: { readonly target: "Flow.Even" } + readonly odd: { readonly target: "Flow.Odd" } + } + } + }).handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + on: { Reset: { update: "Flow", data: () => ({ count: 0 }) } }, + initial: { target: "Flow.Idle" }, + states: { + Idle: { + on: { + Bump: { + target: "Flow.First", + update: "Flow", + data: ({ ancestors }) => ({ count: ancestors.Flow.count + 1 }) + } + } + }, + First: { choice: { branches: "first", resolve: ({ select }) => select.next() } }, + Second: { + choice: { + branches: "second", + resolve: ({ containingState, select }) => containingState.count % 2 === 0 ? select.even() : select.odd() + } + }, + Even: { + on: { + Stay: { + target: "Flow.First", + update: "Flow", + reenter: true, + data: ({ ancestors }) => ({ count: ancestors.Flow.count + 2 }) + } + } + }, + Odd: { + on: { + Bump: { + target: "Flow.First", + update: "Flow", + data: ({ ancestors }) => ({ count: ancestors.Flow.count + 1 }) + } + } + } + } + }, + Outside: {} + } + }) +const flowOf = (snapshot: Machine.Snapshot) => { + assert.strictEqual(snapshot.state.path, "Flow") + if (snapshot.state.path !== "Flow") throw new Error("Expected Flow") + return { count: snapshot.state.value.count, path: snapshot.state.state.path } +} +const nested = makeNested({ even: { target: "Flow.Even" }, odd: { target: "Flow.Odd" } }) + +describe("Machine target and update into a choice", () => { + it.effect("updates the owner before the choice resolves and enters the chosen branch", () => + Effect.gen(function*() { + observed = [] + const initial = yield* Machine.planInitial(machine) + const plan = yield* Machine.plan(machine, initial.state, Events.Bump()) + assert.deepStrictEqual(observed, [1, 1, 1]) + assert.strictEqual(plan.next.value.count, 1) + assert.strictEqual(plan.next.state.path, "High") + assert.deepStrictEqual(plan.microsteps.map(({ transitions }) => transitions), [[ + { + source: "Idle", + trigger: { type: "event", event: "Bump" }, + reenter: false, + branchIndex: 0, + branchKey: undefined, + target: "Route", + resolvedTarget: "High", + updates: [""] + }, + { + source: "Route", + trigger: { type: "choice" }, + reenter: false, + branchIndex: 1, + branchKey: "high", + target: "High", + resolvedTarget: "High", + updates: [] + } + ]]) + })) + + it("inspects the choice selection and its owner update without running resolvers", () => { + const bump = Machine.transitionDefinitions(machine).find(({ source }) => source === "Idle") + assert.deepStrictEqual(bump?.branches, [{ + type: "direct", + target: "Route", + selection: { path: "Route", kind: "choice", scope: "branch" }, + updates: [""] + }]) + }) + + it.effect("renders the owner update on the edge into the choice", () => + Effect.gen(function*() { + const initial = yield* Machine.planInitial(nested) + const text = makeTextRenderer(Machine)(nested, initial.state) + const mermaid = makeMermaidRenderer(Machine)(nested, initial.state) + assert.include(text, "├─ ● Idle\n │ │ └─ ◇ on: Bump\n │ │ └┄ → First / update Flow") + assert.include(text, "└─ ◇ on: Stay [reenter]\n │ │ └┄ → First / update Flow") + assert.include(text, "├─ ○ First [choice]\n │ │ └─ ◇ choice\n │ │ └┄ [next] → Second") + assert.include(mermaid, "state state_3 <>") + assert.include(mermaid, "state_2 --> state_3: Bump / update Flow") + assert.include(mermaid, "state_5 --> state_3: Stay [reenter] / update Flow") + assert.include(mermaid, "state_4 --> state_6: choice [odd]") + })) + + it.effect("applies the update and the chosen target in a running machine", () => + Effect.gen(function*() { + const ref = yield* Machine.start(machine) + yield* ref.send(Events.Bump()) + const { state } = yield* Machine.waitFor(ref, ({ state }) => state.state.path === "High") + assert.strictEqual(state.value.count, 1) + })) + + it.effect("resolves nested choices against the updated owner", () => + Effect.gen(function*() { + const initial = yield* Machine.planInitial(nested) + const first = yield* Machine.plan(nested, initial.state, NestedEvents.Bump()) + assert.deepStrictEqual(flowOf(first.next), { count: 1, path: "Flow.Odd" }) + assert.deepStrictEqual( + first.microsteps[0]?.transitions.map(({ source, branchKey, target, resolvedTarget, updates }) => ({ + source, + branchKey, + target, + resolvedTarget, + updates + })), + [ + { + source: "Flow.Idle", + branchKey: undefined, + target: "Flow.First", + resolvedTarget: "Flow.Odd", + updates: ["Flow"] + }, + { + source: "Flow.First", + branchKey: "next", + target: "Flow.Second", + resolvedTarget: "Flow.Second", + updates: [] + }, + { source: "Flow.Second", branchKey: "odd", target: "Flow.Odd", resolvedTarget: "Flow.Odd", updates: [] } + ] + ) + const second = yield* Machine.plan(nested, first.next, NestedEvents.Bump()) + assert.deepStrictEqual(flowOf(second.next), { count: 2, path: "Flow.Even" }) + assert.deepStrictEqual(second.microsteps[0]?.exitPaths, ["Flow.Odd"]) + assert.deepStrictEqual(second.microsteps[0]?.entryPaths, ["Flow.Even"]) + })) + + it.effect("satisfies MachineTest definition laws for updates entering choices", () => + Effect.gen(function*() { + const events = [NestedEvents.Bump(), NestedEvents.Bump(), NestedEvents.Stay(), NestedEvents.Reset()] + const trace = yield* MachineTest.run(nested, { events }) + yield* MachineTest.verify(nested, trace) + const coverage = MachineTest.coverage(nested, trace) + assert.strictEqual(coverage.microsteps.choiceTriggered, 6) + })) + + it.effect("reenters the source when a reentering update resolves the choice back to it", () => + Effect.gen(function*() { + const initial = yield* Machine.planInitial(nested) + const odd = yield* Machine.plan(nested, initial.state, NestedEvents.Bump()) + const even = yield* Machine.plan(nested, odd.next, NestedEvents.Bump()) + const plan = yield* Machine.plan(nested, even.next, NestedEvents.Stay()) + assert.deepStrictEqual(flowOf(plan.next), { count: 4, path: "Flow.Even" }) + assert.strictEqual(plan.microsteps[0]?.transitions[0]?.reenter, true) + assert.deepStrictEqual(plan.microsteps[0]?.exitPaths, ["Flow.Even"]) + assert.deepStrictEqual(plan.microsteps[0]?.entryPaths, ["Flow.Even"]) + })) + + it.effect("keeps a targetless owner update in place", () => + Effect.gen(function*() { + const initial = yield* Machine.planInitial(nested) + const odd = yield* Machine.plan(nested, initial.state, NestedEvents.Bump()) + const plan = yield* Machine.plan(nested, odd.next, NestedEvents.Reset()) + assert.deepStrictEqual(flowOf(plan.next), { count: 0, path: "Flow.Odd" }) + assert.deepStrictEqual(plan.microsteps[0]?.exitPaths, []) + assert.deepStrictEqual(plan.microsteps[0]?.entryPaths, []) + })) + + it.effect("enters a choice from a declared branch that also updates its owner", () => + Effect.gen(function*() { + const grouped = Machine.make({ + root: States, + events: Events, + branches: { + route: { low: { target: "Low" }, high: { target: "High" } }, + bump: { toRoute: { target: "Route", update: "root", title: "Bump and route" } } + } + }).handle({ + root: { count: 0 }, + initial: { target: "Idle" }, + states: { + Idle: { + on: { + Bump: { + branches: "bump", + resolve: ({ root, select }) => select.toRoute({ update: { data: { count: root.count + 1 } } }) + } + } + }, + Route: { + choice: { + branches: "route", + resolve: ({ containingState, select }) => containingState.count > 0 ? select.high() : select.low() + } + }, + Low: {}, + High: {} + } + }) + assert.deepStrictEqual( + Machine.transitionDefinitions(grouped).find(({ source }) => source === "Idle")?.branches, + [{ + type: "branch", + key: "toRoute", + title: "Bump and route", + target: "Route", + selection: { path: "Route", kind: "choice", scope: "branch" }, + updates: [""] + }] + ) + const initial = yield* Machine.planInitial(grouped) + const plan = yield* Machine.plan(grouped, initial.state, Events.Bump()) + assert.strictEqual(plan.next.value.count, 1) + assert.strictEqual(plan.next.state.path, "High") + assert.deepStrictEqual( + plan.microsteps[0]?.transitions.map(({ source, branchKey, updates }) => ({ source, branchKey, updates })), + [ + { source: "Idle", branchKey: "toRoute", updates: [""] }, + { source: "Route", branchKey: "high", updates: [] } + ] + ) + })) + + it("rejects a choice branch that can leave the updated owner", () => { + assert.throws( + () => makeNested({ out: { target: "Outside" }, even: { target: "Flow.Even" }, odd: { target: "Flow.Odd" } }), + "Machine transition from \"Flow.Idle\" updates \"Flow\" before choice \"Flow.Second\", whose branch \"out\" targets \"Outside\" outside that owner" + ) + }) + + it("rejects a direct choice transition that leaves the updated owner", () => { + const definition = Machine.make({ root: NestedStates, events: NestedEvents }) + assert.throws( + () => + definition.handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + initial: { target: "Flow.Idle" }, + states: { + Idle: { + on: { + Bump: { + target: "Flow.First", + update: "Flow", + data: ({ ancestors }: any) => ({ count: ancestors.Flow.count + 1 }) + } as any + } + }, + First: { choice: { target: "Outside" } }, + Second: { choice: { target: "Flow.Even" } }, + Even: {}, + Odd: {} + } + }, + Outside: {} + } + }), + "Machine transition from \"Flow.Idle\" updates \"Flow\" before choice \"Flow.First\", whose transition targets \"Outside\" outside that owner" + ) + }) + + it("keeps rejecting owners that are not valued retained ancestors", () => { + const definition = Machine.make({ root: NestedStates, events: NestedEvents }) + assert.throws( + () => + definition.handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + initial: { target: "Flow.Idle" }, + states: { + Idle: { on: { Bump: { target: "Flow.First", update: "Flow.Idle" } as any } }, + First: { choice: { target: "Flow.Even" } }, + Second: { choice: { target: "Flow.Even" } }, + Even: {}, + Odd: {} + } + }, + Outside: {} + } + }), + "Machine updating owner \"Flow.Idle\" must be a valued ancestor retained by source \"Flow.Idle\" and target \"Flow.First\"" + ) + }) +}) diff --git a/packages/effect-machine/typetest/machine/ChoiceUpdate.tst.ts b/packages/effect-machine/typetest/machine/ChoiceUpdate.tst.ts new file mode 100644 index 0000000..097f06a --- /dev/null +++ b/packages/effect-machine/typetest/machine/ChoiceUpdate.tst.ts @@ -0,0 +1,327 @@ +import { Effect, Schema } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" + +const States = Machine.state({ + fields: { count: Schema.Number }, + states: { Idle: {}, Route: { type: "choice" }, Low: {}, High: {} } +}) +const Events = Machine.events({ Bump: {} }) + +const Nested = Machine.state({ + states: { + Flow: { + fields: { count: Schema.Number }, + states: { Idle: {}, First: { type: "choice" }, Second: { type: "choice" }, Even: {}, Odd: {}, Done: {} } + }, + Outside: {} + } +}) +const NestedEvents = Machine.events({ Bump: {} }) + +describe("Machine target and update into a choice", () => { + const definition = Machine.make({ + root: States, + events: Events, + branches: { + route: { low: { target: "Low" }, high: { target: "High" } }, + bump: { toRoute: { target: "Route", update: "root" } } + } + }) + + it("updates the owner while entering a choice with owner construction data", () => { + const machine = definition.handle({ + root: { count: 0 }, + initial: { target: "Idle" }, + states: { + Idle: { + on: { + Bump: { + target: "Route", + update: "root", + data: ({ root }) => { + expect(root.count).type.toBe() + return { count: root.count + 1 } + } + } + } + }, + Route: { + choice: { + branches: "route", + resolve: ({ containingState, select }) => containingState.count > 0 ? select.high() : select.low() + } + }, + Low: {}, + High: {} + } + }) + expect(Machine.planInitial(machine)).type.toBeAssignableTo>() + expect(definition.handle({ + root: { count: 0 }, + initial: { target: "Idle" }, + states: { + Idle: { on: { Bump: { target: "Route", update: "root", decoded: true, data: ({ root }) => root } } }, + Route: { + choice: { + branches: "route", + resolve: ({ containingState, select }) => containingState.count > 0 ? select.high() : select.low() + } + }, + Low: {}, + High: {} + } + })).type.not.toRaiseError() + }) + + it("requires owner data and rejects choice data", () => { + expect(definition.handle({ + root: { count: 0 }, + initial: { target: "Idle" }, + states: { + Idle: { on: { Bump: { target: "Route", update: "root" } } }, + Route: { + choice: { + branches: "route", + resolve: ({ containingState, select }) => containingState.count > 0 ? select.high() : select.low() + } + }, + Low: {}, + High: {} + } + })).type.toRaiseError() + expect(definition.handle({ + root: { count: 0 }, + initial: { target: "Idle" }, + states: { + Idle: { on: { Bump: { target: "Route", update: "root", data: { target: undefined, update: { count: 1 } } } } }, + Route: { + choice: { + branches: "route", + resolve: ({ containingState, select }) => containingState.count > 0 ? select.high() : select.low() + } + }, + Low: {}, + High: {} + } + })).type.toRaiseError() + }) + + it("rejects destinations that are neither states nor choices", () => { + expect(definition.handle({ + root: { count: 0 }, + initial: { target: "Idle" }, + states: { + Idle: { on: { Bump: { target: "Missing", update: "root", data: { count: 1 } } } }, + Route: { + choice: { + branches: "route", + resolve: ({ containingState, select }) => containingState.count > 0 ? select.high() : select.low() + } + }, + Low: {}, + High: {} + } + })).type.toRaiseError() + }) + + it("constructs only the owner update from a declared branch", () => { + definition.handle({ + root: { count: 0 }, + initial: { target: "Idle" }, + states: { + Idle: { + on: { + Bump: { + branches: "bump", + resolve: ({ root, select }) => { + expect(select.toRoute).type.not.toBeCallableWith() + expect(select.toRoute).type.not.toBeCallableWith({ data: { count: 1 }, update: { data: { count: 1 } } }) + return select.toRoute({ update: { data: { count: root.count + 1 } } }) + } + } + } + }, + Route: { + choice: { + branches: "route", + resolve: ({ containingState, select }) => containingState.count > 0 ? select.high() : select.low() + } + }, + Low: {}, + High: {} + } + }) + }) +}) + +describe("choice branches entered with an owner update", () => { + const definition = Machine.make({ + root: Nested, + events: NestedEvents, + branches: { + first: { next: { target: "Flow.Second" } }, + contained: { even: { target: "Flow.Even" }, odd: { target: "Flow.Odd" } }, + escaping: { even: { target: "Flow.Even" }, out: { target: "Outside" } }, + bump: { toFirst: { target: "Flow.First", update: "Flow" } } + } + }) + + it("accepts nested choices whose reachable branches stay below the owner", () => { + expect(definition.handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + initial: { target: "Flow.Idle" }, + states: { + Idle: { + on: { + Bump: { + target: "Flow.First", + update: "Flow", + data: ({ ancestors }) => ({ count: ancestors.Flow.count + 1 }) + } + } + }, + First: { choice: { branches: "first", resolve: ({ select }) => select.next() } }, + Second: { choice: { branches: "contained", resolve: ({ select }) => select.even() } }, + Even: {}, + Odd: {}, + Done: {} + } + }, + Outside: {} + } + })).type.not.toRaiseError() + }) + + it("names the nested choice branch that leaves the owner", () => { + expect(definition.handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + initial: { target: "Flow.Idle" }, + states: { + Idle: { + on: { + Bump: { + target: "Flow.First", + update: "Flow", + data: ({ ancestors }) => ({ count: ancestors.Flow.count + 1 }) + } + } + }, + First: { choice: { branches: "first", resolve: ({ select }) => select.next() } }, + Second: { choice: { branches: "escaping", resolve: ({ select }) => select.even() } }, + Even: {}, + Odd: {}, + Done: {} + } + }, + Outside: {} + } + })).type.toRaiseError("Choice 'Flow.Second' branch 'out' targets 'Outside' outside the updated owner 'Flow'") + }) + + it("checks direct choice transitions, declared branches, and invocation outcomes", () => { + expect(definition.handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + initial: { target: "Flow.Idle" }, + states: { + Idle: { + on: { + Bump: { + target: "Flow.First", + update: "Flow", + data: ({ ancestors }) => ({ count: ancestors.Flow.count + 1 }) + } + } + }, + First: { choice: { target: "Outside" } }, + Second: { choice: { target: "Flow.Even" } }, + Even: {}, + Odd: {}, + Done: {} + } + }, + Outside: {} + } + })).type.toRaiseError("Choice 'Flow.First' transition targets 'Outside' outside the updated owner 'Flow'") + expect(definition.handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + initial: { target: "Flow.Idle" }, + states: { + Idle: { + on: { + Bump: { + branches: "bump", + resolve: ({ ancestors, select }) => select.toFirst({ update: { data: ancestors.Flow } }) + } + } + }, + First: { choice: { target: "Outside" } }, + Second: { choice: { target: "Flow.Even" } }, + Even: {}, + Odd: {}, + Done: {} + } + }, + Outside: {} + } + })).type.toRaiseError("Choice 'Flow.First' transition targets 'Outside' outside the updated owner 'Flow'") + const withEffects = Machine.make({ + effects: { load: Effect.succeed(1) }, + root: Nested, + events: NestedEvents + }) + expect(withEffects.handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + initial: { target: "Flow.Idle" }, + states: { + Idle: { + invoke: { + src: "load", + onDone: { target: "Flow.First", update: "Flow", data: ({ output }) => ({ count: output }) } + } + }, + First: { choice: { target: "Outside" } }, + Second: { choice: { target: "Flow.Even" } }, + Even: {}, + Odd: {}, + Done: {} + } + }, + Outside: {} + } + })).type.toRaiseError("Choice 'Flow.First' transition targets 'Outside' outside the updated owner 'Flow'") + }) + + it("rejects an owner that does not contain the choice as an invalid destination", () => { + const Sibling = Machine.state({ + states: { + Flow: { fields: { count: Schema.Number }, states: { Idle: {} } }, + Route: { type: "choice" }, + Outside: {} + } + }) + expect( + Machine.make({ root: Sibling, events: NestedEvents }).handle({ + initial: { target: "Flow", data: { count: 0 } }, + states: { + Flow: { + initial: { target: "Flow.Idle" }, + states: { Idle: { on: { Bump: { target: "Route", update: "Flow", data: { count: 1 } } } } } + }, + Route: { choice: { target: "Outside" } }, + Outside: {} + } + }) + ).type.toRaiseError() + }) +})