diff --git a/.changeset/brave-machines-update.md b/.changeset/brave-machines-update.md new file mode 100644 index 0000000..bfbec5d --- /dev/null +++ b/.changeset/brave-machines-update.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add `to.local.update(...)` and `to.branch..update(...)` for replacing an active compound or parallel state's value without reconstructing its active descendants. + +Updates accept decoded values through `target(value)` or schema make input through `target.from(input)`. They preserve descendant configuration and state-owned work by default, support named branches and declinable resolvers, and expose the updated owner through transition inspection. diff --git a/README.md b/README.md index 294143d..183e3e6 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,47 @@ choice destinations remain calls such as `to.full.Running()`. Runtime named branch builders remain callable, including `select.unchanged()`, because their result carries the selected branch evidence. +### Update an active scope value + +Use `to.local.update(...)` to replace the value owned by the nearest active +compound scope without rebuilding its active child. Use +`to.branch..update(...)` for a valued compound or parallel ancestor of the +handler source: + +```ts +Increment: ; +;((to) => + to.branch.root.session.update(({ ancestors, target }) => target.from({ count: ancestors["root.session"].count + 1 }))) +``` + +The update keeps the exact active descendants, their values, history records, +completion outputs, and unrelated parallel regions. It runs no exit or entry +actions and does not restart state-owned work. Eventless stabilization still +runs, so an `always` transition can react to the new value. + +`update` is callable when used directly and is also a static selection for a +named branch: + +```ts +to.branches({ + changed: { target: to.local.update }, + unchanged: { target: to.none } +}).resolve(({ select, event }) => + event.changed + ? select.changed.from({ count: event.count }) + : select.unchanged() +) +``` + +The resolver must return `target(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. + +The selector omits `update` for schema-less scopes, atomic and final states, +inactive branches, parallel sibling regions, and choice resolvers. Updating a +parallel sibling requires an event handled by that region. + Use `declinable: true` when a resolver may decide that its transition is not enabled. Only that resolver receives `decline()`, and its return type expands to accept the opaque declined result: diff --git a/docs/agent-guide.md b/docs/agent-guide.md index eec45cf..79af6ba 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -388,6 +388,23 @@ 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. +When only an active compound or parallel state's value changes, use its static +update selection instead of reconstructing its active descendants: + +```ts +Changed: (to) => + to.local.update(({ ancestors, target }) => + target.from({ revision: ancestors.document.revision + 1 }) + ) +``` + +`to.local.update` addresses the nearest valued compound scope. +`to.branch..update` addresses a valued compound or parallel ancestor of +the handler source. Both preserve the complete active descendant +configuration. Neither runs lifecycle actions or restarts state-owned work by +default. Use an event handled inside a parallel sibling when that sibling owns +the value that must change. + ## Test paths and invariants Test the statechart as a graph. Send domain events, inspect reached states, and diff --git a/src/Machine.ts b/src/Machine.ts index 6e755b6..23d4f41 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -3577,9 +3577,10 @@ export declare namespace Machine { * * **Details** * - * Every branch exposes its selected target without executing its resolver. - * A compound local or branch target covers its descendants; - * `undefined` identifies an explicitly targetless branch. + * Every branch exposes its static selection without executing its resolver. + * A compound local or branch target covers its descendants. An `update` + * selection keeps `target` undefined and records its value owner in + * `selection.path`; `none` identifies an explicitly targetless branch. * * @category models * @since 0.4.0 @@ -4461,6 +4462,33 @@ export declare namespace Machine { > } + /** + * Opaque instruction that replaces one active compound or parallel state's + * value without changing its active descendants. + * + * @category models + * @since 0.21.0 + */ + export interface StateUpdate< + States extends StateSchemas, + StateId extends ValuedStateIdentifier + > { + readonly [Topology.StateUpdateTypeId]: typeof Topology.StateUpdateTypeId + readonly path: StateId + readonly value: StateByIdentifier + } + + /** @internal */ + type StateUpdateBuilder< + States extends StateSchemas, + StateId extends ValuedStateIdentifier + > = + & ((value: StateByIdentifier) => StateUpdate) + & FromMethod< + readonly [input: SchemaByIdentifier["~type.make.in"]], + StateUpdate + > + /** * Opaque result returned by an explicitly targetless transition. * @@ -4745,6 +4773,45 @@ export declare namespace Machine { & SelectionTreeWithPrefix : SelectionMethod + /** @internal */ + type StateUpdateSelectionForNode< + AllStates extends StateSchemas, + Node, + Path extends string + > = Node extends { readonly states: StateSchemas } ? NodeSchema extends never ? {} + : { + readonly update: SelectionValue< + StateUpdateBuilder>>, + Path, + "update" + > + } + : {} + + /** @internal */ + type BranchUpdateSelectionPath< + AllStates extends StateSchemas, + Node, + Path extends string, + Rest extends string + > = + & StateUpdateSelectionForNode + & (Node extends { readonly states: infer Children extends StateSchemas } ? + Rest extends `${infer Head}.${infer Tail}` ? Head extends keyof Children ? { + readonly [Key in Head]: BranchUpdateSelectionPath< + AllStates, + Children[Head], + JoinPath, + Tail + > + } + : {} + : Rest extends keyof Children ? { + readonly [Key in Rest]: StateUpdateSelectionForNode> + } + : {} + : {}) + type FullSelectionNode< AllStates extends StateSchemas, Node, @@ -4769,13 +4836,17 @@ export declare namespace Machine { Source extends StateNodeIdentifier, Root extends string = Source extends `${infer Head}.${string}` ? Head : Source > = Root extends ActiveStateKey ? Root extends keyof BranchTargetBuilder ? { - readonly [Key in Root]: SelectionNode< - States, - States[Key], - Key, - "branch", - BranchTargetBuilder[Key] - > + readonly [Key in Root]: + & SelectionNode< + States, + States[Key], + Key, + "branch", + BranchTargetBuilder[Key] + > + & (Source extends ChoiceIdentifier ? {} + : Source extends `${Key}.${infer Rest}` ? BranchUpdateSelectionPath + : StateUpdateSelectionForNode) } : {} : {} @@ -4783,14 +4854,21 @@ export declare namespace Machine { type LocalTargetSelector< States extends StateSchemas, Source extends StateNodeIdentifier - > = NearestCompoundScope extends infer Scope extends StateIdentifier ? - ChildrenOf extends infer Children extends StateSchemas ? - LocalTargetBuilder extends infer Builder ? - & SelectionTreeWithPrefix - & ("with" extends keyof Builder ? { - readonly with: SelectionValue - } - : {}) + > = NearestCompoundScope extends infer Scope ? [Scope] extends [never] ? {} + : Scope extends StateIdentifier ? + ChildrenOf extends infer Children extends StateSchemas ? + LocalTargetBuilder extends infer Builder ? + & SelectionTreeWithPrefix + & ("with" extends keyof Builder ? { + readonly with: SelectionValue + } + : {}) + & (Source extends ChoiceIdentifier ? {} + : Scope extends ValuedStateIdentifier ? { + readonly update: SelectionValue, Scope, "update"> + } + : {}) + : {} : {} : {} : {} @@ -4831,9 +4909,9 @@ export declare namespace Machine { > { /** Handles the trigger without selecting a destination. */ readonly none: SelectionValue["none"], never, "none"> - /** Selects a destination inside the nearest active compound scope. */ + /** Selects a destination or updates the nearest active compound scope. */ readonly local: LocalTargetSelector - /** Selects a destination elsewhere under the currently active root. */ + /** Selects a destination or updates a valued active ancestor under the current root. */ readonly branch: BranchTargetSelector /** Selects a complete destination under any top-level state. */ readonly full: FullTargetSelector @@ -5243,9 +5321,9 @@ export declare namespace Machine { * **Details** * * Handlers return snapshots for complete state replacement, target builder - * results for path-safe partial transitions, or `target.none()` for an - * explicitly targetless transition. Raw decoded state values and `void` are - * not accepted at transition boundaries. + * results for path-safe partial transitions, state-value updates, or + * `target.none()` for an explicitly targetless transition. Raw decoded state + * values and `void` are not accepted at transition boundaries. * * @category utility types * @since 0.4.0 @@ -5255,11 +5333,13 @@ export declare namespace Machine { | Target> | HistoryTarget> | ChoiceTarget> + | StateUpdate> | StateConstruction< | Snapshot | Target> | HistoryTarget> | ChoiceTarget> + | StateUpdate> > | NoTarget @@ -5639,6 +5719,28 @@ export declare namespace Machine { | (SelectionKind extends "none" ? undefined : SelectedTargetResult | undefined) | Declined + /** @internal */ + type StateUpdateResolver< + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + Context, + Selection + > = ( + context: TransitionResolveContext, + enqueue: Enqueue, EmitOf> + ) => SelectedTargetResult + + /** @internal */ + type DeclinableStateUpdateResolver< + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + Context, + Selection + > = ( + context: TransitionResolveContext & DeclineCapability, + enqueue: Enqueue, EmitOf> + ) => SelectedTargetResult | Declined + /** One named destination declared by a branching transition. */ export interface TransitionBranchInput< Selection extends TargetSelection = TargetSelection @@ -5880,6 +5982,80 @@ export declare namespace Machine { : {} : {}) + /** @internal */ + interface StateUpdateTransitionRequired< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Selection extends TargetSelection + > { + ( + resolve: StateUpdateResolver, + options?: TransitionRequiredOptions + ): BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + SelectedTargetResult, + "required" + > + } + + /** @internal */ + interface StateUpdateTransitionDeclinable< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Selection extends TargetSelection + > { + ( + resolve: DeclinableStateUpdateResolver, + options: TransitionDeclinableOptions + ): BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + SelectedTargetResult | Declined, + "declinable" + > + } + + /** @internal */ + type StateUpdateTransition< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Acceptance extends TransitionAcceptance, + Selection extends TargetSelection + > = + & Selection + & StateUpdateTransitionRequired + & ("declinable" extends Acceptance ? StateUpdateTransitionDeclinable< + States, + Events, + Emits, + StateId, + Context, + Reenter, + Selection + > + : {}) + /** @internal Type evidence retained by a machine initial-entry declaration. */ export interface InitialBuilderEvidence { readonly [InitialBuilderTypeId]: Types.Covariant @@ -5933,19 +6109,18 @@ export declare namespace Machine { Reenter extends boolean, Acceptance extends TransitionAcceptance, Node - > = Node extends (...args: infer Args) => infer Selection ? Selection extends TargetSelection ? - & ((...args: Args) => TransitionTarget< - States, - Events, - Emits, - StateId, - Context, - Reenter, - Acceptance, - Selection - >) - & { - readonly [Key in keyof Node]: TransitionSelectorNode< + > = Node extends TargetSelection ? StateUpdateTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + Acceptance, + Node + > + : Node extends (...args: infer Args) => infer Selection ? Selection extends TargetSelection ? + & ((...args: Args) => TransitionTarget< States, Events, Emits, @@ -5953,10 +6128,21 @@ export declare namespace Machine { Context, Reenter, Acceptance, - Node[Key] - > - } - : never + Selection + >) + & { + readonly [Key in keyof Node]: TransitionSelectorNode< + States, + Events, + Emits, + StateId, + Context, + Reenter, + Acceptance, + Node[Key] + > + } + : never : Node extends TargetSelection ? TransitionTarget< States, Events, @@ -8760,9 +8946,10 @@ export const initialDefinition: (machine: M) => Machine.I * * Event handlers retain their handler-key order within each source state and * are followed by eventless and completion handlers. This function does not - * execute resolvers. Every direct, named, and targetless branch exposes the - * destination selected by its required static `target` declaration, while - * `acceptance` reports whether the resolver may decline the transition. + * execute resolvers. Every branch exposes its static selection. State updates + * retain the updated owner in `selection.path` while leaving `target` + * undefined because they do not change topology. `acceptance` reports whether + * the resolver may decline the transition. * * @category getters * @since 0.4.0 diff --git a/src/internal/machine/executionPlan.ts b/src/internal/machine/executionPlan.ts index 5842749..874d64a 100644 --- a/src/internal/machine/executionPlan.ts +++ b/src/internal/machine/executionPlan.ts @@ -47,7 +47,7 @@ import { validateDeclaredTransitionTarget } from "./planner.js" import { decodeEmitSync, decodeEventSync, decodeInputSync, decodeStateValueSync } from "./protocol.js" -import { isInitialTarget, isNoTarget, isSnapshot, isTarget, TargetSnapshotTypeId } from "./topology.js" +import { isInitialTarget, isNoTarget, isSnapshot, isStateUpdate, isTarget, TargetSnapshotTypeId } from "./topology.js" interface IndexedExecutionDescriptor { readonly flat: boolean @@ -548,7 +548,25 @@ const collectIndexedEvaluatedTransition = ( selection.context, selection.transition.evaluate ) - const unresolvedTarget = transitionResult.state + const update = isStateUpdate(transitionResult.state) + ? (() => { + const index = descriptor.indexByPath.get(transitionResult.state.path) + const node = index === undefined ? undefined : descriptor.nodes[index] + if ( + index === undefined || node === undefined || state.active[index] !== 1 || node.schema === undefined || + (node.type !== "compound" && node.type !== "parallel") + ) { + throw new Error( + `Machine state update owner "${transitionResult.state.path}" must be an active valued compound or parallel state` + ) + } + return { + path: node.path, + value: decodeStateValueSync(machine, node, transitionResult.state.value) + } + })() + : undefined + const unresolvedTarget = update === undefined ? transitionResult.state : undefined validateDeclaredTransitionTarget( selection.sourcePath, selection.trigger, @@ -568,9 +586,14 @@ const collectIndexedEvaluatedTransition = ( throw new Error("Machine expected indexed transition target to be a snapshot or target builder result") } const next = target === undefined - ? state + ? update === undefined ? state : (() => { + const next = copyOwnedIndexedState(state) + next.values[descriptor.indexByPath.get(update.path)!] = update.value + return next + })() : normalizeIndexedTargetStateSync(machine, descriptor, state, target as any, selection.leafIndex) const changed = selection.transition.reenter || !hasSameIndexedActive(state, next) + const stabilize = changed || update !== undefined if (!changed) { return { selection, @@ -578,11 +601,13 @@ const collectIndexedEvaluatedTransition = ( branchKey: transitionResult.branchKey, unresolvedTarget: unresolvedTarget as any, target: target as any, + update, next, commands: [...transitionResult.commands, ...(initialResolution?.commands ?? [])], raisedEvents: [...transitionResult.raisedEvents, ...(initialResolution?.raisedEvents ?? [])], emittedEvents: [...transitionResult.emittedEvents, ...(initialResolution?.emittedEvents ?? [])], changed: false, + stabilize, exitPaths: [], entryPaths: [], choiceTransitions: initialResolution?.transitions ?? [] @@ -603,11 +628,13 @@ const collectIndexedEvaluatedTransition = ( branchKey: transitionResult.branchKey, unresolvedTarget: unresolvedTarget as any, target: target as any, + update, next, commands: [...transitionResult.commands, ...(initialResolution?.commands ?? [])], raisedEvents: [...transitionResult.raisedEvents, ...(initialResolution?.raisedEvents ?? [])], emittedEvents: [...transitionResult.emittedEvents, ...(initialResolution?.emittedEvents ?? [])], changed: true, + stabilize, exitPaths: getExitPaths(machine, activeConfigurationFromIndexedState(descriptor, state), boundary), entryPaths: getEntryPaths(machine, activeConfigurationFromIndexedState(descriptor, next), boundary), choiceTransitions: initialResolution?.transitions ?? [] @@ -663,6 +690,13 @@ const indexedMicrostep = ( if (transitions.length === 1) { next = transitions[0]!.next } else { + for (const transition of transitions) { + if (transition.update !== undefined) { + const values = next.values.slice() + values[descriptor.indexByPath.get(transition.update.path)!] = transition.update.value + next = { ...next, values } + } + } const applicationOrder = [ ...transitions.filter((transition) => !transition.changed), ...transitions.filter((transition) => transition.changed) diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index fd28b2d..74e7d5a 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -227,6 +227,15 @@ const decorateTransitionSelection = (selection: Topology.TargetSelection): Topol reenter: () => makeDirectTransitionDescriptor(selection, undefined, { reenter: true }) }) +const decorateStateUpdateSelection = (selection: Topology.TargetSelection): Topology.TargetSelection => { + const update = ( + resolve: (context: any, enqueue: unknown) => unknown, + options?: unknown + ) => makeDirectTransitionDescriptor(selection, resolve, options) + Object.assign(update, selection) + return Object.freeze(update) as unknown as Topology.TargetSelection +} + const noneTransitionSelection = decorateTransitionSelection(Topology.noneTargetSelection) const makeInitialBuilderDescriptor = ( @@ -268,6 +277,7 @@ const decorateInitialSelectorNode = (node: unknown): unknown => { const decorateTransitionSelectorNode = (node: unknown): unknown => { if (Topology.isTargetSelection(node)) { + if (node.kind === "update") return decorateStateUpdateSelection(node) return node === Topology.noneTargetSelection ? noneTransitionSelection : decorateTransitionSelection(node) } if (typeof node === "function") { @@ -363,22 +373,29 @@ const makeSelectionValue = ( scope: Topology.TargetSelectionScope ): Topology.TargetSelection => Topology.makeTargetSelection(kind, path, scope) +const makeStateUpdateSelection = ( + path: string, + scope: "local" | "branch" +): Topology.TargetSelection => Topology.makeTargetSelection("update", path, scope) + const addSelectionChildren = ( builder: Record, stateNodes: Machine.StateNodes, parent: string, - scope: "local" | "branch" + scope: "local" | "branch", + source?: string ): void => { for (const node of stateNodes.byPath.values()) { if (node.parent !== parent || node.type === "history") continue - builder[node.key] = makeSelectionNode(stateNodes, node.path, scope) + builder[node.key] = makeSelectionNode(stateNodes, node.path, scope, source) } } const makeSelectionNode = ( stateNodes: Machine.StateNodes, path: string, - scope: Topology.TargetSelectionScope + scope: Topology.TargetSelectionScope, + source?: string ): unknown => { const node = getTargetBuilderNode(stateNodes, path) const kind: Topology.TargetSelectionKind = node.type === "choice" ? "choice" : "state" @@ -389,7 +406,17 @@ const makeSelectionNode = ( enumerable: true }) if (scope === "local" || scope === "branch") { - addSelectionChildren(method, stateNodes, path, scope) + addSelectionChildren(method, stateNodes, path, scope, source) + } + if ( + scope === "branch" && source !== undefined && node.schema !== undefined && + (source === path || source.startsWith(`${path}.`)) && + getTargetBuilderNode(stateNodes, source).type !== "choice" + ) { + Object.defineProperty(method, "update", { + value: makeStateUpdateSelection(path, "branch"), + enumerable: true + }) } } return method @@ -424,13 +451,16 @@ const makeTargetSelector = ( } const branch: Record = {} const root = getTargetBuilderNode(stateNodes, source.split(".")[0]!) - branch[root.key] = makeSelectionNode(stateNodes, root.path, "branch") + branch[root.key] = makeSelectionNode(stateNodes, root.path, "branch", source) const local: Record = {} const localScope = getLocalTargetScope(stateNodes, source) if (localScope !== undefined) { const localScopeNode = getTargetBuilderNode(stateNodes, localScope) if (localScopeNode.schema !== undefined) { local.with = makeSelectionValue("state", localScope, "local") + if (getTargetBuilderNode(stateNodes, source).type !== "choice") { + local.update = makeStateUpdateSelection(localScope, "local") + } } addSelectionChildren(local, stateNodes, localScope, "local") } @@ -469,6 +499,13 @@ const getSelectionBuilder = ( source: string ): unknown => { if (selection.kind === "none") return target.none + if (selection.kind === "update") { + return withFrom( + (value: unknown) => Topology.makeStateUpdate(selection.path!, value), + "leaf", + true + ) + } let builder: any let parts = selection.path!.split(".") if (selection.kind === "history") { @@ -514,6 +551,12 @@ const validateResolvedSelection = ( } return } + if (selection.kind === "update") { + if (!Topology.isStateUpdate(result) || result.path !== selection.path) { + throw new Error(`Machine state update for "${selection.path}" must return its selected update builder`) + } + return + } if (result === undefined) return const resultPath = typeof result === "object" && result !== null && hasProperty(result, "path") && typeof result.path === "string" @@ -556,6 +599,9 @@ const runCapturedBranch = ( return resolved === undefined ? constructSelectedTarget(selectedTarget) : resolved } +const topologyTargetPath = (selection: Topology.TargetSelection): string | undefined => + selection.kind === "update" ? undefined : selection.path + const isArrayIndexKey = (key: string): boolean => { const index = Number(key) return Number.isInteger(index) && index >= 0 && index < 0xffff_ffff && String(index) === key @@ -738,7 +784,9 @@ const captureTransition = ( declinable, targets: [ ...new Set( - branches.flatMap((branch) => branch.selection.path === undefined ? [] : [branch.selection.path]) + branches.flatMap((branch) => + topologyTargetPath(branch.selection) === undefined ? [] : [branch.selection.path!] + ) ) ], branches: branches.map((branch) => @@ -746,7 +794,7 @@ const captureTransition = ( type: "branch" as const, key: branch.key, title: branch.title, - target: branch.selection.path, + target: topologyTargetPath(branch.selection), selection: transitionTargetSelection(branch.selection) }) ), @@ -763,10 +811,10 @@ const captureTransition = ( return { reenter, declinable, - targets: branch.selection.path === undefined ? [] : [branch.selection.path], + targets: topologyTargetPath(branch.selection) === undefined ? [] : [branch.selection.path!], branches: [{ type: "direct" as const, - target: branch.selection.path, + target: topologyTargetPath(branch.selection), selection: transitionTargetSelection(branch.selection) }], evaluate, diff --git a/src/internal/machine/planner.ts b/src/internal/machine/planner.ts index 7e1439a..190d3c7 100644 --- a/src/internal/machine/planner.ts +++ b/src/internal/machine/planner.ts @@ -52,6 +52,7 @@ import { isInitialTarget, isNoTarget, isSnapshot, + isStateUpdate, isTarget, makeChoiceTarget, makeTarget, @@ -78,6 +79,11 @@ export type MicrostepPlan = { readonly changed: boolean } +type SettlingMicrostep = MicrostepPlan & { + /** Internal signal that eventless stabilization must run again. */ + readonly stabilize: boolean +} + export type MacrostepPlan = & { readonly next: State @@ -580,10 +586,15 @@ export type EvaluatedTransition | Machine.Target> | undefined + readonly update: { + readonly path: string + readonly value: unknown + } | undefined readonly commands: ReadonlyArray readonly raisedEvents: ReadonlyArray readonly emittedEvents: ReadonlyArray readonly changed: boolean + readonly stabilize: boolean readonly exitPaths: ReadonlyArray readonly entryPaths: ReadonlyArray readonly choiceTransitions: ReadonlyArray<{ @@ -1143,7 +1154,9 @@ export const removeConflictingTransitions = < let preempted = false const transitionsToRemove = new Set>() for (const selected of filtered) { - if (hasPathIntersection(transition.exitPaths, selected.exitPaths)) { + const writesSameState = transition.update !== undefined && selected.update !== undefined && + transition.update.path === selected.update.path + if (hasPathIntersection(transition.exitPaths, selected.exitPaths) || writesSameState) { if (isDescendantOf(transition.selection.sourcePath, selected.selection.sourcePath)) { transitionsToRemove.add(selected) } else { @@ -1344,6 +1357,7 @@ const collectEvaluatedTransition = < throw new Error("Machine transition returned decline without declaring declinable: true") } const unresolvedTarget = transitionResult.state === undefined + || isStateUpdate(transitionResult.state) ? undefined : transitionResult.state as | Machine.Snapshot @@ -1356,6 +1370,21 @@ const collectEvaluatedTransition = < selection.transition.targets, unresolvedTarget ) + const update = isStateUpdate(transitionResult.state) + ? (() => { + const node = getNode(machine, transitionResult.state.path) + if ( + !state.active.has(node.path) || node.schema === undefined || + (node.type !== "compound" && node.type !== "parallel") + ) { + throw new Error(`Machine state update owner "${node.path}" must be an active valued compound or parallel state`) + } + return { + path: node.path, + value: decodeStateValueSync(machine, node, transitionResult.state.value) + } + })() + : undefined const choiceResolution = unresolvedTarget === undefined ? undefined : resolveChoiceTarget( @@ -1456,7 +1485,10 @@ const collectEvaluatedTransition = < ? getTargetNodePath(target) : getTargetNodePath(unresolvedTarget) let stateAfterTransition = target === undefined - ? state + ? update === undefined ? state : { + ...state, + values: new Map(state.values).set(update.path, update.value) + } : normalizeTargetConfigurationSync(machine, state, target) for (const additionalTarget of additionalChoiceTargets) { stateAfterTransition = normalizeTargetConfigurationSync( @@ -1466,6 +1498,7 @@ const collectEvaluatedTransition = < ) } const changed = selection.transition.reenter || !hasSameActivePaths(state, stateAfterTransition) + const stabilize = changed || update !== undefined if (!changed) { return { @@ -1474,6 +1507,7 @@ const collectEvaluatedTransition = < branchKey: transitionResult.branchKey, unresolvedTarget, target, + update, commands: [ ...transitionResult.commands, ...(choiceResolution?.commands ?? []), @@ -1496,6 +1530,7 @@ const collectEvaluatedTransition = < ...additionalTargetEmittedEvents ], changed, + stabilize, exitPaths: [], entryPaths: [], choiceTransitions: [ @@ -1521,6 +1556,7 @@ const collectEvaluatedTransition = < branchKey: transitionResult.branchKey, unresolvedTarget, target, + update, commands: [ ...transitionResult.commands, ...(choiceResolution?.commands ?? []), @@ -1543,6 +1579,7 @@ const collectEvaluatedTransition = < ...additionalTargetEmittedEvents ], changed, + stabilize, exitPaths: reenteredHistoryTarget !== undefined ? sortExitPaths( machine, @@ -1744,7 +1781,8 @@ export const planInitialSync = < emittedEvents: [...choiceResolution.emittedEvents, ...initialHistoryEmittedEvents], exitPaths: [], entryPaths: [], - changed: false + changed: false, + stabilize: false }] ) @@ -1850,7 +1888,8 @@ const microstep = < emittedEvents: [], exitPaths: [], entryPaths: [], - changed: false + changed: false, + stabilize: false } } @@ -1881,6 +1920,17 @@ const microstep = < ...transition.choiceTransitions ]) let stateAfterTransition = state + // Updates never reactivate topology. Apply every retained value write to the + // original active configuration before any control target becomes + // authoritative. + for (const transition of sortedTransitions) { + if (transition.update !== undefined) { + stateAfterTransition = { + ...stateAfterTransition, + values: new Map(stateAfterTransition.values).set(transition.update.path, transition.update.value) + } + } + } // Value-only targets are evaluated against the original configuration. If // one is applied after a control-changing transition, it can resurrect a // branch that the changing transition exited. Apply value-only updates @@ -1901,6 +1951,7 @@ const microstep = < } const changed = transitions.some((transition) => transition.changed) + const stabilize = transitions.some((transition) => transition.stabilize) const transitionActions = sortedTransitions .flatMap((transition) => transition.commands) const transitionRaisedEvents = sortedTransitions @@ -1918,7 +1969,8 @@ const microstep = < emittedEvents: transitionEmittedEvents, exitPaths: [], entryPaths: [], - changed: false + changed: false, + stabilize } } @@ -1951,7 +2003,8 @@ const microstep = < emittedEvents: [...exit.emittedEvents, ...transitionEmittedEvents, ...entry.emittedEvents], exitPaths, entryPaths, - changed: true + changed: true, + stabilize } } @@ -1974,7 +2027,7 @@ const settle = < commands: Array, raisedEvents: Array>, emittedEvents: Array, - microsteps: Array, E, R>> + microsteps: Array, E, R>> ) => { let currentState = state let currentEvent = event @@ -2010,7 +2063,7 @@ const settle = < pendingCompletions.length === 0 ? [] : [pendingCompletions.shift()!] ) if (done.length > 0) { - const doneStep: MicrostepPlan, E, R> = microstep( + const doneStep: SettlingMicrostep, E, R> = microstep( machine, currentState, currentEvent, @@ -2021,7 +2074,7 @@ const settle = < emittedEvents.push(...doneStep.emittedEvents) microsteps.push(doneStep) currentState = doneStep.next - shouldRunAlways = doneStep.changed + shouldRunAlways = doneStep.stabilize continue } if (isActiveFinalConfiguration(machine, currentState)) { @@ -2038,7 +2091,7 @@ const settle = < ? selectAlwaysTransitions(machine, currentState, currentEvent) : [] if (always.length > 0) { - const alwaysStep: MicrostepPlan, E, R> = microstep( + const alwaysStep: SettlingMicrostep, E, R> = microstep( machine, currentState, currentEvent, @@ -2049,7 +2102,7 @@ const settle = < emittedEvents.push(...alwaysStep.emittedEvents) microsteps.push(alwaysStep) currentState = alwaysStep.next - shouldRunAlways = alwaysStep.changed + shouldRunAlways = alwaysStep.stabilize continue } diff --git a/src/internal/machine/topology.ts b/src/internal/machine/topology.ts index 2e9581d..362cac9 100644 --- a/src/internal/machine/topology.ts +++ b/src/internal/machine/topology.ts @@ -29,6 +29,8 @@ export const DeclinedTypeId: unique symbol = Symbol("effect/Machine/Declined") export const TargetSelectionTypeId: unique symbol = Symbol("effect/Machine/TargetSelection") +export const StateUpdateTypeId: unique symbol = Symbol("effect/Machine/StateUpdate") + export const SelectedBranchTypeId: unique symbol = Symbol("effect/Machine/SelectedBranch") interface StateInput { @@ -73,7 +75,7 @@ export interface Declined { readonly [DeclinedTypeId]: typeof DeclinedTypeId } -export type TargetSelectionKind = "state" | "initial" | "history" | "choice" | "none" +export type TargetSelectionKind = "state" | "initial" | "history" | "choice" | "update" | "none" export type TargetSelectionScope = "local" | "branch" | "full" | "initial" @@ -111,6 +113,21 @@ export const noneTargetSelection: TargetSelection = makeTargetSelection("none", export const isTargetSelection = (u: unknown): u is TargetSelection => hasProperty(u, TargetSelectionTypeId) +export interface StateUpdate { + readonly [StateUpdateTypeId]: typeof StateUpdateTypeId + readonly path: string + readonly value: unknown +} + +export const makeStateUpdate = (path: string, value: unknown): StateUpdate => + Object.freeze({ + [StateUpdateTypeId]: StateUpdateTypeId, + path, + value + }) + +export const isStateUpdate = (u: unknown): u is StateUpdate => hasProperty(u, StateUpdateTypeId) + export const makeSelectedBranch = ( owner: object, branchIndex: number, diff --git a/src/internal/testing/machine/verification.ts b/src/internal/testing/machine/verification.ts index 689aca9..d7dd0da 100644 --- a/src/internal/testing/machine/verification.ts +++ b/src/internal/testing/machine/verification.ts @@ -475,7 +475,7 @@ const targetWithinSelection = ( nodeByPath: ReadonlyMap ): boolean => { const selection = branch.selection - if (selection.kind === "none") return target === undefined + if (selection.kind === "none" || selection.kind === "update") return target === undefined if (target === undefined || selection.path === undefined) return false if (target === selection.path) return true const selectedNode = nodeByPath.get(selection.path) @@ -564,6 +564,7 @@ export const coverage = ( const exitHits = new Set() const transitionCoverage = makeTransitionCoverageCollector(machine) + const transitionDefinitions = Machine.transitionDefinitions(machine) const declaredEvents = publicEventTags(machine) const declaredEventTags = declaredEvents.tags @@ -577,6 +578,7 @@ export const coverage = ( let microsteps = 0 let changedMicrosteps = 0 let targetlessTransitions = 0 + let stateUpdates = 0 let raisedEvents = 0 let emittedEvents = 0 let eventTriggered = 0 @@ -630,7 +632,13 @@ export const coverage = ( hitPaths(microstep.exitPaths, exitHits) observeSnapshot(microstep.next) for (const retained of microstep.transitions) { - if (retained.target === undefined) targetlessTransitions += 1 + const definition = transitionDefinitions.find((candidate) => + candidate.source === retained.source && candidate.reenter === retained.reenter && + sameTransitionTrigger(candidate.trigger, retained.trigger) + ) + const branch = definition?.branches[retained.branchIndex] + if (branch?.selection.kind === "update") stateUpdates += 1 + else if (retained.target === undefined) targetlessTransitions += 1 if (retained.trigger.type === "event") eventTriggered += 1 else if (retained.trigger.type === "always") alwaysTriggered += 1 else if (retained.trigger.type === "done") doneTriggered += 1 @@ -718,6 +726,7 @@ export const coverage = ( total: microsteps, changed: changedMicrosteps, targetless: targetlessTransitions, + updates: stateUpdates, raised: raisedEvents, emitted: emittedEvents, eventTriggered, @@ -1598,7 +1607,11 @@ export const verify = ( const resolvedTarget = transition.resolvedTarget === undefined ? undefined : String(transition.resolvedTarget) const targetNode = target === undefined ? undefined : byPath.get(target) let expected = target - let explanation = target === undefined ? "an unresolved targetless transition" : `target "${target}"` + let explanation = branches[index]?.selection.kind === "update" + ? `update owner "${String(branches[index]?.selection.path)}"` + : target === undefined + ? "an unresolved targetless transition" + : `target "${target}"` if (transition.trigger.type === "choice") { explanation = target === undefined ? "a targetless choice edge" : `choice edge target "${target}"` diff --git a/src/testing/MachineTest.ts b/src/testing/MachineTest.ts index d1532ef..d1259c4 100644 --- a/src/testing/MachineTest.ts +++ b/src/testing/MachineTest.ts @@ -1822,6 +1822,8 @@ export interface MicrostepCoverageEvidence { readonly total: number readonly changed: number readonly targetless: number + /** Retained state-value update operations. */ + readonly updates: number readonly raised: number readonly emitted: number readonly eventTriggered: number diff --git a/test/internal/machine/strategyDifferential.test.ts b/test/internal/machine/strategyDifferential.test.ts index ee0644a..c6f228a 100644 --- a/test/internal/machine/strategyDifferential.test.ts +++ b/test/internal/machine/strategyDifferential.test.ts @@ -138,6 +138,126 @@ describe("machine planner and runtime strategies", () => { assert.deepStrictEqual(planned.microsteps[0]?.entryPaths, ["Count"]) })) + it.effect("matches generic and indexed-hierarchical state updates", () => { + class Root extends Schema.TaggedClass("StrategyUpdateRoot")("Root", { revision: Schema.Number }) {} + class Work extends Schema.TaggedClass("StrategyUpdateWork")("Work", {}) {} + class Left extends Schema.TaggedClass("StrategyUpdateLeft")("Left", { value: Schema.Number }) {} + class Right extends Schema.TaggedClass("StrategyUpdateRight")("Right", { value: Schema.Number }) {} + class Leaf extends Schema.TaggedClass("StrategyUpdateLeaf")("Leaf", {}) {} + class Outside extends Schema.TaggedClass("StrategyUpdateOutside")("Outside", {}) {} + class UpdateRegions extends Schema.TaggedClass("StrategyUpdateRegions")("UpdateRegions", {}) {} + class Compete extends Schema.TaggedClass("StrategyUpdateCompete")("Compete", {}) {} + class ExitRoot extends Schema.TaggedClass("StrategyUpdateExitRoot")("ExitRoot", {}) {} + class ReenterUpdate extends Schema.TaggedClass("StrategyUpdateReenter")("ReenterUpdate", {}) {} + const states = Machine.states({ + Root: { + schema: Root, + initial: "Work", + states: { + Work: { + schema: Work, + type: "parallel", + states: { + Left: { + schema: Left, + initial: "Leaf", + states: { Leaf } + }, + Right: { + schema: Right, + initial: "Leaf", + states: { Leaf } + } + } + } + } + }, + Outside + }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(UpdateRegions, Compete, ExitRoot, ReenterUpdate), + initial: (to) => + to.Root.initial.resolve(({ target }) => + target( + new Root({ revision: 0 }), + (root) => + root.Work(new Work({}), (work) => + work.Left(new Left({ value: 0 }), (left) => left.Leaf(new Leaf({}))) + .Right(new Right({ value: 0 }), (right) => right.Leaf(new Leaf({})))) + ) + ) + }).handle({ + Root: { + states: { + Work: { + states: { + Left: { + states: { + Leaf: { + on: { + UpdateRegions: (to) => + to.local.update(({ ancestors, target }) => + target(new Left({ value: ancestors["Root.Work.Left"].value + 1 })) + ), + Compete: (to) => to.branch.Root.update(({ target }) => target(new Root({ revision: 1 }))), + ExitRoot: (to) => to.branch.Root.update(({ target }) => target(new Root({ revision: 3 }))), + ReenterUpdate: (to) => + to.local.update( + ({ ancestors, target }) => target(new Left({ value: ancestors["Root.Work.Left"].value + 1 })), + { reenter: true } + ) + } + } + } + }, + Right: { + states: { + Leaf: { + on: { + UpdateRegions: (to) => + to.local.update(({ ancestors, target }) => + target(new Right({ value: ancestors["Root.Work.Right"].value + 2 })) + ), + Compete: (to) => to.branch.Root.update(({ target }) => target(new Root({ revision: 2 }))), + ExitRoot: (to) => to.full.Outside().resolve(({ target }) => target(new Outside({}))) + } + } + } + } + } + } + } + } + }) + + return Effect.gen(function*() { + yield* verifyPlannerStrategies({ + machine, + events: [new UpdateRegions({}), new ReenterUpdate({}), new Compete({}), new ExitRoot({})], + expected: "indexed-hierarchical", + label: "state updates" + }) + + const initial = yield* Machine.planInitial(machine) + const updated = yield* Machine.plan(machine, initial.state, new UpdateRegions({})) + if (updated.next.path !== "Root") throw new Error("expected Root") + assert.strictEqual(updated.next.state.states.Left.value.value, 1) + assert.strictEqual(updated.next.state.states.Right.value.value, 2) + + const reentered = yield* Machine.plan(machine, updated.next, new ReenterUpdate({})) + assert.deepStrictEqual(reentered.microsteps[0]?.exitPaths, ["Root.Work.Left.Leaf"]) + assert.deepStrictEqual(reentered.microsteps[0]?.entryPaths, ["Root.Work.Left.Leaf"]) + + const competed = yield* Machine.plan(machine, reentered.next, new Compete({})) + if (competed.next.path !== "Root") throw new Error("expected Root") + assert.strictEqual(competed.next.value.revision, 1) + + const exited = yield* Machine.plan(machine, competed.next, new ExitRoot({})) + assert.strictEqual(exited.next.path, "Outside") + }) + }) + it.effect("retains indexed execution microstep evidence without widening frozen execution values", () => Effect.gen(function*() { const machine = makeFlatMachine() diff --git a/test/machine/MermaidVisualization.test.ts b/test/machine/MermaidVisualization.test.ts index feaca1c..2526215 100644 --- a/test/machine/MermaidVisualization.test.ts +++ b/test/machine/MermaidVisualization.test.ts @@ -49,8 +49,20 @@ const inspection: InspectionApi = { reenter: false, acceptance: "required", branches: [ - { type: "branch", key: "approved", title: "approved %%\nnow", target: "Root.Done" }, - { type: "branch", key: "unchanged", title: "unchanged", target: undefined } + { + type: "branch", + key: "approved", + title: "approved %%\nnow", + target: "Root.Done", + selection: { kind: "state", scope: "local", path: "Root.Done" } + }, + { + type: "branch", + key: "unchanged", + title: "unchanged", + target: undefined, + selection: { kind: "none", scope: "local", path: undefined } + } ] }, { @@ -58,7 +70,11 @@ const inspection: InspectionApi = { trigger: { type: "event", event: "Retry" }, reenter: false, acceptance: "declinable", - branches: [{ type: "direct", target: "Root.Route" }] + branches: [{ + type: "direct", + target: "Root.Route", + selection: { kind: "choice", scope: "local", path: "Root.Route" } + }] } ], activityDefinitions: () => [ diff --git a/test/machine/StateUpdate.test.ts b/test/machine/StateUpdate.test.ts new file mode 100644 index 0000000..a516b52 --- /dev/null +++ b/test/machine/StateUpdate.test.ts @@ -0,0 +1,509 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { Machine } from "../../src/index.js" +import { MachineTest } from "../../src/testing/index.js" + +describe("state value updates", () => { + it.effect("updates the local owner without changing its active descendants", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ + Session: { count: Schema.Number }, + Editing: { draft: Schema.String }, + Idle: {} + }) + const Event = Schema.TaggedUnion({ Increment: {} }) + const states = Machine.states({ + session: { + schema: State.cases.Session, + initial: "editing", + states: { + editing: { + schema: State.cases.Editing, + initial: "idle", + states: { idle: State.cases.Idle } + } + } + } + }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(Event), + initial: (to) => + to.session.initial.resolve(({ target }) => + target.from({ count: 0 }, (session) => + session.editing.from({ draft: "kept" }, (editing) => editing.idle.from())) + ) + }).handle({ + session: { + states: { + editing: { + states: { + idle: { + on: { + Increment: (to) => + to.branch.session.update(({ ancestors, target }) => + target.from({ count: ancestors.session.count + 1 }) + ) + } + } + } + } + } + } + }) + + assert.deepStrictEqual(Machine.transitionDefinitions(machine), [{ + source: "session.editing.idle", + trigger: { type: "event", event: "Increment" }, + reenter: false, + acceptance: "required", + branches: [{ + type: "direct", + target: undefined, + selection: { kind: "update", scope: "branch", path: "session" } + }] + }]) + + const initial = yield* Machine.planInitial(machine) + const planned = yield* Machine.plan(machine, initial.state, Event.cases.Increment.make({})) + + assert.deepStrictEqual(planned.next, { + path: "session", + value: State.cases.Session.make({ count: 1 }), + state: { + path: "session.editing", + value: State.cases.Editing.make({ draft: "kept" }), + state: { path: "session.editing.idle", value: State.cases.Idle.make({}) } + } + }) + assert.deepStrictEqual(planned.microsteps[0]?.transitions, [{ + source: "session.editing.idle", + trigger: { type: "event", event: "Increment" }, + reenter: false, + branchIndex: 0, + branchKey: undefined, + target: undefined, + resolvedTarget: undefined + }]) + assert.deepStrictEqual(planned.microsteps[0]?.exitPaths, []) + assert.deepStrictEqual(planned.microsteps[0]?.entryPaths, []) + assert.isFalse(planned.microsteps[0]?.changed) + + const trace = yield* MachineTest.run(machine, { events: [Event.cases.Increment.make({})] }) + yield* MachineTest.verify(machine, trace) + const coverage = MachineTest.coverage(machine, trace) + assert.strictEqual(coverage.microsteps.updates, 1) + assert.strictEqual(coverage.microsteps.targetless, 0) + })) + + it.effect("selects updates as named branches without turning them into topology targets", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ Scope: { count: Schema.Number }, Idle: {} }) + const Event = Schema.TaggedUnion({ Set: { changed: Schema.Boolean } }) + const states = Machine.states({ + scope: { + schema: State.cases.Scope, + initial: "idle", + states: { idle: State.cases.Idle } + } + }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(Event), + initial: (to) => + to.scope.initial.resolve(({ target }) => target.from({ count: 0 }, (scope) => scope.idle.from())) + }).handle({ + scope: { + states: { + idle: { + on: { + Set: (to) => + to.branches({ + changed: { target: to.local.update, title: "Value changed" }, + unchanged: { target: to.none } + }).resolve(({ event, select }) => + event.changed ? select.changed.from({ count: 1 }) : select.unchanged() + ) + } + } + } + } + }) + + assert.deepStrictEqual(Machine.transitionDefinitions(machine)[0]?.branches, [{ + type: "branch", + key: "changed", + title: "Value changed", + target: undefined, + selection: { kind: "update", scope: "local", path: "scope" } + }, { + type: "branch", + key: "unchanged", + title: "unchanged", + target: undefined, + selection: { kind: "none", scope: "local", path: undefined } + }]) + + const initial = yield* Machine.planInitial(machine) + const changed = yield* Machine.plan(machine, initial.state, Event.cases.Set.make({ changed: true })) + assert.strictEqual(changed.next.value.count, 1) + assert.strictEqual(changed.microsteps[0]?.transitions[0]?.branchKey, "changed") + + const unchanged = yield* Machine.plan(machine, initial.state, Event.cases.Set.make({ changed: false })) + assert.deepStrictEqual(unchanged.next, initial.state) + assert.strictEqual(unchanged.microsteps[0]?.transitions[0]?.branchKey, "unchanged") + })) + + it.effect("reenters the handler source, not the updated owner", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ Scope: { count: Schema.Number }, Idle: {} }) + const Event = Schema.TaggedUnion({ Quiet: {}, Loud: {} }) + const lifecycle: Array = [] + const states = Machine.states({ + scope: { + schema: State.cases.Scope, + initial: "idle", + states: { idle: State.cases.Idle } + } + }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(Event), + initial: (to) => + to.scope.initial.resolve(({ target }) => target.from({ count: 0 }, (scope) => scope.idle.from())) + }).handle({ + scope: { + entry: () => { + lifecycle.push("enter scope") + return undefined + }, + exit: () => { + lifecycle.push("exit scope") + return undefined + }, + states: { + idle: { + entry: () => { + lifecycle.push("enter idle") + return undefined + }, + exit: () => { + lifecycle.push("exit idle") + return undefined + }, + on: { + Quiet: (to) => + to.local.update(({ ancestors, target }) => target.from({ count: ancestors.scope.count + 1 })), + Loud: (to) => + to.local.update( + ({ ancestors, target }) => target.from({ count: ancestors.scope.count + 1 }), + { reenter: true } + ) + } + } + } + } + }) + + const initial = yield* Machine.planInitial(machine) + lifecycle.length = 0 + const quiet = yield* Machine.plan(machine, initial.state, Event.cases.Quiet.make({})) + assert.deepStrictEqual(lifecycle, []) + assert.deepStrictEqual(quiet.microsteps[0]?.exitPaths, []) + assert.deepStrictEqual(quiet.microsteps[0]?.entryPaths, []) + + const loud = yield* Machine.plan(machine, quiet.next, Event.cases.Loud.make({})) + assert.deepStrictEqual(lifecycle, ["exit idle", "enter idle"]) + assert.deepStrictEqual(loud.microsteps[0]?.exitPaths, ["scope.idle"]) + assert.deepStrictEqual(loud.microsteps[0]?.entryPaths, ["scope.idle"]) + })) + + it.effect("runs eventless stabilization again after an update", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ Scope: { count: Schema.Number }, Idle: {} }) + const states = Machine.states({ + scope: { + schema: State.cases.Scope, + initial: "idle", + states: { idle: State.cases.Idle } + } + }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(), + initial: (to) => + to.scope.initial.resolve(({ target }) => target.from({ count: 0 }, (scope) => scope.idle.from())) + }).handle({ + scope: { + states: { + idle: { + always: (to) => + to.local.update(({ ancestors, decline, target }) => + ancestors.scope.count < 2 + ? target.from({ count: ancestors.scope.count + 1 }) + : decline(), { declinable: true }) + } + } + } + }) + + const initial = yield* Machine.planInitial(machine) + assert.strictEqual(initial.state.value.count, 2) + assert.strictEqual(initial.microsteps.length, 2) + assert.deepStrictEqual(initial.microsteps.map((step) => step.changed), [false, false]) + })) + + it.effect("preserves history records while replacing the history owner's value", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ Root: { count: Schema.Number }, A: {}, Outside: {} }) + const Event = Schema.TaggedUnion({ Leave: {}, Return: {}, Update: {} }) + const states = Machine.states({ + root: { + schema: State.cases.Root, + initial: "a", + states: { + a: State.cases.A, + recent: { type: "history", history: "deep" } + } + }, + outside: State.cases.Outside + }) + const initialRoot = () => ({ + path: "root" as const, + value: State.cases.Root.make({ count: 0 }), + state: { path: "root.a" as const, value: State.cases.A.make({}) } + }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(Event), + initial: (to) => to.root.initial.resolve(() => initialRoot()) + }).handle({ + root: { + history: { recent: { default: initialRoot } }, + states: { + a: { + on: { + Leave: (to) => to.full.outside().resolve(({ target }) => target.from()), + Update: (to) => + to.local.update(({ ancestors, target }) => target.from({ count: ancestors.root.count + 1 })) + } + } + } + }, + outside: { + on: { + Return: (to) => to.history.root.recent.resolve(({ target }) => target()) + } + } + }) + + const initial = yield* Machine.planInitial(machine) + const outside = yield* Machine.plan(machine, initial.state, Event.cases.Leave.make({})) + const restored = yield* Machine.plan(machine, outside.next, Event.cases.Return.make({})) + const historyBefore = restored.next.history + const updated = yield* Machine.plan(machine, restored.next, Event.cases.Update.make({})) + + assert.deepStrictEqual(updated.next.history, historyBefore) + if (updated.next.path !== "root") throw new Error("expected restored root") + assert.strictEqual(updated.next.value.count, 1) + assert.strictEqual(updated.next.state.path, "root.a") + })) + + it.effect("preserves completion outputs and does not replay completion", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ + Root: { revision: Schema.Number }, + Left: {}, + Done: {}, + Right: {}, + Idle: {} + }) + const Event = Schema.TaggedUnion({ Update: {} }) + const states = Machine.states({ + root: { + schema: State.cases.Root, + type: "parallel", + states: { + left: { + schema: State.cases.Left, + initial: "done", + states: { + done: { schema: State.cases.Done, type: "final", output: Schema.String } + } + }, + right: { + schema: State.cases.Right, + initial: "idle", + states: { idle: State.cases.Idle } + } + } + } + }) + let completions = 0 + const machine = Machine.make({ + states: states.states, + events: Machine.events(Event), + initial: (to) => + to.root.initial.resolve(({ target }) => + target.from({ revision: 0 }, (root) => + root.left.from((left) => left.done.from()) + .right.from((right) => right.idle.from())) + ) + }).handle({ + root: { + states: { + left: { + onDone: (to) => + to.none.resolve(() => { + completions += 1 + return undefined + }), + states: { done: { output: () => "complete" } } + }, + right: { + states: { + idle: { + on: { + Update: (to) => + to.branch.root.update(({ ancestors, target }) => + target.from({ revision: ancestors.root.revision + 1 }) + ) + } + } + } + } + } + } + }) + + const initial = yield* Machine.planInitial(machine) + const completedBefore = initial.state.completed + const completionCount = completions + const updated = yield* Machine.plan(machine, initial.state, Event.cases.Update.make({})) + + assert.deepStrictEqual(updated.next.completed, completedBefore) + assert.strictEqual(completions, completionCount) + assert.strictEqual(updated.next.value.revision, 1) + })) + + it.effect("reports update construction failures through the state schema boundary", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ Scope: { count: Schema.Number }, Idle: {} }) + const Event = Schema.TaggedUnion({ Break: {} }) + const states = Machine.states({ + scope: { + schema: State.cases.Scope, + initial: "idle", + states: { idle: State.cases.Idle } + } + }) + const machine = Machine.make({ + id: "state-update-schema", + states: states.states, + events: Machine.events(Event), + initial: (to) => + to.scope.initial.resolve(({ target }) => target.from({ count: 0 }, (scope) => scope.idle.from())) + }).handle({ + scope: { + states: { + idle: { + on: { + Break: (to) => to.local.update(({ target }) => target.from({ count: "bad" } as any)) + } + } + } + } + }) + + const initial = yield* Machine.planInitial(machine) + const error = yield* Machine.plan(machine, initial.state, Event.cases.Break.make({})).pipe(Effect.flip) + assert.instanceOf(error, Machine.MachineSchemaDecodeError) + assert.strictEqual(error.boundary, "state") + assert.strictEqual(error.state, "scope") + })) + + it.effect("updates from an invocation outcome without restarting the source", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ Scope: { count: Schema.Number }, Idle: {} }) + const states = Machine.states({ + scope: { + schema: State.cases.Scope, + initial: "idle", + states: { idle: State.cases.Idle } + } + }) + let starts = 0 + const machine = Machine.make({ + states: states.states, + events: Machine.events(), + initial: (to) => + to.scope.initial.resolve(({ target }) => target.from({ count: 0 }, (scope) => scope.idle.from())) + }).handle({ + scope: { + states: { + idle: { + invoke: (from) => + from.effect("load", () => Effect.sync(() => ++starts)).onDone((to) => + to.local.update(({ output, target }) => target.from({ count: output })) + ) + } + } + } + }) + + const ref = yield* Machine.start(machine) + for (let index = 0; index < 5; index += 1) yield* Effect.yieldNow + + assert.strictEqual((yield* ref.state).value.count, 1) + assert.strictEqual(starts, 1) + yield* ref.stop + })) + + it.effect("retains commands, raised events, and emitted events", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ Scope: { count: Schema.Number }, Idle: {} }) + const Event = Schema.TaggedUnion({ Update: {}, Raised: {} }) + const Emission = Schema.TaggedUnion({ Changed: { count: Schema.Number } }) + const Events = Machine.events(Event) + const Emissions = Machine.emittedEvents(Emission) + const states = Machine.states({ + scope: { + schema: State.cases.Scope, + initial: "idle", + states: { idle: State.cases.Idle } + } + }) + const machine = Machine.make({ + states: states.states, + events: Events, + emittedEvents: Emissions, + initial: (to) => + to.scope.initial.resolve(({ target }) => target.from({ count: 0 }, (scope) => scope.idle.from())) + }).handle({ + scope: { + states: { + idle: { + on: { + Update: (to) => + to.local.update(({ self, target }, enqueue) => { + enqueue.raise(Events.Raised()) + enqueue.emit(Emissions.Changed({ count: 1 })) + enqueue.sendTo(self, Events.Raised()) + return target.from({ count: 1 }) + }), + Raised: (to) => to.none + } + } + } + } + }) + + const initial = yield* Machine.planInitial(machine) + const planned = yield* Machine.plan(machine, initial.state, Events.Update()) + + assert.strictEqual(planned.commands.length, 1) + assert.deepStrictEqual(planned.emittedEvents, [Emission.cases.Changed.make({ count: 1 })]) + assert.deepStrictEqual(planned.microsteps.map(({ event }) => event._tag), ["Update", "Raised"]) + assert.strictEqual(planned.next.value.count, 1) + })) +}) diff --git a/test/machine/Visualization.test.ts b/test/machine/Visualization.test.ts index 9c16b06..9ce21e8 100644 --- a/test/machine/Visualization.test.ts +++ b/test/machine/Visualization.test.ts @@ -108,7 +108,7 @@ const makeMachine = (unsafeStart = false) => to.local.running().resolve(({ target }) => target(new Running({}), (running) => running.editing(new Editing({}))) ), - Refresh: (to) => to.none + Refresh: (to) => to.local.update(({ target }) => target(new Workflow({}))) } }, running: { @@ -246,7 +246,7 @@ describe("Machine structural visualization", () => { branches: [{ type: "direct", target: undefined, - selection: { path: undefined, kind: "none", scope: "local" } + selection: { path: "application.workflow", kind: "update", scope: "local" } }] }, { @@ -376,8 +376,10 @@ describe("Machine structural visualization", () => { "├─ ● application [parallel]", "│ ├─ ● workflow [compound, initial: idle]", "│ │ ├─ ● idle", - "│ │ │ └─ ◇ on: Start", - "│ │ │ └┄ → running", + "│ │ │ ├─ ◇ on: Start", + "│ │ │ │ └┄ → running", + "│ │ │ └─ ◇ on: Refresh", + "│ │ │ └┄ update application.workflow", "│ │ ├─ ○ running [compound, initial: editing]", "│ │ │ ├─ ○ editing", "│ │ │ └─ ○ complete [final]", @@ -405,6 +407,7 @@ describe("Machine structural visualization", () => { assert.include(rendered, "state \"○ recent [history: shallow]\" as state_6") assert.include(rendered, "[*] --> state_0") assert.include(rendered, "state_2 --> state_3: Start") + assert.include(rendered, "state_2: Refresh / update application.workflow") assert.include(rendered, "state_8 --> state_9: Disconnect") assert.notMatch(rendered, /state_\d+ --> state_\d+: Refresh/) assert.notInclude(rendered, "Candidate events") diff --git a/test/machine/visualization/mermaid.ts b/test/machine/visualization/mermaid.ts index e924774..8249848 100644 --- a/test/machine/visualization/mermaid.ts +++ b/test/machine/visualization/mermaid.ts @@ -166,6 +166,12 @@ export const makeMermaidRenderer = ( const source = ids.get(definition.source) if (source === undefined) continue for (const branch of definition.branches) { + if (branch.selection.kind === "update" && branch.selection.path !== undefined) { + lines.push( + ` ${source}: ${branchLabel(definition, branch)} / update ${escapeText(branch.selection.path)}` + ) + continue + } const target = branch.target === undefined ? undefined : ids.get(branch.target) if (target !== undefined) lines.push(` ${source} --> ${target}: ${branchLabel(definition, branch)}`) } diff --git a/test/machine/visualization/model.ts b/test/machine/visualization/model.ts index dbd65ea..8fbdf0b 100644 --- a/test/machine/visualization/model.ts +++ b/test/machine/visualization/model.ts @@ -46,12 +46,22 @@ export interface TransitionDefinition { | { readonly type: "direct" readonly target: string | undefined + readonly selection: { + readonly kind: "state" | "initial" | "history" | "choice" | "update" | "none" + readonly scope: "local" | "branch" | "full" | "initial" | undefined + readonly path: string | undefined + } } | { readonly type: "branch" readonly key: string readonly title: string readonly target: string | undefined + readonly selection: { + readonly kind: "state" | "initial" | "history" | "choice" | "update" | "none" + readonly scope: "local" | "branch" | "full" | "initial" | undefined + readonly path: string | undefined + } } > } diff --git a/test/machine/visualization/text.ts b/test/machine/visualization/text.ts index f6426be..64c7f94 100644 --- a/test/machine/visualization/text.ts +++ b/test/machine/visualization/text.ts @@ -21,6 +21,13 @@ interface TransitionLabel { const triggerLabels = (definitions: ReadonlyArray): ReadonlyArray => definitions.flatMap((definition) => { const branches = definition.branches.flatMap((branch) => { + if (branch.selection.kind === "update" && branch.selection.path !== undefined) { + return [ + branch.type === "direct" + ? `update ${branch.selection.path}` + : `[${branch.title}] update ${branch.selection.path}` + ] + } if (branch.target === undefined) return [] const target = branch.target.slice(branch.target.lastIndexOf(".") + 1) diff --git a/typetest/machine/StateUpdate.tst.ts b/typetest/machine/StateUpdate.tst.ts new file mode 100644 index 0000000..5ba85f5 --- /dev/null +++ b/typetest/machine/StateUpdate.tst.ts @@ -0,0 +1,193 @@ +import { Schema } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" + +class Root extends Schema.TaggedClass("Root")("Root", { revision: Schema.Number }) {} +class Work extends Schema.TaggedClass("Work")("Work", { revision: Schema.Number }) {} +class Auth extends Schema.TaggedClass("Auth")("Auth", { user: Schema.String }) {} +class Sync extends Schema.TaggedClass("Sync")("Sync", { cursor: Schema.Number }) {} +class SignedOut extends Schema.TaggedClass("SignedOut")("SignedOut", {}) {} +class SignedIn extends Schema.TaggedClass("SignedIn")("SignedIn", {}) {} +class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} +class Tick extends Schema.TaggedClass("Tick")("Tick", {}) {} + +const States = Machine.states({ + root: { + schema: Root, + initial: "work", + states: { + work: { + schema: Work, + type: "parallel", + states: { + auth: { + schema: Auth, + initial: "signedOut", + states: { signedOut: SignedOut, signedIn: { schema: SignedIn, type: "final" } } + }, + sync: { + schema: Sync, + initial: "idle", + states: { idle: Idle } + } + } + }, + routing: { type: "choice" } + } + }, + structural: { + initial: "idle", + states: { idle: Idle } + } +}) + +describe("Machine state-value updates", () => { + it("exposes updates only for the valued active ancestor chain", () => { + const machine = Machine.make({ + states: States.states, + events: Machine.events(Tick), + initial: (to) => + to.root.initial.resolve(({ target }) => + target(new Root({ revision: 0 }), (root) => + root.work( + new Work({ revision: 0 }), + (work) => + work.auth(new Auth({ user: "" }), (auth) => auth.signedOut(new SignedOut({}))) + .sync(new Sync({ cursor: 0 }), (sync) => sync.idle(new Idle({}))) + )) + ) + }) + + machine.handle({ + root: { + states: { + work: { + states: { + auth: { + states: { + signedOut: { + on: { + Tick: (to) => { + expect(to.local).type.toHaveProperty("update") + expect(to.local.update).type.not.toHaveProperty("resolve") + expect(to.branch.root).type.toHaveProperty("update") + expect(to.branch.root.work).type.toHaveProperty("update") + expect(to.branch.root.work.auth).type.toHaveProperty("update") + expect(to.branch.root.work.auth.signedOut).type.not.toHaveProperty("update") + expect(to.branch.root.work.sync).type.not.toHaveProperty("update") + expect(to.full).type.not.toHaveProperty("update") + expect(to.history).type.not.toHaveProperty("update") + expect(to.none).type.not.toHaveProperty("update") + + return to.local.update(({ state, target }) => { + expect(state).type.toBe() + expect(target).type.toBeCallableWith(new Auth({ user: "next" })) + expect(target.from).type.toBeCallableWith({ user: "next" }) + return target(new Auth({ user: "next" })) + }, { reenter: true }) + } + } + } + } + } + } + } + } + } + }) + }) + + it("supports named update branches and rejects missing update evidence", () => { + const update = null as unknown as Machine.Machine.TransitionSelector< + typeof States.states, + readonly [typeof Tick], + readonly [], + "root.work.auth.signedOut", + Machine.Machine.HandlerContext< + typeof States.states, + readonly [typeof Tick], + readonly [], + "root.work.auth.signedOut", + "Tick", + never, + never + >, + true, + "required" | "declinable" + > + + update.branches({ + changed: { target: update.branch.root.update, title: "Root changed" }, + unchanged: { target: update.none } + }).resolve(({ select }) => select.changed.from({ revision: 1 })) + + update.local.update(({ target }) => target.from({ user: "next" }), { declinable: true }) + update.local.update(({ decline }) => decline(), { declinable: true }) + + update.local.update( + // @ts-expect-error! + () => new Auth({ user: "next" }) + ) + update.local.update( + // @ts-expect-error! + () => undefined + ) + }) + + it("omits updates from structural scopes and choice resolvers", () => { + type StructuralSelector = Machine.Machine.TransitionSelector< + typeof States.states, + readonly [typeof Tick], + readonly [], + "structural.idle", + Machine.Machine.HandlerContext< + typeof States.states, + readonly [typeof Tick], + readonly [], + "structural.idle", + "Tick", + never, + never + >, + true, + "required" + > + const structural = null as unknown as StructuralSelector + expect(structural.local).type.not.toHaveProperty("update") + expect(structural.branch.structural).type.not.toHaveProperty("update") + + type ChoiceSelector = Machine.Machine.TransitionSelector< + typeof States.states, + readonly [typeof Tick], + readonly [], + "root.routing", + Machine.Machine.ChoiceContext, + false, + "required" + > + const choice = null as unknown as ChoiceSelector + expect(choice.local).type.not.toHaveProperty("update") + expect(choice.branch.root).type.not.toHaveProperty("update") + + type FinalSelector = Machine.Machine.TransitionSelector< + typeof States.states, + readonly [typeof Tick], + readonly [], + "root.work.auth.signedIn", + Machine.Machine.HandlerContext< + typeof States.states, + readonly [typeof Tick], + readonly [], + "root.work.auth.signedIn", + "Tick", + never, + never + >, + true, + "required" + > + const final = null as unknown as FinalSelector + expect(final.branch.root.work.auth.signedIn).type.not.toHaveProperty("update") + expect(final.branch.root.work.auth).type.toHaveProperty("update") + }) +})