Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/update-into-choice-states.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/effect-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/effect-machine/docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 121 additions & 17 deletions packages/effect-machine/src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message extends string> = {
readonly "~effect/Machine/RootCompatibilityError": Message
}
Expand Down Expand Up @@ -5084,7 +5094,7 @@ export declare namespace Machine {
| ChoiceTarget<States, ChoiceIdentifier<States>>
| StateUpdate<States, ValuedStateIdentifier<States>>
| CombinedTarget<
Target<States, StateIdentifier<States>> | Snapshot<States>,
Target<States, StateIdentifier<States>> | Snapshot<States> | ChoiceTarget<States, ChoiceIdentifier<States>>,
States,
ValuedStateIdentifier<States>
>
Expand Down Expand Up @@ -6445,7 +6455,10 @@ export declare namespace Machine {
| {
/** Selects a declared descendant; its transition or bound branch constructor supplies required values. */
readonly target: ReferenceUnion<S, DestinationPath<S>>
/** 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<S, ValuedStateIdentifier<S>>
/** Enters a compound or parallel subtree through its declared initialization. */
readonly initial?: never
Expand Down Expand Up @@ -6620,6 +6633,14 @@ export declare namespace Machine {
P,
"state",
"branch"
>
: P extends ChoiceIdentifier<S> ? TargetSelection<
(construction: {
readonly update: ConstructionData<NodeByIdentifier<S, Owner>>
}) => CombinedTarget<ChoiceTarget<S, P>, S, Owner>,
P,
"choice",
"branch"
> :
never
: never
Expand Down Expand Up @@ -6737,6 +6758,7 @@ export declare namespace Machine {
) => { readonly target: NodeValue<NodeByIdentifier<S, P>>; readonly update: StateByIdentifier<S, Owner> }
readonly from?: never
})
: P extends ChoiceIdentifier<S> ? ObjectConstruction<C, StateUpdateBuilder<S, Owner>>
: never
type InlineCombined<S extends StateSchemas, Src extends StateNodeIdentifier<S>, C> = Src extends ChoiceIdentifier<S> ?
never :
Expand All @@ -6747,7 +6769,11 @@ export declare namespace Machine {
& {
/** Selects a declared descendant; its transition or bound branch constructor supplies required values. */
readonly target: ReferenceAt<S, P>
/** 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<S, Owner>
/** Enters a compound or parallel subtree through its declared initialization. */
readonly initial?: never
Expand Down Expand Up @@ -7479,37 +7505,115 @@ export declare namespace Machine {
: Id extends LiteralInvokeIdAt<I, Exclude<Extract<keyof I, `${number}`>, K>, R> ? Id
: never
: never
type ValidateInvokeEntry<I, R, Duplicate extends string> = I extends {
type ValidateInvokeEntry<I, R, Duplicate extends string, Root> = I extends {
/** Name of the source registered in make. */
readonly src: infer Src extends PropertyKey
} ? {
readonly [K in keyof I]: [Duplicate] extends [never] ? ValidateInvokeField<I, K, R, Extract<Src, string>>
readonly [K in keyof I]: [Duplicate] extends [never] ? ValidateInvokeField<I, K, R, Extract<Src, string>, Root>
: K extends ("id" extends keyof I ? "id" : "src") ? InvocationDefinitionError<
`Invocation id '${Duplicate}' is declared more than once in this state`,
Extract<Src, string>,
K
>
: ValidateInvokeField<I, K, R, Extract<Src, string>>
: ValidateInvokeField<I, K, R, Extract<Src, string>, Root>
} :
never
type ValidateInvokeField<I, K extends keyof I, R, Src extends string> = K extends AllowedInvokeChannels<R, Src>
? ValidateObjectTransition<I[K]>
type ValidateInvokeField<I, K extends keyof I, R, Src extends string, Root> = K extends
AllowedInvokeChannels<R, Src> ? ValidateObjectTransition<I[K]> & ValidateChoiceUpdate<I[K], R, Root>
: K extends "src" | "id" | "address" | "input" ? unknown
: InvokeFieldError<K, Src>
type ValidateObjectInvoke<I, R> = I extends ReadonlyArray<unknown> ?
number extends I["length"] ? { readonly [K in keyof I]: ValidateInvokeEntry<I[K], R, never> }
: { readonly [K in keyof I]: ValidateInvokeEntry<I[K], R, DuplicateInvokeId<I, K, R>> } :
ValidateInvokeEntry<I, R, never>
type ValidateObjectInvoke<I, R, Root> = I extends ReadonlyArray<unknown> ?
number extends I["length"] ? { readonly [K in keyof I]: ValidateInvokeEntry<I[K], R, never, Root> }
: { readonly [K in keyof I]: ValidateInvokeEntry<I[K], R, DuplicateInvokeId<I, K, R>, Root> } :
ValidateInvokeEntry<I, R, never, Root>
// Handler config of a dotted state path, read from the root handler config.
type HandlerConfigAt<Config, Path extends string> = Config extends { readonly states: infer Children } ?
Path extends `${infer Head}.${infer Rest}` ? Head extends keyof Children ? HandlerConfigAt<Children[Head], Rest>
: never
: Path extends keyof Children ? Children[Path]
: never
: never
type ChoiceConfigAt<Root, Path extends string> = HandlerConfigAt<Root, Path> extends {
/** Implements this choice pseudo-state with one required synchronous transition. */
readonly choice: infer Choice
} ? Choice
: never
type ChoiceBranchDeclaration<Choice, R> = Choice extends {
/** Named groups of inspectable destinations used by transition resolvers. */
readonly branches: infer G extends keyof Registered<R, "branches">
} ? {
readonly [K in Extract<keyof Registered<R, "branches">[G], string>]: {
readonly label: `branch '${K}'`
readonly declaration: Registered<R, "branches">[G][K]
}
}[Extract<keyof Registered<R, "branches">[G], string>]
: { readonly label: "transition"; readonly declaration: Choice }
type IsBelowOwner<Path extends string, Owner extends string> = Owner extends "" ? Path extends "" ? false : true
: Path extends `${Owner}.${string}` ? true
: false
type ChoiceBranchEscape<Path extends string, Owner extends string, Choice extends string, Label extends string> =
IsBelowOwner<Path, Owner> extends true ? never : TransitionDefinitionError<
`Choice '${Choice}' ${Label} targets '${PathName<Path>}' outside the updated owner '${PathName<Owner>}'`,
Choice,
PathName<Path>
>
// Every branch reachable from a choice entered with an owner update must stay below that owner.
type ChoiceUpdateEscape<Root, R, Choice extends string, Owner extends string, Visited extends string = never> =
Choice extends Visited ? never
: ChoiceBranchDeclaration<ChoiceConfigAt<Root, Choice>, 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<Root, PathOf<Ref>>] extends [never] ? ChoiceBranchEscape<PathOf<Ref>, Owner, Choice, Label>
: ChoiceUpdateEscape<Root, R, PathOf<Ref>, Owner, Visited | Choice>
: D extends {
/** Restores the declared history reference, using its fallback on first entry. */
readonly history: infer Ref extends string
} ? ChoiceBranchEscape<PathOf<Ref>, 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, R, Root> = 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<Root, PathOf<Ref>>] extends [never] ? never
: ChoiceUpdateEscape<Root, R, PathOf<Ref>, PathOf<Owner>>
: never
type ValidateChoiceUpdate<T, R, Root> = T extends {
/** Named groups of inspectable destinations used by transition resolvers. */
readonly branches: infer G extends keyof Registered<R, "branches">
} ? {
readonly [K in keyof Registered<R, "branches">[G]]: ChoiceUpdateEscapeOf<Registered<R, "branches">[G][K], R, Root>
}[keyof Registered<R, "branches">[G]] extends infer Escape ? [Escape] extends [never] ? unknown : {
/** Named groups of inspectable destinations used by transition resolvers. */
readonly branches: Escape
}
: never
: ChoiceUpdateEscapeOf<T, R, Root> 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<C> =
| Extract<keyof C, "on" | "always" | "onDone" | "choice" | "invoke">
| (C extends { readonly states: infer Children }
? { readonly [K in keyof Children]: ObjectDeclarations<Children[K]> }[keyof Children]
: never)
type ValidateObjectConfig<C, R> = [ObjectDeclarations<C>] extends [never] ? unknown : {
readonly [K in keyof C]: K extends "states" ? { readonly [P in keyof C[K]]: ValidateObjectConfig<C[K][P], R> }
: K extends "on" ? { readonly [P in keyof C[K]]: ValidateObjectTransition<C[K][P]> }
: K extends "always" | "onDone" | "choice" ? ValidateObjectTransition<C[K]>
: K extends "invoke" ? ValidateObjectInvoke<C[K], R>
type ValidateObjectConfig<C, R, Root = C> = [ObjectDeclarations<C>] extends [never] ? unknown : {
readonly [K in keyof C]: K extends "states" ? { readonly [P in keyof C[K]]: ValidateObjectConfig<C[K][P], R, Root> }
: K extends "on" ? {
readonly [P in keyof C[K]]: ValidateObjectTransition<C[K][P]> & ValidateChoiceUpdate<C[K][P], R, Root>
}
: K extends "always" | "onDone" ? ValidateObjectTransition<C[K]> & ValidateChoiceUpdate<C[K], R, Root>
: K extends "choice" ? ValidateObjectTransition<C[K]>
: K extends "invoke" ? ValidateObjectInvoke<C[K], R, Root>
: unknown
}
type NormalizeObjectInvoke<I, R> = I extends ReadonlyArray<unknown> ?
Expand Down
66 changes: 64 additions & 2 deletions packages/effect-machine/src/internal/machine/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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<string, Machine.TransitionDefinition>()
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<string>()
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<string, unknown>) => {
const captured = InitialDeclaration.capture(declaration.root, config)
Expand Down Expand Up @@ -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 = (
Expand Down
Loading
Loading