From f04db683e3816afd9fd30c813218fbe60d035d9e Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sat, 7 Nov 2026 14:58:07 +0900 Subject: [PATCH] Declare transitions locally with branches, stay, commands, and update records Move conditional destinations from make into an inline branches list that the compiler checks against resolve in both directions, so one transition can be edited without reading anything else. Replace none with stay, add commands to plain transitions, and make update a record so data only ever means the target's value. Restrict guard and decline to on and always, which have another candidate to fall through to. Retained branch keys are now the entry as written. Retire no-redundant-resolve, which the compiler now covers, and port the conditional rule to stay. Co-Authored-By: Claude Opus 5.5 --- .../oxlint-conditional-none-event-handlers.md | 14 - .changeset/transition-declarations.md | 31 + .oxlintrc.json | 3 +- LLMS.md | 158 +-- .../src/10_modeling/01_create-a-machine.ts | 2 +- ai-docs/src/10_modeling/index.md | 66 +- ai-docs/src/20_root-api/index.md | 59 +- ai-docs/src/30_react/index.md | 25 +- ai-docs/src/40_review/index.md | 6 +- api-reference.config.json | 8 +- packages/devtools/src/MachineDocument.ts | 3 +- .../src/internal/browser/chart-model.ts | 4 +- .../src/internal/browser/example-machine.ts | 27 +- .../browser/hierarchy-routing-example.ts | 12 +- .../browser/invoke-outcomes-example.ts | 19 +- .../browser/layout-resilience-example.ts | 37 +- .../browser/parallel-completion-example.ts | 4 +- .../browser/parallel-owner-routing-example.ts | 4 +- .../src/internal/browser/planner-example.ts | 15 +- .../browser/protocol-events-example.ts | 35 +- .../src/internal/browser/text-tree.ts | 4 +- .../browser/transition-semantics-example.ts | 67 +- .../src/internal/browser/visualizer-app.ts | 4 +- .../src/internal/browser/visualizer-model.ts | 2 +- .../devtools/src/internal/machineDocument.ts | 2 +- .../src/internal/machineWalkthrough.ts | 2 +- .../devtools/test/MachineWalkthrough.test.ts | 2 +- .../InteractiveTextVisualization.test.ts | 7 +- .../test/internal/browser/StaticChart.test.ts | 16 +- .../test/RootContext.test.tsx | 2 +- .../test/ServerRendering.test.tsx | 2 +- .../typetest/MachineAtom.tst.ts | 2 +- packages/effect-machine/README.md | 106 +- .../runtime/effect-machine-compatibility.mjs | 11 +- .../effect-machine/perf/types/composition.ts | 5 +- .../perf/types/dynamic-invoke.ts | 8 +- .../perf/types/exact-channels.ts | 5 +- .../perf/types/named-branches.ts | 39 +- .../perf/types/transition-construction.ts | 18 +- packages/effect-machine/src/Machine.ts | 1079 +++++++---------- .../src/internal/machine/declaration.ts | 73 +- .../internal/machine/initialDeclaration.ts | 16 + .../src/internal/machine/machine.ts | 337 ++--- .../src/internal/machine/planner.ts | 2 +- .../internal/testing/machine/finiteModel.ts | 47 +- .../effect-machine/src/testing/MachineTest.ts | 2 +- .../test/fixtures/observability.ts | 6 +- .../machine/ProtocolValidation.test.ts | 4 +- .../internal/machine/ScopedLifetime.test.ts | 2 +- .../test/internal/machine/activities.test.ts | 8 +- .../internal/machine/rootStrategies.test.ts | 43 +- .../machine/strategyDifferential.test.ts | 242 ++-- .../transitionConstructionStrategies.test.ts | 32 +- .../machine/ActivityLifecycleModel.test.ts | 40 +- .../test/machine/ChildStoppedByParent.test.ts | 7 +- .../test/machine/Choice.test.ts | 187 +-- .../test/machine/ChoiceUpdate.test.ts | 91 +- .../test/machine/Declarative.test.ts | 17 +- .../test/machine/DynamicChildren.test.ts | 14 +- .../test/machine/History.test.ts | 58 +- .../test/machine/InvocationTurn.test.ts | 5 +- .../test/machine/Invoke.test.ts | 50 +- .../test/machine/LiveInspection.test.ts | 15 +- .../test/machine/LocalTargetWith.test.ts | 20 +- .../test/machine/Machine.test.ts | 364 +++--- .../test/machine/MachineReferences.test.ts | 20 +- .../test/machine/MermaidVisualization.test.ts | 4 +- .../test/machine/ObjectConstruction.test.ts | 11 +- .../effect-machine/test/machine/Paths.test.ts | 24 +- .../test/machine/ProtocolOwnership.test.ts | 6 +- .../effect-machine/test/machine/Root.test.ts | 24 +- .../test/machine/RootInput.test.ts | 7 +- .../test/machine/RuntimeDifferential.test.ts | 51 +- .../test/machine/Scheduling.test.ts | 16 +- .../test/machine/ScopedLifetime.test.ts | 2 +- .../test/machine/SnapshotContext.test.ts | 47 +- .../test/machine/StateUpdate.test.ts | 101 +- .../test/machine/StructuralStates.test.ts | 41 +- .../test/machine/SynchronousDelivery.test.ts | 4 +- .../machine/TransitionConstruction.test.ts | 49 +- .../test/machine/Transitions.test.ts | 272 +++++ .../test/machine/Visualization.test.ts | 75 +- .../test/machine/visualization/mermaid.ts | 2 +- .../test/machine/visualization/model.ts | 1 - .../test/machine/visualization/text.ts | 4 +- .../effect-machine/test/testing/Check.test.ts | 47 +- .../test/testing/Coverage.test.ts | 44 +- .../test/testing/Exploration.test.ts | 17 +- .../test/testing/MachineTest.test.ts | 18 +- .../effect-machine/test/testing/Probe.test.ts | 11 +- .../test/testing/Runtime.test.ts | 9 +- .../test/testing/TraceValues.test.ts | 2 +- .../test/testing/Verification.test.ts | 93 +- .../unstable/cluster/ClusterMachine.test.ts | 32 +- .../unstable/reactivity/AtomMachine.test.ts | 28 +- .../typetest/machine/Activities.tst.ts | 4 +- .../typetest/machine/Choice.tst.ts | 28 +- .../typetest/machine/ChoiceUpdate.tst.ts | 119 +- .../typetest/machine/Declarative.tst.ts | 48 +- .../typetest/machine/DynamicChildren.tst.ts | 8 +- .../typetest/machine/EventByTag.tst.ts | 8 +- .../typetest/machine/EventConstructors.tst.ts | 8 +- .../typetest/machine/History.tst.ts | 6 +- .../typetest/machine/InitialEntry.tst.ts | 28 +- .../typetest/machine/Inspection.tst.ts | 4 +- .../typetest/machine/InvocationErrors.tst.ts | 10 +- .../typetest/machine/Machine.tst.ts | 203 ++-- .../typetest/machine/MachineReferences.tst.ts | 18 +- .../machine/ObjectConstruction.tst.ts | 53 +- .../typetest/machine/Paths.tst.ts | 35 +- .../typetest/machine/Readiness.tst.ts | 6 +- .../typetest/machine/RootInput.tst.ts | 17 +- .../typetest/machine/SnapshotContext.tst.ts | 25 +- .../typetest/machine/StateUpdate.tst.ts | 64 +- .../typetest/machine/StructuralStates.tst.ts | 20 +- .../machine/TransitionConstruction.tst.ts | 56 +- .../typetest/machine/Transitions.tst.ts | 241 ++++ .../typetest/testing/Check.tst.ts | 2 +- .../typetest/testing/Exploration.tst.ts | 4 +- .../typetest/testing/Invariant.tst.ts | 4 +- .../typetest/testing/MachineTest.tst.ts | 6 +- .../typetest/testing/Probe.tst.ts | 4 +- .../unstable/reactivity/AtomMachine.tst.ts | 2 +- packages/oxlint-plugin/README.md | 78 +- packages/oxlint-plugin/src/index.ts | 6 +- .../oxlint-plugin/src/internal/planning.ts | 10 +- ...neBranch.ts => noConditionalStayBranch.ts} | 94 +- .../src/internal/rules/noRedundantResolve.ts | 146 --- packages/oxlint-plugin/src/recommended.ts | 3 +- .../test/noAsyncPlanningCallback.test.ts | 2 +- .../test/noConditionalNoneBranch.test.ts | 140 --- .../test/noConditionalStayBranch.test.ts | 99 ++ .../test/noRedundantResolve.test.ts | 101 -- scripts/fixtures/consumer/consumer.ts | 16 +- scripts/fixtures/consumer/deep-bound.ts | 23 +- scripts/invoke-autocomplete.test.mjs | 36 +- scripts/oxlint-plugin-pack-check.mjs | 19 +- 137 files changed, 3074 insertions(+), 3389 deletions(-) delete mode 100644 .changeset/oxlint-conditional-none-event-handlers.md create mode 100644 .changeset/transition-declarations.md create mode 100644 packages/effect-machine/test/machine/Transitions.test.ts create mode 100644 packages/effect-machine/typetest/machine/Transitions.tst.ts rename packages/oxlint-plugin/src/internal/rules/{noConditionalNoneBranch.ts => noConditionalStayBranch.ts} (67%) delete mode 100644 packages/oxlint-plugin/src/internal/rules/noRedundantResolve.ts delete mode 100644 packages/oxlint-plugin/test/noConditionalNoneBranch.test.ts create mode 100644 packages/oxlint-plugin/test/noConditionalStayBranch.test.ts delete mode 100644 packages/oxlint-plugin/test/noRedundantResolve.test.ts diff --git a/.changeset/oxlint-conditional-none-event-handlers.md b/.changeset/oxlint-conditional-none-event-handlers.md deleted file mode 100644 index bb18bcef..00000000 --- a/.changeset/oxlint-conditional-none-event-handlers.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@typeonce/oxlint-plugin-effect-machine": patch ---- - -`effect-machine/no-conditional-none-branch` now checks only event handlers in `on`. `Machine.can` queries events, so resolvers for invocation outcomes such as `onSnapshot` and `onDone`, and for eventless `always` transitions, may select a `none` branch conditionally: - -```ts -onSnapshot: { - branches: "progress", - resolve: ({ snapshot, select }) => snapshot.ready ? select.ready() : select.waiting() -} -``` - -Remove any suppression comments added for these resolvers. diff --git a/.changeset/transition-declarations.md b/.changeset/transition-declarations.md new file mode 100644 index 00000000..f384018c --- /dev/null +++ b/.changeset/transition-declarations.md @@ -0,0 +1,31 @@ +--- +"@typeonce/effect-machine": minor +"@typeonce/effect-machine-react": minor +"@typeonce/effect-machine-devtools": minor +"@typeonce/oxlint-plugin-effect-machine": minor +--- + +Declare conditional transitions inline, with `branches` next to `resolve`, so one transition can be read and edited alone. The compiler checks one against the other in both directions, and rejects a resolver that does not return a `select` call on every path: + +```ts +Pay: { + branches: [{ target: "Charging" }, { update: "root" }, { stay: true }, { decline: true }], + resolve: ({ event, select }) => { + if (event.amount < 0) return select.decline() + if (event.amount === 0) return select.stay() + return event.preview + ? select.update({ root: { total: event.amount } }) + : select.target("Charging", { data: { amount: event.amount } }) + } +} +``` + +To migrate: move each `make({ branches })` group into its transition and drop its keys and `title`, so `select.key(...)` becomes `select.target(path, ...)`, `select.update({ owner: value })`, `select.history(path)`, `select.initialize({ input })`, or `select.stay()`. Two keys that reached the same destination are now one entry. Remove `declinable: true`, list `{ decline: true }`, and replace the `decline()` context function with `select.decline()`. + +`none` is replaced: `{ none: true }` becomes `{ stay: true }`, `{ none: true, reenter: true }` becomes `{ reenter: true }`, and `{ none: true, resolve }` becomes `{ stay: true, commands }`. Every plain transition accepts `commands: (context, enqueue) => ...`. + +`update` is now a record with one owner, so `data` only ever means the target's value: `{ update: "root", data }` becomes `{ update: { root: data } }`, and `{ target, update: "Owner", data: () => ({ target, update }) }` becomes `{ target, data, update: { Owner: value } }`. Inside a `branches` list, an entry still names its owner as a string, `{ target, update: "Owner" }`, and the value goes in its `select` call. + +`guard` and `decline` now exist only on `on` handlers and `always`. On `onDone`, `onFailure`, `onElement`, `onSnapshot`, and `choice`, which have no other candidate, use `{ stay: true }` or select a destination. A retained transition's `branchKey` and a transition definition's `key` are now the entry as written, such as `target:Paid`, and `title` is removed. + +In the Oxlint plugin, `no-conditional-none-branch` becomes `no-conditional-stay-branch`, and `no-redundant-resolve` is removed because the compiler now rejects what it reported. Resolvers for invocation outcomes and `always` may select `stay` conditionally, because `Machine.can` queries only events. diff --git a/.oxlintrc.json b/.oxlintrc.json index 45551e82..5041edc0 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -73,10 +73,9 @@ "rules": { "effect-machine/no-async-planning-callback": "error", "effect-machine/no-browser-api-in-planning": "error", - "effect-machine/no-conditional-none-branch": "error", + "effect-machine/no-conditional-stay-branch": "error", "effect-machine/no-conflicting-invocation-identity": "error", "effect-machine/no-nondeterministic-planning": "error", - "effect-machine/no-redundant-resolve": "error", "effect-machine/prefer-inline-handle": "error" } } diff --git a/LLMS.md b/LLMS.md index f33a5aa4..48493a36 100644 --- a/LLMS.md +++ b/LLMS.md @@ -58,7 +58,7 @@ export const CounterMachine = Machine.make({ }, Running: { on: { - Increment: { update: "Running", data: ({ state }) => ({ count: state.count + 1 }) }, + Increment: { update: { Running: ({ state }) => ({ count: state.count + 1 }) } }, Stop: { target: "Idle" } } } @@ -437,24 +437,18 @@ const ReviewEvents = Machine.eventsFromSchemas( const ReviewMachine = Machine.make({ root: ReviewStates, - events: ReviewEvents, - branches: { - evaluate: { - accepted: { target: "Accepted" }, - rejected: { target: "Rejected" } - } - } + events: ReviewEvents }).handle({ initial: { target: "Pending" }, states: { Pending: { on: { Evaluate: { - branches: "evaluate", + branches: [{ target: "Accepted" }, { target: "Rejected" }], resolve: ({ event, select }) => event.score >= 80 - ? select.accepted() - : select.rejected() + ? select.target("Accepted") + : select.target("Rejected") } } }, @@ -469,31 +463,53 @@ Do not read the clock, generate randomness, call a service, or await work while choosing a transition. Receive such values in an event or produce them through state-owned work first. -A `{ none: true }` branch accepts its event without changing state, so -`Machine.can` reports `true` when a resolver selects it. When the event must be -refused, for example to disable a control, use `guard`, or declare -`declinable: true` and return `decline()` from the resolver. +`resolve` is checked against `branches` in both directions, and each `select` +call takes the values of the path it is given. An agent can edit this one +transition without reading anything else: a missing entry, an entry that is never +selected, and a path that returns nothing are all compile errors that name the fix. + +A `{ stay: true }` accepts its event without changing state, so `Machine.can` +reports `true` when a resolver selects it. When the event must be refused, for +example to disable a control, use `guard`, or list `{ decline: true }` and return +`select.decline()` from the resolver. `guard` and `decline` exist only on `on` +handlers and `always`: an invocation outcome, a completion, and a choice have no +other candidate, so they use `{ stay: true }` or select a destination. A declined +invocation outcome would strand the machine in its state. + +A `{ stay: true }` in one parallel region accepts the event, so it blocks a handler +for that event on a parallel ancestor. `{ decline: true }` lets that handler run. + +Enqueue work with `commands` on any plain transition, and with `enqueue` in +`resolve`: + +```ts +Cancelled: { + target: "Archived", + commands: (_, enqueue) => enqueue.emit(Emitted.Cancelled()) +} +``` When only an active state's value changes, use `update` to preserve its active descendants and running work: ```ts Changed: { - update: "Document", - data: ({ ancestors }) => ({ - ...ancestors.Document, - revision: ancestors.Document.revision + 1 - }) + update: { + Document: ({ ancestors }) => ({ + ...ancestors.Document, + revision: ancestors.Document.revision + 1 + }) + } } ``` Choose a valued source or retained ancestor explicitly. To change a parallel sibling, send an event handled by that sibling. Combine a destination and one -retained owner update with `{ target, update, data }`; the callback returns -`{ target: destinationInput, update: completeOwnerInput }`. Both values are -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. +retained owner update with `{ target, data, update: { Owner: ... } }`, where `data` +only ever means the target's value. Both values are validated atomically, and +destination entry sees the new owner value. A `branches` entry names the same pair +as `{ target, update: "Owner" }`, and its call passes the value: +`select.target(target, { data, update: { Owner: value } })`. 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 @@ -639,7 +655,7 @@ export const CounterMachine = Machine.make({ }, Running: { on: { - Increment: { update: "Running", data: ({ state }) => ({ count: state.count + 1 }) }, + Increment: { update: { Running: ({ state }) => ({ count: state.count + 1 }) } }, Stop: { target: "Idle" } } } @@ -732,9 +748,9 @@ that name. It is not a transition destination. `{ initialize: ... }` reconstructs root data and follows its initial declarations using fresh machine input: a value or a callback of the transition context. A machine without input declares `{ initialize: true }`. `initialize` -rejects `target`, `update`, `history`, `none`, `data`, and `decoded`. A named -branch declares `{ initialize: true }`, and its resolver passes the input with -`select.branch({ input })`. +rejects `target`, `update`, `history`, `stay`, `data`, and `decoded`. A `branches` +entry declares `{ initialize: true }`, and its resolver passes the input with +`select.initialize({ input })`. Initializing root retains its lifecycle unless it is reentered. Put a machine-wide reset handler at root and use `reenter: true` to exit and enter root as well as @@ -743,11 +759,11 @@ reentry, retained root work continues; it does not automatically acquire new construction values. Ordinary transition conflict and lifecycle rules still apply. This does not create a new machine reference or discard history records. -`{ update: "root", data: ... }` instead replaces root's complete value, +`{ update: { root: ... } }` instead replaces root's complete value, retaining its active children and running work. It takes no input and does not run initial constructors. Other source/ancestor updates follow the same rules. -### Inline and named construction +### Inline and branching construction Ordinary transitions stay inline: @@ -760,32 +776,21 @@ Load: { Targeting a compound or parallel state follows its initial declarations. Event transitions use `target`, never `initial`. `initial` names only handler startup -edges. `history` restores a declared history state; `none: true` accepts an +edges. `history` restores a declared history state; `stay: true` accepts an event without changing its target configuration. -Declare branch topology in `make` when a resolver chooses an outcome, builds an -explicit subtree, or enqueues commands: - -```ts -branches: { - open: { - checkout: { target: "Checkout" }, - unchanged: { none: true } - } -} -``` - -The resolver calls a constructor bound to that declaration: +List `branches` next to `resolve` when a transition chooses an outcome or builds +an explicit subtree. The compiler checks one against the other in both directions: ```ts Open: { - branches: "open", + branches: [{ target: "Checkout" }, { stay: true }], resolve: ({ event, select }) => event.open - ? select.checkout({ + ? select.target("Checkout", { data: { cartId: event.cartId }, states: { Review: { data: { total: event.total } } } }) - : select.unchanged() + : select.stay() } ``` @@ -800,35 +805,34 @@ requires every region. Source-local parallel construction can select a single region while retaining its active siblings, preserving the existing scope rules. Names under `states` cannot collide with construction metadata. -Declared branch targets remain inspectable without executing a resolver. +Each branch's destination remains inspectable without executing `resolve`. Dynamically selected descendants are visible after resolution; this API does not add static analysis of resolver bodies. ### Retained-owner updates -An inline combined transition keeps `target` and `update` paths separate: +An inline combined transition keeps the target's value and the owner's value +separate, so `data` only ever means the target's value: ```ts Save: { target: "Checkout.Saving", - update: "Checkout", - data: ({ event }) => ({ - target: { requestId: event.requestId }, - update: { cartId: event.cartId } - }) + data: ({ event }) => ({ requestId: event.requestId }), + update: { Checkout: ({ event }) => ({ cartId: event.cartId }) } } ``` -For the same pair declared as a named branch, construct both in one call: +A `branches` entry names the same pair, `{ target: "Checkout.Saving", update: "Checkout" }`, +and its call constructs both: ```ts -select.saved({ +select.target("Checkout.Saving", { data: { requestId: event.requestId }, - update: { data: { cartId: event.cartId } } + update: { Checkout: { cartId: event.cartId } } }) ``` -`update` is required only for a branch declaring an owner update. It is not an +`update` takes exactly one owner. It is not an arbitrary nested-state operation. The owner must be active and retained across the transition. Both complete values validate before either is committed. @@ -856,8 +860,9 @@ including required ancestors and parallel siblings. Its root data is state data, not startup input. `Machine.resume` restores a validated snapshot without rerunning startup constructors. -`guard` declines before construction. A resolver may return `decline()` only with -`declinable: true`. Effect, Stream, timer, logic, child invocation, completion, +`guard` declines before construction. A resolver may return `select.decline()` only +when `branches` lists `{ decline: true }`. Both exist only on `on` handlers and +`always`. Effect, Stream, timer, logic, child invocation, completion, entry/exit, and required outcome channels retain their existing contracts. ## Effect Atom and React @@ -1208,39 +1213,36 @@ Changes to `draftAtom` recompute acceptance. The event atom contains the event input itself rather than an `AsyncResult`. `can` is `true` whenever the active configuration selects a transition. A -`{ none: true }` branch is a selected transition: it accepts the event without -changing state. A resolver that falls back to a `none` branch therefore keeps -the button enabled: +`{ stay: true }` branch is a selected transition: it accepts the event without +changing state. A resolver that falls back to `stay` therefore keeps the button +enabled: ```ts // Before: an incomplete form still accepts Submitted, so `can` is true. -// In make: branches: { submit: { review: { target: "Reviewing" }, incomplete: { none: true } } } Submitted: { - branches: "submit", + branches: [{ target: "Reviewing" }, { stay: true }], resolve: ({ state, select }) => - state.email.length > 0 ? select.review() : select.incomplete() + state.email.length > 0 ? select.target("Reviewing") : select.stay() } ``` When the event must be refused, decline it. Use `guard` for an ordinary -transition, or `declinable: true` with `decline()` in a resolver: +transition, or `{ decline: true }` with `select.decline()` in a resolver: ```ts // After: an incomplete form declines Submitted, so `can` is false. Submitted: { target: "Reviewing", guard: ({ state }) => state.email.length > 0 } // A resolver that needs commands or construction declines explicitly. -// In make: branches: { submit: { review: { target: "Reviewing" } } } Submitted: { - branches: "submit", - declinable: true, - resolve: ({ state, select, decline }) => - state.email.length > 0 ? select.review() : decline() + branches: [{ target: "Reviewing" }, { decline: true }], + resolve: ({ state, select }) => + state.email.length > 0 ? select.target("Reviewing") : select.decline() } ``` -Keep `none` for events that are deliberately accepted and ignored. The -`effect-machine/no-conditional-none-branch` lint rule reports the conditional +Keep `stay` for events that are deliberately accepted and ignored. The +`effect-machine/no-conditional-stay-branch` lint rule reports the conditional fallback. Query acceptance during render through `AtomMachine.can(event)(machine)`. Do @@ -1504,12 +1506,10 @@ Reserve named branch groups and `resolve` for conditional outcomes, commands, or nested construction. The group lives in `make`; its resolver selects only constructors derived from those declarations. Avoid an extra branch group for an ordinary transition. Use `reenter: true` when the source must restart. -An empty targetless resolver simplifies to `{ none: true }`. - -A `{ none: true }` branch accepts its event, so `Machine.can` and +A `{ stay: true }` branch accepts its event, so `Machine.can` and `AtomMachine.can` return `true` when a resolver selects it. Review check: for every event that enables or disables a control, refuse it with `guard` or with -`declinable: true` and `decline()`. Keep `none` only for events that are +`{ decline: true }` and `select.decline()`. Keep `stay` only for events that are deliberately accepted and ignored. ### Choose React ownership or keyed family lookup diff --git a/ai-docs/src/10_modeling/01_create-a-machine.ts b/ai-docs/src/10_modeling/01_create-a-machine.ts index 7edc3460..cf533e2a 100644 --- a/ai-docs/src/10_modeling/01_create-a-machine.ts +++ b/ai-docs/src/10_modeling/01_create-a-machine.ts @@ -34,7 +34,7 @@ export const CounterMachine = Machine.make({ }, Running: { on: { - Increment: { update: "Running", data: ({ state }) => ({ count: state.count + 1 }) }, + Increment: { update: { Running: ({ state }) => ({ count: state.count + 1 }) } }, Stop: { target: "Idle" } } } diff --git a/ai-docs/src/10_modeling/index.md b/ai-docs/src/10_modeling/index.md index 5d3a4cc7..0f62ab71 100644 --- a/ai-docs/src/10_modeling/index.md +++ b/ai-docs/src/10_modeling/index.md @@ -50,7 +50,7 @@ export const CounterMachine = Machine.make({ }, Running: { on: { - Increment: { update: "Running", data: ({ state }) => ({ count: state.count + 1 }) }, + Increment: { update: { Running: ({ state }) => ({ count: state.count + 1 }) } }, Stop: { target: "Idle" } } } @@ -429,24 +429,18 @@ const ReviewEvents = Machine.eventsFromSchemas( const ReviewMachine = Machine.make({ root: ReviewStates, - events: ReviewEvents, - branches: { - evaluate: { - accepted: { target: "Accepted" }, - rejected: { target: "Rejected" } - } - } + events: ReviewEvents }).handle({ initial: { target: "Pending" }, states: { Pending: { on: { Evaluate: { - branches: "evaluate", + branches: [{ target: "Accepted" }, { target: "Rejected" }], resolve: ({ event, select }) => event.score >= 80 - ? select.accepted() - : select.rejected() + ? select.target("Accepted") + : select.target("Rejected") } } }, @@ -461,31 +455,53 @@ Do not read the clock, generate randomness, call a service, or await work while choosing a transition. Receive such values in an event or produce them through state-owned work first. -A `{ none: true }` branch accepts its event without changing state, so -`Machine.can` reports `true` when a resolver selects it. When the event must be -refused, for example to disable a control, use `guard`, or declare -`declinable: true` and return `decline()` from the resolver. +`resolve` is checked against `branches` in both directions, and each `select` +call takes the values of the path it is given. An agent can edit this one +transition without reading anything else: a missing entry, an entry that is never +selected, and a path that returns nothing are all compile errors that name the fix. + +A `{ stay: true }` accepts its event without changing state, so `Machine.can` +reports `true` when a resolver selects it. When the event must be refused, for +example to disable a control, use `guard`, or list `{ decline: true }` and return +`select.decline()` from the resolver. `guard` and `decline` exist only on `on` +handlers and `always`: an invocation outcome, a completion, and a choice have no +other candidate, so they use `{ stay: true }` or select a destination. A declined +invocation outcome would strand the machine in its state. + +A `{ stay: true }` in one parallel region accepts the event, so it blocks a handler +for that event on a parallel ancestor. `{ decline: true }` lets that handler run. + +Enqueue work with `commands` on any plain transition, and with `enqueue` in +`resolve`: + +```ts +Cancelled: { + target: "Archived", + commands: (_, enqueue) => enqueue.emit(Emitted.Cancelled()) +} +``` When only an active state's value changes, use `update` to preserve its active descendants and running work: ```ts Changed: { - update: "Document", - data: ({ ancestors }) => ({ - ...ancestors.Document, - revision: ancestors.Document.revision + 1 - }) + update: { + Document: ({ ancestors }) => ({ + ...ancestors.Document, + revision: ancestors.Document.revision + 1 + }) + } } ``` Choose a valued source or retained ancestor explicitly. To change a parallel sibling, send an event handled by that sibling. Combine a destination and one -retained owner update with `{ target, update, data }`; the callback returns -`{ target: destinationInput, update: completeOwnerInput }`. Both values are -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. +retained owner update with `{ target, data, update: { Owner: ... } }`, where `data` +only ever means the target's value. Both values are validated atomically, and +destination entry sees the new owner value. A `branches` entry names the same pair +as `{ target, update: "Owner" }`, and its call passes the value: +`select.target(target, { data, update: { Owner: value } })`. 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 diff --git a/ai-docs/src/20_root-api/index.md b/ai-docs/src/20_root-api/index.md index 1b398620..78e60876 100644 --- a/ai-docs/src/20_root-api/index.md +++ b/ai-docs/src/20_root-api/index.md @@ -74,9 +74,9 @@ that name. It is not a transition destination. `{ initialize: ... }` reconstructs root data and follows its initial declarations using fresh machine input: a value or a callback of the transition context. A machine without input declares `{ initialize: true }`. `initialize` -rejects `target`, `update`, `history`, `none`, `data`, and `decoded`. A named -branch declares `{ initialize: true }`, and its resolver passes the input with -`select.branch({ input })`. +rejects `target`, `update`, `history`, `stay`, `data`, and `decoded`. A `branches` +entry declares `{ initialize: true }`, and its resolver passes the input with +`select.initialize({ input })`. Initializing root retains its lifecycle unless it is reentered. Put a machine-wide reset handler at root and use `reenter: true` to exit and enter root as well as @@ -85,11 +85,11 @@ reentry, retained root work continues; it does not automatically acquire new construction values. Ordinary transition conflict and lifecycle rules still apply. This does not create a new machine reference or discard history records. -`{ update: "root", data: ... }` instead replaces root's complete value, +`{ update: { root: ... } }` instead replaces root's complete value, retaining its active children and running work. It takes no input and does not run initial constructors. Other source/ancestor updates follow the same rules. -### Inline and named construction +### Inline and branching construction Ordinary transitions stay inline: @@ -102,32 +102,21 @@ Load: { Targeting a compound or parallel state follows its initial declarations. Event transitions use `target`, never `initial`. `initial` names only handler startup -edges. `history` restores a declared history state; `none: true` accepts an +edges. `history` restores a declared history state; `stay: true` accepts an event without changing its target configuration. -Declare branch topology in `make` when a resolver chooses an outcome, builds an -explicit subtree, or enqueues commands: - -```ts -branches: { - open: { - checkout: { target: "Checkout" }, - unchanged: { none: true } - } -} -``` - -The resolver calls a constructor bound to that declaration: +List `branches` next to `resolve` when a transition chooses an outcome or builds +an explicit subtree. The compiler checks one against the other in both directions: ```ts Open: { - branches: "open", + branches: [{ target: "Checkout" }, { stay: true }], resolve: ({ event, select }) => event.open - ? select.checkout({ + ? select.target("Checkout", { data: { cartId: event.cartId }, states: { Review: { data: { total: event.total } } } }) - : select.unchanged() + : select.stay() } ``` @@ -142,35 +131,34 @@ requires every region. Source-local parallel construction can select a single region while retaining its active siblings, preserving the existing scope rules. Names under `states` cannot collide with construction metadata. -Declared branch targets remain inspectable without executing a resolver. +Each branch's destination remains inspectable without executing `resolve`. Dynamically selected descendants are visible after resolution; this API does not add static analysis of resolver bodies. ### Retained-owner updates -An inline combined transition keeps `target` and `update` paths separate: +An inline combined transition keeps the target's value and the owner's value +separate, so `data` only ever means the target's value: ```ts Save: { target: "Checkout.Saving", - update: "Checkout", - data: ({ event }) => ({ - target: { requestId: event.requestId }, - update: { cartId: event.cartId } - }) + data: ({ event }) => ({ requestId: event.requestId }), + update: { Checkout: ({ event }) => ({ cartId: event.cartId }) } } ``` -For the same pair declared as a named branch, construct both in one call: +A `branches` entry names the same pair, `{ target: "Checkout.Saving", update: "Checkout" }`, +and its call constructs both: ```ts -select.saved({ +select.target("Checkout.Saving", { data: { requestId: event.requestId }, - update: { data: { cartId: event.cartId } } + update: { Checkout: { cartId: event.cartId } } }) ``` -`update` is required only for a branch declaring an owner update. It is not an +`update` takes exactly one owner. It is not an arbitrary nested-state operation. The owner must be active and retained across the transition. Both complete values validate before either is committed. @@ -198,6 +186,7 @@ including required ancestors and parallel siblings. Its root data is state data, not startup input. `Machine.resume` restores a validated snapshot without rerunning startup constructors. -`guard` declines before construction. A resolver may return `decline()` only with -`declinable: true`. Effect, Stream, timer, logic, child invocation, completion, +`guard` declines before construction. A resolver may return `select.decline()` only +when `branches` lists `{ decline: true }`. Both exist only on `on` handlers and +`always`. Effect, Stream, timer, logic, child invocation, completion, entry/exit, and required outcome channels retain their existing contracts. diff --git a/ai-docs/src/30_react/index.md b/ai-docs/src/30_react/index.md index 5732d91d..8ed737a5 100644 --- a/ai-docs/src/30_react/index.md +++ b/ai-docs/src/30_react/index.md @@ -346,39 +346,36 @@ Changes to `draftAtom` recompute acceptance. The event atom contains the event input itself rather than an `AsyncResult`. `can` is `true` whenever the active configuration selects a transition. A -`{ none: true }` branch is a selected transition: it accepts the event without -changing state. A resolver that falls back to a `none` branch therefore keeps -the button enabled: +`{ stay: true }` branch is a selected transition: it accepts the event without +changing state. A resolver that falls back to `stay` therefore keeps the button +enabled: ```ts // Before: an incomplete form still accepts Submitted, so `can` is true. -// In make: branches: { submit: { review: { target: "Reviewing" }, incomplete: { none: true } } } Submitted: { - branches: "submit", + branches: [{ target: "Reviewing" }, { stay: true }], resolve: ({ state, select }) => - state.email.length > 0 ? select.review() : select.incomplete() + state.email.length > 0 ? select.target("Reviewing") : select.stay() } ``` When the event must be refused, decline it. Use `guard` for an ordinary -transition, or `declinable: true` with `decline()` in a resolver: +transition, or `{ decline: true }` with `select.decline()` in a resolver: ```ts // After: an incomplete form declines Submitted, so `can` is false. Submitted: { target: "Reviewing", guard: ({ state }) => state.email.length > 0 } // A resolver that needs commands or construction declines explicitly. -// In make: branches: { submit: { review: { target: "Reviewing" } } } Submitted: { - branches: "submit", - declinable: true, - resolve: ({ state, select, decline }) => - state.email.length > 0 ? select.review() : decline() + branches: [{ target: "Reviewing" }, { decline: true }], + resolve: ({ state, select }) => + state.email.length > 0 ? select.target("Reviewing") : select.decline() } ``` -Keep `none` for events that are deliberately accepted and ignored. The -`effect-machine/no-conditional-none-branch` lint rule reports the conditional +Keep `stay` for events that are deliberately accepted and ignored. The +`effect-machine/no-conditional-stay-branch` lint rule reports the conditional fallback. Query acceptance during render through `AtomMachine.can(event)(machine)`. Do diff --git a/ai-docs/src/40_review/index.md b/ai-docs/src/40_review/index.md index 103b1482..d851df49 100644 --- a/ai-docs/src/40_review/index.md +++ b/ai-docs/src/40_review/index.md @@ -41,12 +41,10 @@ Reserve named branch groups and `resolve` for conditional outcomes, commands, or nested construction. The group lives in `make`; its resolver selects only constructors derived from those declarations. Avoid an extra branch group for an ordinary transition. Use `reenter: true` when the source must restart. -An empty targetless resolver simplifies to `{ none: true }`. - -A `{ none: true }` branch accepts its event, so `Machine.can` and +A `{ stay: true }` branch accepts its event, so `Machine.can` and `AtomMachine.can` return `true` when a resolver selects it. Review check: for every event that enables or disables a control, refuse it with `guard` or with -`declinable: true` and `decline()`. Keep `none` only for events that are +`{ decline: true }` and `select.decline()`. Keep `stay` only for events that are deliberately accepted and ignored. ### Choose React ownership or keyed family lookup diff --git a/api-reference.config.json b/api-reference.config.json index b4a5f6b9..4809ddd3 100644 --- a/api-reference.config.json +++ b/api-reference.config.json @@ -172,12 +172,12 @@ "label": "Inline transitions" }, { - "reflection": "Machine.BranchDeclaration", - "label": "Named branch declarations" + "reflection": "Machine.BranchEntry", + "label": "Branch entries" }, { - "reflection": "Machine.DeclineCapability", - "label": "Declinable transitions" + "reflection": "Machine.BranchSelect", + "label": "Branch selection" }, { "reflection": "Enqueue", diff --git a/packages/devtools/src/MachineDocument.ts b/packages/devtools/src/MachineDocument.ts index f495d633..ca9b74b5 100644 --- a/packages/devtools/src/MachineDocument.ts +++ b/packages/devtools/src/MachineDocument.ts @@ -149,8 +149,7 @@ export const Branch = Schema.Union([ Schema.Struct({ ...BranchFields, type: Schema.tag("branch"), - key: Schema.String, - title: Schema.String + key: Schema.String }) ]) diff --git a/packages/devtools/src/internal/browser/chart-model.ts b/packages/devtools/src/internal/browser/chart-model.ts index 49c905a5..1c2cca23 100644 --- a/packages/devtools/src/internal/browser/chart-model.ts +++ b/packages/devtools/src/internal/browser/chart-model.ts @@ -89,7 +89,7 @@ const activityLabel = (activity: VisualizationActivity): string => { const transitionLabel = (transition: VisualizationTransition, branch: VisualizationBranch): string => { const trigger = triggerLabel(transition) - return branch.type === "branch" ? `${trigger} · ${branch.title}` : trigger + return branch.type === "branch" ? `${trigger} · ${branch.key}` : trigger } interface TriggerPresentation { @@ -118,7 +118,7 @@ const triggerPresentation = (transition: VisualizationTransition): TriggerPresen const visibleLabel = (transition: VisualizationTransition, branches: ReadonlyArray): string => { const presentation = triggerPresentation(transition) if (branches.length !== 1 || branches[0]?.type !== "branch") return presentation.label - return [presentation.label, branches[0].title].filter((part) => part.length > 0).join(" · ") + return [presentation.label, branches[0].key].filter((part) => part.length > 0).join(" · ") } const labelBadges = ( diff --git a/packages/devtools/src/internal/browser/example-machine.ts b/packages/devtools/src/internal/browser/example-machine.ts index 2c42b793..b84f06f9 100644 --- a/packages/devtools/src/internal/browser/example-machine.ts +++ b/packages/devtools/src/internal/browser/example-machine.ts @@ -96,11 +96,6 @@ const initialWorkflow = (): Machine.Machine.CompleteSnapshotContaining<{ readonly "": typeof States.node }, "application.workflow"> => snapshot export const machine = Machine.make({ - branches: { - transition1: { - destination: { target: "application.workflow.running", update: "application.workflow" } - } - }, id: "inspection-example", root: States, events: Machine.eventsFromSchemas(Start, Finish, Disconnect, Refresh) @@ -138,19 +133,27 @@ export const machine = Machine.make({ idle: { on: { Start: { - branches: "transition1", - resolve: ({ select: { destination: target } }) => - target({ + branches: [{ target: "application.workflow.running", update: "application.workflow" }], + resolve: ({ select }) => + select.target("application.workflow.running", { data: new Running({}), decoded: true, states: { editing: { data: new Editing({}), decoded: true } }, - update: { data: new Workflow({ document: "Machine.ts", unsavedChanges: 3 }), decoded: true } + update: { + "application.workflow": { + decoded: true, + data: new Workflow({ document: "Machine.ts", unsavedChanges: 3 }) + } + } }) }, Refresh: { - update: "application.workflow", - decoded: true, - data: () => (new Workflow({ document: "Machine.ts", unsavedChanges: 0 })) + update: { + "application.workflow": { + decoded: true, + data: () => (new Workflow({ document: "Machine.ts", unsavedChanges: 0 })) + } + } } } }, diff --git a/packages/devtools/src/internal/browser/hierarchy-routing-example.ts b/packages/devtools/src/internal/browser/hierarchy-routing-example.ts index 4a35281b..e4bce13e 100644 --- a/packages/devtools/src/internal/browser/hierarchy-routing-example.ts +++ b/packages/devtools/src/internal/browser/hierarchy-routing-example.ts @@ -29,12 +29,6 @@ const ReviewEvents = Machine.eventsFromSchemas(Schema.TaggedUnion({ const saveReview: Effect.Effect = Effect.succeed("deterministic-chart") const publishReview = Effect.succeed("deterministic-chart") export const hierarchyRoutingMachine = Machine.make({ - branches: { - transition1: { - save: { target: "Workflow.Saving", title: "Save the review" }, - invalid: { target: "Workflow.Review.Failed", title: "Show validation failure" } - } - }, effects: { source1: Effect.suspend(() => saveReview), source2: Effect.suspend(() => publishReview) }, id: "hierarchy-routing", root: ReviewStates, @@ -56,11 +50,11 @@ export const hierarchyRoutingMachine = Machine.make({ }, on: { Submit: { - branches: "transition1", + branches: [{ target: "Workflow.Saving" }, { target: "Workflow.Review.Failed" }], resolve: ({ event, select }) => event.route === "save" - ? select.save({}) - : select.invalid({ data: { message: "Add a title before continuing." } }) + ? select.target("Workflow.Saving", {}) + : select.target("Workflow.Review.Failed", { data: { message: "Add a title before continuing." } }) } }, states: { diff --git a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts index da3869f2..c115e58e 100644 --- a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts +++ b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts @@ -122,12 +122,6 @@ const GalleryStates = Machine.state({ } }) export const invokeOutcomesMachine = Machine.make({ - branches: { - transition13: { - ready: { target: "Completed", title: "Worker reports ready" }, - waiting: { none: true, title: "Worker is still starting" } - } - }, effects: { source1: ({ state }: { readonly state: LoadingDocument @@ -209,8 +203,8 @@ export const invokeOutcomesMachine = Machine.make({ src: "source2", id: "document-updates", onElement: { - none: true, - resolve: ({ element }, enqueue) => { + stay: true, + commands: ({ element }, enqueue) => { enqueue.raise(GalleryInternalEvents.StreamValue({ value: element })) } }, @@ -255,11 +249,14 @@ export const invokeOutcomesMachine = Machine.make({ data: ({ error }) => (new Failed({ source: "process", message: String(error) })) }, onSnapshot: { - branches: "transition13", + branches: [{ target: "Completed" }, { stay: true }], resolve: ({ select, snapshot }) => snapshot.state === "ready" - ? select.ready({ data: new Completed({ source: "process", result: "ready" }), decoded: true }) - : select.waiting() + ? select.target("Completed", { + data: new Completed({ source: "process", result: "ready" }), + decoded: true + }) + : select.stay() } } }, diff --git a/packages/devtools/src/internal/browser/layout-resilience-example.ts b/packages/devtools/src/internal/browser/layout-resilience-example.ts index bbc8e876..8be455c9 100644 --- a/packages/devtools/src/internal/browser/layout-resilience-example.ts +++ b/packages/devtools/src/internal/browser/layout-resilience-example.ts @@ -48,16 +48,6 @@ const AuthEvents = Machine.eventsFromSchemas(Schema.TaggedUnion({ Submit: { route: Schema.Literals(["verification", "login", "invalid"]) } })) export const layoutResilienceMachine = Machine.make({ - branches: { - transition4: { - requestVerification: { - target: "Editing.RequestingVerification", - title: "Request a verification code" - }, - login: { target: "Editing.SubmittingLogin", title: "Submit password login" }, - invalid: { target: "Editing.Form.Failed", title: "Show validation failure" } - } - }, effects: { source1: ({ containingState }: { readonly containingState: { @@ -101,27 +91,34 @@ export const layoutResilienceMachine = Machine.make({ }, on: { EmailChanged: { - update: "Editing", - data: ({ ancestors: { "Editing": current }, event }) => ({ ...current, email: event.value }) + update: { + Editing: ({ ancestors: { "Editing": current }, event }) => ({ ...current, email: event.value }) + } }, LoginMethodChanged: { - update: "Editing", - data: ({ ancestors: { "Editing": current }, event }) => ({ ...current, loginMethod: event.value }) + update: { + Editing: ({ ancestors: { "Editing": current }, event }) => ({ ...current, loginMethod: event.value }) + } }, PasswordChanged: { - update: "Editing", - data: ({ ancestors: { "Editing": current }, event }) => ({ ...current, password: event.value }) + update: { + Editing: ({ ancestors: { "Editing": current }, event }) => ({ ...current, password: event.value }) + } }, Submit: { - branches: "transition4", + branches: [{ target: "Editing.RequestingVerification" }, { target: "Editing.SubmittingLogin" }, { + target: "Editing.Form.Failed" + }], resolve: ({ event, select }) => { if (event.route === "login") { - return select.login({}) + return select.target("Editing.SubmittingLogin", {}) } if (event.route === "verification") { - return select.requestVerification({}) + return select.target("Editing.RequestingVerification", {}) } - return select.invalid({ data: { message: "Enter valid authentication details." } }) + return select.target("Editing.Form.Failed", { + data: { message: "Enter valid authentication details." } + }) } } }, diff --git a/packages/devtools/src/internal/browser/parallel-completion-example.ts b/packages/devtools/src/internal/browser/parallel-completion-example.ts index 40dd5a68..68268613 100644 --- a/packages/devtools/src/internal/browser/parallel-completion-example.ts +++ b/packages/devtools/src/internal/browser/parallel-completion-example.ts @@ -206,8 +206,8 @@ export const parallelCompletionMachine = Machine.make({ src: "source1", id: "packing-sla", onDone: { - none: true, - resolve: (_, enqueue) => { + stay: true, + commands: (_, enqueue) => { enqueue.raise(ParallelInternalEvents.AutoShip()) } } diff --git a/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts b/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts index db519f1c..00318f4d 100644 --- a/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts +++ b/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts @@ -61,8 +61,8 @@ export const parallelOwnerRoutingChildMachine = Machine.make({ src: "source1", id: "signal-parent", input: (context) => context, - onDone: { none: true }, - onFailure: { none: true } + onDone: { stay: true }, + onFailure: { stay: true } } } } diff --git a/packages/devtools/src/internal/browser/planner-example.ts b/packages/devtools/src/internal/browser/planner-example.ts index 44b48c67..70689bdd 100644 --- a/packages/devtools/src/internal/browser/planner-example.ts +++ b/packages/devtools/src/internal/browser/planner-example.ts @@ -110,12 +110,6 @@ const States = Machine.state({ } }) export const plannerMachine = Machine.make({ - branches: { - transition1: { - urgent: { target: "Working", title: "Finish immediately" }, - normal: { target: "Working", title: "Wait in working" } - } - }, effects: { source1: Effect.suspend(() => Effect.never) }, id: "planner-example", root: States, @@ -163,17 +157,18 @@ export const plannerMachine = Machine.make({ Idle: { on: { Begin: { - branches: "transition1", + branches: [{ target: "Working" }, { target: "Finished" }], resolve: ({ event, self, select, state }, enqueue) => { + if (!event.approved) { + return select.target("Finished", { data: new Finished({ job: event.job }), decoded: true }) + } enqueue.emit(new Planned({ job: event.job })) enqueue.sendTo(self, Events.Cancel({ reason: "planner command example" })) if (event.priority === "urgent") { enqueue.raise(InternalEvents.AutoFinish()) } const working = new Working({ owner: state.owner, job: event.job }) - return event.priority === "urgent" - ? select.urgent({ data: working, decoded: true }) - : select.normal({ data: working, decoded: true }) + return select.target("Working", { data: working, decoded: true }) } } } diff --git a/packages/devtools/src/internal/browser/protocol-events-example.ts b/packages/devtools/src/internal/browser/protocol-events-example.ts index a2ad284c..23dfcaf7 100644 --- a/packages/devtools/src/internal/browser/protocol-events-example.ts +++ b/packages/devtools/src/internal/browser/protocol-events-example.ts @@ -58,10 +58,6 @@ const ChildStates = Machine.state({ } }) export const requiredParentChildMachine = Machine.make({ - branches: { - transition1: { destination: { target: "Working" } }, - transition3: { destination: { target: "Working" } } - }, effects: { source1: ({ parent, state }: { readonly parent: Machine.MachineTarget> @@ -84,11 +80,11 @@ export const requiredParentChildMachine = Machine.make({ Idle: { on: { BeginChildWork: { - branches: "transition1", - resolve: ({ event, select: { destination: target } }, enqueue) => { + branches: [{ target: "Working" }], + resolve: ({ event, select }, enqueue) => { enqueue.raise(ChildInternalEvents.Heartbeat({ percent: 25 })) enqueue.emit(ChildEmissions.ChildTrace({ message: `started ${event.job}` })) - return target({ data: new ChildWorking({ job: event.job, progress: 0 }), decoded: true }) + return select.target("Working", { data: new ChildWorking({ job: event.job, progress: 0 }), decoded: true }) } } } @@ -98,7 +94,7 @@ export const requiredParentChildMachine = Machine.make({ src: "source1", id: "report-progress-to-parent", input: (context) => context, - onDone: { none: true }, + onDone: { stay: true }, onFailure: { target: "Cancelled", decoded: true, @@ -107,10 +103,13 @@ export const requiredParentChildMachine = Machine.make({ }, on: { Heartbeat: { - branches: "transition3", - resolve: ({ event, state, select: { destination: target } }, enqueue) => { + branches: [{ target: "Working" }], + resolve: ({ event, state, select }, enqueue) => { enqueue.raise(ChildInternalEvents.CommitChildWork()) - return target({ data: new ChildWorking({ job: state.job, progress: event.percent }), decoded: true }) + return select.target("Working", { + data: new ChildWorking({ job: state.job, progress: event.percent }), + decoded: true + }) } }, CommitChildWork: { @@ -161,10 +160,6 @@ const ParentStates = Machine.state({ } }) export const parentProtocolMachine = Machine.make({ - branches: { - transition1: { destination: { target: "Supervising" } }, - transition3: { destination: { target: "Complete" } } - }, children: { source1: ProtocolChild }, id: "parent-child-protocol", root: ParentStates, @@ -235,7 +230,6 @@ class PublishOutside extends Schema.TaggedClass("OptionalParentP } const OptionalParentStates = Machine.state({ states: { Detached, Published } }) export const optionalParentMachine = Machine.make({ - branches: { transition1: { destination: { target: "Published" } } }, id: "optional-parent-protocol", root: OptionalParentStates, events: Machine.eventsFromSchemas(PublishOutside), @@ -250,12 +244,15 @@ export const optionalParentMachine = Machine.make({ Detached: { on: { PublishOutside: { - branches: "transition1", - resolve: ({ event, parent, select: { destination: target } }, enqueue) => { + branches: [{ target: "Published" }], + resolve: ({ event, parent, select }, enqueue) => { if (parent !== undefined) { enqueue.sendTo(parent, ParentEvents.ChildFinished({ result: event.result })) } - return target({ data: new Published({ deliveredToParent: parent !== undefined }), decoded: true }) + return select.target("Published", { + data: new Published({ deliveredToParent: parent !== undefined }), + decoded: true + }) } } } diff --git a/packages/devtools/src/internal/browser/text-tree.ts b/packages/devtools/src/internal/browser/text-tree.ts index ff538146..91be488a 100644 --- a/packages/devtools/src/internal/browser/text-tree.ts +++ b/packages/devtools/src/internal/browser/text-tree.ts @@ -62,7 +62,7 @@ const branchLabel = (branch: VisualizationBranch): string | undefined => { if (branch.target === null && branch.updates.length > 0) { return branch.type === "direct" ? `update ${branch.updates.join(", ")}` - : `[${branch.title}] update ${branch.updates.join(", ")}` + : `[${branch.key}] update ${branch.updates.join(", ")}` } if (branch.target === null) return undefined @@ -70,7 +70,7 @@ const branchLabel = (branch: VisualizationBranch): string | undefined => { const updates = branch.updates.length === 0 ? "" : ` / update ${branch.updates.join(", ")}` return branch.type === "direct" ? `→ ${target}${updates}` - : `[${branch.title}] → ${target}${updates}` + : `[${branch.key}] → ${target}${updates}` } const activityLabel = (definition: VisualizationActivity): string => { diff --git a/packages/devtools/src/internal/browser/transition-semantics-example.ts b/packages/devtools/src/internal/browser/transition-semantics-example.ts index 7d29272f..1411a633 100644 --- a/packages/devtools/src/internal/browser/transition-semantics-example.ts +++ b/packages/devtools/src/internal/browser/transition-semantics-example.ts @@ -129,18 +129,6 @@ const defaultWorkspaceSnapshot = () => ({ } }) export const transitionSemanticsMachine = Machine.make({ - branches: { - transition4: { - draft: { target: "Workspace.Draft", title: "Preferred route is draft" }, - review: { target: "Workspace.Review", title: "Preferred route is review" } - }, - transition7: { - review: { target: "Workspace.Review", title: "Enter the review flow" }, - publish: { target: "Workspace.Finished", title: "Publish without review" } - }, - transition14: { destination: { history: "Workspace.recent" } }, - transition15: { destination: { history: "Workspace.exact" } } - }, id: "transition-semantics", root: TransitionStates, events: Machine.eventsFromSchemas( @@ -183,12 +171,15 @@ export const transitionSemanticsMachine = Machine.make({ data: ({ event }) => (new Paused({ reason: event.reason })) }, BumpWorkspace: { - update: "Workspace", - decoded: true, - data: ({ state: current }) => (new Workspace({ - revision: current.revision + 1, - preferredRoute: current.preferredRoute - })) + update: { + Workspace: { + decoded: true, + data: ({ state: current }) => (new Workspace({ + revision: current.revision + 1, + preferredRoute: current.preferredRoute + })) + } + } } }, onDone: { @@ -199,11 +190,14 @@ export const transitionSemanticsMachine = Machine.make({ states: { Routing: { choice: { - branches: "transition4", + branches: [{ target: "Workspace.Draft" }, { target: "Workspace.Review" }], resolve: ({ containingState, select }) => containingState.preferredRoute === "review" - ? select.review({ data: new Review({ requestedBy: "initial route" }), decoded: true }) - : select.draft({ data: new Draft({ text: "", autosaves: 0 }), decoded: true }) + ? select.target("Workspace.Review", { + data: new Review({ requestedBy: "initial route" }), + decoded: true + }) + : select.target("Workspace.Draft", { data: new Draft({ text: "", autosaves: 0 }), decoded: true }) } }, Draft: { @@ -215,18 +209,23 @@ export const transitionSemanticsMachine = Machine.make({ }, Save: { target: "Workspace.AutoSaving", decoded: true, data: () => (new AutoSaving({})) }, Submit: { - branches: "transition7", + branches: [{ target: "Workspace.Review" }, { target: "Workspace.Finished" }], resolve: ({ event, select }) => event.mode === "publish" - ? select.publish({ data: new WorkspaceFinished({ result: "published directly" }), decoded: true }) - : select.review({ data: new Review({ requestedBy: event.requestedBy }), decoded: true }) + ? select.target("Workspace.Finished", { + data: new WorkspaceFinished({ result: "published directly" }), + decoded: true + }) + : select.target("Workspace.Review", { + data: new Review({ requestedBy: event.requestedBy }), + decoded: true + }) }, - Refresh: { none: true, reenter: true }, - Ignore: { none: true }, + Refresh: { reenter: true }, + Ignore: { stay: true }, MaybeHandle: { - none: true, - resolve: ({ decline, event }) => event.accept ? undefined : decline(), - declinable: true + branches: [{ stay: true }, { decline: true }], + resolve: ({ select, event }) => event.accept ? select.stay() : select.decline() } } }, @@ -285,8 +284,14 @@ export const transitionSemanticsMachine = Machine.make({ decoded: true, data: ({ event }) => (new Workspace({ revision: 0, preferredRoute: event.route })) }, - ResumeShallow: { branches: "transition14", resolve: ({ select: { destination: target } }) => target() }, - ResumeDeep: { branches: "transition15", resolve: ({ select: { destination: target } }) => target() }, + ResumeShallow: { + branches: [{ history: "Workspace.recent" }], + resolve: ({ select }) => select.history("Workspace.recent") + }, + ResumeDeep: { + branches: [{ history: "Workspace.exact" }], + resolve: ({ select }) => select.history("Workspace.exact") + }, Restart: { target: "Workspace", decoded: true, diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts index be92516d..91e8ccf6 100644 --- a/packages/devtools/src/internal/browser/visualizer-app.ts +++ b/packages/devtools/src/internal/browser/visualizer-app.ts @@ -81,7 +81,7 @@ const renderBranch = ( ): HTMLElement => { const row = createElement("div", "branch-row") const main = createElement("div", "branch-main") - if (branch.type === "branch") main.append(badge(branch.title, "condition")) + if (branch.type === "branch") main.append(badge(branch.key, "condition")) if (branch.target !== null) { main.append(createElement("span", "branch-arrow", "→"), stateLink(branch.target, branch.target, navigate)) } else if (branch.selection.kind === "update") { @@ -158,7 +158,7 @@ const renderIncomingTransition = ( if (api === undefined) { details.push(["Selection", incoming.branch.selection.kind], ["Scope", incoming.branch.selection.scope]) } - if (incoming.branch.type === "branch") details.unshift(["Title", incoming.branch.title]) + if (incoming.branch.type === "branch") details.unshift(["Branch", incoming.branch.key]) card.append(metadata(details)) return card } diff --git a/packages/devtools/src/internal/browser/visualizer-model.ts b/packages/devtools/src/internal/browser/visualizer-model.ts index 3edec364..569baeab 100644 --- a/packages/devtools/src/internal/browser/visualizer-model.ts +++ b/packages/devtools/src/internal/browser/visualizer-model.ts @@ -77,7 +77,7 @@ const pathName = (path: string): string => JSON.stringify(path === "" ? "root" : /** Public declaration represented by a retained transition branch. */ export const branchTargetApi = (branch: VisualizationBranch): string | undefined => { const { selection } = branch - if (selection.kind === "none") return "{ none: true }" + if (selection.kind === "none") return "{ stay: true }" const path = selection.path if (path === null) return undefined if (selection.scope === "initial") return "initialConfiguration" diff --git a/packages/devtools/src/internal/machineDocument.ts b/packages/devtools/src/internal/machineDocument.ts index fa921529..8f18c5c6 100644 --- a/packages/devtools/src/internal/machineDocument.ts +++ b/packages/devtools/src/internal/machineDocument.ts @@ -152,7 +152,7 @@ export const make = ( } return branch.type === "direct" ? { ...common, type: "direct" } - : { ...common, type: "branch", key: branch.key, title: branch.title } + : { ...common, type: "branch", key: branch.key } }) } }) diff --git a/packages/devtools/src/internal/machineWalkthrough.ts b/packages/devtools/src/internal/machineWalkthrough.ts index 4d289588..fbb5116d 100644 --- a/packages/devtools/src/internal/machineWalkthrough.ts +++ b/packages/devtools/src/internal/machineWalkthrough.ts @@ -226,7 +226,7 @@ export const choices = (self: MachineWalkthrough.Session): ReadonlyArray { choice.trigger.type === "event" && choice.trigger.event === "Begin" ) assert.strictEqual(begin.length, 2) - assert.deepStrictEqual(begin.map(({ title }) => title), ["Finish immediately", "Wait in working"]) + assert.deepStrictEqual(begin.map(({ title }) => title), ["target:Working", "target:Finished"]) assert.isTrue(begin.every(({ decisions }) => decisions.includes("conditional-branch"))) assert.isTrue(begin.every(({ input }) => input !== null)) }) diff --git a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts index ac2af167..e6c328d1 100644 --- a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts +++ b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts @@ -35,8 +35,7 @@ describe("Interactive text visualization", () => { branches: [{ id: "application.workflow.idle:transition:0:branch:0", type: "branch", - key: "destination", - title: "destination", + key: "target:application.workflow.running+update:application.workflow", target: "application.workflow.running", selection: { path: "application.workflow.running", kind: "state", scope: "branch" }, updates: ["application.workflow"] @@ -128,7 +127,7 @@ describe("Interactive text visualization", () => { assert.deepStrictEqual( begin?.branches.map((branch) => branchTargetApi(branch)), - ["{ target: \"Working\" }", "{ target: \"Working\" }"] + ["{ target: \"Working\" }", "{ target: \"Finished\" }"] ) assert.strictEqual( @@ -185,7 +184,7 @@ describe("Interactive text visualization", () => { kind: "none", scope: "local" }), - "{ none: true }" + "{ stay: true }" ) assert.strictEqual(api("application", document.initial.selection), "initialConfiguration") }) diff --git a/packages/devtools/test/internal/browser/StaticChart.test.ts b/packages/devtools/test/internal/browser/StaticChart.test.ts index 1c8e0d72..19c084ad 100644 --- a/packages/devtools/test/internal/browser/StaticChart.test.ts +++ b/packages/devtools/test/internal/browser/StaticChart.test.ts @@ -453,15 +453,14 @@ describe("Static chart", () => { accessibleLabel: edge.accessibleLabel, badges: edge.badges })), - [{ - label: "Begin", - accessibleLabel: "Begin · 2 branches", - badges: [{ type: "branches", count: 2 }] - }] + [ + { label: "Begin · target:Working", accessibleLabel: "Begin · target:Working", badges: [] }, + { label: "Begin · target:Finished", accessibleLabel: "Begin · target:Finished", badges: [] } + ] ) assert.deepStrictEqual( model.edges.find((edge) => edge.transitionId === "Idle:transition:0")?.branchIds, - ["Idle:transition:0:branch:0", "Idle:transition:0:branch:1"] + ["Idle:transition:0:branch:0"] ) assert.deepStrictEqual(model.initials.map(({ target }) => target), ["", "Idle"]) }) @@ -506,8 +505,7 @@ describe("Static chart", () => { "choice", "failure", "element", - "snapshot", - "branches" + "snapshot" ]) ) @@ -770,7 +768,7 @@ describe("Static chart", () => { const source = layout.nodes.find(({ node }) => node.path === "Workflow.Review") const target = layout.nodes.find(({ node }) => node.path === "Workflow.Review.Failed") const parentTransition = layout.edges.find((edge) => - edge.kind === "transition" && edge.edge.label === "Submit · Show validation failure" + edge.kind === "transition" && edge.edge.label === "Submit · target:Workflow.Review.Failed" ) const externalFailure = layout.edges.find((edge) => edge.kind === "transition" && diff --git a/packages/effect-machine-react/test/RootContext.test.tsx b/packages/effect-machine-react/test/RootContext.test.tsx index 754d55be..8d166c48 100644 --- a/packages/effect-machine-react/test/RootContext.test.tsx +++ b/packages/effect-machine-react/test/RootContext.test.tsx @@ -19,7 +19,7 @@ const counter = Machine.make({ target: "Open" }, root: ({ input }) => ({ count: input }), - on: { Increment: { update: "root", data: ({ root: current }) => ({ count: current.count + 1 }) } }, + on: { Increment: { update: { root: ({ root: current }) => ({ count: current.count + 1 }) } } }, states: { Open: { on: { Close: { target: "Closed" } } }, Closed: {} diff --git a/packages/effect-machine-react/test/ServerRendering.test.tsx b/packages/effect-machine-react/test/ServerRendering.test.tsx index 8f7854e1..805ef078 100644 --- a/packages/effect-machine-react/test/ServerRendering.test.tsx +++ b/packages/effect-machine-react/test/ServerRendering.test.tsx @@ -285,7 +285,7 @@ const QuantityMachine = Machine.make({ }).handle({ root: ({ input }) => ({ quantity: input }), initial: { target: "Counting" }, - on: { Set: { update: "root", data: ({ event }) => ({ quantity: event.quantity }) } }, + on: { Set: { update: { root: ({ event }) => ({ quantity: event.quantity }) } } }, states: { Counting: {} } }) const QuantityChild = Machine.child("quantity", QuantityMachine) diff --git a/packages/effect-machine-react/typetest/MachineAtom.tst.ts b/packages/effect-machine-react/typetest/MachineAtom.tst.ts index 536111f4..cf383e9f 100644 --- a/packages/effect-machine-react/typetest/MachineAtom.tst.ts +++ b/packages/effect-machine-react/typetest/MachineAtom.tst.ts @@ -22,7 +22,7 @@ const machine = Machine.make({ states: { Idle: { on: { - Continue: { none: true } + Continue: { stay: true } } } } diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index aab48300..69aaf710 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -61,8 +61,7 @@ const Counter = Machine.make({ root: () => ({ count: 0 }), on: { Increment: { - update: "root", - data: ({ root, event }) => ({ count: root.count + event.by }) + update: { root: ({ root, event }) => ({ count: root.count + event.by }) } } } }) @@ -72,7 +71,7 @@ const program = Effect.scoped(Effect.gen(function*() { })) ``` -Root data is shared state. `update: "root"` replaces that value while +Root data is shared state. `update: { root: ... }` replaces that value while retaining the active child configuration and its running work. Ordinary transitions target a descendant; the root itself is not a destination. @@ -199,24 +198,18 @@ before any resolver executes: ```ts const machine = Machine.make({ root: Root, - events: Events, - branches: { - search: { - loading: { target: "Loading", title: "Query supplied" }, - idle: { target: "Idle", title: "Empty query" } - } - } + events: Events }).handle({ initial: { target: "Idle" }, states: { Idle: { on: { Search: { - branches: "search", + branches: [{ target: "Loading" }, { target: "Idle" }], resolve: ({ event, select }) => event.query.length > 0 - ? select.loading({ data: { query: event.query } }) - : select.idle() + ? select.target("Loading", { data: { query: event.query } }) + : select.target("Idle") } } } @@ -224,19 +217,24 @@ const machine = Machine.make({ }) ``` -Each selector is bound to its declared destination. Its constructor rejects a -payload belonging to another branch. A single-target group works the same way; -there is no second path declaration in the resolver. For a compound target, -`select.checkout({ data: parentValues, states: { Review: { data: childValues } } })` +The compiler checks `resolve` against `branches` in both directions: selecting an +undeclared entry and declaring an entry that is never selected are both errors, and +each one names the entry to add or remove. A resolver must return a `select` call on +every path, and `select.target` takes the values of the path it is given. For a +compound target, +`select.target("Checkout", { data: parentValues, states: { Review: { data: childValues } } })` constructs the explicitly selected subtree. Parallel constructors require every entered region; source-local construction can retain active sibling regions. Every compound has an initial edge, including inactive branches. Final outputs, history defaults, and choices remain part of machine readiness checking. -Use `guard: context => boolean` to decline before construction or commands. -For a resolver that can decline, explicitly add `declinable: true`; only then may -it return `context.decline()`. `reenter: true` restarts the handler's source -where that operation supports reentry. A retained source-value update does not -restart its lifecycle. +Use `guard: context => boolean` to decline before commands or construction, and +`commands: (context, enqueue) => ...` to enqueue work on any plain transition. A +resolver that can decline lists `{ decline: true }` and returns `select.decline()`. +`guard` and `decline` exist only on `on` handlers and `always`. An invocation +outcome, a completion, and a choice have no other candidate to fall through to, so +they use `{ stay: true }` or select a destination. `reenter: true` restarts the +handler's source where that operation supports reentry. A retained source-value +update does not restart its lifecycle. ### Select destinations and retain owners @@ -245,15 +243,26 @@ spelled the same way as snapshot paths. The root node is `"root"`, so a top-level state cannot use that name. Paths are checked at compile time and suggested by the editor: -| Declaration | Meaning | -| ------------------------------------------ | ------------------------------------------------------------------------- | -| `{ target: "Checkout.Review", data: ... }` | Enter a declared destination with its value. | -| `{ target: "Checkout", data: ... }` | Enter the declared initial configuration of a compound or parallel state. | -| `{ history: "Checkout.recent" }` | Restore a declared history state. | -| `{ update: "Checkout", data: ... }` | Replace a retained active owner's value. | -| `{ update: "root", data: ... }` | Replace root data and retain active descendants. | -| `{ initialize: ... }` | Reconstruct root and its initial children from fresh machine input. | -| `{ none: true }` | Accept an event without changing the configuration. | +| Plain transition | Entry in `branches` | Call in `resolve` | Meaning | +| ------------------------------------------ | -------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------- | +| `{ target: "Checkout.Review", data: ... }` | `{ target: "Checkout.Review" }` | `select.target("Checkout.Review", { data })` | Enter a declared destination with its value. | +| `{ target: "Checkout", data: ... }` | `{ target: "Checkout" }` | `select.target("Checkout", { data })` | Enter the declared initial configuration of a compound or parallel state. | +| `{ target, update: { Checkout: ... } }` | `{ target, update: "Checkout" }` | `select.target(target, { update: { Checkout: value } })` | Enter a destination and replace one retained owner's value. | +| `{ history: "Checkout.recent" }` | `{ history: "Checkout.recent" }` | `select.history("Checkout.recent")` | Restore a declared history state. | +| `{ update: { Checkout: ... } }` | `{ update: "Checkout" }` | `select.update({ Checkout: value })` | Replace a retained active owner's value. | +| `{ update: { root: ... } }` | `{ update: "root" }` | `select.update({ root: value })` | Replace root data and retain active descendants. | +| `{ initialize: ... }` | `{ initialize: true }` | `select.initialize({ input })` | Reconstruct root and its initial children from fresh machine input. | +| `{ stay: true }` | `{ stay: true }` | `select.stay()` | Accept an event without changing the configuration. | +| `{ reenter: true }` | | | Exit and reenter the source. | +| | `{ decline: true }` | `select.decline()` | Refuse the event, and let the next candidate handle it. | + +In a plain transition `update` is a record holding the new value, with exactly one +owner. A `branches` entry has no values yet, so it names the owner as a string; the +value is passed to `select`. `data` only ever means the target's value. + +A `{ stay: true }` in one parallel region accepts the event, so it blocks a handler +for that event on a parallel ancestor, while `{ decline: true }` lets that handler +run. Declarations written inline are checked in place. A declaration stored in a variable before it reaches `make` or `.handle` needs `as const` so its paths @@ -264,41 +273,34 @@ An atomic transition can enter a destination and update one retained owner: ```ts Save: { target: "Checkout.Saving", - update: "Checkout", - data: ({ event, ancestors }) => ({ - target: { request: event.request }, - update: { ...ancestors.Checkout, revision: event.revision } - }) + data: ({ event }) => ({ request: event.request }), + update: { Checkout: ({ event, ancestors }) => ({ ...ancestors.Checkout, revision: event.revision }) } } ``` The complete replacement values are validated before either change is applied. -Advanced construction declares both paths in a branch and uses -`select.saved({ data: destinationValues, update: { data: ownerValues } })`. 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: +The destination can be a choice, which has no `data` of its own. The owner is +updated first, so the choice resolver reads the new value: ```ts Bump: { target: "Route", - update: "root", - data: ({ root }) => ({ count: root.count + 1 }) + update: { root: ({ 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 } })`. +below the owner. 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 the root constructor and initial declarations in `.handle`. Add `reenter: true` to also exit and re-enter the root lifecycle. A branch declares -`{ initialize: true }` and its resolver supplies `select.branch({ input })`. +`{ initialize: true }` and its resolver supplies `select.initialize({ input })`. History defaults retain complete subtree construction for restoration. ### Protocols and ownership @@ -392,17 +394,17 @@ const invocations = [ { src: "updates", onElement: { - none: true, - resolve: ({ element }, enqueue) => { + stay: true, + commands: ({ element }, enqueue) => { enqueue.raise(Events.Changed({ value: element })) } }, - onDone: { none: true }, - onFailure: { none: true } + onDone: { stay: true }, + onFailure: { stay: true } }, { src: "timeout", onDone: { target: "Expired" } }, - { src: "worker", id: "worker", address: WorkerAddress, onSnapshot: { none: true } }, - { src: "validation", input: ({ state }) => ({ query: state.query }), onDone: { none: true } } + { src: "worker", id: "worker", address: WorkerAddress, onSnapshot: { stay: true } }, + { src: "validation", input: ({ state }) => ({ query: state.query }), onDone: { stay: true } } ] ``` @@ -697,8 +699,6 @@ pnpm install --frozen-lockfile pnpm check ``` -Use `guard: predicate` to decline before construction, or `declinable: true` -when a resolver can return `decline()`. Pull requests that change `src/` or `package.json` need a changeset and the performance checks described in `AGENTS.md`. diff --git a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs index 86044792..7f8c0c4a 100644 --- a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs +++ b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs @@ -42,6 +42,15 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { return false } })() + const hasStayTransitions = hasHandlerInitial && (() => { + try { + Machine.make({ root: Machine.state({ states: { Probe: {} } }), events: Machine.events({ Probe: {} }) }) + .handle({ initial: { target: "Probe" }, on: { Probe: { stay: true } } }) + return true + } catch { + return false + } + })() const rootDefinitions = new WeakMap() const transitionDefinition = Symbol("benchmark transition") const invocationDefinition = Symbol("benchmark invocation") @@ -207,7 +216,7 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { : hasFluentTransitions ? fluentTransition(definition) : legacy, - targetless: hasObjectTransitions ? { none: true } : hasValueSelectors + targetless: hasObjectTransitions ? (hasStayTransitions ? { stay: true } : { none: true }) : hasValueSelectors ? (to) => to.none : hasStaticTransitions || hasFluentTransitions ? { target: Machine.targetless } diff --git a/packages/effect-machine/perf/types/composition.ts b/packages/effect-machine/perf/types/composition.ts index a49f8c1f..e05f340c 100644 --- a/packages/effect-machine/perf/types/composition.ts +++ b/packages/effect-machine/perf/types/composition.ts @@ -11,7 +11,6 @@ class CompositionService extends Context.Service()(" class CompositionFailure extends Data.TaggedError("CompositionFailure")<{}> { } const machine = Machine.make({ - branches: { enter: { app: { target: "App" } } }, root: States, events: Machine.eventsFromSchemas() }) @@ -85,9 +84,9 @@ const handled = machine.handle({ }, Route: { choice: { - branches: "enter", + branches: [{ target: "App" }], resolve: ({ select }) => - select.app({ + select.target("App", { data: App.make({}), states: { Workspace: { diff --git a/packages/effect-machine/perf/types/dynamic-invoke.ts b/packages/effect-machine/perf/types/dynamic-invoke.ts index e52c5bdb..7c1f5eaa 100644 --- a/packages/effect-machine/perf/types/dynamic-invoke.ts +++ b/packages/effect-machine/perf/types/dynamic-invoke.ts @@ -20,16 +20,16 @@ const invoked = machine.handle({ src: "load-user", input: ({ state }) => state.userId, onDone: { - none: true, - resolve: ({ output }) => { + stay: true, + commands: ({ output }) => { const user: User = output void user return undefined } }, onFailure: { - none: true, - resolve: ({ error }) => { + stay: true, + commands: ({ error }) => { const loadError: LoadError = error void loadError return undefined diff --git a/packages/effect-machine/perf/types/exact-channels.ts b/packages/effect-machine/perf/types/exact-channels.ts index dfe96f16..9ed2b0b3 100644 --- a/packages/effect-machine/perf/types/exact-channels.ts +++ b/packages/effect-machine/perf/types/exact-channels.ts @@ -7,7 +7,6 @@ type Equal = (() => Type extends Left ? 1 : 2) extends ( = Value type IsAny = 0 extends 1 & Value ? true : false const machine = Machine.make({ - branches: { finish: { done: { target: "Done" } } }, root: States, events: Machine.eventsFromSchemas(Start), internalEvents: Machine.internalEventsFromSchemas(Loaded), @@ -22,10 +21,10 @@ const complete = machine.handle({ entry: () => {}, on: { Start: { - branches: "finish", + branches: [{ target: "Done" }], resolve: ({ event, select }, enqueue) => { enqueue.emit(Notice.make({ value: event.value })) - return select.done({ data: Done.make({ value: event.value }) }) + return select.target("Done", { data: Done.make({ value: event.value }) }) } }, Loaded: { target: "Done", data: ({ event }) => ({ value: event.value }) } diff --git a/packages/effect-machine/perf/types/named-branches.ts b/packages/effect-machine/perf/types/named-branches.ts index b34cf555..801cc7cd 100644 --- a/packages/effect-machine/perf/types/named-branches.ts +++ b/packages/effect-machine/perf/types/named-branches.ts @@ -1,21 +1,6 @@ import { Machine } from "../../dist/index.js" import { Route, State, States } from "./named-branches-control.js" const machine = Machine.make({ - branches: { - route: { - length1: { target: "Text" }, - length2: { target: "Count" }, - length3: { target: "Text" }, - length4: { target: "Count" }, - length5: { target: "Text" }, - length6: { none: true }, - length7: { target: "Count" }, - length8: { target: "Text" }, - length9: { target: "Count" }, - length10: { target: "Idle" }, - unchanged: { none: true } - } - }, root: States, events: Machine.eventsFromSchemas(Route) }) @@ -25,32 +10,32 @@ const handled = machine.handle({ Idle: { on: { Route: { - branches: "route", + branches: [{ target: "Text" }, { target: "Count" }, { stay: true }, { target: "Idle" }], resolve: ({ event, select }) => { const value = event.value switch (value.length) { case 1: - return select.length1({ data: State.cases.Text.make({ value }) }) + return select.target("Text", { data: State.cases.Text.make({ value }) }) case 2: - return select.length2({ data: State.cases.Count.make({ value: value.length }) }) + return select.target("Count", { data: State.cases.Count.make({ value: value.length }) }) case 3: - return select.length3({ data: State.cases.Text.make({ value }) }) + return select.target("Text", { data: State.cases.Text.make({ value }) }) case 4: - return select.length4({ data: State.cases.Count.make({ value: value.length }) }) + return select.target("Count", { data: State.cases.Count.make({ value: value.length }) }) case 5: - return select.length5({ data: State.cases.Text.make({ value }) }) + return select.target("Text", { data: State.cases.Text.make({ value }) }) case 6: - return select.length6() + return select.stay() case 7: - return select.length7({ data: State.cases.Count.make({ value: value.length }) }) + return select.target("Count", { data: State.cases.Count.make({ value: value.length }) }) case 8: - return select.length8({ data: State.cases.Text.make({ value: value.toUpperCase() }) }) + return select.target("Text", { data: State.cases.Text.make({ value: value.toUpperCase() }) }) case 9: - return select.length9({ data: State.cases.Count.make({ value: value.length }) }) + return select.target("Count", { data: State.cases.Count.make({ value: value.length }) }) case 10: - return select.length10({ data: State.cases.Idle.make({}) }) + return select.target("Idle", { data: State.cases.Idle.make({}) }) default: - return select.unchanged() + return select.stay() } } } diff --git a/packages/effect-machine/perf/types/transition-construction.ts b/packages/effect-machine/perf/types/transition-construction.ts index 115bd791..333b6798 100644 --- a/packages/effect-machine/perf/types/transition-construction.ts +++ b/packages/effect-machine/perf/types/transition-construction.ts @@ -2,7 +2,6 @@ import { Schema } from "effect" import { Machine } from "../../dist/index.js" import { Root, Saved, States } from "./transition-construction-control.js" const machine = Machine.make({ - branches: { reset: { idle: { target: "Idle" }, same: { none: true } } }, root: States, events: Machine.events({ Save: { text: Schema.String }, Retry: {}, Reset: {} }) }) @@ -11,15 +10,15 @@ const handled = machine.handle({ target: "Idle" }, root: () => ({ count: 0 }), - on: { Reset: { update: "root", guard: ({ root }) => root.count > 0, data: () => ({ count: 0 }) } }, + on: { Reset: { update: { root: () => ({ count: 0 }) }, guard: ({ root }) => root.count > 0 } }, states: { Idle: { on: { Save: { target: "Saved", - update: "root", + update: { root: ({ root, event }) => ({ count: root.count + 1 }) }, guard: ({ event }) => event.text.length > 0, - data: ({ root, event }) => ({ target: { text: event.text }, update: { count: root.count + 1 } }) + data: ({ root, event }) => ({ text: event.text }) } } }, @@ -27,19 +26,16 @@ const handled = machine.handle({ on: { Save: { target: "Saved", - update: "root", + update: { root: { decoded: true, data: ({ root, event }) => (new Root({ count: root.count + 1 })) } }, reenter: true, decoded: true, - data: ({ root, event }) => ({ - target: new Saved({ text: event.text }), - update: new Root({ count: root.count + 1 }) - }) + data: ({ root, event }) => (new Saved({ text: event.text })) }, Retry: { target: "Saved", reenter: true, data: ({ state }) => ({ text: state.text }) }, Reset: { - branches: "reset", + branches: [{ target: "Idle" }, { stay: true }], reenter: true, - resolve: ({ state, select }) => state.text.length === 0 ? select.idle({}) : select.same() + resolve: ({ state, select }) => state.text.length === 0 ? select.target("Idle", {}) : select.stay() } } } diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index 38213b67..ee1b3871 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -112,7 +112,7 @@ type IsAny = 0 extends (1 & A) ? true : false * **Gotchas** * * Use `guard` to decline a transition before constructing its next state. - * Use `declinable: true` when a named branch resolver may return `decline()`. + * List `{ decline: true }` in `branches` when `resolve` may return `select.decline()`. * Register cancellable state-scoped delays in `make({ timers })`. * * **Example** (Declining an event with a guard) @@ -130,9 +130,8 @@ type IsAny = 0 extends (1 & A) ? true : false * root: () => ({ count: 0 }), * on: { * Add: { - * update: "root", - * guard: ({ event }) => event.by > 0, - * data: ({ root, event }) => ({ count: root.count + event.by }) + * update: { root: ({ root, event }) => ({ count: root.count + event.by }) }, + * guard: ({ event }) => event.by > 0 * } * } * }) @@ -320,8 +319,7 @@ export interface Definition< * Count: { * on: { * Increment: { - * update: "Count", - * decoded: ({ event, state }) => new Count({ value: state.value + event.by }) + * update: { Count: {} } * } * } * } @@ -3504,8 +3502,8 @@ export declare namespace Machine { } | { readonly type: "branch" + /** The entry as written: `target:Paid`, `target:Paid+update:root`, `update:root`, `history:X`, `initialize`, or `stay`. */ readonly key: string - readonly title: string readonly target: Path | undefined readonly selection: TransitionTargetSelection readonly updates: ReadonlyArray @@ -3571,7 +3569,7 @@ export declare namespace Machine { readonly reenter: boolean /** Zero-based index of the selected static branch. */ readonly branchIndex: number - /** Stable key of a named branch, or `undefined` for a direct transition. */ + /** The selected `branches` entry as written, such as `target:Paid`, or `undefined` for a direct transition. */ readonly branchKey: string | undefined /** Path returned by the handler, including a choice or history pseudo-state. */ readonly target: TargetPath | undefined @@ -4470,18 +4468,6 @@ export declare namespace Machine { } } - /** @internal */ - type StateUpdateBuilder< - States extends StateSchemas, - StateId extends ValuedStateIdentifier - > = { - readonly decoded: (value: StateByIdentifier) => StateUpdate - readonly from: FromCallable< - readonly [input: SchemaByIdentifier["~type.make.in"]], - StateUpdate - > - } - /** * Opaque result returned by an explicitly targetless transition. * @@ -4496,8 +4482,8 @@ export declare namespace Machine { } /** - * Opaque result returned when a declinable transition does not accept the - * current event or lifecycle outcome. + * Opaque result of `select.decline()`, when a transition does not accept the + * current event. * * Declining selects no transition and discards operations enqueued by that * resolver. Hierarchical event and eventless dispatch continues with the @@ -5111,9 +5097,8 @@ export declare namespace Machine { * * Handlers return snapshots for complete state replacement, target builder * results for path-safe partial transitions, state-value updates, or - * an explicit targetless result. In a machine definition, select `{ none: true }`; - * its optional resolver returns `undefined`. Raw decoded state values are - * not accepted as transition targets. + * an explicit targetless result. In a machine definition, declare `{ stay: true }`. + * Raw decoded state values are not accepted as transition targets. * * @category utility types * @since 0.4.0 @@ -5454,89 +5439,7 @@ export declare namespace Machine { Acceptance extends TransitionAcceptance = "required" > = Transition - export type SelectionBuilder = Selection extends TargetSelection ? Builder - : never - export type SelectionKind = Selection extends TargetSelection ? Kind : never - export type SelectionPath = Selection extends TargetSelection ? Path : never - export type TargetBuilderResult = - | (Builder extends (...args: any) => infer Result ? Result : never) - | (Builder extends { readonly decoded: (...args: any) => infer Result } ? Result : never) - | (Builder extends { readonly from: (...args: any) => infer Result } ? Result : never) - export type SelectedTargetResult = SelectionBuilder extends infer Builder ? - TargetBuilderResult - : never - - /** - * Destination construction returned when a transition declares one retained - * valued owner with a `{ target, update }` declaration. - * - * @category models - * @since 0.22.0 - */ - export interface UpdatingStateConstruction< - Result, - States extends StateSchemas, - Owner extends ValuedStateIdentifier - > { - /** Combines the selected topology with the required owner replacement. */ - readonly update: ( - update: StateUpdate - ) => CombinedTarget, States, Owner> - } - - type UpdatingCallable< - Callable, - States extends StateSchemas, - Owner extends ValuedStateIdentifier - > = Callable extends { - (...args: infer Arguments1): infer Result1 - (...args: infer Arguments2): infer Result2 - } ? { - (...args: Arguments1): UpdatingStateConstruction - (...args: Arguments2): UpdatingStateConstruction - } - : Callable extends (...args: infer Arguments) => infer Result ? - (...args: Arguments) => UpdatingStateConstruction - : never - - type UpdatingTargetBuilder< - Builder, - States extends StateSchemas, - Owner extends ValuedStateIdentifier - > = { - readonly [Key in keyof Builder]: Key extends "from" | "decoded" ? UpdatingCallable - : Builder[Key] - } - - type SelectionSupportsDefaultConstruction = SelectionKind extends "none" ? true - : SelectionBuilder extends { readonly from: (...args: infer Args) => any } ? [] extends Args ? true - : false - : false - - export type TransitionResolveContext< - Context, - Selection - > = - & Omit - & (SelectionKind extends "none" ? {} : { readonly target: SelectionBuilder }) - - /** Context capability available only to explicitly declinable resolvers. */ - export interface DeclineCapability { - /** Declines this candidate and continues hierarchical transition selection. */ - readonly decline: () => Declined - } - - /** One named destination declared by a branching transition. */ - export interface TransitionBranchInput< - Selection extends TargetSelection = TargetSelection - > { - /** Exact topology destination available to the branching resolver. */ - readonly target: Selection - /** Optional human-readable branch label used by visualization tooling. */ - readonly title?: string - } - - /** Opaque evidence that a branching resolver selected one declared branch. */ + /** Opaque evidence that `resolve` selected one declared branch. */ export interface SelectedBranch { readonly [Topology.SelectedBranchTypeId]: { readonly key: Types.Covariant @@ -5544,65 +5447,6 @@ export declare namespace Machine { } } - type CompletedBranchResult = R extends { readonly update: unknown } - ? R extends UpdatingStateConstruction - ? CombinedTarget, S, Owner> - : R - : R - type SelectedConstruction = R extends { readonly update: unknown } ? - R extends UpdatingStateConstruction ? { - readonly update: SelectedBranchBuilder, K> extends infer B ? { - readonly [M in keyof B]: - StateUpdateBuilder[Extract>] extends - (...args: infer A) => unknown - ? (...args: A) => SelectedBranch, S, Owner>> - : never - } : - never - } - : SelectedBranch : - SelectedBranch - type SelectedBranchCallable = Callable extends { - (...args: infer Arguments1): infer Result1 - (...args: infer Arguments2): infer Result2 - } ? { - (...args: Arguments1): SelectedConstruction - (...args: Arguments2): SelectedConstruction - } - : Callable extends (...args: infer Arguments) => infer Result ? - (...args: Arguments) => SelectedConstruction - : {} - - type SelectedBranchBuilderProperties = { - readonly [Property in keyof Builder]: Builder[Property] extends (...args: any) => any ? - SelectedBranchCallable - : Builder[Property] - } - - /** Target-specific builder that brands every construction with its branch key. */ - export type SelectedBranchBuilder = - & SelectedBranchCallable - & SelectedBranchBuilderProperties - - export type BranchSelectors>> = { - readonly [Key in Extract]: SelectedBranchBuilder< - SelectionBuilder, - Key - > - } - - export type BranchSelectionResult>> = { - readonly [Key in Extract]: SelectedBranch< - Key, - CompletedBranchResult> - > - }[Extract] - - export type TransitionBranchesResolveContext< - Context, - Branches extends Readonly> - > = Omit & { readonly select: BranchSelectors } - export type InvokeTransition< States extends StateSchemas, Events extends ReadonlyArray, @@ -6474,92 +6318,6 @@ export declare namespace Machine { Paths > type DestinationPath = Exclude, ""> - type RootSchemas = { readonly "": Root } - - /** A topology declaration; its state values are constructed by a resolver. */ - export type BranchDeclaration> = - & { - /** Optional presentation label for this named branch. */ - readonly title?: string - } - & ( - | { - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target: ReferenceUnion> - /** - * 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 - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - /** Reconstructs root and its initial children from fresh machine input. */ - readonly initialize?: never - } - | { - /** Reconstructs root and its initial children from fresh machine input supplied by the resolver. */ - readonly initialize: true - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target?: never - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - } - | { - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update: ReferenceUnion> - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - /** Reconstructs root and its initial children from fresh machine input. */ - readonly initialize?: never - } - | { - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history: ReferenceUnion> - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target?: never - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - /** Reconstructs root and its initial children from fresh machine input. */ - readonly initialize?: never - } - | { - /** Accepts the event without selecting a new destination. */ - readonly none: true - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target?: never - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: never - /** Reconstructs root and its initial children from fresh machine input. */ - readonly initialize?: never - } - ) - export type BranchDefinitions = Readonly< - Record>>> - > /** Source programs and named topology declarations captured by a machine definition. */ export interface Registrations { /** Lazy Effects or functions with one required input, invoked by registered name. */ @@ -6572,8 +6330,6 @@ export declare namespace Machine { readonly logic?: LogicSources /** Child machine descriptors registered for state-owned invocation. */ readonly children?: ChildSources - /** Named groups of inspectable destinations used by transition resolvers. */ - readonly branches?: Readonly>>> } type RegisteredInput = R extends { readonly "~input": infer I extends Schema.Top } ? I : typeof Schema.Void type Registered = Kind extends keyof R ? NonNullable : {} @@ -6626,86 +6382,68 @@ export declare namespace Machine { /** Fresh machine input used by root construction and root initial callbacks. */ readonly input: RegisteredInput["Type"] }) - type ObjectSelection< - S extends StateSchemas, - Src extends StateNodeIdentifier, - P extends StateNodeIdentifier, - R - > = P extends "" ? ConstructionCall, InitialTarget

> - : P extends ChoiceIdentifier ? () => ChoiceTarget - : P extends StateIdentifier ? ConstructionCall< - ConstructionObject< - NodeByIdentifier, - Src extends `${P}.${infer Rest}` ? Rest : Src extends P ? "" : never - > & { readonly update?: never }, - Target - > - : never - type SelectionOf, D, R = {}> = D extends { - readonly initialize: true - } ? TargetSelection>, R>, "", "state", "branch"> - : D extends { - readonly target: infer Ref - } ? - RefPath extends infer P extends StateNodeIdentifier ? - D extends { readonly update: infer Update } - ? RefPath extends - infer Owner extends Extract, ParentStateIdentifier

= Types.IsUnion

extends true + ? { readonly [K in `select.target takes one path, got: ${JoinUnion

}. Call it once per path`]: never } + : unknown + type OneOwner = Types.IsUnion extends true ? `update takes one owner, got: ${JoinUnion}` + : unknown + /** + * Selects one entry of a transition's `branches` list. The compiler checks that `resolve` selects exactly the + * declared entries, so each call here must match one of them. + * + * @category models + * @since 0.47.0 + */ + export interface BranchSelect, R> { + /** Selects `{ target }`, or `{ target, update }` when the construction has an `update` record. */ + readonly target: { +

, O extends ReferenceUnion>>( + path: P & OnePath

, + construction: TargetConstruction & { + /** The new value of the retained owner named by this branch. */ + readonly update: + & { readonly [Owner in O]: UpdateValue, StateIdentifier>>> } + & OneOwner + } + ): SelectedBranch< + `target:${P}+update:${O}`, + CombinedTarget, S, Extract, ValuedStateIdentifier>> + > +

>( + path: P & OnePath

, + ...construction: TargetArguments> + ): SelectedBranch<`target:${P}`, TargetResult> + } + /** Selects `{ update }`, replacing the value of one retained source or ancestor. */ + readonly update: >>( + updates: + & { readonly [Owner in O]: UpdateValue, StateIdentifier>>> } + & OneOwner + ) => SelectedBranch<`update:${O}`, StateUpdate, ValuedStateIdentifier>>> + /** Selects `{ history }`. */ + readonly history:

>>( + path: P + ) => SelectedBranch<`history:${P}`, HistoryTarget, HistoryIdentifier>>> + /** Selects `{ initialize: true }`. */ + readonly initialize: ConstructionCall, SelectedBranch<"initialize", InitialTarget<"">>> + /** Selects `{ stay: true }`. */ + readonly stay: () => SelectedBranch<"stay", NoTarget> + /** Selects `{ decline: true }`, passing the event on to the next candidate. */ + readonly decline: () => SelectedBranch<"decline", Declined> + } type ObjectPolicy = { /** Set to true to exit and reenter the handler source. */ readonly reenter?: Reenter extends true ? boolean : never @@ -6736,147 +6474,110 @@ export declare namespace Machine { */ readonly initialize: RegisteredInput extends typeof Schema.Void ? true : DataValue["Type"]> + /** Selects a declared descendant, constructing the values it requires. */ readonly target?: never + /** Replaces the value of one retained state without replacing its active children. */ readonly update?: never + /** Restores the declared history reference, using its fallback on first entry. */ + readonly history?: never + /** Accepts the event and stays in the current states. */ + readonly stay?: never + /** The destinations this transition can select, each one checked against `resolve`. */ readonly branches?: never + /** Selects one declared branch and may enqueue synchronous commands. */ readonly resolve?: never - readonly history?: never - readonly none?: never } + type OwnerUpdates, C> = { + readonly [O in Owners as PathName]?: ShortConstruction> + } + type TargetOwner, P extends string> = Src extends + ChoiceIdentifier ? never + : Extract, ParentStateIdentifier

& ValuedStateIdentifier> + type UpdatableOwner> = Src extends ChoiceIdentifier ? + never + : Extract, ValuedStateIdentifier> type InlineDestination, C> = { readonly [P in DestinationPath]: & { - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ + /** Selects a declared descendant, constructing the values it requires. */ readonly target: ReferenceAt - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never + /** + * Replaces the value of one 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?: OwnerUpdates, C> + /** Reconstructs root and its initial children from fresh machine input. */ + readonly initialize?: never /** Restores the declared history reference, using its fallback on first entry. */ readonly history?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - /** Named groups of inspectable destinations used by transition resolvers. */ + /** Accepts the event and stays in the current states. */ + readonly stay?: never + /** The destinations this transition can select, each one checked against `resolve`. */ readonly branches?: never /** Selects one declared branch and may enqueue synchronous commands. */ readonly resolve?: never - /** Set to true when the resolver can explicitly return decline(). */ - readonly declinable?: never } & InlineDestinationConstruction }[DestinationPath] - type InlinePairConstruction< + type InlineUpdate, C> = [UpdatableOwner] extends + [never] ? never : { + /** Replaces the value of one retained source or ancestor without replacing its active children. */ + readonly update: OwnerUpdates, C> + /** Selects a declared descendant, constructing the values it requires. */ + readonly target?: never + /** Reconstructs root and its initial children from fresh machine input. */ + readonly initialize?: never + /** Restores the declared history reference, using its fallback on first entry. */ + readonly history?: never + /** Accepts the event and stays in the current states. */ + readonly stay?: never + /** The destinations this transition can select, each one checked against `resolve`. */ + readonly branches?: never + /** Selects one declared branch and may enqueue synchronous commands. */ + readonly resolve?: never + } + /** + * One destination a `resolve` function can select. Here `update` names the owner as a string, because the value is + * passed to the `select` call. + * + * @category models + * @since 0.47.0 + */ + export type BranchEntry< S extends StateSchemas, Src extends StateNodeIdentifier, - P extends string, - Owner extends ValuedStateIdentifier, - C - > = P extends StateIdentifier ? - | { - readonly from: ( - context: C - ) => { - readonly target: [NodeSchema>] extends [never] ? undefined - : NodeMakeInput> - readonly update: SchemaByIdentifier["~type.make.in"] - } - readonly decoded?: never - } - | ([NodeSchema>] extends [never] ? never - : { - readonly decoded: ( - context: C - ) => { readonly target: NodeValue>; readonly update: StateByIdentifier } - readonly from?: never - }) - : P extends ChoiceIdentifier ? ObjectConstruction> - : never - type InlineCombined, C> = Src extends ChoiceIdentifier ? - never : - [Extract, ValuedStateIdentifier>] extends [never] ? never : - { + Acceptance extends TransitionAcceptance + > = + | { readonly [P in DestinationPath]: { - readonly [Owner in Extract, ParentStateIdentifier

& ValuedStateIdentifier>]: - & { - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target: ReferenceAt - /** - * 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 - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - /** Named groups of inspectable destinations used by transition resolvers. */ - readonly branches?: never - /** Selects one declared branch and may enqueue synchronous commands. */ - readonly resolve?: never - /** Set to true when the resolver can explicitly return decline(). */ - readonly declinable?: never - } - & InlinePairConstruction - }[Extract, ParentStateIdentifier

& ValuedStateIdentifier>] + /** Selects a declared descendant through `select.target`. */ + readonly target: ReferenceAt + /** The retained ancestor that this branch's `select.target` call updates. */ + readonly update?: ReferenceUnion> + } }[DestinationPath] - type InlineUpdate, C> = Src extends ChoiceIdentifier ? - never : - { - readonly [P in Extract, ValuedStateIdentifier>]: - & { - /** Set to true to exit and reenter the handler source. */ - readonly reenter?: P extends Src ? never : boolean - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update: ReferenceAt - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - /** Named groups of inspectable destinations used by transition resolvers. */ - readonly branches?: never - /** Selects one declared branch and may enqueue synchronous commands. */ - readonly resolve?: never - /** Set to true when the resolver can explicitly return decline(). */ - readonly declinable?: never - } - & ObjectConstruction> - }[Extract, ValuedStateIdentifier>] - type BranchSelections, Group, R> = { - readonly [K in Extract]: { - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target: SelectionOf + | ([UpdatableOwner] extends [never] ? never : { + /** The retained state updated through `select.update`. */ + readonly update: ReferenceUnion> + readonly target?: never + }) + | { + /** Restores this history reference through `select.history`. */ + readonly history: ReferenceUnion> } - } - type ObjectBranchResolver< - Ev extends ReadonlyArray, - Em extends ReadonlyArray, - C, - B extends Readonly>, - Declinable extends boolean - > = ( - context: C & { readonly select: BranchSelectors } & DeclineCapability, - enqueue: Enqueue, EmittedEventOf> - ) => BranchSelectionResult | (Declinable extends true ? Declined : never) - type InvalidBranchSelection, Group, R> = { - readonly [K in keyof Group]: Src extends ChoiceIdentifier ? Group[K] extends { - /** Accepts the event without selecting a new destination. */ - readonly none: true - } | { - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update: unknown - } ? K - : [SelectionBuilder>] extends [never] ? K - : never - : [SelectionBuilder>] extends [never] ? K - : never - }[keyof Group] + | { + /** Reconstructs root from fresh machine input through `select.initialize`. */ + readonly initialize: true + } + | (Src extends ChoiceIdentifier ? never : { + /** Accepts the event and stays in the current states through `select.stay`. */ + readonly stay: true + }) + | (Acceptance extends "declinable" ? { + /** Passes the event on to the next candidate through `select.decline`. */ + readonly decline: true + } : + never) type ObjectBranches< S extends StateSchemas, Src extends StateNodeIdentifier, @@ -6884,41 +6585,40 @@ export declare namespace Machine { Ev extends ReadonlyArray, Em extends ReadonlyArray, R, + Reenter extends boolean, Acceptance extends TransitionAcceptance > = { - readonly [K in Extract, string>]: - & ([InvalidBranchSelection[K], R>] extends [never] ? unknown : never) - & { - /** Named groups of inspectable destinations used by transition resolvers. */ - readonly branches: K - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target?: never - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - /** Constructs schema make input from the typed source context. */ - readonly from?: never - /** Supplies an already decoded schema value from the typed source context. */ - readonly decoded?: never - } - & { - /** Set to true when the resolver can explicitly return decline(). */ - readonly declinable?: "declinable" extends Acceptance ? boolean : false - /** Selects one declared branch and may enqueue synchronous commands. */ - readonly resolve: ObjectBranchResolver< - Ev, - Em, - C, - BranchSelections[K], R>, - true - > - } - }[Extract, string>] + /** The destinations this transition can select, each one checked against `resolve`. */ + readonly branches?: ReadonlyArray> + /** Selects one declared branch and may enqueue synchronous commands. */ + readonly resolve?: ( + context: C & { + /** Selects one of the declared `branches`. */ + readonly select: BranchSelect + }, + enqueue: Enqueue, EmittedEventOf> + ) => unknown + /** Set to true to exit and reenter the handler source. */ + readonly reenter?: Reenter extends true ? boolean : never + /** Branch on the condition inside `resolve`, returning `select.decline()`. */ + readonly guard?: never + /** Enqueue commands inside `resolve`. */ + readonly commands?: never + /** Constructs values through the `select` calls inside `resolve`. */ + readonly data?: never + /** Constructs values through the `select` calls inside `resolve`. */ + readonly decoded?: never + /** Selects a declared descendant, constructing the values it requires. */ + readonly target?: never + /** Replaces the value of one retained state without replacing its active children. */ + readonly update?: never + /** Reconstructs root and its initial children from fresh machine input. */ + readonly initialize?: never + /** Restores the declared history reference, using its fallback on first entry. */ + readonly history?: never + /** Accepts the event and stays in the current states. */ + readonly stay?: never + } type DataValue = A | ((context: C) => A) type DeclarationData = T extends { readonly from: (context: infer C) => infer A } ? Omit & { readonly data: DataValue; readonly decoded?: false } @@ -6942,63 +6642,70 @@ export declare namespace Machine { Reenter extends boolean = false, Acceptance extends TransitionAcceptance = "required" > = - & ObjectPolicy - & DeclarationData< - ( + | ( + & ObjectPolicy + & { + /** Enqueues synchronous commands when this transition is selected. */ + readonly commands?: (context: C, enqueue: Enqueue, EmittedEventOf>) => void + } + & DeclarationData< | InlineDestination | InlineUpdate - | InlineCombined | InlineRoot | { /** Restores the declared history reference, using its fallback on first entry. */ readonly history: ReferenceUnion> - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ + /** Selects a declared descendant, constructing the values it requires. */ readonly target?: never - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ + /** Replaces the value of one retained state without replacing its active children. */ readonly update?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - /** Named groups of inspectable destinations used by transition resolvers. */ + /** Reconstructs root and its initial children from fresh machine input. */ + readonly initialize?: never + /** Accepts the event and stays in the current states. */ + readonly stay?: never + /** The destinations this transition can select, each one checked against `resolve`. */ readonly branches?: never /** Selects one declared branch and may enqueue synchronous commands. */ readonly resolve?: never - /** Constructs schema make input from the typed source context. */ - readonly from?: never - /** Supplies an already decoded schema value from the typed source context. */ - readonly decoded?: never - /** Set to true when the resolver can explicitly return decline(). */ - readonly declinable?: never } | (Src extends ChoiceIdentifier ? never : { - /** Accepts the event without selecting a new destination. */ - readonly none: true - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ + /** Accepts the event and stays in the current states. */ + readonly stay: true + /** Selects a declared descendant, constructing the values it requires. */ readonly target?: never - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ + /** Replaces the value of one retained state without replacing its active children. */ readonly update?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never + /** Reconstructs root and its initial children from fresh machine input. */ + readonly initialize?: never /** Restores the declared history reference, using its fallback on first entry. */ readonly history?: never - /** Named groups of inspectable destinations used by transition resolvers. */ + /** The destinations this transition can select, each one checked against `resolve`. */ readonly branches?: never - /** Constructs schema make input from the typed source context. */ - readonly from?: never - /** Supplies an already decoded schema value from the typed source context. */ - readonly decoded?: never - /** Set to true when the resolver can explicitly return decline(). */ - readonly declinable?: "declinable" extends Acceptance ? boolean : false /** Selects one declared branch and may enqueue synchronous commands. */ - readonly resolve?: ( - context: C & DeclineCapability, - enqueue: Enqueue, EmittedEventOf> - ) => undefined | Declined + readonly resolve?: never }) - | ObjectBranches - ) - > + | (Src extends ChoiceIdentifier ? never : Reenter extends true ? { + /** Exits and reenters the source. */ + readonly reenter: true + /** Selects a declared descendant, constructing the values it requires. */ + readonly target?: never + /** Replaces the value of one retained state without replacing its active children. */ + readonly update?: never + /** Reconstructs root and its initial children from fresh machine input. */ + readonly initialize?: never + /** Restores the declared history reference, using its fallback on first entry. */ + readonly history?: never + /** Accepts the event and stays in the current states. */ + readonly stay?: never + /** The destinations this transition can select, each one checked against `resolve`. */ + readonly branches?: never + /** Selects one declared branch and may enqueue synchronous commands. */ + readonly resolve?: never + } : + never) + > + ) + | ObjectBranches type SourceValue = { readonly [Kind in "effects" | "streams" | "timers" | "logic" | "children"]: K extends keyof Registered ? Registered[K] @@ -7072,7 +6779,7 @@ export declare namespace Machine { Em, R, true, - TransitionAcceptance + "required" > } : Channel extends "onFailure" ? { @@ -7085,7 +6792,7 @@ export declare namespace Machine { Em, R, true, - TransitionAcceptance + "required" > } : Channel extends "onElement" ? { @@ -7098,7 +6805,7 @@ export declare namespace Machine { Em, R, true, - TransitionAcceptance + "required" > } : { @@ -7111,7 +6818,7 @@ export declare namespace Machine { Em, R, true, - TransitionAcceptance + "required" > } type RegisteredInvokeError = SourceKind extends "children" @@ -7411,7 +7118,7 @@ export declare namespace Machine { Em, R, true, - TransitionAcceptance + "declinable" > } /** Eventless transition evaluated during stabilization. */ @@ -7423,7 +7130,7 @@ export declare namespace Machine { Em, R, false, - TransitionAcceptance + "declinable" > /** Handles successful invocation or state completion with its exact output type. */ readonly onDone?: Transition< @@ -7434,7 +7141,7 @@ export declare namespace Machine { Em, R, false, - TransitionAcceptance + "required" > /** Required total transition for a transient choice state. */ readonly choice?: never @@ -7460,9 +7167,102 @@ export declare namespace Machine { readonly history?: never }) : never) - type ValidateObjectTransition = + type LastOfUnion = Types.UnionToIntersection U : never> extends () => infer Last ? Last + : never + type JoinUnion = [U] extends [never] ? "" + : LastOfUnion extends infer Last extends string + ? [Exclude] extends [never] ? Last : `${JoinUnion>}, ${Last}` + : never + type BranchEntryKey = Entry extends + { readonly target: infer P extends string; readonly update: infer O extends string } ? `target:${P}+update:${O}` + : Entry extends { readonly target: infer P extends string } ? `target:${P}` + : Entry extends { readonly update: infer O extends string } ? `update:${O}` + : Entry extends { readonly history: infer P extends string } ? `history:${P}` + : Entry extends { readonly initialize: true } ? "initialize" + : Entry extends { readonly stay: true } ? "stay" + : Entry extends { readonly decline: true } ? "decline" + : never + type BranchLabel = Key extends `target:${infer P}+update:${infer O}` + ? `{ target: "${P}", update: "${O}" }` + : Key extends `target:${infer P}` ? `{ target: "${P}" }` + : Key extends `update:${infer O}` ? `{ update: "${O}" }` + : Key extends `history:${infer P}` ? `{ history: "${P}" }` + : Key extends "initialize" | "stay" | "decline" ? `{ ${Key}: true }` + : never + type BranchEntryKeyAt, K> = K extends keyof B ? BranchEntryKey : never + type DuplicateBranchKey, Indices extends keyof B = Extract> = { + readonly [K in Indices]: BranchEntryKeyAt extends BranchEntryKeyAt> + ? BranchEntryKeyAt + : never + }[Indices] + type SelectedBranchKey = Result extends SelectedBranch ? Key : never + type ValidateBranchSelection, Result> = number extends B["length"] ? + { readonly branches: "branches must list its entries inline" } + : B["length"] extends 0 ? { readonly branches: "branches needs at least one entry" } + : [DuplicateBranchKey] extends [never] ? + 0 extends 1 & Result ? { readonly resolve: "resolve must return a select call, got any" } + : unknown extends Result ? { readonly resolve: "resolve must return a select call, got unknown" } + : [Exclude>] extends [never] ? + [Exclude, BranchEntryKey>] extends [never] ? + [Exclude, SelectedBranchKey>] extends [never] ? unknown : { + readonly branches: `Declared branch ${BranchLabel< + Exclude, SelectedBranchKey> + > extends infer Label extends string ? JoinUnion

& ValuedStateIdentifier> - ? P extends StateIdentifier ? TargetSelection< - ConstructionCall< - ConstructionObject< - NodeByIdentifier, - Src extends `${P}.${infer Rest}` ? Rest : Src extends P ? "" : never - > & { - readonly update: ConstructionData> - }, - CombinedTarget, S, Owner> - >, - P, - "state", - "branch" - > - : P extends ChoiceIdentifier ? TargetSelection< - (construction: { - readonly update: ConstructionData> - }) => CombinedTarget, S, Owner>, - P, - "choice", - "branch" - > : - never - : never - : TargetSelection< - ObjectSelection, - P, - P extends ChoiceIdentifier ? "choice" : "state", - "branch" - > - : never - : D extends { readonly history: infer Ref } - ? RefPath extends infer P extends HistoryIdentifier - ? TargetSelection<() => HistoryTarget, P, "history", "full"> : - never - : D extends { readonly update: infer Ref } - ? RefPath extends infer P extends Extract, ValuedStateIdentifier> - ? TargetSelection< - ConstructionCall>, StateUpdate>, - P, - "update", - "branch" - > : - never - : D extends { readonly none: true } ? TargetSelection<() => NoTarget, never, "none", "local"> + type UpdateValue = OrdinaryData> | { + readonly decoded: true + readonly data: NodeValue + } + type TargetConstruction, P extends DestinationPath> = + P extends StateIdentifier ? + ConstructionObject, Src extends `${P}.${infer Rest}` ? Rest : Src extends P ? "" : never> + : {} + type TargetArguments = {} extends A ? [construction?: A & { readonly update?: never }] + : [construction: A & { readonly update?: never }] + type TargetResult = P extends ChoiceIdentifier ? ChoiceTarget + : P extends StateIdentifier ? Target : never - type BuilderArgs = K extends keyof B - ? B[K] extends (...args: infer A) => unknown ? A : never - : never - type ObjectConstruction = { - readonly [M in Extract]: BuilderArgs extends readonly [unknown?] ? - & { readonly [K in M]: (context: C) => BuilderArgs[0] } - & { readonly [K in Exclude<"from" | "decoded", M>]?: never } - : never - }[Extract] + type OnePath