From 07e04fda6a4cad679e46c200dfa25fa47cb072ce Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 6 Sep 2026 17:12:49 +0200 Subject: [PATCH] Unify transition construction, guards, and reentry --- .changeset/calm-transitions-compose.md | 8 + .../browser/transition-semantics-example.ts | 2 +- packages/effect-machine/README.md | 45 +-- packages/effect-machine/docs/root-api.md | 45 +++ .../runtime/effect-machine-compatibility.mjs | 8 +- .../types/transition-construction-control.ts | 15 + .../perf/types/transition-construction.ts | 32 ++ packages/effect-machine/src/Machine.ts | 358 +++++++++++++----- .../src/internal/machine/machine.ts | 145 ++++--- .../internal/testing/machine/finiteModel.ts | 2 +- .../machine/strategyDifferential.test.ts | 14 +- .../transitionConstructionStrategies.test.ts | 105 +++++ .../machine/ActivityLifecycleModel.test.ts | 7 +- .../test/machine/History.test.ts | 5 +- .../test/machine/LocalTargetWith.test.ts | 5 +- .../test/machine/Machine.test.ts | 5 +- .../test/machine/StateUpdate.test.ts | 5 +- .../machine/TransitionConstruction.test.ts | 249 ++++++++++++ .../test/machine/Visualization.test.ts | 2 +- .../test/testing/Coverage.test.ts | 4 +- .../effect-machine/test/testing/Probe.test.ts | 6 +- .../test/testing/Verification.test.ts | 6 +- .../typetest/machine/Machine.tst.ts | 4 +- .../typetest/machine/StateUpdate.tst.ts | 4 +- .../machine/TransitionConstruction.tst.ts | 102 +++++ packages/oxlint-plugin/README.md | 2 +- .../src/internal/rules/noRedundantResolve.ts | 34 +- .../test/noRedundantResolve.test.ts | 10 +- ...runtime-performance-compatibility.test.mjs | 10 + scripts/type-performance.mjs | 14 + 30 files changed, 1007 insertions(+), 246 deletions(-) create mode 100644 .changeset/calm-transitions-compose.md create mode 100644 packages/effect-machine/perf/types/transition-construction-control.ts create mode 100644 packages/effect-machine/perf/types/transition-construction.ts create mode 100644 packages/effect-machine/test/internal/machine/transitionConstructionStrategies.test.ts create mode 100644 packages/effect-machine/test/machine/TransitionConstruction.test.ts create mode 100644 packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts diff --git a/.changeset/calm-transitions-compose.md b/.changeset/calm-transitions-compose.md new file mode 100644 index 0000000..e374d14 --- /dev/null +++ b/.changeset/calm-transitions-compose.md @@ -0,0 +1,8 @@ +--- +"@typeonce/effect-machine": minor +"@typeonce/oxlint-plugin-effect-machine": patch +--- + +Construct atomic destination and retained-owner updates with `.updating(owner).from(({ current, event }) => ({ target, update }))`, or use `.decoded(...)` for decoded values. Both values are complete replacements; `.resolve(...)` remains available for explicit configuration builders and commands. Value updates and atomic transitions now support `.guard(...)`, declining before construction and commands while preserving ancestor fallback. + +Use chainable `.reenter()` before `.from(...)`, `.decoded(...)`, or `.resolve(...)` to force source exit and entry. Replace `.resolve(callback, { reenter: true })` with `.reenter().resolve(callback)` and omit reentry entirely when it is false. Named branching transitions use `.branches(...).reenter().resolve(...)`. The redundant-resolver lint rule preserves these modifiers when simplifying default construction. diff --git a/packages/devtools/src/internal/browser/transition-semantics-example.ts b/packages/devtools/src/internal/browser/transition-semantics-example.ts index 68d91d9..e0b3953 100644 --- a/packages/devtools/src/internal/browser/transition-semantics-example.ts +++ b/packages/devtools/src/internal/browser/transition-semantics-example.ts @@ -190,7 +190,7 @@ export const transitionSemanticsMachine = Machine.make({ ? select.publish.decoded(new WorkspaceFinished({ result: "published directly" })) : select.review.decoded(new Review({ requestedBy: event.requestedBy })) ), - Refresh: (to) => to.none.resolve(() => undefined, { reenter: true }), + Refresh: (to) => to.none.reenter(), Ignore: (to) => to.none, MaybeHandle: (to) => to.none.resolve(({ decline, event }) => event.accept ? undefined : decline(), { diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 3855b66..dffbe82 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -411,8 +411,9 @@ selecting a destination. Concrete destinations stay narrowed inside their resolver, and `to.branches({...})` gives the resolver only the declared named `select` builders. Builders describe the next logical configuration. Shared states exit and enter only when paths -change; call `.reenter()` for resolver-free reentry or pass `{ reenter: true }` -to `.resolve(...)` when the source must restart. With `to.none`, reentry +change; call `.reenter()` before `.from(...)`, `.decoded(...)`, or `.resolve(...)` +when the source must restart. Default-constructible targets can finish at +`.reenter()`. With `to.none`, reentry restarts the source while retaining its configuration. Topology-only definition instructions are values: `to.none`, declared @@ -430,7 +431,7 @@ handler source: ```ts const handlers = { - Increment: (to) => to.branch.root.session.update(({ current, owner }) => owner.from({ count: current.count + 1 })) + Increment: (to) => to.branch.root.session.update.from(({ current }) => ({ count: current.count + 1 })) } ``` @@ -463,30 +464,28 @@ const handlers = { CreatePlan: (to) => to.local.SavingPlan() .updating(to.branch.Ready) - .resolve(({ current, event, owner, target }) => - target.from({ - request: { _tag: "Create", input: event.input } - }).update( - owner.decoded(new Ready({ ...current, notice: null })) - ) - ) + .from(({ current, event }) => ({ + target: { request: { _tag: "Create", input: event.input } }, + update: { ...current, notice: null } + })) } ``` `to.local.SavingPlan()` selects topology. `.updating(to.branch.Ready)` names -the retained valued owner and makes its replacement mandatory: the resolver -does not type-check unless destination construction finishes with -`.update(...)`. `current` is that owner's decoded value from the -pre-transition snapshot. `target` constructs the destination; `owner` -constructs the complete replacement owner value. +the retained valued owner and makes its replacement mandatory. `.from(...)` +returns `{ target, update }` with constructor inputs for both values; +`.decoded(...)` returns already decoded values for both. `current` is that +owner's decoded value from the pre-transition snapshot. Use `.resolve(...)` +when constructing explicit children, mixing construction methods, or queuing +commands; its `target` and `owner` builders construct the two values. The topology change and owner replacement apply atomically in one microstep. The owner does not exit or reenter, its work is not restarted, and destination entry actions observe the new owner value. Eventless stabilization follows. Only one retained owner may be replaced by a combined target. A `full` target, or any target that exits the selected owner, does not expose `.updating`. -Combined updates use a direct resolver in this release; named branches continue -to support value-only updates. +Named branches support value-only updates; a combined update declares its +destination directly. For a schema-less destination, construction remains explicit: @@ -511,11 +510,13 @@ before lifecycle actions run. Competing transitions that write the same owner conflict; document order and hierarchy select one writer rather than applying last-write-wins behavior. -The resolver must return `target.decoded(value)` or `target.from(input)`. It -may return `decline()` only with `{ declinable: true }`. Pass `{ reenter: true }` -on event or invocation transitions when the handler source should exit and -enter again. Reentry applies to that source, not to the ancestor whose value -changed. +Use `.guard(predicate)` before construction to decline an update without +constructing values or queuing commands. It is available on standalone and +combined updates. A false guard allows ancestor fallback. A resolver may also +return `decline()` with `{ declinable: true }` for decisions during resolution. +Call `.reenter()` before construction on event or invocation transitions when +the handler source should exit and enter again. Reentry applies to that source, +not to the retained ancestor whose value changed. The selector omits `update` for schema-less scopes, atomic and final states, inactive branches, parallel sibling regions, and choice resolvers. Updating a diff --git a/packages/effect-machine/docs/root-api.md b/packages/effect-machine/docs/root-api.md index ceda809..00296ff 100644 --- a/packages/effect-machine/docs/root-api.md +++ b/packages/effect-machine/docs/root-api.md @@ -164,6 +164,51 @@ an event without changing topology; declining and accepting have different semantics. Named branches keep `{ target, title? }`: `target` identifies the checked destination and `title` supplies optional presentation metadata. +Guards also apply to standalone owner updates and combined transitions: + +```ts +Increment: (to) => to.self.update + .guard(({ current }) => current.count < 10) + .from(({ current }) => ({ ...current, count: current.count + 1 })) + +Save: (to) => to.local.Saving().updating(to.root) + .guard(({ current }) => current.draft.length > 0) + .from(({ current }) => ({ + target: { requestId: current.draft }, + update: { ...current, attempts: current.attempts + 1 } + })) +``` + +Combined `.from` returns constructor inputs for both the destination and the +complete owner replacement. `.decoded` returns their decoded values instead. +For a destination with no construction arguments, use `target: undefined`. +Both values are validated before applying either change; destination entry +observes the updated owner. Use `.resolve` for mixed construction methods, +explicit child configurations, or commands. + +Reentry is a modifier before construction: + +```ts +Retry: (to) => to.local.Saving().reenter() + .from(({ event }) => ({ requestId: event.requestId })) + +Refresh: (to) => to.none.reenter() + +Choose: (to) => to.branches({ + saving: { target: to.local.Saving() }, + idle: { target: to.local.Idle() } +}).reenter().resolve(({ state, select }) => + state.retry ? select.saving.from({ requestId: state.requestId }) : select.idle.from() +) +``` + +`.reenter()` restarts the handler source. It composes with `.updating`, `.guard`, +`.from`, `.decoded`, and `.resolve` wherever reentry is supported. Apply it to +the whole named-branches builder, whose individual targets describe topology. +Migrate `.resolve(callback, { reenter: true })` to `.reenter().resolve(callback)`; +remove `{ reenter: false }`. `to.self.update` retains the source lifecycle and +does not expose `.reenter()`. + ## Completion and history A compound root returns its completed direct workflow's output when that child diff --git a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs index 04693ad..c6a5dc4 100644 --- a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs +++ b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs @@ -19,13 +19,15 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { const fluentTransition = (definition) => (to) => { const selection = selectInstruction(definition.target(hasRoot ? { ...to, full: to.branch } : to)) + const reentered = definition.reenter === true && typeof selection.reenter === "function" ? selection.reenter() : undefined if (definition.resolve !== undefined) { - return selection.resolve(definition.resolve, { - ...(definition.reenter === true ? { reenter: true } : {}), + const chainable = reentered !== undefined && typeof reentered.resolve === "function" + return (chainable ? reentered : selection).resolve(definition.resolve, { + ...(definition.reenter === true && !chainable ? { reenter: true } : {}), ...(definition.declinable === true ? { declinable: true } : {}) }) } - return definition.reenter === true ? selection.reenter() : selection + return reentered ?? selection } const fluentInitial = (definition) => (to) => { diff --git a/packages/effect-machine/perf/types/transition-construction-control.ts b/packages/effect-machine/perf/types/transition-construction-control.ts new file mode 100644 index 0000000..51ca12e --- /dev/null +++ b/packages/effect-machine/perf/types/transition-construction-control.ts @@ -0,0 +1,15 @@ +import { Schema } from "effect" +import { Machine } from "../../dist/index.js" + +export class Root extends Schema.TaggedClass("Root")("Root", { count: Schema.Number }) {} +export class Saved extends Schema.TaggedClass("Saved")("Saved", { text: Schema.String }) {} + +export const machine = Machine.make({ + root: Machine.state({ + schema: Root, + initial: "Idle", + states: { Idle: {}, Saved: { schema: Saved } } + }), + events: Machine.events({ Save: { text: Schema.String }, Retry: {}, Reset: {} }), + initial: (root) => root.from(() => ({ count: 0 })) +}) diff --git a/packages/effect-machine/perf/types/transition-construction.ts b/packages/effect-machine/perf/types/transition-construction.ts new file mode 100644 index 0000000..470bbb8 --- /dev/null +++ b/packages/effect-machine/perf/types/transition-construction.ts @@ -0,0 +1,32 @@ +import { Machine } from "../../dist/index.js" +import { machine, Root, Saved } from "./transition-construction-control.js" + +const handled = machine.handle({ + on: { + Reset: (to) => to.self.update.guard(({ current }) => current.count > 0).from(() => ({ count: 0 })) + }, + states: { + Idle: { + on: { + Save: (to) => + to.local.Saved().updating(to.root).guard(({ event }) => event.text.length > 0) + .from(({ current, event }) => ({ target: { text: event.text }, update: { count: current.count + 1 } })) + } + }, + Saved: { + on: { + Save: (to) => + to.local.Saved().updating(to.root).reenter().decoded(({ current, event }) => ({ + target: new Saved({ text: event.text }), + update: new Root({ count: current.count + 1 }) + })), + Retry: (to) => to.local.Saved().reenter().from(({ state }) => ({ text: state.text })), + Reset: (to) => + to.branches({ idle: { target: to.local.Idle() }, same: { target: to.none } }).reenter() + .resolve(({ state, select }) => state.text.length === 0 ? select.idle.from() : select.same()) + } + } + } +}) + +void Machine.planInitial(handled) diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index 66579dd..973e079 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -4729,7 +4729,7 @@ export declare namespace Machine { * do not directly control state re-entry. Exit and entry paths are derived * from the previous and next active paths. Shared active ancestors remain * entered even when a `full` target supplies their values again. Use an event - * transition with `reenter: true` when the source should explicitly exit and + * transition with `.reenter()` when the source should explicitly exit and * enter again. * * @category models @@ -6015,25 +6015,17 @@ export declare namespace Machine { } } - type TransitionReenterOption = [Reenter] extends [true] ? { - /** Forces the source state to exit and enter even when active paths remain unchanged. */ - readonly reenter?: boolean - } - : { readonly reenter?: never } - - type TransitionRequiredOptions = - & TransitionReenterOption - & { - /** Keeps the transition required. The resolver cannot return `decline()`. */ - readonly declinable?: false - } + type TransitionRequiredOptions = { + /** Keeps the transition required. The resolver cannot return `decline()`. */ + readonly declinable?: false + readonly reenter?: never + } - type TransitionDeclinableOptions = - & TransitionReenterOption - & { - /** Adds `decline()` to the resolver context and permits declining this candidate. */ - readonly declinable: true - } + type TransitionDeclinableOptions = { + /** Adds `decline()` to the resolver context and permits declining this candidate. */ + readonly declinable: true + readonly reenter?: never + } type BuiltTransition< States extends StateSchemas, @@ -6059,7 +6051,7 @@ export declare namespace Machine { > { ( resolve: TransitionResolver, - options?: TransitionRequiredOptions + options?: TransitionRequiredOptions ): BuiltTransition< States, Events, @@ -6083,7 +6075,7 @@ export declare namespace Machine { > { ( resolve: DeclinableTransitionResolver, - options: TransitionDeclinableOptions + options: TransitionDeclinableOptions ): BuiltTransition< States, Events, @@ -6145,7 +6137,7 @@ export declare namespace Machine { & { readonly resolve: ( resolve: TransitionResolver, - options?: TransitionRequiredOptions + options?: TransitionRequiredOptions ) => BuiltTransition< States, Events, @@ -6161,19 +6153,15 @@ export declare namespace Machine { /** * A selected transition target with target-specific resolver operations. * - * **Example** (Updating state while reentering) + * **Example** (Constructing state while reentering) * * ```ts * Reset: (to) => - * to.branch.Ready().resolve( - * ({ target }) => target.from(), - * { reenter: true } - * ) + * to.branch.Ready().reenter().from(({ event }) => ({ count: event.count })) * ``` * * @inlineType TransitionRequiredOptions * @inlineType TransitionDeclinableOptions - * @inlineType TransitionReenterOption */ export type TransitionTarget< States extends StateSchemas, @@ -6233,21 +6221,14 @@ export declare namespace Machine { > : {}) } - & ([Reenter] extends [true] ? SelectionSupportsDefaultConstruction extends true ? { - /** Reenters the source using the selected target's default construction. */ - readonly reenter: () => BuiltTransition< - States, - Events, - Emits, - StateId, - Context, - Reenter, - SelectionKind extends "none" ? undefined : SelectedTargetResult | undefined, - "required" - > - } - : {} - : {}) + & ([Reenter] extends [true] ? { + /** Forces source exit and entry, preserving subsequent construction and guards. */ + readonly reenter: () => Omit< + TransitionTarget, + typeof Topology.TargetSelectionTypeId + > + } : + {}) & (SelectionKind extends "state" ? SelectionScope extends "local" | "branch" ? RetainedUpdateOwner extends infer Owner extends ValuedStateIdentifier ? @@ -6272,7 +6253,91 @@ export declare namespace Machine { : {} : {}) - /** A topology selection that requires one retained owner replacement. */ + type UpdatingConstructionCallbacks = { + readonly [ + Method in Extract as Builder[Method] extends + (...args: infer Args) => unknown ? Args extends readonly [unknown?] ? Method : never : never + ]: Builder[Method] extends (...args: infer Args) => unknown ? + OwnerBuilder[Method] extends (value: infer Update) => unknown ? + (construct: (context: Context) => { readonly target: Args[0]; readonly update: Update }) => Result + : never : + never + } + + type UpdatingCallbacks< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Selection extends TargetSelection, + Owner extends ValuedStateIdentifier, + Acceptance extends TransitionAcceptance + > = UpdatingConstructionCallbacks< + Omit, "owner" | "target">, + SelectionBuilder, + StateUpdateBuilder, + BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + | CombinedTarget>, States, Owner> + | (Acceptance extends "declinable" ? Declined : never), + Acceptance + > + > + + type GuardedUpdatingTransition< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Selection extends TargetSelection, + Owner extends ValuedStateIdentifier + > = UpdatingCallbacks & { + readonly resolve: ( + resolve: ( + context: UpdatingTransitionResolveContext, + enqueue: Enqueue, EmittedEventOf> + ) => CombinedTarget>, States, Owner>, + options?: TransitionRequiredOptions + ) => BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + CombinedTarget>, States, Owner> | Declined, + "declinable" + > + } + + /** + * A topology selection that requires one retained owner replacement. + * + * `.from` constructs both values from schema inputs; `.decoded` accepts both + * decoded values. Both callbacks return `{ target, update }` and read the same + * pre-transition snapshot. Use `.resolve` for explicit child construction, + * mixed construction methods, or queued commands. + * + * **Example** (Guarding an atomic destination and owner update) + * + * ```ts + * Save: (to) => to.local.Saving().updating(to.root) + * .guard(({ current }) => current.draft.length > 0) + * .from(({ current }) => ({ + * target: { text: current.draft }, + * update: { ...current, attempts: current.attempts + 1 } + * })) + * ``` + */ export type UpdatingTransitionTarget< States extends StateSchemas, Events extends ReadonlyArray, @@ -6283,32 +6348,35 @@ export declare namespace Machine { Acceptance extends TransitionAcceptance, Selection extends TargetSelection, Owner extends ValuedStateIdentifier - > = Selection & { - /** @internal */ - readonly "~effect/Machine/UpdatingTransitionTarget": Owner - readonly resolve: - & (( - resolve: ( - context: UpdatingTransitionResolveContext, - enqueue: Enqueue, EmittedEventOf> - ) => CombinedTarget>, States, Owner>, - options?: TransitionRequiredOptions - ) => BuiltTransition< - States, - Events, - Emits, - StateId, - Context, - Reenter, - CombinedTarget>, States, Owner>, - "required" - >) - & ("declinable" extends Acceptance ? ( + > = + & Selection + & UpdatingCallbacks + & ([Reenter] extends [true] ? { + readonly reenter: () => Omit< + UpdatingTransitionTarget, + typeof Topology.TargetSelectionTypeId + > + } : + {}) + & ("declinable" extends Acceptance ? { + /** Declines before either value is constructed or any commands are enqueued. */ + readonly guard: ( + predicate: ( + context: Omit, "owner" | "target"> + ) => boolean + ) => GuardedUpdatingTransition + } : + {}) + & { + /** @internal */ + readonly "~effect/Machine/UpdatingTransitionTarget": Owner + readonly resolve: + & (( resolve: ( - context: UpdatingTransitionResolveContext & DeclineCapability, + context: UpdatingTransitionResolveContext, enqueue: Enqueue, EmittedEventOf> - ) => CombinedTarget>, States, Owner> | Declined, - options: TransitionDeclinableOptions + ) => CombinedTarget>, States, Owner>, + options?: TransitionRequiredOptions ) => BuiltTransition< States, Events, @@ -6316,11 +6384,27 @@ export declare namespace Machine { StateId, Context, Reenter, - CombinedTarget>, States, Owner> | Declined, - "declinable" - > - : {}) - } + CombinedTarget>, States, Owner>, + "required" + >) + & ("declinable" extends Acceptance ? ( + resolve: ( + context: UpdatingTransitionResolveContext & DeclineCapability, + enqueue: Enqueue, EmittedEventOf> + ) => CombinedTarget>, States, Owner> | Declined, + options: TransitionDeclinableOptions + ) => BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + CombinedTarget>, States, Owner> | Declined, + "declinable" + > + : {}) + } /** @internal */ interface StateUpdateTransitionRequired< @@ -6340,7 +6424,7 @@ export declare namespace Machine { Context, Extract, ValuedStateIdentifier> >, - options?: TransitionRequiredOptions + options?: TransitionRequiredOptions ): BuiltTransition< States, Events, @@ -6371,7 +6455,7 @@ export declare namespace Machine { Context, Extract, ValuedStateIdentifier> >, - options: TransitionDeclinableOptions + options: TransitionDeclinableOptions ): BuiltTransition< States, Events, @@ -6384,6 +6468,54 @@ export declare namespace Machine { > } + type GuardedStateUpdateTransition< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Selection extends TargetSelection + > = + & ConstructionCallbacks< + Omit< + StateUpdateResolveContext, ValuedStateIdentifier>>, + "owner" + >, + SelectionBuilder, + BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + SelectedTargetResult | Declined, + "declinable" + > + > + & { + readonly resolve: ( + resolve: StateUpdateResolver< + States, + Events, + Emits, + Context, + Extract, ValuedStateIdentifier> + >, + options?: TransitionRequiredOptions + ) => BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + SelectedTargetResult | Declined, + "declinable" + > + } + /** @internal */ type StateUpdateTransition< States extends StateSchemas, @@ -6396,6 +6528,30 @@ export declare namespace Machine { Selection extends TargetSelection > = & Selection + & ([Reenter] extends [true] ? { + /** Reenters the source while updating its retained valued ancestor. */ + readonly reenter: () => Omit< + StateUpdateTransition, + typeof Topology.TargetSelectionTypeId + > + } : + {}) + & ("declinable" extends Acceptance ? { + /** Declines before replacing the selected owner value. */ + readonly guard: ( + predicate: ( + context: Omit< + StateUpdateResolveContext< + States, + Context, + Extract, ValuedStateIdentifier> + >, + "owner" + > + ) => boolean + ) => GuardedStateUpdateTransition + } : + {}) & ConstructionCallbacks< Omit< StateUpdateResolveContext, ValuedStateIdentifier>>, @@ -6583,7 +6739,7 @@ export declare namespace Machine { > { ( resolve: TransitionBranchesResolver, - options?: TransitionRequiredOptions + options?: TransitionRequiredOptions ): BuiltTransition< States, Events, @@ -6607,7 +6763,7 @@ export declare namespace Machine { > { ( resolve: DeclinableTransitionBranchesResolver, - options: TransitionDeclinableOptions + options: TransitionDeclinableOptions ): BuiltTransition< States, Events, @@ -6641,6 +6797,38 @@ export declare namespace Machine { > } + type TransitionBranchesTarget< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Acceptance extends TransitionAcceptance, + Branches extends Readonly> + > = + & { + /** Resolves exactly one declared branch after this transition is selected. */ + readonly resolve: + & TransitionBranchesResolveRequired + & ("declinable" extends Acceptance + ? TransitionBranchesResolveDeclinable + : {}) + } + & ([Reenter] extends [true] ? { + readonly reenter: () => TransitionBranchesTarget< + States, + Events, + Emits, + StateId, + Context, + Reenter, + Acceptance, + Branches + > + } : + {}) + /** Selector supplied to inline transition declarations. */ export interface TransitionSelector< in out States extends StateSchemas, @@ -6654,21 +6842,7 @@ export declare namespace Machine { /** Declares a closed set of named destinations for one resolver. */ readonly branches: >>( branches: Branches & ValidateTransitionBranchRecord> - ) => { - /** Resolves exactly one declared branch after this transition is selected. */ - readonly resolve: - & TransitionBranchesResolveRequired - & ("declinable" extends Acceptance ? TransitionBranchesResolveDeclinable< - States, - Events, - Emits, - StateId, - Context, - Reenter, - Branches - > - : {}) - } + ) => TransitionBranchesTarget } /** Inline transition declaration accepted by state and invocation handlers. */ diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index 46939dc..f890e25 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -140,7 +140,7 @@ type DirectTransitionDescriptor = { readonly type: "direct" readonly selection: Topology.TargetSelection readonly resolver?: (context: any, enqueue: unknown) => unknown - readonly reenter: boolean + readonly reenterSource: boolean readonly declinable: boolean } @@ -149,7 +149,7 @@ type BranchesTransitionDescriptor = { readonly type: "branches" readonly declarations: unknown readonly resolve: (context: any, enqueue: unknown) => unknown - readonly reenter: boolean + readonly reenterSource: boolean readonly declinable: boolean } @@ -168,25 +168,27 @@ const plainTargetSelection = (selection: Topology.TargetSelection): Topology.Tar ? Topology.noneTargetSelection : Topology.makeTargetSelection(selection.kind, selection.path, selection.scope, selection.updatePath) -const transitionOptions = (options: unknown): { readonly reenter: boolean; readonly declinable: boolean } => { +const transitionOptions = (options: unknown): { readonly declinable: boolean } => { const configuration = typeof options === "object" && options !== null - ? options as { readonly reenter?: unknown; readonly declinable?: unknown } + ? options as { readonly declinable?: unknown } : {} - return { - reenter: configuration.reenter === true, - declinable: configuration.declinable === true + if (hasProperty(configuration, "reenter")) { + throw new Error("Use .reenter() before construction instead of the resolver reenter option") } + return { declinable: configuration.declinable === true } } const makeDirectTransitionDescriptor = ( selection: Topology.TargetSelection, resolve: ((context: any, enqueue: unknown) => unknown) | undefined, - options: unknown + options: unknown, + reenterSource = false ): DirectTransitionDescriptor => { const shared: Omit = { [TransitionBuilderDescriptorTypeId]: TransitionBuilderDescriptorTypeId, type: "direct", selection: plainTargetSelection(selection), + reenterSource, ...transitionOptions(options) } return resolve === undefined @@ -194,34 +196,67 @@ const makeDirectTransitionDescriptor = ( : Object.freeze({ ...shared, resolver: resolve }) } -const guardedSelection = (selection: Topology.TargetSelection, predicate: (context: any) => boolean): object => { +// The authored callback crosses an erased schema boundary here. Each builder +// retains its construction mode, and planning validates both resulting values. +const constructSelectionValue = ( + selection: Topology.TargetSelection, + context: any, + method: "from" | "decoded", + value: unknown +): unknown => { + if (selection.kind === "update") return context.owner[method](value) + if (selection.updatePath === undefined) return context.target[method](value) + const values = value as { readonly target: unknown; readonly update: unknown } + return context.target[method](values.target).update(context.owner[method](values.update)) +} + +const guardedSelection = ( + selection: Topology.TargetSelection, + predicate: (context: any) => boolean, + reenterSource: boolean +): object => { const wrap = (resolve: (context: any, enqueue: unknown) => unknown, options?: unknown) => makeDirectTransitionDescriptor( selection, (context, enqueue) => predicate(context) ? resolve(context, enqueue) : Topology.makeDeclined(), - { ...transitionOptions(options), declinable: true } + { ...transitionOptions(options), declinable: true }, + reenterSource ) return Object.freeze({ - ...wrap(( - context - ) => (selection.kind === "none" ? Topology.makeNoTarget() : constructSelectedTarget(context.target))), - from: (value: (context: any) => unknown) => wrap((context) => context.target.from(value(context))), - decoded: (value: (context: any) => unknown) => wrap((context) => context.target.decoded(value(context))), + ...wrap((context) => selection.kind === "none" ? undefined : constructSelectedTarget(context.target)), + from: (value: (context: any) => unknown) => + wrap((context) => constructSelectionValue(selection, context, "from", value(context))), + decoded: (value: (context: any) => unknown) => + wrap((context) => constructSelectionValue(selection, context, "decoded", value(context))), resolve: wrap }) } -const decorateTransitionSelection = (selection: Topology.TargetSelection): Topology.TargetSelection => +const decorateTransitionSelection = ( + selection: Topology.TargetSelection, + reenterSource = false +): Topology.TargetSelection => Object.freeze({ ...selection, - guard: (predicate: (context: any) => boolean) => guardedSelection(selection, predicate), + ...(reenterSource ? makeDirectTransitionDescriptor(selection, undefined, undefined, true) : {}), + guard: (predicate: (context: any) => boolean) => guardedSelection(selection, predicate, reenterSource), from: (value: (context: any) => unknown) => - makeDirectTransitionDescriptor(selection, (context) => context.target.from(value(context)), undefined), + makeDirectTransitionDescriptor( + selection, + (context) => constructSelectionValue(selection, context, "from", value(context)), + undefined, + reenterSource + ), decoded: (value: (context: any) => unknown) => - makeDirectTransitionDescriptor(selection, (context) => context.target.decoded(value(context)), undefined), + makeDirectTransitionDescriptor( + selection, + (context) => constructSelectionValue(selection, context, "decoded", value(context)), + undefined, + reenterSource + ), resolve: (resolve: (context: any, enqueue: unknown) => unknown, options?: unknown) => - makeDirectTransitionDescriptor(selection, resolve, options), - reenter: () => makeDirectTransitionDescriptor(selection, undefined, { reenter: true }), + makeDirectTransitionDescriptor(selection, resolve, options, reenterSource), + reenter: () => decorateTransitionSelection(selection, true), updating: (owner: unknown) => { if (typeof owner !== "function") { throw new Error("Machine updating owner must be a state selector") @@ -234,22 +269,12 @@ const decorateTransitionSelection = (selection: Topology.TargetSelection): Topol throw new Error("Machine updating owner must be addressed by one branch state selector") } return decorateTransitionSelection( - Topology.makeTargetSelection(selection.kind, selection.path, selection.scope, ownerSelection.path) + Topology.makeTargetSelection(selection.kind, selection.path, selection.scope, ownerSelection.path), + reenterSource ) } }) -const decorateStateUpdateSelection = (selection: Topology.TargetSelection): Topology.TargetSelection => - Object.freeze({ - ...selection, - from: (value: (context: any) => unknown) => - makeDirectTransitionDescriptor(selection, (context) => context.owner.from(value(context)), undefined), - decoded: (value: (context: any) => unknown) => - makeDirectTransitionDescriptor(selection, (context) => context.owner.decoded(value(context)), undefined), - resolve: (resolve: (context: any, enqueue: unknown) => unknown, options?: unknown) => - makeDirectTransitionDescriptor(selection, resolve, options) - }) - const noneTransitionSelection = decorateTransitionSelection(Topology.noneTargetSelection) const makeInitialBuilderDescriptor = ( @@ -301,7 +326,6 @@ const decorateTransitionSelectorNode = (node: unknown): unknown => { initial: decorateTransitionSelection(node.initial as Topology.TargetSelection) }) } - if (node.kind === "update") return decorateStateUpdateSelection(node) return node === Topology.noneTargetSelection ? noneTransitionSelection : decorateTransitionSelection(node) } if (typeof node === "function") { @@ -323,29 +347,31 @@ const decorateTransitionSelectorNode = (node: unknown): unknown => { return node } +const decorateBranches = (declarations: unknown, reenterSource = false): object => + Object.freeze({ + reenter: () => decorateBranches(declarations, true), + resolve: ( + resolve: (context: any, enqueue: unknown) => unknown, + options?: unknown + ): BranchesTransitionDescriptor => + Object.freeze({ + [TransitionBuilderDescriptorTypeId]: TransitionBuilderDescriptorTypeId, + type: "branches", + declarations, + resolve, + reenterSource, + ...transitionOptions(options) + }) + }) + const makeTransitionSelector = ( stateNodes: Machine.StateNodes, source: string -): unknown => { - const selector = { - ...decorateTransitionSelectorNode(makeTargetSelector(stateNodes, source)) as Record - } - selector.branches = (declarations: unknown) => - Object.freeze({ - resolve: ( - resolve: (context: any, enqueue: unknown) => unknown, - options?: unknown - ): BranchesTransitionDescriptor => - Object.freeze({ - [TransitionBuilderDescriptorTypeId]: TransitionBuilderDescriptorTypeId, - type: "branches", - declarations, - resolve, - ...transitionOptions(options) - }) - }) - return Object.freeze(selector) -} +): unknown => + Object.freeze({ + ...decorateTransitionSelectorNode(makeTargetSelector(stateNodes, source)) as Record, + branches: (declarations: unknown) => decorateBranches(declarations) + }) const normalizeTransitionBuilder = ( transition: (selector: unknown) => unknown, @@ -353,7 +379,7 @@ const normalizeTransitionBuilder = ( path: string ): unknown => { const result = transition(makeTransitionSelector(stateNodes, path)) - if (Topology.isTargetSelection(result)) { + if (Topology.isTargetSelection(result) && !hasProperty(result, TransitionBuilderDescriptorTypeId)) { const selection = plainTargetSelection(result) return { target: () => selection } } @@ -363,14 +389,14 @@ const normalizeTransitionBuilder = ( return { branches: () => descriptor.declarations, resolve: descriptor.resolve, - reenter: descriptor.reenter, + reenter: descriptor.reenterSource, declinable: descriptor.declinable } } return { target: () => descriptor.selection, resolve: descriptor.resolver, - reenter: descriptor.reenter, + reenter: descriptor.reenterSource, declinable: descriptor.declinable } } @@ -753,6 +779,11 @@ const captureNamedBranches = ( if (target.updatePath !== undefined) { throw new Error(`Machine transition branch "${key}" cannot declare an updating target`) } + if (hasProperty(target, TransitionBuilderDescriptorTypeId)) { + throw new Error( + `Machine transition branch "${key}" requires a topology selection; apply .reenter() to .branches(...)` + ) + } if (title !== undefined && (typeof title !== "string" || title.length === 0)) { throw new Error(`Machine transition branch "${key}" title must be a non-empty string`) } diff --git a/packages/effect-machine/src/internal/testing/machine/finiteModel.ts b/packages/effect-machine/src/internal/testing/machine/finiteModel.ts index 9c2f88f..3769afd 100644 --- a/packages/effect-machine/src/internal/testing/machine/finiteModel.ts +++ b/packages/effect-machine/src/internal/testing/machine/finiteModel.ts @@ -1442,7 +1442,7 @@ const makeHandlers = ( ? () => undefined : ({ target }: { readonly target: any }) => resolveDefinitionTarget(target, path, transition.target!, byPath, transition.targetValue) - return selected.resolve(resolve, "reenter" in transition ? { reenter: transition.reenter } : undefined) + return ("reenter" in transition && transition.reenter ? selected.reenter() : selected).resolve(resolve) } if (transition.trigger.type === "event") on[transition.trigger.event] = config else if (transition.trigger.type === "always") always = config diff --git a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts index 3dca378..aaf303e 100644 --- a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts +++ b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts @@ -47,7 +47,7 @@ const makeFlatMachine = () => { Noop: (to) => to.none, Increment: (to) => to.branch.Count().resolve(({ state, target }) => target.decoded(new Count({ value: state.value + 1 }))), - Reenter: (to) => to.none.resolve(() => undefined, { reenter: true }), + Reenter: (to) => to.none.reenter().resolve(() => undefined), Finish: (to) => to.branch.Done().resolve(({ state, target }) => target.decoded(new Done({ value: state.value }))) } @@ -224,9 +224,8 @@ describe("machine planner and runtime strategies", () => { ExitRoot: (to) => to.branch.Root.update.resolve(({ owner }) => owner.decoded(new Root({ revision: 3 }))), ReenterUpdate: (to) => - to.local.update.resolve( - ({ current, owner }) => owner.decoded(new Left({ value: current.value + 1 })), - { reenter: true } + to.local.update.reenter().resolve( + ({ current, owner }) => owner.decoded(new Left({ value: current.value + 1 })) ) } } @@ -1216,11 +1215,8 @@ describe("machine planner and runtime strategies", () => { ), on: { Reenter: (to) => - to.branch.Loading().resolve( - ({ state, target }) => target.decoded(new Loading({ epoch: state.epoch + 1 })), - { - reenter: true - } + to.branch.Loading().reenter().resolve( + ({ state, target }) => target.decoded(new Loading({ epoch: state.epoch + 1 })) ), Stale: (to) => to.branch.Failed().resolve(({ target }) => target.decoded(new Failed({}))) } diff --git a/packages/effect-machine/test/internal/machine/transitionConstructionStrategies.test.ts b/packages/effect-machine/test/internal/machine/transitionConstructionStrategies.test.ts new file mode 100644 index 0000000..e7d7fc9 --- /dev/null +++ b/packages/effect-machine/test/internal/machine/transitionConstructionStrategies.test.ts @@ -0,0 +1,105 @@ +import { it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { Machine } from "../../../src/index.js" +import { verifyPlannerStrategies } from "./support/strategyDifferential.js" + +it.effect("compares flat updates and verifies guarded updates retain generic planning", () => + Effect.gen(function*() { + for (const guarded of [false, true]) { + const events = Machine.events({ Add: { by: Schema.Number }, Refresh: {} }) + const machine = Machine.make({ + root: Machine.state({ fields: { count: Schema.Number } }), + events, + initial: (root) => root.from(() => ({ count: 0 })) + }).handle({ + on: { + Add: (to) => + (guarded ? to.self.update.guard(({ event }) => event.by > 0) : to.self.update).from(( + { current, event } + ) => ({ count: current.count + event.by })), + Refresh: (to) => + to.none.reenter().resolve((_, enqueue) => { + enqueue.raise(events.Add({ by: 1 })) + }) + } + }) + yield* verifyPlannerStrategies({ + machine, + expected: guarded ? "generic" : "indexed-flat", + label: "guarded flat construction", + events: [ + { _tag: "Add", by: -1 }, + { _tag: "Add", by: 2 }, + { _tag: "Refresh" } + ] + }) + } + })) + +it.effect("compares atomic construction and verifies guards retain generic planning", () => + Effect.gen(function*() { + for (const guarded of [false, true]) { + class Root extends Schema.TaggedClass("Root")("Root", { count: Schema.Number }) {} + class Saved extends Schema.TaggedClass("Saved")("Saved", { text: Schema.String }) {} + const events = Machine.events({ Save: { allowed: Schema.Boolean }, Decoded: {}, Branch: {}, Finish: {} }) + const machine = Machine.make({ + root: Machine.state({ + schema: Root, + initial: "Idle", + states: { Idle: {}, Saved: { schema: Saved }, Done: { type: "final" } } + }), + events, + initial: (root) => root.from(() => ({ count: 0 })) + }).handle({ + on: { Save: (to) => to.self.update.from(({ current }) => ({ count: current.count + 10 })) }, + states: { + Idle: { + on: { + Save: (to) => { + const selected = to.local.Saved().updating(to.root) + return (guarded ? selected.guard(({ event }) => event.allowed) : selected).from(({ current }) => ({ + target: { text: "saved" }, + update: { count: current.count + 1 } + })) + }, + Decoded: (to) => + to.local.Saved().updating(to.root).decoded(({ current }) => ({ + target: new Saved({ text: "decoded" }), + update: new Root({ count: current.count + 2 }) + })) + } + }, + Saved: { + on: { + Save: (to) => { + const selected = to.local.Saved().updating(to.root).reenter() + return (guarded ? selected.guard(({ event }) => event.allowed) : selected).from(( + { current, state } + ) => ({ + target: { text: state.text }, + update: { count: current.count + 1 } + })) + }, + Branch: (to) => + to.branches({ saved: { target: to.local.Saved() } }).reenter().resolve(({ state, select }) => + select.saved.decoded(state) + ), + Finish: (to) => to.local.Done() + } + } + } + }) + yield* verifyPlannerStrategies({ + machine, + expected: guarded ? "generic" : "indexed-hierarchical", + label: "atomic construction", + events: [ + { _tag: "Decoded" }, + { _tag: "Save", allowed: false }, + { _tag: "Save", allowed: true }, + { _tag: "Branch" }, + { _tag: "Finish" } + ] + }) + } + })) diff --git a/packages/effect-machine/test/machine/ActivityLifecycleModel.test.ts b/packages/effect-machine/test/machine/ActivityLifecycleModel.test.ts index 20fdf78..1bce15f 100644 --- a/packages/effect-machine/test/machine/ActivityLifecycleModel.test.ts +++ b/packages/effect-machine/test/machine/ActivityLifecycleModel.test.ts @@ -91,7 +91,7 @@ describe("machine activity lifecycle model", () => { on: { Leave: (to) => to.branch.Idle().resolve(({ target }) => target.decoded(new Idle({}))), Restart: (to) => - to.branch.Active().resolve(({ target }) => target.decoded(new Active({})), { reenter: true }) + to.branch.Active().reenter().resolve(({ target }) => target.decoded(new Active({}))) } } } @@ -205,9 +205,8 @@ describe("machine activity lifecycle model", () => { }).onDone((to) => to.none).onFailure((to) => to.none), on: { Restart: (to) => - to.branch.Active().resolve( - ({ state, target }) => target.decoded(new EpochActive({ acknowledged: state.acknowledged })), - { reenter: true } + to.branch.Active().reenter().resolve( + ({ state, target }) => target.decoded(new EpochActive({ acknowledged: state.acknowledged })) ), QueueBarrier: (to) => to.branch.Active().resolve(({ state, target }) => diff --git a/packages/effect-machine/test/machine/History.test.ts b/packages/effect-machine/test/machine/History.test.ts index baf2e93..780e41a 100644 --- a/packages/effect-machine/test/machine/History.test.ts +++ b/packages/effect-machine/test/machine/History.test.ts @@ -177,7 +177,7 @@ const makeCheckoutMachine = ( to.local.shipping().resolve(({ event, target }) => target.decoded(new Shipping({ address: event.address })) ), - ReenterHistory: (to) => to.history.checkout.exact.resolve(({ target }) => target(), { reenter: true }) + ReenterHistory: (to) => to.history.checkout.exact.reenter().resolve(({ target }) => target()) }, states: { shipping: { @@ -489,8 +489,7 @@ const nestedHistoryMachine = Machine.make({ states: { preview: { on: { - RestoreEditor: (to) => - to.history.workspace.editor.exact.resolve(({ target }) => target(), { reenter: true }), + RestoreEditor: (to) => to.history.workspace.editor.exact.reenter().resolve(({ target }) => target()), DefaultEditor: (to) => to.history.workspace.editor.exact.resolve(({ target }) => target()) } }, diff --git a/packages/effect-machine/test/machine/LocalTargetWith.test.ts b/packages/effect-machine/test/machine/LocalTargetWith.test.ts index d3450d5..b79568c 100644 --- a/packages/effect-machine/test/machine/LocalTargetWith.test.ts +++ b/packages/effect-machine/test/machine/LocalTargetWith.test.ts @@ -39,9 +39,8 @@ describe("local compound target selection", () => { search: { on: { UpdateQuery: (to) => - to.local.with.resolve( - ({ event, target }) => target.from({ query: event.query }, (search) => search.Updated.from()), - { reenter: true } + to.local.with.reenter().resolve( + ({ event, target }) => target.from({ query: event.query }, (search) => search.Updated.from()) ) }, states: { diff --git a/packages/effect-machine/test/machine/Machine.test.ts b/packages/effect-machine/test/machine/Machine.test.ts index 07f2ede..68511d8 100644 --- a/packages/effect-machine/test/machine/Machine.test.ts +++ b/packages/effect-machine/test/machine/Machine.test.ts @@ -265,10 +265,11 @@ describe("Machine", () => { refresh: { title: "Refresh stable state", target: to.branch.Stable() } } declarations = captured - return to.branches(captured).resolve(({ event, select }) => + return to.branches(captured).reenter().resolve(({ event, select }) => event.route ? select.refresh.decoded(new Stable({})) - : select.unchanged(), { reenter: true }) + : select.unchanged() + ) } } } diff --git a/packages/effect-machine/test/machine/StateUpdate.test.ts b/packages/effect-machine/test/machine/StateUpdate.test.ts index 9e488ce..171613b 100644 --- a/packages/effect-machine/test/machine/StateUpdate.test.ts +++ b/packages/effect-machine/test/machine/StateUpdate.test.ts @@ -380,9 +380,8 @@ describe("state value updates", () => { Quiet: (to) => to.local.update.resolve(({ current, owner }) => owner.from({ count: current.count + 1 })), Loud: (to) => - to.local.update.resolve( - ({ current, owner }) => owner.from({ count: current.count + 1 }), - { reenter: true } + to.local.update.reenter().resolve( + ({ current, owner }) => owner.from({ count: current.count + 1 }) ) } } diff --git a/packages/effect-machine/test/machine/TransitionConstruction.test.ts b/packages/effect-machine/test/machine/TransitionConstruction.test.ts new file mode 100644 index 0000000..9720e02 --- /dev/null +++ b/packages/effect-machine/test/machine/TransitionConstruction.test.ts @@ -0,0 +1,249 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { Machine } from "../../src/index.js" + +class Root extends Schema.TaggedClass("Root")("Root", { count: Schema.Number }) {} +class Saved extends Schema.TaggedClass("Saved")("Saved", { text: Schema.String }) {} + +const events = Machine.events({ + Save: { text: Schema.String, count: Schema.Number }, + Reset: {}, + Change: { allowed: Schema.Boolean } +}) +const definition = Machine.make({ + root: Machine.state({ schema: Root, initial: "Idle", states: { Idle: {}, Saved: { schema: Saved } } }), + events, + initial: (root) => root.from(() => ({ count: 0 })) +}) + +describe("transition construction", () => { + it.effect("constructs destination and root values atomically before entry", () => + Effect.gen(function*() { + const observations: Array = [] + const machine = definition.handle({ + states: { + Idle: { + on: { + Save: (to) => + to.local.Saved().updating(to.root).from(({ event }) => ({ + target: { text: event.text }, + update: { count: event.count } + })) + } + }, + Saved: { + entry: ({ state, containingState }) => { + observations.push([containingState.count, state.text]) + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + const plan = yield* Machine.plan(machine, initial.state, events.Save({ text: "saved", count: 4 })) + assert.deepStrictEqual(observations, [[4, "saved"]]) + assert.strictEqual(plan.next.value.count, 4) + assert.strictEqual(plan.next.state.path, "Saved") + assert.strictEqual(initial.state.value.count, 0) + assert.strictEqual(initial.state.state.path, "Idle") + })) + + it.effect("preserves decoded classes for both sides of an atomic transition", () => + Effect.gen(function*() { + const targetValue = new Saved({ text: "decoded" }) + const rootValue = new Root({ count: 2 }) + const machine = definition.handle({ + states: { + Idle: { + on: { + Save: (to) => + to.local.Saved().updating(to.root).decoded(() => ({ target: targetValue, update: rootValue })) + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + const plan = yield* Machine.plan(machine, initial.state, events.Save({ text: "ignored", count: 0 })) + assert.instanceOf(plan.next.value, Root) + assert.instanceOf(plan.next.state.value, Saved) + assert.strictEqual(plan.next.value, rootValue) + assert.strictEqual(plan.next.state.value, targetValue) + })) + + it.effect("constructs a structural destination with an explicit owner replacement", () => + Effect.gen(function*() { + const machine = definition.handle({ + states: { + Idle: { on: { Save: (to) => to.local.Saved().from(({ event }) => ({ text: event.text })) } }, + Saved: { + on: { + Reset: (to) => to.local.Idle().updating(to.root).from(() => ({ target: undefined, update: { count: 5 } })) + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + const saved = yield* Machine.plan(machine, initial.state, events.Save({ text: "saved", count: 0 })) + const reset = yield* Machine.plan(machine, saved.next, events.Reset()) + assert.strictEqual(reset.next.state.path, "Idle") + assert.strictEqual(reset.next.value.count, 5) + assert.strictEqual(saved.next.state.path, "Saved") + })) + + it.effect("rejects invalid destination or owner values through typed failures before entry", () => + Effect.gen(function*() { + for (const invalidOwner of [false, true]) { + let entered = false + const machine = definition.handle({ + states: { + Idle: { + on: { + Save: (to) => + to.local.Saved().updating(to.root).from(() => ({ + target: { text: invalidOwner ? "valid" : 42 as unknown as string }, + update: { count: invalidOwner ? "invalid" as unknown as number : 1 } + })) + } + }, + Saved: { + entry: () => { + entered = true + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + const failure = yield* Machine.plan(machine, initial.state, events.Save({ text: "", count: 0 })).pipe( + Effect.flip + ) + assert.instanceOf(failure, Machine.MachineSchemaDecodeError) + assert.strictEqual(entered, false) + assert.strictEqual(initial.state.value.count, 0) + } + })) + + it.effect("declines guarded updates and combined transitions before construction and commands", () => + Effect.gen(function*() { + for (const combined of [false, true]) { + let constructed = 0 + const machine = definition.handle({ + on: { Change: (to) => to.self.update.from(({ current }) => ({ count: current.count + 10 })) }, + states: { + Idle: { + on: { + Change: (to) => + combined + ? to.local.Saved().updating(to.root).guard(({ current, event }) => + current.count === 0 && event.allowed + ).from(() => { + constructed++ + return { target: { text: "saved" }, update: { count: 1 } } + }) + : to.root.update.guard(({ current, event }) => current.count === 0 && event.allowed).resolve( + ({ owner }, enqueue) => { + constructed++ + enqueue.raise(events.Reset()) + return owner.from({ count: 1 }) + } + ) + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + assert.isFalse(yield* Machine.can(machine, initial.state, events.Save({ text: "", count: 0 }))) + const declined = yield* Machine.plan(machine, initial.state, events.Change({ allowed: false })) + assert.strictEqual(constructed, 0) + assert.strictEqual(declined.next.value.count, 10) + assert.strictEqual(declined.next.state.path, "Idle") + assert.deepStrictEqual(declined.microsteps[0]?.raisedEvents, []) + const accepted = yield* Machine.plan(machine, initial.state, events.Change({ allowed: true })) + assert.strictEqual(constructed, 1) + assert.strictEqual(accepted.next.value.count, 1) + assert.strictEqual(accepted.next.state.path, combined ? "Saved" : "Idle") + } + })) + + it.effect("reenters only the source and preserves reentry through atomic construction and guards", () => + Effect.gen(function*() { + const lifecycle: Array = [] + const machine = definition.handle({ + entry: () => { + lifecycle.push("root") + }, + states: { + Idle: { on: { Save: (to) => to.local.Saved().from(({ event }) => ({ text: event.text })) } }, + Saved: { + entry: () => { + lifecycle.push("enter") + }, + exit: () => { + lifecycle.push("exit") + }, + on: { + Change: (to) => + to.local.Saved().updating(to.root).reenter().guard(({ event }) => event.allowed) + .from(({ current, state }) => ({ + target: { text: state.text }, + update: { count: current.count + 1 } + })), + Reset: (to) => + to.none.reenter().resolve((_, enqueue) => { + enqueue.raise(events.Change({ allowed: false })) + }) + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + const saved = yield* Machine.plan(machine, initial.state, events.Save({ text: "saved", count: 0 })) + lifecycle.length = 0 + const declined = yield* Machine.plan(machine, saved.next, events.Change({ allowed: false })) + assert.deepStrictEqual(lifecycle, []) + assert.strictEqual(declined.next.value.count, 0) + const changed = yield* Machine.plan(machine, saved.next, events.Change({ allowed: true })) + assert.deepStrictEqual(lifecycle, ["exit", "enter"]) + assert.strictEqual(changed.next.value.count, 1) + lifecycle.length = 0 + yield* Machine.plan(machine, changed.next, events.Reset()) + assert.deepStrictEqual(lifecycle, ["exit", "enter"]) + })) + + it.effect("guards default targetless reentry without requiring a resolver", () => + Effect.gen(function*() { + let entered = 0 + const machine = definition.handle({ + states: { + Idle: { + entry: () => { + entered++ + }, + on: { Change: (to) => to.none.reenter().guard(({ event }) => event.allowed) } + } + } + }) + const initial = yield* Machine.planInitial(machine) + assert.strictEqual(entered, 1) + assert.isFalse(yield* Machine.can(machine, initial.state, events.Change({ allowed: false }))) + assert.isTrue(yield* Machine.can(machine, initial.state, events.Change({ allowed: true }))) + yield* Machine.plan(machine, initial.state, events.Change({ allowed: false })) + assert.strictEqual(entered, 1) + const accepted = yield* Machine.plan(machine, initial.state, events.Change({ allowed: true })) + assert.strictEqual(entered, 2) + assert.strictEqual(accepted.next.state.path, "Idle") + })) + + it("rejects removed resolver options instead of silently ignoring reentry", () => { + assert.throws(() => + definition.handle({ + states: { + Idle: { + on: { + Reset: (to) => + // @ts-expect-error reentry is a modifier, not a resolver option + to.none.resolve(() => undefined, { reenter: true }) + } + } + } + }), /Use .reenter\(\)/) + }) +}) diff --git a/packages/effect-machine/test/machine/Visualization.test.ts b/packages/effect-machine/test/machine/Visualization.test.ts index f2df0b2..234a7e5 100644 --- a/packages/effect-machine/test/machine/Visualization.test.ts +++ b/packages/effect-machine/test/machine/Visualization.test.ts @@ -307,7 +307,7 @@ describe("Machine structural visualization", () => { states: { idle: { on: { - Refresh: (to) => to.none.resolve(() => undefined, { reenter: true }) + Refresh: (to) => to.none.reenter().resolve(() => undefined) }, always: (to) => to.none, onDone: (to) => to.none diff --git a/packages/effect-machine/test/testing/Coverage.test.ts b/packages/effect-machine/test/testing/Coverage.test.ts index d231c06..00e3886 100644 --- a/packages/effect-machine/test/testing/Coverage.test.ts +++ b/packages/effect-machine/test/testing/Coverage.test.ts @@ -27,9 +27,9 @@ const counterMachine = Machine.make({ count: { on: { Add: (to) => - to.branch.count().resolve( + to.branch.count().reenter().resolve( ({ event, state, target }) => target.decoded(new Count({ value: state.value + event.amount })), - { reenter: true, declinable: true } + { declinable: true } ), Finish: (to) => to.branch.done().resolve(({ target }) => target.decoded(new Done({}))) } diff --git a/packages/effect-machine/test/testing/Probe.test.ts b/packages/effect-machine/test/testing/Probe.test.ts index ee7eda8..72ea79d 100644 --- a/packages/effect-machine/test/testing/Probe.test.ts +++ b/packages/effect-machine/test/testing/Probe.test.ts @@ -37,9 +37,9 @@ const machine = Machine.make({ Noop: (to) => to.none, Decline: (to) => to.none.resolve(({ decline }) => decline(), { declinable: true }), Reenter: (to) => - to.branch.Counter().resolve(({ state, target }) => target.decoded(new Counter({ count: state.count })), { - reenter: true - }), + to.branch.Counter().reenter().resolve(({ state, target }) => + target.decoded(new Counter({ count: state.count })) + ), Burst: (to) => to.branch.Counter().resolve(({ state, target }, enqueue) => { enqueue.raise(new RaisedIncrement({})) diff --git a/packages/effect-machine/test/testing/Verification.test.ts b/packages/effect-machine/test/testing/Verification.test.ts index b8d0304..d8fc6a2 100644 --- a/packages/effect-machine/test/testing/Verification.test.ts +++ b/packages/effect-machine/test/testing/Verification.test.ts @@ -227,9 +227,9 @@ const reentryMachine = Machine.make({ app: { on: { Restart: (to) => - to.branch.app().resolve(({ target }) => target.decoded(new App({}), (app) => app.one.decoded(new One({}))), { - reenter: true - }) + to.branch.app().reenter().resolve(({ target }) => + target.decoded(new App({}), (app) => app.one.decoded(new One({}))) + ) } } } diff --git a/packages/effect-machine/typetest/machine/Machine.tst.ts b/packages/effect-machine/typetest/machine/Machine.tst.ts index 7fb0ac5..f9f42b9 100644 --- a/packages/effect-machine/typetest/machine/Machine.tst.ts +++ b/packages/effect-machine/typetest/machine/Machine.tst.ts @@ -1194,7 +1194,7 @@ describe("Machine", () => { title: "active user", target: to.none } - }).resolve(({ event, select, state }) => { + }).reenter().resolve(({ event, select, state }) => { expect(event).type.toBe() expect(state).type.toBe() expect(select.recognized.decoded).type.toBeCallableWith(new Down({})) @@ -1209,7 +1209,7 @@ describe("Machine", () => { default: return select.recognized.decoded(new Down({})) } - }, { reenter: true }) + }) } } } diff --git a/packages/effect-machine/typetest/machine/StateUpdate.tst.ts b/packages/effect-machine/typetest/machine/StateUpdate.tst.ts index 0f2ee0c..95e993e 100644 --- a/packages/effect-machine/typetest/machine/StateUpdate.tst.ts +++ b/packages/effect-machine/typetest/machine/StateUpdate.tst.ts @@ -88,13 +88,13 @@ describe("Machine state-value updates", () => { expect(to.history).type.not.toHaveProperty("update") expect(to.none).type.not.toHaveProperty("update") - return to.local.update.resolve(({ current, owner, state }) => { + return to.local.update.reenter().resolve(({ current, owner, state }) => { expect(state).type.toBe() expect(current).type.toBe() expect(owner.decoded).type.toBeCallableWith(new Auth({ user: "next" })) expect(owner.from).type.toBeCallableWith({ user: "next" }) return owner.decoded(new Auth({ user: "next" })) - }, { reenter: true }) + }) } } } diff --git a/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts b/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts new file mode 100644 index 0000000..61c464c --- /dev/null +++ b/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts @@ -0,0 +1,102 @@ +import { Schema } from "effect" +import { describe, expect, test } from "tstyche" +import { Machine } from "../../src/index.js" + +class Root extends Schema.TaggedClass("Root")("Root", { count: Schema.Number }) {} +class Saved extends Schema.TaggedClass("Saved")("Saved", { text: Schema.String }) {} +const definition = Machine.make({ + root: Machine.state({ + schema: Root, + initial: "Idle", + states: { + Idle: {}, + Saved: { schema: Saved }, + Nested: { + fields: { label: Schema.String }, + initial: "Child", + states: { Child: { fields: { required: Schema.String } } } + } + } + }), + events: Machine.events({ Save: { text: Schema.String } }), + initial: (root) => { + expect(root).type.not.toHaveProperty("guard") + expect(root).type.not.toHaveProperty("reenter") + return root.from(() => ({ count: 0 })) + } +}) + +describe("transition construction", () => { + test("infers both values and preserves explicit construction requirements", () => { + definition.handle({ + states: { + Idle: { + on: { + Save: (to) => { + const combined = to.local.Saved().updating(to.root) + expect(combined).type.toHaveProperty("from") + expect(combined).type.toHaveProperty("decoded") + expect(combined).type.toHaveProperty("guard") + expect(to.local.Nested().updating(to.root)).type.not.toHaveProperty("from") + expect(to.self).type.not.toHaveProperty("update") + // @ts-expect-error Property 'text' is missing + combined.from(() => ({ target: {}, update: { count: 1 } })) + // @ts-expect-error Property 'count' is missing + combined.from(() => ({ target: { text: "" }, update: {} })) + // @ts-expect-error Property 'update' is missing + combined.from(() => ({ target: { text: "" } })) + // @ts-expect-error Property '_tag' is missing + combined.decoded(() => ({ target: { text: "" }, update: new Root({ count: 1 }) })) + // @ts-expect-error Property '_tag' is missing + combined.decoded(() => ({ target: new Saved({ text: "" }), update: { count: 1 } })) + combined.decoded(() => ({ target: new Saved({ text: "" }), update: new Root({ count: 1 }) })) + return combined.reenter().guard(({ current, event }) => { + expect(current).type.toBe() + expect(event.text).type.toBe() + return event.text.length > 0 + }).from(({ current, event }) => ({ target: { text: event.text }, update: { count: current.count + 1 } })) + } + } + } + } + }) + }) + + test("guards updates without allowing incomplete replacements or implicit reentry", () => { + definition.handle({ + on: { + Save: (to) => { + expect(to.self.update).type.not.toHaveProperty("reenter") + const guarded = to.self.update.guard(({ current, event }) => current.count > event.text.length) + // @ts-expect-error Property 'count' is missing + guarded.from(() => ({})) + return guarded.decoded(({ current }) => current) + } + } + }) + }) + + test("reentry composes with value construction and branch resolution", () => { + definition.handle({ + states: { + Idle: { + on: { + Save: (to) => { + // @ts-expect-error No overload matches this call + to.local.Saved().resolve(({ target }) => target.from({ text: "" }), { reenter: true }) + const target = to.local.Saved().reenter() + expect(target).type.toHaveProperty("from") + expect(target).type.toHaveProperty("decoded") + expect(target).type.toHaveProperty("resolve") + // @ts-expect-error Property '[Topology.TargetSelectionTypeId]' is missing + to.branches({ saved: { target } }) + return to.branches({ saved: { target: to.local.Saved() } }).reenter().resolve(({ event, select }) => + select.saved.from({ text: event.text }) + ) + } + } + } + } + }) + }) +}) diff --git a/packages/oxlint-plugin/README.md b/packages/oxlint-plugin/README.md index 9ae5657..10d7c22 100644 --- a/packages/oxlint-plugin/README.md +++ b/packages/oxlint-plugin/README.md @@ -84,7 +84,7 @@ Resolver-only reentry uses `.reenter()`: ```ts // Before -to.local.Ready().resolve(({ target }) => target.from(), { reenter: true }) +to.local.Ready().reenter().resolve(({ target }) => target.from()) // After `oxlint --fix` to.local.Ready().reenter() diff --git a/packages/oxlint-plugin/src/internal/rules/noRedundantResolve.ts b/packages/oxlint-plugin/src/internal/rules/noRedundantResolve.ts index 573ebe7..03b0431 100644 --- a/packages/oxlint-plugin/src/internal/rules/noRedundantResolve.ts +++ b/packages/oxlint-plugin/src/internal/rules/noRedundantResolve.ts @@ -49,18 +49,9 @@ const isEmptyResolver = ( ): boolean => node.body?.type === "BlockStatement" && node.body.body.length === 0 const isTargetlessReceiver = (node: ESTree.Expression): boolean => - node.type === "MemberExpression" && staticMemberName(node) === "none" - -const isReenterOnlyOptions = (node: ESTree.Expression | undefined): boolean => { - if (node?.type !== "ObjectExpression" || node.properties.length !== 1) return false - const property = node.properties[0] - return property?.type === "Property" && - !property.computed && - property.key.type === "Identifier" && - property.key.name === "reenter" && - property.value.type === "Literal" && - property.value.value === true -} + (node.type === "MemberExpression" && staticMemberName(node) === "none") || + (node.type === "CallExpression" && node.arguments.length === 0 && node.callee.type === "MemberExpression" && + staticMemberName(node.callee) === "reenter" && isTargetlessReceiver(node.callee.object)) export const noRedundantResolve: Rule = { meta: { @@ -74,10 +65,8 @@ export const noRedundantResolve: Rule = { messages: { redundantResolver: "Remove this resolver. The selected target already applies default construction, so use the target selector directly.", - redundantReenterResolver: - "Replace this resolver with .reenter(). It applies the same default construction while explicitly reentering the selected state.", redundantTargetlessResolver: - "Remove this empty resolver. A targetless transition performs the same work as to.none; use to.none directly." + "Remove this empty resolver. Use the targetless selector directly, preserving its modifiers." } }, create(context) { @@ -87,7 +76,7 @@ export const noRedundantResolve: Rule = { CallExpression(node) { if ( !hasMachineImport(bindings) || - (node.arguments.length !== 1 && node.arguments.length !== 2) || + node.arguments.length !== 1 || node.callee.type !== "MemberExpression" || staticMemberName(node.callee) !== "resolve" ) return @@ -107,17 +96,8 @@ export const noRedundantResolve: Rule = { const targetless = isTargetlessReceiver(receiver) && isEmptyResolver(callback) if (!defaultConstruction && !targetless) return - const options = node.arguments[1] - if (options?.type === "SpreadElement") return - const reenter = options === undefined ? false : isReenterOnlyOptions(options) - if (options !== undefined && !reenter) return - - const messageId = reenter - ? "redundantReenterResolver" - : targetless - ? "redundantTargetlessResolver" - : "redundantResolver" - const replacement = `${context.sourceCode.getText(receiver)}${reenter ? ".reenter()" : ""}` + const messageId = targetless ? "redundantTargetlessResolver" : "redundantResolver" + const replacement = context.sourceCode.getText(receiver) context.report({ node, messageId, diff --git a/packages/oxlint-plugin/test/noRedundantResolve.test.ts b/packages/oxlint-plugin/test/noRedundantResolve.test.ts index 5d8ea8d..ffab4d8 100644 --- a/packages/oxlint-plugin/test/noRedundantResolve.test.ts +++ b/packages/oxlint-plugin/test/noRedundantResolve.test.ts @@ -26,7 +26,7 @@ function unrelated(definition: any) { definition.handle({ states: { Ready: { on: `import { Machine } from "@typeonce/effect-machine" Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from({ id: "ready" })) })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from(), { reenter: true, actions: [] }) })`, +Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from(), { declinable: true }) })`, `import { Machine } from "@typeonce/effect-machine" const other = { resolve: (_callback: unknown) => undefined } other.resolve(({ target }) => target.from())`, @@ -68,13 +68,13 @@ EM.Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => /* preserv { code: `import { Machine } from "@typeonce/effect-machine" Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { on: { - Reset: (to) => to.branch.Ready().resolve(({ target }) => target.from(), { reenter: true }) + Reset: (to) => to.branch.Ready().reenter().resolve(({ target }) => target.from()) } } } })`, output: `import { Machine } from "@typeonce/effect-machine" Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { on: { Reset: (to) => to.branch.Ready().reenter() } } } })`, - errors: [{ messageId: "redundantReenterResolver" }] + errors: [{ messageId: "redundantResolver" }] }, { code: `import { Machine } from "@typeonce/effect-machine" @@ -85,10 +85,10 @@ Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none.resolve(() => {}, { reenter: true }) } } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none.reenter().resolve(() => {}) } } })`, output: `import { Machine } from "@typeonce/effect-machine" Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none.reenter() } } })`, - errors: [{ messageId: "redundantReenterResolver" }] + errors: [{ messageId: "redundantTargetlessResolver" }] } ] }) diff --git a/scripts/runtime-performance-compatibility.test.mjs b/scripts/runtime-performance-compatibility.test.mjs index 9e64047..645106f 100644 --- a/scripts/runtime-performance-compatibility.test.mjs +++ b/scripts/runtime-performance-compatibility.test.mjs @@ -80,6 +80,16 @@ test("adapts benchmark definitions to value selectors and target-first initial e assert.equal(api.targetless({ none: selected }), selected) }) +test("uses chainable reentry without passing removed resolver options", () => { + const calls = [] + const resolve = () => undefined + const reentered = { resolve: (...args) => { calls.push(args); return "resolved" } } + const selected = { reenter: () => reentered } + const api = makeEffectMachineBenchmarkApi({}) + assert.equal(api.transition({ target: (to) => to.selected, resolve, reenter: true, declinable: true })({ selected }), "resolved") + assert.deepEqual(calls, [[resolve, { declinable: true }]]) +}) + test("uses the object child invocation compatibility capability when available", () => { const calls = [] const noTarget = Symbol("no-target") diff --git a/scripts/type-performance.mjs b/scripts/type-performance.mjs index cff3ae6..f0340ef 100644 --- a/scripts/type-performance.mjs +++ b/scripts/type-performance.mjs @@ -93,6 +93,20 @@ const scenarios = [ maxInstantiations: 160_000, maxMarginalInstantiations: 145_000 }, + { + id: "transition-construction-control", + label: "transition construction control", + file: "transition-construction-control.ts", + hidden: true + }, + { + id: "transition-construction", + label: "atomic construction, guards, and reentry", + file: "transition-construction.ts", + control: "transition-construction-control", + maxInstantiations: 145_000, + maxMarginalInstantiations: 115_000 + }, { id: "dynamic-invoke-control", label: "dynamic invocation control",