From 9fd7235afb40bde43815ca775e308011f7b39861 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:39:00 +0000 Subject: [PATCH] Allow target plus update transitions to enter choice states The owner is updated before the choice resolves, so the resolver reads the new value. Every branch the choice can reach, transitively through nested choices, must stay below the owner: escaping branches are a branded type error and are rejected by handle at construction. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MHnF2EL6ReDTKwoM9TVH7L --- .changeset/update-into-choice-states.md | 13 + packages/effect-machine/README.md | 15 + packages/effect-machine/docs/agent-guide.md | 7 + packages/effect-machine/src/Machine.ts | 138 ++++++- .../src/internal/machine/machine.ts | 66 +++- .../machine/strategyDifferential.test.ts | 90 +++++ .../test/machine/ChoiceUpdate.test.ts | 362 ++++++++++++++++++ .../typetest/machine/ChoiceUpdate.tst.ts | 327 ++++++++++++++++ 8 files changed, 999 insertions(+), 19 deletions(-) create mode 100644 .changeset/update-into-choice-states.md create mode 100644 packages/effect-machine/test/machine/ChoiceUpdate.test.ts create mode 100644 packages/effect-machine/typetest/machine/ChoiceUpdate.tst.ts 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 3cfdc60..7435797 100644 --- a/packages/effect-machine/docs/agent-guide.md +++ b/packages/effect-machine/docs/agent-guide.md @@ -429,6 +429,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() + }) +})