From 607a0c465c3a275882067e66d6b39072b6bc4eb1 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sat, 1 Aug 2026 15:08:26 +0200 Subject: [PATCH] fix: enter nested parallel targets --- .changeset/calm-machines-enter.md | 7 ++ README.md | 5 + docs/agent-guide.md | 8 +- src/Machine.ts | 201 ++++++++++++++++++++++++----- src/internal/machineModel.ts | 65 ++++++++++ test/Machine.test.ts | 202 ++++++++++++++++++++++++++++++ typetest/Machine.tst.ts | 138 ++++++++++++++++++++ 7 files changed, 595 insertions(+), 31 deletions(-) create mode 100644 .changeset/calm-machines-enter.md diff --git a/.changeset/calm-machines-enter.md b/.changeset/calm-machines-enter.md new file mode 100644 index 0000000..5219390 --- /dev/null +++ b/.changeset/calm-machines-enter.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": patch +--- + +Allow local and branch targets to enter inactive nested parallel states. These +targets now require a complete selection for every parallel region while +preserving partial updates for parallel states that are already active. diff --git a/README.md b/README.md index 55917cd..db9dc83 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,11 @@ Transition contexts expose three typed target builders: | `target.branch` | Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions | | `target.full` | Any top-level root | Builds a complete active snapshot for the selected root | +When `target.local` or `target.branch` enters an inactive nested parallel +state, its callback must select every region, just like `initial` and +`target.full`. When that parallel state is already active, `target.branch` +can still update one region directly and preserves the other active regions. + The builder controls how the next configuration is assembled; it does not by itself decide which invokes restart. The runtime derives exit and entry paths from the previous and next active paths. Shared active ancestors remain entered, diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 7ab2f57..6b95710 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -170,7 +170,8 @@ const ParallelStates = Machine.defineStates({ ``` Every parallel region needs an active state in initial and full snapshot -builders. +builders. The same rule applies when a local or branch target enters an +inactive nested parallel state. Use `type: "final"` for a terminal leaf in `Machine.defineStates`. A final child completes its compound parent. Put `onDone` on that completed parent, @@ -208,6 +209,11 @@ until every declared output schema has an implementation. | `target.branch` | The destination is elsewhere under the active top-level root | Omitted current ancestor values and parallel regions | | `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot | +Entering an inactive parallel state through `target.local` or `target.branch` +requires a complete callback with one selection per region. A parallel state +that is already active remains partially addressable through `target.branch`; +unmentioned active regions are preserved. + These describe configuration construction, not automatic process restart. Machine planning compares active paths and derives the actual exit and entry sets. A `target.full` result with the same active paths can update values without diff --git a/src/Machine.ts b/src/Machine.ts index 675a474..1e0581d 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -715,7 +715,9 @@ type LocalTargetResult< Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends { readonly states: infer Children extends Machine.StateSchemas } ? - LocalTargetResultWithPrefix + States[StateId] extends { readonly type: "parallel" } ? + Machine.Target> + : LocalTargetResultWithPrefix : Machine.Target> type LocalTargetResultWithPrefix< @@ -729,9 +731,10 @@ type LocalTargetResultWithPrefix< type LocalTargetBuilderWithPrefix< AllStates extends Machine.StateSchemas, States extends Machine.StateSchemas, - Prefix extends string + Prefix extends string, + Source extends Machine.StateIdentifier > = { - readonly [Key in Extract]: LocalTargetMethod + readonly [Key in Extract]: LocalTargetMethod } type LocalTargetMethod< @@ -739,9 +742,27 @@ type LocalTargetMethod< States extends Machine.StateSchemas, StateId extends Extract, Prefix extends string, + Source extends Machine.StateIdentifier, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? - Node extends { readonly states: infer Children extends Machine.StateSchemas } ? < + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + Source extends Path | `${Path}.${string}` ? >( + value: Machine.NodeSchema["Type"], + state: ( + builder: LocalTargetBuilderWithPrefix + ) => Result + ) => Result + : ( + value: Machine.NodeSchema["Type"], + states: ( + builder: FullParallelBuilder + ) => SnapshotBuilderComplete> + ) => Machine.Target> + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? < Result extends LocalTargetResultWithPrefix< AllStates, Children, @@ -750,7 +771,7 @@ type LocalTargetMethod< >( value: Machine.NodeSchema["Type"], state: ( - builder: LocalTargetBuilderWithPrefix + builder: LocalTargetBuilderWithPrefix ) => Result ) => Result : (value: Machine.NodeSchema["Type"]) => Machine.Target> @@ -758,9 +779,10 @@ type LocalTargetMethod< type LocalTargetBuilderForScope< States extends Machine.StateSchemas, - Scope extends Machine.StateIdentifier + Scope extends Machine.StateIdentifier, + Source extends Machine.StateIdentifier > = ChildrenOf extends infer Children extends Machine.StateSchemas ? - & LocalTargetBuilderWithPrefix + & LocalTargetBuilderWithPrefix & { /** * Updates the value of the state containing the local group and moves to @@ -771,7 +793,7 @@ type LocalTargetBuilderForScope< readonly with: >( value: Machine.StateByIdentifier, state: ( - builder: LocalTargetBuilderWithPrefix + builder: LocalTargetBuilderWithPrefix ) => Result ) => Result } @@ -784,7 +806,9 @@ type BranchTargetResult< Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends { readonly states: infer Children extends Machine.StateSchemas } ? - BranchTargetResultWithPrefix + States[StateId] extends { readonly type: "parallel" } ? + Machine.Target> + : BranchTargetResultWithPrefix : Machine.Target> type BranchTargetResultWithPrefix< @@ -798,9 +822,10 @@ type BranchTargetResultWithPrefix< type BranchTargetBuilderWithPrefix< AllStates extends Machine.StateSchemas, States extends Machine.StateSchemas, - Prefix extends string + Prefix extends string, + Source extends Machine.StateIdentifier > = { - readonly [Key in Extract]: BranchTargetMethod + readonly [Key in Extract]: BranchTargetMethod } type BranchTargetMethod< @@ -808,24 +833,41 @@ type BranchTargetMethod< States extends Machine.StateSchemas, StateId extends Extract, Prefix extends string, + Source extends Machine.StateIdentifier, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? - Node extends { readonly states: infer Children extends Machine.StateSchemas } ? + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + Source extends Path | `${Path}.${string}` ? + & (>( + value: Machine.NodeSchema["Type"], + state: ( + builder: BranchTargetBuilderWithPrefix + ) => Result + ) => Result) + & BranchTargetBuilderWithPrefix + : ( + value: Machine.NodeSchema["Type"], + states: ( + builder: FullParallelBuilder + ) => SnapshotBuilderComplete> + ) => Machine.Target> + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? & (>( value: Machine.NodeSchema["Type"], state: ( - builder: BranchTargetBuilderWithPrefix + builder: BranchTargetBuilderWithPrefix ) => Result ) => Result) - & BranchTargetBuilderWithPrefix + & BranchTargetBuilderWithPrefix : (value: Machine.NodeSchema["Type"]) => Machine.Target> : never type BranchTargetBuilderForRoot< States extends Machine.StateSchemas, - Root extends Extract + Root extends Extract, + Source extends Machine.StateIdentifier > = { - readonly [Key in Root]: BranchTargetMethod + readonly [Key in Root]: BranchTargetMethod } type SpawnRequirements = Exclude< @@ -2306,6 +2348,7 @@ export declare namespace Machine { StateId extends StateIdentifier > { readonly [Model.TargetTypeId]: typeof Model.TargetTypeId + readonly [Model.TargetSnapshotTypeId]?: SnapshotByIdentifier readonly path: StateId readonly value: StateByIdentifier readonly values?: Partial< @@ -2343,7 +2386,7 @@ export declare namespace Machine { States extends StateSchemas, Source extends StateIdentifier > = NearestCompoundScope extends infer Scope ? [Scope] extends [never] ? {} - : Scope extends StateIdentifier ? LocalTargetBuilderForScope + : Scope extends StateIdentifier ? LocalTargetBuilderForScope : {} : {} @@ -2361,7 +2404,11 @@ export declare namespace Machine { export type BranchTargetBuilder< States extends StateSchemas, Source extends StateIdentifier - > = BranchTargetBuilderForRoot, Extract>> + > = BranchTargetBuilderForRoot< + States, + Extract, Extract>, + Source + > /** * Machine-bound target builders available in transition contexts. @@ -3951,6 +3998,48 @@ const makeTargetWithValues = ( ? Model.makeTarget(path as any, value as any, { values: values as any }) : Model.makeTarget(path as any, value as any) +const getTargetBuilderDefinition = ( + states: Machine.StateTree, + targetPath: string +): Machine.TaggedSchema | Machine.StateNodeConfig => { + let children = states + let path = "" + let definition: Machine.TaggedSchema | Machine.StateNodeConfig | undefined + for (const key of targetPath.split(".")) { + if (!hasProperty(children, key)) { + throw new Error(`Machine expected state path "${targetPath}" to exist`) + } + definition = children[key] + path = path === "" ? key : `${path}.${key}` + const node = Model.getStateNodeDefinition(path, definition) + children = node.states ?? {} + } + return definition! +} + +const makeParallelTarget = ( + states: Machine.StateTree, + node: Machine.StateNode, + value: unknown, + selector: ((builder: unknown) => unknown) | undefined, + values: Readonly> | undefined +): Machine.Target => { + if (selector === undefined) { + throw new Error(`Machine expected parallel target "${node.path}" builder to provide every active region`) + } + const snapshot = makeSnapshotForNode( + getTargetBuilderDefinition(states, node.path), + node.key, + value, + selector, + { mode: "full", prefix: node.parent ?? "" } + ) + return Model.makeTarget(node.path as any, value as any, { + snapshot: snapshot as any, + values: values as any + }) +} + const extendTargetValues = ( values: Readonly> | undefined, path: string, @@ -3967,9 +4056,11 @@ const extendTargetValues = ( } const makeLocalTargetChildBuilder = ( + states: Machine.StateTree, stateNodes: Machine.StateNodes, parentPath: string, - values: Readonly> | undefined + values: Readonly> | undefined, + source: string ): unknown => { const parent = getTargetBuilderNode(stateNodes, parentPath) const builder: Record = {} @@ -3979,13 +4070,30 @@ const makeLocalTargetChildBuilder = ( if (child.type === "atomic" || child.type === "final") { return makeTargetWithValues(child.path, value, values) } + if (child.type === "parallel") { + if (source !== child.path && !source.startsWith(`${child.path}.`)) { + return makeParallelTarget(states, child, value, selector, values) + } + if (selector === undefined) { + throw new Error(`Machine expected target "${child.path}" builder to provide an active child state`) + } + return selector(makeLocalTargetChildBuilder( + states, + stateNodes, + child.path, + extendTargetValues(values, child.path, value), + source + )) + } if (selector === undefined) { throw new Error(`Machine expected target "${child.path}" builder to provide an active child state`) } return selector(makeLocalTargetChildBuilder( + states, stateNodes, child.path, - extendTargetValues(values, child.path, value) + extendTargetValues(values, child.path, value), + source )) } } @@ -3993,6 +4101,7 @@ const makeLocalTargetChildBuilder = ( } const makeLocalTargetBuilder = ( + states: Machine.StateTree, stateNodes: Machine.StateNodes, source: string ): unknown => { @@ -4000,58 +4109,90 @@ const makeLocalTargetBuilder = ( if (scope === undefined) { return {} } - const builder = makeLocalTargetChildBuilder(stateNodes, scope, undefined) as Record + const builder = makeLocalTargetChildBuilder(states, stateNodes, scope, undefined, source) as Record builder.with = (value: unknown, selector?: (builder: unknown) => unknown) => { if (selector === undefined) { throw new Error(`Machine expected target "${scope}" builder to provide an active child state`) } - return selector(makeLocalTargetChildBuilder(stateNodes, scope, { [scope]: value })) + return selector(makeLocalTargetChildBuilder(states, stateNodes, scope, { [scope]: value }, source)) } return builder } const addBranchTargetChildren = ( builder: Record, + states: Machine.StateTree, stateNodes: Machine.StateNodes, parentPath: string, - values: Readonly> | undefined + values: Readonly> | undefined, + source: string ): void => { const parent = getTargetBuilderNode(stateNodes, parentPath) for (const childPath of parent.children) { const child = getTargetBuilderNode(stateNodes, childPath) - builder[child.key] = makeBranchTargetNodeBuilder(stateNodes, child.path, values) + builder[child.key] = makeBranchTargetNodeBuilder(states, stateNodes, child.path, values, source) } } const makeBranchTargetNodeBuilder = ( + states: Machine.StateTree, stateNodes: Machine.StateNodes, path: string, - values: Readonly> | undefined + values: Readonly> | undefined, + source: string ): unknown => { const node = getTargetBuilderNode(stateNodes, path) if (node.type === "atomic" || node.type === "final") { return (value: unknown) => makeTargetWithValues(node.path, value, values) } const builder = ((value: unknown, selector?: (builder: unknown) => unknown) => { + if (node.type === "parallel") { + if (source !== node.path && !source.startsWith(`${node.path}.`)) { + return makeParallelTarget(states, node, value, selector, values) + } + if (selector === undefined) { + throw new Error(`Machine expected target "${node.path}" builder to provide an active child state`) + } + const nextBuilder: Record = {} + addBranchTargetChildren( + nextBuilder, + states, + stateNodes, + node.path, + extendTargetValues(values, node.path, value), + source + ) + return selector(nextBuilder) + } if (selector === undefined) { throw new Error(`Machine expected target "${node.path}" builder to provide an active child state`) } const nextBuilder: Record = {} - addBranchTargetChildren(nextBuilder, stateNodes, node.path, extendTargetValues(values, node.path, value)) + addBranchTargetChildren( + nextBuilder, + states, + stateNodes, + node.path, + extendTargetValues(values, node.path, value), + source + ) return selector(nextBuilder) }) as unknown as Record - addBranchTargetChildren(builder, stateNodes, node.path, values) + if (node.type !== "parallel" || source === node.path || source.startsWith(`${node.path}.`)) { + addBranchTargetChildren(builder, states, stateNodes, node.path, values, source) + } return builder } const makeBranchTargetBuilder = ( + states: Machine.StateTree, stateNodes: Machine.StateNodes, source: string ): unknown => { const rootPath = source.split(".")[0]! const root = getTargetBuilderNode(stateNodes, rootPath) return { - [root.key]: makeBranchTargetNodeBuilder(stateNodes, root.path, undefined) + [root.key]: makeBranchTargetNodeBuilder(states, stateNodes, root.path, undefined, source) } } @@ -4062,8 +4203,8 @@ const makeTargetBuilder = ( const full = makeSnapshotBuilder(states, { mode: "full", prefix: "" }) as Machine.FullTargetBuilder return >(source: Source): Machine.TargetBuilder => ({ - local: makeLocalTargetBuilder(stateNodes, source), - branch: makeBranchTargetBuilder(stateNodes, source), + local: makeLocalTargetBuilder(states, stateNodes, source), + branch: makeBranchTargetBuilder(states, stateNodes, source), full }) as Machine.TargetBuilder } diff --git a/src/internal/machineModel.ts b/src/internal/machineModel.ts index e132e81..30532a7 100644 --- a/src/internal/machineModel.ts +++ b/src/internal/machineModel.ts @@ -13,6 +13,7 @@ import type { Machine } from "../Machine.js" import { MachineSchemaDecodeError, MachineSchemaEncodeError } from "./machineErrors.js" export const TargetTypeId = "~effect/Machine/Target" +export const TargetSnapshotTypeId: unique symbol = Symbol("effect/Machine/TargetSnapshot") export const getStateNodeDefinition = ( path: string, @@ -122,6 +123,7 @@ export const makeTarget = < path: StateId, value: Machine.StateByIdentifier, options?: { + readonly snapshot?: Machine.SnapshotByIdentifier readonly values?: Partial< { readonly [AncestorStateId in Machine.StateIdentifier]: Machine.StateByIdentifier< @@ -134,6 +136,7 @@ export const makeTarget = < ): Machine.Target => ({ [TargetTypeId]: TargetTypeId, + [TargetSnapshotTypeId]: options?.snapshot, path, value, values: options?.values @@ -681,12 +684,74 @@ export const configurationFromTargetPathEffect = Effect.fnUntraced(function*( return { active, values, outputs } as ActiveConfiguration }) +export const configurationFromTargetSnapshotEffect = Effect.fnUntraced(function*( + machine: Machine.Any, + current: ActiveConfiguration, + snapshot: Machine.AtomicSnapshot, + providedValues: Readonly> | undefined +) { + const subtree = yield* configurationFromSnapshotEffect(machine, snapshot) + const active = new Set(subtree.active) + const values = new Map(subtree.values) + const outputs = new Map(subtree.outputs) + const paths = getPathToRoot(machine, String(snapshot.path)) + const pathSet = new Set(paths) + + for (const ancestor of paths.slice(0, -1)) { + const node = getNode(machine, ancestor) + active.add(ancestor) + if (providedValues !== undefined && hasOwn(providedValues, ancestor)) { + values.set(ancestor, yield* decodeStateValue(machine, node, providedValues[ancestor])) + } else if (current.values.has(ancestor)) { + values.set(ancestor, current.values.get(ancestor)) + } else { + throw new Error(`Machine target "${snapshot.path}" requires a value for ancestor state "${ancestor}"`) + } + } + + for (const ancestor of paths.slice(0, -1)) { + const ancestorNode = getNode(machine, ancestor) + if (ancestorNode.type === "parallel") { + for (const child of ancestorNode.children) { + if (pathSet.has(child) || !current.active.has(child)) { + continue + } + for (const activePath of current.active) { + if (isPathInSubtree(activePath, child)) { + active.add(activePath) + if (current.values.has(activePath)) { + values.set(activePath, current.values.get(activePath)) + } + if (current.outputs.has(activePath)) { + outputs.set(activePath, current.outputs.get(activePath)) + } + } + } + } + } + } + + return { active, values, outputs } as ActiveConfiguration +}) + export const normalizeTargetConfigurationEffect = ( machine: Machine.Any, current: ActiveConfiguration, target: Machine.Snapshot | Machine.Target> ): Effect.Effect => { if (isTarget(target)) { + const snapshot = target[TargetSnapshotTypeId] + if (snapshot !== undefined) { + if (String(snapshot.path) !== String(target.path)) { + throw new Error(`Machine expected target snapshot path to be "${target.path}"`) + } + return configurationFromTargetSnapshotEffect( + machine, + current, + snapshot, + target.values as Readonly> | undefined + ) + } return configurationFromTargetPathEffect( machine, current, diff --git a/test/Machine.test.ts b/test/Machine.test.ts index 3bd7d6c..14612c5 100644 --- a/test/Machine.test.ts +++ b/test/Machine.test.ts @@ -1752,6 +1752,208 @@ describe("Machine", () => { ]) })) + it.effect("uses target.local to enter an inactive nested parallel state", () => + Effect.gen(function*() { + const workflow = new Payment({ id: "workflow-1" }) + const states = Machine.defineStates({ + workflow: { + schema: Payment, + initial: "idle", + states: { + idle: Idle, + fulfillment: { + schema: Fulfillment, + type: "parallel", + states: { + inventory: { + schema: Inventory, + initial: "checking", + states: { + checking: CheckingInventory, + reserved: InventoryReserved + } + }, + shipping: { + schema: Shipping, + initial: "quoting", + states: { + quoting: QuotingShipping, + quoted: ShippingQuoted + } + } + } + } + } + } + }) + const machine = Machine.make({ + states: states.states, + events: [Submit], + initial: () => + states.initial.workflow( + workflow, + (workflow) => workflow.idle(new Idle({ userId: "user-1" })) + ) + }).handle({ + workflow: { + states: { + idle: { + on: { + Submit: ({ event, target }) => + target.local.fulfillment( + new Fulfillment({ id: event.value }), + (fulfillment) => + fulfillment + .inventory( + new Inventory({ warehouse: "warehouse-1" }), + (inventory) => + inventory.reserved(new InventoryReserved({ reservationId: event.value })) + ) + .shipping( + new Shipping({ address: "Main Street" }), + (shipping) => shipping.quoted(new ShippingQuoted({ quoteId: event.value })) + ) + ) + } + } + } + } + }) + + const planned = yield* Machine.plan( + machine, + states.initial.workflow( + workflow, + (workflow) => workflow.idle(new Idle({ userId: "user-1" })) + ), + new Submit({ value: "order-1" }) + ) + + assertCompoundStateSnapshot(planned.next as any, "workflow", workflow, { + path: "workflow.fulfillment", + value: new Fulfillment({ id: "order-1" }), + states: { + inventory: { + path: "workflow.fulfillment.inventory", + value: new Inventory({ warehouse: "warehouse-1" }), + state: { + path: "workflow.fulfillment.inventory.reserved", + value: new InventoryReserved({ reservationId: "order-1" }) + } + }, + shipping: { + path: "workflow.fulfillment.shipping", + value: new Shipping({ address: "Main Street" }), + state: { + path: "workflow.fulfillment.shipping.quoted", + value: new ShippingQuoted({ quoteId: "order-1" }) + } + } + } + } as any) + assert.deepStrictEqual(planned.microsteps[0]?.entryPaths, [ + "workflow.fulfillment", + "workflow.fulfillment.inventory", + "workflow.fulfillment.shipping", + "workflow.fulfillment.inventory.reserved", + "workflow.fulfillment.shipping.quoted" + ]) + })) + + it.effect("uses target.branch to enter a nested parallel state and preserve outer regions", () => + Effect.gen(function*() { + const app = new Fulfillment({ id: "app-1" }) + const flow = new Payment({ id: "flow-1" }) + const monitor = new QuotingShipping({ postalCode: "12345" }) + const states = Machine.defineStates({ + app: { + schema: Fulfillment, + type: "parallel", + states: { + flow: { + schema: Payment, + initial: "idle", + states: { + idle: Idle, + fulfillment: { + schema: Fulfillment, + type: "parallel", + states: { + inventory: Inventory, + shipping: Shipping + } + } + } + }, + monitor: QuotingShipping + } + } + }) + const initial = states.initial.app( + app, + (app) => + app + .flow( + flow, + (flow) => flow.idle(new Idle({ userId: "user-1" })) + ) + .monitor(monitor) + ) + const machine = Machine.make({ + states: states.states, + events: [Submit], + initial: () => initial + }).handle({ + app: { + states: { + flow: { + states: { + idle: { + on: { + Submit: ({ event, target }) => + target.branch.app.flow.fulfillment( + new Fulfillment({ id: event.value }), + (fulfillment) => + fulfillment + .inventory(new Inventory({ warehouse: "warehouse-1" })) + .shipping(new Shipping({ address: "Main Street" })) + ) + } + } + } + } + } + } + }) + + const planned = yield* Machine.plan(machine, initial, new Submit({ value: "order-1" })) + + assertParallelStateSnapshot(planned.next as any, "app", app, { + flow: { + path: "app.flow", + value: flow, + state: { + path: "app.flow.fulfillment", + value: new Fulfillment({ id: "order-1" }), + states: { + inventory: { + path: "app.flow.fulfillment.inventory", + value: new Inventory({ warehouse: "warehouse-1" }) + }, + shipping: { + path: "app.flow.fulfillment.shipping", + value: new Shipping({ address: "Main Street" }) + } + } + } + }, + monitor: { + path: "app.monitor", + value: monitor + } + }) + })) + it.effect("uses target.local to preserve parent and sibling parallel region values", () => Effect.gen(function*() { const states = Machine.defineStates({ diff --git a/typetest/Machine.tst.ts b/typetest/Machine.tst.ts index dee9150..33f135d 100644 --- a/typetest/Machine.tst.ts +++ b/typetest/Machine.tst.ts @@ -96,6 +96,38 @@ describe("Machine", () => { down: Down }) + const NestedParallelStates = Machine.defineStates({ + root: { + schema: Up, + initial: "idle", + states: { + idle: Down, + work: { + schema: Payment, + type: "parallel", + states: { + auth: { + schema: Auth, + initial: "signedOut", + states: { + signedOut: SignedOut, + signedIn: SignedIn + } + }, + sync: { + schema: Sync, + initial: "idle", + states: { + idle: SyncIdle, + syncing: Syncing + } + } + } + } + } + } + }) + type ChildBuilder = Method extends (value: any, build: (builder: infer Builder) => any) => any ? Builder : never type IsCallable = A extends (...args: ReadonlyArray) => any ? true : false @@ -130,6 +162,26 @@ describe("Machine", () => { never > + type NestedIdleContext = Machine.Machine.HandlerContext< + typeof NestedParallelStates.states, + readonly [typeof SignIn], + [], + "root.idle", + "SignIn", + never, + never + > + + type NestedActiveContext = Machine.Machine.HandlerContext< + typeof NestedParallelStates.states, + readonly [typeof SignIn], + [], + "root.work.auth.signedOut", + "SignIn", + never, + never + > + it("defineStates preserves literal state paths", () => { expect>().type.toBe< | "up" @@ -1634,6 +1686,92 @@ describe("Machine", () => { expect(auth.signedIn).type.toBeCallableWith(new SignedIn({ userId: "user-1" })) }) + it("target.local requires every region when entering an inactive nested parallel state", () => { + const context = null as unknown as NestedIdleContext + const target = context.target.local.work( + new Payment({}), + (work) => + work + .auth( + new Auth({ userId: "guest" }), + (auth) => auth.signedIn(new SignedIn({ userId: "user-1" })) + ) + .sync( + new Sync({ enabled: true }), + (sync) => sync.syncing(new Syncing({ requestId: "sync-1" })) + ) + ) + + expect(target).type.toBeAssignableTo< + Machine.Machine.Target + >() + expect(target.path).type.toBe<"root.work">() + expect(context.target.local.work).type.not.toBeCallableWith( + new Payment({}), + (work: ChildBuilder) => + work.auth( + new Auth({ userId: "guest" }), + (auth) => auth.signedOut(new SignedOut({})) + ) + ) + }) + + it("target.branch requires every region when entering an inactive nested parallel state", () => { + const context = null as unknown as NestedIdleContext + const target = context.target.branch.root.work( + new Payment({}), + (work) => + work + .auth( + new Auth({ userId: "guest" }), + (auth) => auth.signedOut(new SignedOut({})) + ) + .sync( + new Sync({ enabled: true }), + (sync) => sync.idle(new SyncIdle({})) + ) + ) + + expect(target.path).type.toBe<"root.work">() + expect(context.target.branch.root.work).type.not.toHaveProperty("auth") + expect(context.target.branch.root.work).type.not.toBeCallableWith( + new Payment({}), + (work: ChildBuilder) => + work.auth( + new Auth({ userId: "guest" }), + (auth) => auth.signedOut(new SignedOut({})) + ) + ) + }) + + it("nested parallel target builders remove regions and validate payloads", () => { + const context = null as unknown as NestedIdleContext + const work = null as unknown as ChildBuilder + const afterAuth = work.auth( + new Auth({ userId: "guest" }), + (auth) => auth.signedOut(new SignedOut({})) + ) + + expect(afterAuth).type.not.toHaveProperty("auth") + expect(afterAuth).type.toHaveProperty("sync") + expect(work.auth).type.not.toBeCallableWith( + new Sync({ enabled: true }), + (auth: ChildBuilder) => auth.signedOut(new SignedOut({})) + ) + }) + + it("target.branch keeps partial navigation for an already-active parallel state", () => { + const context = null as unknown as NestedActiveContext + const target = context.target.branch.root.work.sync( + new Sync({ enabled: true }), + (sync) => sync.syncing(new Syncing({ requestId: "sync-1" })) + ) + + expect(context.target.branch.root.work).type.toHaveProperty("auth") + expect(context.target.branch.root.work).type.toHaveProperty("sync") + expect(target.path).type.toBe<"root.work.sync.syncing">() + }) + it("target.local constructs typed local leaf targets", () => { const context = null as unknown as SignedOutContext const target = context.target.local.signedIn(new SignedIn({ userId: "user-1" }))