From 8eefdc78b22645be93e2cfdf58c98375280cd7f5 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Fri, 14 Aug 2026 15:31:28 +0200 Subject: [PATCH 1/2] Add schema-less active states --- .changeset/quiet-states.md | 16 + README.md | 21 + docs/agent-guide.md | 84 ++- src/Machine.ts | 667 ++++++++++-------- src/internal/machine/atom.ts | 13 +- src/internal/machine/configuration.ts | 125 +++- src/internal/machine/executionPlan.ts | 6 + src/internal/machine/invocation.ts | 2 +- src/internal/machine/machine.ts | 142 ++-- src/internal/machine/planner.ts | 87 ++- src/internal/machine/serialization.ts | 87 ++- src/internal/machine/stateDefinition.ts | 33 +- src/internal/machine/topology.ts | 39 +- src/unstable/reactivity/AtomMachine.ts | 11 +- .../machine/strategyDifferential.test.ts | 18 + test/machine/StateDefinition.test.ts | 64 ++ test/machine/StructuralStates.test.ts | 334 +++++++++ typetest/machine/Inspection.tst.ts | 8 +- typetest/machine/StructuralStates.tst.ts | 207 ++++++ .../unstable/reactivity/AtomMachine.tst.ts | 40 ++ 20 files changed, 1492 insertions(+), 512 deletions(-) create mode 100644 .changeset/quiet-states.md create mode 100644 test/machine/StructuralStates.test.ts create mode 100644 typetest/machine/StructuralStates.tst.ts diff --git a/.changeset/quiet-states.md b/.changeset/quiet-states.md new file mode 100644 index 0000000..12c965d --- /dev/null +++ b/.changeset/quiet-states.md @@ -0,0 +1,16 @@ +--- +"@typeonce/effect-machine": minor +--- + +Allow active states to omit `schema` when they own no data. Schema-less atomic, compound, parallel, and final states keep full control-flow semantics while exposing value-free `.from(...)` builders, `undefined` handler state, and snapshot-only query APIs. + +```ts +const States = Machine.defineStates({ + Form: { + initial: "Editing", + states: { Editing: {}, Saving } + } +}) + +States.initial.Form.from((form) => form.Editing.from()) +``` diff --git a/README.md b/README.md index 1fe22c7..2440fce 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,27 @@ defaults, refinements, and tagged-class identity are therefore preserved, and decode failures remain typed machine failures. Pass a value directly only when it is already decoded, such as a value returned by `Machine.retag`. +Omit `schema` when a state represents control flow but owns no data: + +```ts +const States = Machine.defineStates({ + Form: { + initial: "Editing", + states: { + Editing: {}, + Saving + } + } +}) + +States.initial.Form.from((form) => form.Editing.from()) +``` + +Schema-less states remain active, targetable, matchable, and visible through +`getSnapshot`, but have no value to read. Their builders expose only `.from`, +their handler `state` is `undefined`, and `get` / `getWithParents` accept only +schema-backed paths. Add a schema later if the state starts owning data. + Put data on the narrowest state where it is valid. If sibling phases share data, put it on their compound parent. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 85c1279..10b4d09 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -21,7 +21,7 @@ Use this order so inference has all schemas available when handlers are declared: 1. Domain schemas used by state and event fields. -2. Tagged state schemas. +2. Tagged schemas for states that own data. 3. Tagged public-event, internal-event, and emitted-event schemas. 4. `Machine.defineStates`. 5. `Machine.make`, including input, events, internal events, emits, and the @@ -108,24 +108,63 @@ its extra control is required: ## Atomic, compound, parallel, and history states +An active state does not need a schema unless it owns data. Omit `schema` for +control-only atomic, compound, parallel, and final states: + +```ts +const States = Machine.defineStates({ + Idle: {}, + Form: { + initial: "Editing", + states: { + Editing: {}, + Saving: State.cases.Saving + } + } +}) + +States.initial.Idle.from() +States.initial.Form.from((form) => form.Editing.from()) +``` + +Schema-less states have the same control semantics as schema-backed states: +they are active, targetable, matchable, receive lifecycle handlers, and appear +in snapshots. They do not have a state value: + +```ts +Idle: { + on: { + Start: ({ state, target }) => { + // state: undefined + return target.full.Form.from((form) => form.Editing.from()) + } + } +} + +States.matches(snapshot, "Form") // allowed +States.getSnapshot(snapshot, "Form") // allowed +States.get(snapshot, "Form") // type error: no value schema +``` + +For a schema-less path, builders expose only `.from(...)`; the direct callable +form is reserved for already-decoded schema values. Structural ancestors are +also omitted from `parents`; an immediate structural parent is typed as +`undefined`. Add `schema` when a state begins to own data or needs runtime +validation and persistence for that data. + Use an atomic state when no child phase can be active beneath it. Use a compound state when exactly one child phase is active. It must declare an `initial` child: ```ts -const FormState = Schema.TaggedUnion({ - Form: { draft: Schema.String }, - Editing: {}, - Saving: {} -}) +const FormState = Schema.TaggedUnion({ Saving: { draft: Schema.String } }) const FormStates = Machine.defineStates({ Form: { - schema: FormState.cases.Form, initial: "Editing", states: { - Editing: FormState.cases.Editing, + Editing: {}, Saving: FormState.cases.Saving } } @@ -135,35 +174,22 @@ const FormStates = Machine.defineStates({ Use a parallel state when every direct region is active: ```ts -const ParallelState = Schema.TaggedUnion({ - Screen: {}, - Network: {}, - Online: {}, - Offline: {}, - Panel: {}, - Closed: {}, - Open: {} -}) - const ParallelStates = Machine.defineStates({ Screen: { - schema: ParallelState.cases.Screen, type: "parallel", states: { network: { - schema: ParallelState.cases.Network, initial: "Online", states: { - Online: ParallelState.cases.Online, - Offline: ParallelState.cases.Offline + Online: {}, + Offline: {} } }, panel: { - schema: ParallelState.cases.Panel, initial: "Closed", states: { - Closed: ParallelState.cases.Closed, - Open: ParallelState.cases.Open + Closed: {}, + Open: {} } } } @@ -366,9 +392,11 @@ const ready = Option.getOrThrow(States.getSnapshot(snapshot, "Route.Ready")) States.matches(ready, "Route.Ready.Saving") ``` -All paths are checked against the definition. `context.parent` is the immediate -typed parent (`undefined` at a root). Use `parents` when another ancestor is -needed: +All paths are checked against the definition. `get` and `getWithParents` accept +only schema-backed paths; use `matches` or `getSnapshot` for any active path. +`context.parent` is the immediate typed parent value (`undefined` at a root or +when that parent is schema-less). `parents` contains only valued ancestors. Use +its full paths when another ancestor value is needed: ```ts parents["Route.Ready"] diff --git a/src/Machine.ts b/src/Machine.ts index 447f936..4431495 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -558,7 +558,7 @@ type ValidateStateNode & (AllowHistory extends true ? ValidateChoiceStateNode : StateDefinitionError<"Choice states must be declared below an active parent state", Path>) - : Node extends { readonly schema: Machine.TaggedSchema } ? ValidateStateNodeConfig + : Node extends Machine.StateNodeConfig ? ValidateStateNodeConfig : StateDefinitionError<"State nodes must be tagged schemas or state node configs", Path> type ValidateHistoryStateNode = [ @@ -572,27 +572,31 @@ type ValidateChoiceStateNode = [ : StateDefinitionError<"Choice states cannot declare schemas, children, initial states, output, or history"> type ValidateStateNodeConfig< - Node extends { readonly schema: Machine.TaggedSchema }, + Node extends Machine.StateNodeConfig, Path extends PropertyKey > = Node extends { readonly type: "parallel" } ? & ValidateExactStateNodeProperties & ValidateStateNodeWithChildren + & ValidatePseudoStateAnnotations : Node extends { readonly type: "final" } ? & ValidateExactStateNodeProperties & ValidateStateNodeWithoutChildren + & ValidatePseudoStateAnnotations : Node extends { readonly states: infer Children } ? & ValidateExactStateNodeProperties & ValidateStateNodeWithChildren + & ValidatePseudoStateAnnotations : & ValidateExactStateNodeProperties & ValidateStateNodeWithoutChildren + & ValidatePseudoStateAnnotations type ValidateOutputSchema = "output" extends keyof Node ? Node extends { readonly output: Schema.Top } ? unknown : StateDefinitionError<"State output must be a schema"> : unknown type ValidateStateNodeWithChildren< - Node extends { readonly schema: Machine.TaggedSchema }, + Node extends Machine.StateNodeConfig, Children, Path extends PropertyKey > = Children extends Machine.StateSchemas ? @@ -605,7 +609,7 @@ type ValidateStateNodeWithChildren< : StateDefinitionError<"Child states must be a state tree"> type ValidateCompoundStateNode< - Node extends { readonly schema: Machine.TaggedSchema }, + Node extends Machine.StateNodeConfig, Children extends Machine.StateSchemas, Path extends PropertyKey > = Node extends { readonly initial: infer Initial } ? @@ -615,8 +619,8 @@ type ValidateCompoundStateNode< : StateDefinitionError<"Compound initial must be one of its direct child keys"> : StateDefinitionError<"Compound states must declare an initial child"> -type ValidateStateNodeWithoutChildren = "initial" extends - keyof Node ? StateDefinitionError<"Atomic states cannot declare an initial child"> +type ValidateStateNodeWithoutChildren = "initial" extends keyof Node ? + StateDefinitionError<"Atomic states cannot declare an initial child"> : Node extends { readonly type: infer Type } ? Type extends "final" ? ValidateOutputSchema : Type extends "active" | undefined ? "output" extends keyof Node ? StateDefinitionError<"Only final and parallel states can declare output"> @@ -722,6 +726,56 @@ type ConstructionResult = Result | Machine.StateConstruction type UnwrapConstruction = Result extends Machine.StateConstruction ? Value : Result +type NodeValue = [Machine.NodeSchema] extends [never] ? undefined + : Machine.NodeSchema["Type"] + +type NodeMakeInput = [Machine.NodeSchema] extends [never] ? never + : Machine.NodeSchema["~type.make.in"] + +type WithNodeValue> = Machine.NodeSchema extends never ? Rest + : readonly [value: NodeValue, ...Rest] + +type WithNodeInput> = Machine.NodeSchema extends never ? Rest + : readonly [input: NodeMakeInput, ...Rest] + +type NodeBuilderMethod< + Node, + Arguments extends ReadonlyArray, + Result, + FromArguments extends ReadonlyArray, + FromResult +> = Machine.NodeSchema extends never ? { + readonly from: FromCallable + } + : + & ((...args: Arguments) => Result) + & { readonly from: FromCallable } + +type NodeMethod< + Node, + Arguments extends ReadonlyArray, + Result, + FromArguments extends ReadonlyArray +> = Machine.NodeSchema extends never ? FromMethod + : ((...args: Arguments) => Result) & FromMethod + +type NodeConstructionSelectorFromCallable = Machine.NodeSchema extends never ? { + >( + state: (builder: Builder) => Selected + ): Machine.StateConstruction> + } + : ConstructionSelectorFromCallable, Builder, Result> + +type NestedTargetMethod = Machine.NodeSchema extends never ? { + readonly from: NodeConstructionSelectorFromCallable + } + : + & (>( + value: NodeValue, + state: (builder: Builder) => Selected + ) => Selected) + & { readonly from: NodeConstructionSelectorFromCallable } + type ConstructionSelectorFromCallable = {} extends Input ? { >( state: (builder: Builder) => Selected @@ -751,14 +805,18 @@ type InitialSnapshotMethod< States extends Machine.StateSchemas, StateId extends ActiveStateKey, Prefix extends string -> = - & (( - ...args: InitialSnapshotArguments - ) => InitialSnapshotResult) - & FromMethod< +> = Machine.NodeSchema extends never ? FromMethod< InitialSnapshotFromArguments, InitialSnapshotResult > + : + & (( + ...args: InitialSnapshotArguments + ) => InitialSnapshotResult) + & FromMethod< + InitialSnapshotFromArguments, + InitialSnapshotResult + > type InitialSnapshotArguments< States extends Machine.StateSchemas, @@ -766,21 +824,21 @@ type InitialSnapshotArguments< Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? - Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? [ - value: Machine.NodeSchema["Type"], + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + WithNodeValue ) => SnapshotBuilderComplete> - ] + ]> : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - Node extends { readonly initial: infer Initial extends ActiveStateKey | ChoiceStateKey } ? [ - value: Machine.NodeSchema["Type"], + Node extends { readonly initial: infer Initial extends ActiveStateKey | ChoiceStateKey } ? + WithNodeValue, Initial> ) => InitialSelectableResult - ] + ]> : never - : [value: Machine.NodeSchema["Type"]] + : WithNodeValue : never type InitialSnapshotFromArguments< @@ -789,21 +847,21 @@ type InitialSnapshotFromArguments< Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? - Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? [ - input: Machine.NodeSchema["~type.make.in"], + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + WithNodeInput ) => SnapshotBuilderComplete, boolean> - ] + ]> : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - Node extends { readonly initial: infer Initial extends ActiveStateKey | ChoiceStateKey } ? [ - input: Machine.NodeSchema["~type.make.in"], + Node extends { readonly initial: infer Initial extends ActiveStateKey | ChoiceStateKey } ? + WithNodeInput, Initial> ) => ConstructionResult> - ] + ]> : never - : [input: Machine.NodeSchema["~type.make.in"]] + : WithNodeInput : never type InitialSnapshotResult< @@ -815,18 +873,18 @@ type InitialSnapshotResult< Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? Machine.ParallelSnapshot< Path, - Machine.NodeSchema["Type"], + NodeValue, InitialSnapshotRegionsWithPrefix > : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? Node extends { readonly initial: infer Initial extends ActiveStateKey | ChoiceStateKey } ? Machine.CompoundSnapshot< Path, - Machine.NodeSchema["Type"], + NodeValue, InitialSelectableResult > : never - : Machine.AtomicSnapshot["Type"]> + : Machine.AtomicSnapshot> : never type InitialSelectableResult< @@ -853,28 +911,25 @@ type InitialParallelBuilder< > = & SnapshotBuilderComplete & { - readonly [Key in Remaining]: - & (( - ...args: InitialSnapshotArguments - ) => InitialParallelBuilder< + readonly [Key in Remaining]: NodeBuilderMethod< + States[Key], + InitialSnapshotArguments, + InitialParallelBuilder< States, Prefix, Exclude, Regions & { readonly [Region in Key]: InitialSnapshotResult }, Constructed - >) - & { - readonly from: FromCallable< - InitialSnapshotFromArguments, - InitialParallelBuilder< - States, - Prefix, - Exclude, - Regions & { readonly [Region in Key]: InitialSnapshotResult }, - true - > - > - } + >, + InitialSnapshotFromArguments, + InitialParallelBuilder< + States, + Prefix, + Exclude, + Regions & { readonly [Region in Key]: InitialSnapshotResult }, + true + > + > } type FullSnapshotBuilderWithPrefix< @@ -892,14 +947,18 @@ type FullSnapshotMethod< States extends Machine.StateSchemas, StateId extends ActiveStateKey, Prefix extends string -> = - & (( - ...args: FullSnapshotArguments - ) => FullSnapshotResult) - & FromMethod< +> = Machine.NodeSchema extends never ? FromMethod< FullSnapshotFromArguments, FullSnapshotResult > + : + & (( + ...args: FullSnapshotArguments + ) => FullSnapshotResult) + & FromMethod< + FullSnapshotFromArguments, + FullSnapshotResult + > type FullSnapshotArguments< States extends Machine.StateSchemas, @@ -907,21 +966,20 @@ type FullSnapshotArguments< Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? - Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? [ - value: Machine.NodeSchema["Type"], + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + WithNodeValue ) => SnapshotBuilderComplete> - ] - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? [ - value: Machine.NodeSchema["Type"], + ]> + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? WithNodeValue ) => | Machine.SnapshotWithPrefix | Machine.ChoiceTargetInstruction> - ] - : [value: Machine.NodeSchema["Type"]] + ]> + : WithNodeValue : never type FullSnapshotFromArguments< @@ -930,22 +988,21 @@ type FullSnapshotFromArguments< Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? - Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? [ - input: Machine.NodeSchema["~type.make.in"], + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + WithNodeInput ) => SnapshotBuilderComplete, boolean> - ] - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? [ - input: Machine.NodeSchema["~type.make.in"], + ]> + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? WithNodeInput ) => ConstructionResult< | Machine.SnapshotWithPrefix | Machine.ChoiceTargetInstruction> > - ] - : [input: Machine.NodeSchema["~type.make.in"]] + ]> + : WithNodeInput : never type FullSnapshotResult< @@ -964,28 +1021,25 @@ type FullParallelBuilder< > = & SnapshotBuilderComplete & { - readonly [Key in Remaining]: - & (( - ...args: FullSnapshotArguments - ) => FullParallelBuilder< + readonly [Key in Remaining]: NodeBuilderMethod< + States[Key], + FullSnapshotArguments, + FullParallelBuilder< States, Prefix, Exclude, Regions & { readonly [Region in Key]: FullSnapshotResult }, Constructed - >) - & { - readonly from: FromCallable< - FullSnapshotFromArguments, - FullParallelBuilder< - States, - Prefix, - Exclude, - Regions & { readonly [Region in Key]: FullSnapshotResult }, - true - > - > - } + >, + FullSnapshotFromArguments, + FullParallelBuilder< + States, + Prefix, + Exclude, + Regions & { readonly [Region in Key]: FullSnapshotResult }, + true + > + > } type HistorySnapshotArguments< @@ -996,18 +1050,17 @@ type HistorySnapshotArguments< Path extends string = Machine.JoinPath > = Path extends Owner ? FullSnapshotArguments : States[StateId] extends infer Node ? - Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? [ - value: Machine.NodeSchema["Type"], + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + WithNodeValue ) => SnapshotBuilderComplete> - ] - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? [ - value: Machine.NodeSchema["Type"], + ]> + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? WithNodeValue ) => ConstructionResult> - ] + ]> : never : never @@ -1019,18 +1072,17 @@ type HistorySnapshotFromArguments< Path extends string = Machine.JoinPath > = Path extends Owner ? FullSnapshotFromArguments : States[StateId] extends infer Node ? - Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? [ - input: Machine.NodeSchema["~type.make.in"], + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + WithNodeInput ) => SnapshotBuilderComplete, boolean> - ] - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? [ - input: Machine.NodeSchema["~type.make.in"], + ]> + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? WithNodeInput ) => ConstructionResult> - ] + ]> : never : never @@ -1045,12 +1097,12 @@ type HistorySnapshotResult< Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? Machine.ParallelSnapshot< Path, - Machine.NodeSchema["Type"], + NodeValue, HistorySnapshotRegions > : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? Machine.CompoundSnapshot< Path, - Machine.NodeSchema["Type"], + NodeValue, HistorySnapshotWithPrefix > : never @@ -1061,14 +1113,18 @@ type HistorySnapshotMethod< StateId extends ActiveStateKey, Prefix extends string, Owner extends string -> = - & (( - ...args: HistorySnapshotArguments - ) => HistorySnapshotResult) - & FromMethod< +> = Machine.NodeSchema extends never ? FromMethod< HistorySnapshotFromArguments, HistorySnapshotResult > + : + & (( + ...args: HistorySnapshotArguments + ) => HistorySnapshotResult) + & FromMethod< + HistorySnapshotFromArguments, + HistorySnapshotResult + > type HistorySnapshotWithPrefix< States extends Machine.StateSchemas, @@ -1117,50 +1173,48 @@ type HistoryParallelBuilder< & { readonly [Key in Remaining]: Owner extends | Machine.JoinPath - | `${Machine.JoinPath}.${string}` ? - & ((...args: HistorySnapshotArguments) => HistoryParallelBuilder< + | `${Machine.JoinPath}.${string}` ? NodeBuilderMethod< + States[Key], + HistorySnapshotArguments, + HistoryParallelBuilder< States, Prefix, Owner, Exclude, Regions & { readonly [Region in Key]: HistorySnapshotResult }, Constructed - >) - & { - readonly from: FromCallable< - HistorySnapshotFromArguments, - HistoryParallelBuilder< - States, - Prefix, - Owner, - Exclude, - Regions & { readonly [Region in Key]: HistorySnapshotResult }, - true - > - > - } - : - & ((...args: FullSnapshotArguments) => HistoryParallelBuilder< + >, + HistorySnapshotFromArguments, + HistoryParallelBuilder< + States, + Prefix, + Owner, + Exclude, + Regions & { readonly [Region in Key]: HistorySnapshotResult }, + true + > + > + : NodeBuilderMethod< + States[Key], + FullSnapshotArguments, + HistoryParallelBuilder< States, Prefix, Owner, Exclude, Regions & { readonly [Region in Key]: FullSnapshotResult }, Constructed - >) - & { - readonly from: FromCallable< - FullSnapshotFromArguments, - HistoryParallelBuilder< - States, - Prefix, - Owner, - Exclude, - Regions & { readonly [Region in Key]: FullSnapshotResult }, - true - > - > - } + >, + FullSnapshotFromArguments, + HistoryParallelBuilder< + States, + Prefix, + Owner, + Exclude, + Regions & { readonly [Region in Key]: FullSnapshotResult }, + true + > + > } type ParentPath = Path extends `${infer Parent}.${infer Child}` @@ -1245,59 +1299,36 @@ type LocalTargetMethod< Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? - Source extends Path | `${Path}.${string}` ? - & (>>( - value: Machine.NodeSchema["Type"], - state: ( - builder: LocalTargetBuilderWithPrefix - ) => Result - ) => Result) - & { - readonly from: ConstructionSelectorFromCallable< - Machine.NodeSchema["~type.make.in"], - LocalTargetBuilderWithPrefix, - LocalTargetResultWithPrefix - > - } - : - & (( - value: Machine.NodeSchema["Type"], + Source extends Path | `${Path}.${string}` ? NestedTargetMethod< + Node, + LocalTargetBuilderWithPrefix, + LocalTargetResultWithPrefix + > + : NodeMethod< + Node, + WithNodeValue ) => SnapshotBuilderComplete> - ) => Machine.Target>) - & FromMethod< - [ - input: Machine.NodeSchema["~type.make.in"], - states: ( - builder: FullParallelBuilder - ) => SnapshotBuilderComplete, boolean> - ], - Machine.Target> - > - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - & (>>( - value: Machine.NodeSchema["Type"], - state: ( - builder: LocalTargetBuilderWithPrefix - ) => Result - ) => Result) - & { - readonly from: ConstructionSelectorFromCallable< - Machine.NodeSchema["~type.make.in"], - LocalTargetBuilderWithPrefix, - LocalTargetResultWithPrefix - > - } - : - & ((value: Machine.NodeSchema["Type"]) => Machine.Target< - AllStates, - StateIdentifierFromPath - >) - & FromMethod< - [input: Machine.NodeSchema["~type.make.in"]], - Machine.Target> + ]>, + Machine.Target>, + WithNodeInput + ) => SnapshotBuilderComplete, boolean> + ]> + > + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? NestedTargetMethod< + Node, + LocalTargetBuilderWithPrefix, + LocalTargetResultWithPrefix > + : NodeMethod< + Node, + WithNodeValue, + Machine.Target>, + WithNodeInput + > : never type LocalTargetBuilderForScope< @@ -1306,28 +1337,29 @@ type LocalTargetBuilderForScope< Source extends Machine.StateNodeIdentifier > = ChildrenOf extends infer Children extends Machine.StateSchemas ? & LocalTargetBuilderWithPrefix - & { - /** - * Updates the value of the state containing the local group and moves to - * one of the states inside it. Values in other active branches are kept. - * - * @since 0.4.0 - */ - readonly with: - & (>>( - value: Machine.StateByIdentifier, - state: ( - builder: LocalTargetBuilderWithPrefix - ) => Result - ) => Result) - & { - readonly from: ConstructionSelectorFromCallable< - Machine.SchemaByIdentifier["~type.make.in"], - LocalTargetBuilderWithPrefix, - LocalTargetResultWithPrefix - > - } - } + & (Scope extends Machine.ValuedStateIdentifier ? { + /** + * Updates the value of the state containing the local group and moves to + * one of the states inside it. Values in other active branches are kept. + * + * @since 0.4.0 + */ + readonly with: + & (>>( + value: Machine.StateByIdentifier, + state: ( + builder: LocalTargetBuilderWithPrefix + ) => Result + ) => Result) + & { + readonly from: ConstructionSelectorFromCallable< + Machine.SchemaByIdentifier["~type.make.in"], + LocalTargetBuilderWithPrefix, + LocalTargetResultWithPrefix + > + } + } : + {}) : {} type BranchTargetResult< @@ -1381,60 +1413,39 @@ type BranchTargetMethod< > = States[StateId] extends infer Node ? Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? Source extends Path | `${Path}.${string}` ? - & (>>( - value: Machine.NodeSchema["Type"], - state: ( - builder: BranchTargetBuilderWithPrefix - ) => Result - ) => Result) - & { - readonly from: ConstructionSelectorFromCallable< - Machine.NodeSchema["~type.make.in"], - BranchTargetBuilderWithPrefix, - BranchTargetResultWithPrefix - > - } + & NestedTargetMethod< + Node, + BranchTargetBuilderWithPrefix, + BranchTargetResultWithPrefix + > & BranchTargetBuilderWithPrefix - : - & (( - value: Machine.NodeSchema["Type"], + : NodeMethod< + Node, + WithNodeValue ) => SnapshotBuilderComplete> - ) => Machine.Target>) - & FromMethod< - [ - input: Machine.NodeSchema["~type.make.in"], - states: ( - builder: FullParallelBuilder - ) => SnapshotBuilderComplete, boolean> - ], - Machine.Target> - > + ]>, + Machine.Target>, + WithNodeInput + ) => SnapshotBuilderComplete, boolean> + ]> + > : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - & (>>( - value: Machine.NodeSchema["Type"], - state: ( - builder: BranchTargetBuilderWithPrefix - ) => Result - ) => Result) - & { - readonly from: ConstructionSelectorFromCallable< - Machine.NodeSchema["~type.make.in"], - BranchTargetBuilderWithPrefix, - BranchTargetResultWithPrefix - > - } + & NestedTargetMethod< + Node, + BranchTargetBuilderWithPrefix, + BranchTargetResultWithPrefix + > & BranchTargetBuilderWithPrefix - : - & ((value: Machine.NodeSchema["Type"]) => Machine.Target< - AllStates, - StateIdentifierFromPath - >) - & FromMethod< - [input: Machine.NodeSchema["~type.make.in"]], - Machine.Target> - > + : NodeMethod< + Node, + WithNodeValue, + Machine.Target>, + WithNodeInput + > : never type BranchTargetBuilderForRoot< @@ -1471,15 +1482,21 @@ type HasDirectShallowHistory = { readonly [Key in HistoryStateKey]: States[Key] extends { readonly history: "deep" } ? never : Key }[HistoryStateKey] extends never ? false : true +type HasValuedActiveChild = { + readonly [Key in ActiveStateKey]: Machine.NodeSchema extends never ? never : Key +}[ActiveStateKey] extends never ? false : true + type InitializerClosureForNode< AllStates extends Machine.StateSchemas, Node, Path extends string > = Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? - | Extract> + | (HasValuedActiveChild extends true ? Extract> : never) | InitializerClosuresForChildren : Node extends { readonly states: infer Children extends Machine.StateSchemas; readonly initial: infer Initial } ? - | Extract> + | (Initial extends ActiveStateKey ? Machine.NodeSchema extends never ? never + : Extract> + : never) | (Initial extends ActiveStateKey ? InitializerClosureForNode< AllStates, Children[Initial], @@ -2175,8 +2192,9 @@ export declare namespace Machine { * Descriptive annotations exposed for compiled state nodes. * * Schema-backed states resolve their complete Effect Schema annotation map. - * Pseudo-states accept only the descriptive fields below. Annotations never - * affect state identity, targeting, or runtime behavior. + * Schema-less active states and pseudo-states accept only the descriptive + * fields below. Annotations never affect state identity, targeting, or + * runtime behavior. * * @category models * @since 0.4.0 @@ -2187,14 +2205,18 @@ export declare namespace Machine { readonly documentation?: string | undefined } - /** Descriptive annotations accepted by schema-less pseudo-states. */ - export type PseudoStateAnnotations = Pick< + /** Descriptive annotations accepted by schema-less active and pseudo-states. */ + export type SchemaLessStateAnnotations = Pick< StateNodeAnnotations, "title" | "description" | "documentation" > + /** @deprecated Use {@link SchemaLessStateAnnotations}. */ + export type PseudoStateAnnotations = SchemaLessStateAnnotations + /** - * Configuration accepted for an atomic object state node. + * Configuration accepted for an atomic object state node. Omit `schema` when + * the state owns no value; a schema-less final may still declare `output`. * * @category models * @since 0.4.0 @@ -2204,38 +2226,72 @@ export declare namespace Machine { readonly schema: TaggedSchema readonly type?: "active" readonly output?: never + readonly annotations?: never } | { readonly schema: TaggedSchema readonly type: "final" readonly output?: Schema.Top + readonly annotations?: never + } + | { + readonly schema?: never + readonly type?: "active" + readonly output?: never + readonly annotations?: SchemaLessStateAnnotations + } + | { + readonly schema?: never + readonly type: "final" + readonly output?: Schema.Top + readonly annotations?: SchemaLessStateAnnotations } /** - * Configuration accepted for a compound object state node. + * Configuration accepted for a compound object state node. Omit `schema` + * when the compound state exists only to own control topology. * * @category models * @since 0.4.0 */ - export interface CompoundStateNodeConfig { - readonly schema: TaggedSchema - readonly type?: "active" - readonly initial: string - readonly states: StateTree - } + export type CompoundStateNodeConfig = + | { + readonly schema: TaggedSchema + readonly type?: "active" + readonly initial: string + readonly states: StateTree + readonly annotations?: never + } + | { + readonly schema?: never + readonly type?: "active" + readonly initial: string + readonly states: StateTree + readonly annotations?: SchemaLessStateAnnotations + } /** - * Configuration accepted for a parallel object state node. + * Configuration accepted for a parallel object state node. Omit `schema` + * when the parallel state exists only to own its regions. * * @category models * @since 0.4.0 */ - export interface ParallelStateNodeConfig { - readonly schema: TaggedSchema - readonly type: "parallel" - readonly output?: Schema.Top - readonly states: StateTree - } + export type ParallelStateNodeConfig = + | { + readonly schema: TaggedSchema + readonly type: "parallel" + readonly output?: Schema.Top + readonly states: StateTree + readonly annotations?: never + } + | { + readonly schema?: never + readonly type: "parallel" + readonly output?: Schema.Top + readonly states: StateTree + readonly annotations?: SchemaLessStateAnnotations + } /** * Pseudo-state that restores the last active configuration of its parent. @@ -2253,7 +2309,7 @@ export declare namespace Machine { readonly type: "history" /** Defaults to shallow history. */ readonly history?: "shallow" | "deep" - readonly annotations?: PseudoStateAnnotations + readonly annotations?: SchemaLessStateAnnotations } /** @@ -2268,7 +2324,7 @@ export declare namespace Machine { */ export interface ChoiceStateNodeConfig { readonly type: "choice" - readonly annotations?: PseudoStateAnnotations + readonly annotations?: SchemaLessStateAnnotations } /** @@ -2349,13 +2405,13 @@ export declare namespace Machine { * @since 0.4.0 */ readonly get: { - >( + >( snapshot: Snapshot, path: Path ): Option.Option> < const From extends StateIdentifier, - const Path extends StateIdentifier + const Path extends ValuedStateIdentifier >( snapshot: SnapshotByIdentifier, path: Path & (Path extends NoInfer | `${NoInfer}.${string}` ? unknown : never) @@ -2372,7 +2428,7 @@ export declare namespace Machine { * * @since 0.4.0 */ - readonly getWithParents: >( + readonly getWithParents: >( snapshot: Snapshot, path: Path ) => Option.Option> @@ -2434,7 +2490,7 @@ export declare namespace Machine { export interface StateNodeBase { readonly path: Path readonly key: string - /** Resolved Effect Schema annotations, or descriptive pseudo-state annotations. */ + /** Resolved schema annotations, or descriptive schema-less-state annotations. */ readonly annotations: Readonly | undefined readonly order: number } @@ -2444,7 +2500,7 @@ export declare namespace Machine { extends StateNodeBase { readonly type: "atomic" - readonly schema: TaggedSchema + readonly schema: TaggedSchema | undefined readonly output: undefined readonly history: undefined readonly parent: ActivePath | undefined @@ -2459,7 +2515,7 @@ export declare namespace Machine { ChoicePath extends string = ActivePath > extends StateNodeBase { readonly type: "compound" - readonly schema: TaggedSchema + readonly schema: TaggedSchema | undefined readonly output: undefined readonly history: undefined readonly parent: ActivePath | undefined @@ -2473,7 +2529,7 @@ export declare namespace Machine { extends StateNodeBase { readonly type: "parallel" - readonly schema: TaggedSchema + readonly schema: TaggedSchema | undefined readonly output: Schema.Top | undefined readonly history: undefined readonly parent: ActivePath | undefined @@ -2487,7 +2543,7 @@ export declare namespace Machine { extends StateNodeBase { readonly type: "final" - readonly schema: TaggedSchema + readonly schema: TaggedSchema | undefined readonly output: Schema.Top | undefined readonly history: undefined readonly parent: ActivePath | undefined @@ -2697,6 +2753,19 @@ export declare namespace Machine { */ export type StateIdentifier = StateIdentifierWithPrefix + /** Extracts active state paths whose definitions declare a state value schema. */ + export type ValuedStateIdentifier = StateIdentifier extends infer StateId + ? StateId extends StateIdentifier ? NodeSchema> extends never ? never + : StateId + : never + : never + + /** Extracts active state paths whose definitions intentionally omit a state value schema. */ + export type StructuralStateIdentifier = Exclude< + StateIdentifier, + ValuedStateIdentifier + > + /** * Extracts the state path values represented by a state definition under a * parent path prefix. @@ -2907,7 +2976,7 @@ export declare namespace Machine { export type StateByIdentifier< States extends StateSchemas, StateId extends StateIdentifier - > = Extract, SchemaByIdentifier["Type"]> + > = StateId extends ValuedStateIdentifier ? SchemaByIdentifier["Type"] : undefined /** * Extracts every parent state path from a state identifier. @@ -2940,7 +3009,7 @@ export declare namespace Machine { States extends StateSchemas, StateId extends StateIdentifier > = StateId extends StateIdentifier ? { - readonly [Parent in Extract, StateIdentifier>]: StateByIdentifier< + readonly [Parent in Extract, ValuedStateIdentifier>]: StateByIdentifier< States, Parent > @@ -2959,7 +3028,7 @@ export declare namespace Machine { > = StateId extends StateIdentifier ? Extract, StateIdentifier> extends infer Parent ? [Parent] extends [never] ? undefined - : Parent extends StateIdentifier ? StateByIdentifier + : Parent extends ValuedStateIdentifier ? StateByIdentifier : undefined : undefined : never @@ -3144,7 +3213,7 @@ export declare namespace Machine { */ export interface EncodedSnapshotState { readonly path: string - readonly value: unknown + readonly value?: unknown } /** @@ -3329,17 +3398,17 @@ export declare namespace Machine { > = States[StateId] extends { readonly type: "parallel"; readonly states: infer Children } ? Children extends StateSchemas ? ParallelSnapshot< Path, - NodeSchema["Type"], + NodeValue, SnapshotRegionsWithPrefix > - : AtomicSnapshot["Type"]> + : AtomicSnapshot> : States[StateId] extends { readonly states: infer Children } ? Children extends StateSchemas ? CompoundSnapshot< Path, - NodeSchema["Type"], + NodeValue, SnapshotWithPrefix > - : AtomicSnapshot["Type"]> - : AtomicSnapshot["Type"]> + : AtomicSnapshot> + : AtomicSnapshot> /** * Extracts a complete root snapshot whose selected configuration contains a @@ -3455,7 +3524,7 @@ export declare namespace Machine { readonly value: StateByIdentifier readonly values?: Partial< { - readonly [AncestorStateId in StateIdentifier]: StateByIdentifier + readonly [AncestorStateId in ValuedStateIdentifier]: StateByIdentifier } > } @@ -3779,7 +3848,7 @@ export declare namespace Machine { Extract, StateIdentifier> > readonly parents: { - readonly [Parent in Extract, StateIdentifier>]: StateByIdentifier< + readonly [Parent in Extract, ValuedStateIdentifier>]: StateByIdentifier< States, Parent > @@ -4328,10 +4397,14 @@ export declare namespace Machine { StateId extends StateIdentifier > = NodeByIdentifier extends infer Node ? Node extends { readonly type: "parallel"; readonly states: infer Children extends StateSchemas } ? { - readonly [Key in ActiveStateKey]: NodeSchema["Type"] + readonly [Key in ActiveStateKey as NodeSchema extends never ? never : Key]: NodeValue< + Children[Key] + > } : Node extends { readonly states: infer Children extends StateSchemas; readonly initial: infer Initial } ? - Initial extends ActiveStateKey ? NodeSchema["Type"] : never + Initial extends ActiveStateKey ? NodeSchema extends never ? never + : NodeValue + : never : never : never @@ -5303,7 +5376,9 @@ export const isFinal: < * * The returned `states` property is the same object passed to `defineStates`. * The returned `initial` builder creates snapshots without user-authored path - * strings and enforces compound and parallel initial-state rules. + * strings and enforces compound and parallel initial-state rules. Active nodes + * may omit `schema` when they own no value; those builders expose `.from(...)` + * and still participate fully in targeting, matching, and snapshots. * * **Example** (Atomic initial snapshot) * diff --git a/src/internal/machine/atom.ts b/src/internal/machine/atom.ts index 05f600b..fe1e882 100644 --- a/src/internal/machine/atom.ts +++ b/src/internal/machine/atom.ts @@ -431,6 +431,13 @@ type SnapshotIdentifier = SnapshotNode extends infer Node ? Node extends { readonly path: infer Path extends string } ? Path : never : never +type ValuedSnapshotIdentifier = SnapshotNode extends infer Node ? + Node extends { readonly path: infer Path extends string; readonly value: infer Value } ? + [Value] extends [undefined] ? never + : Path + : never + : never + type SnapshotValueByIdentifier> = SnapshotNode extends infer Node ? Node extends { readonly path: Path; readonly value: infer Value } ? Value : never : never @@ -443,7 +450,7 @@ type ChildState = RefState, - Path extends SnapshotIdentifier + Path extends ValuedSnapshotIdentifier >( snapshot: State, path: Path @@ -467,7 +474,7 @@ export const select = < Error, Output, StartError, - const Path extends SnapshotIdentifier + const Path extends ValuedSnapshotIdentifier >( self: MachineAtom, path: Path @@ -498,7 +505,7 @@ export const selectSnapshot = < export const selectChild = < Child extends Machine.ChildMachine.Any, StartError, - const Path extends SnapshotIdentifier> + const Path extends ValuedSnapshotIdentifier> >( self: ChildMachineAtom, path: Path diff --git a/src/internal/machine/configuration.ts b/src/internal/machine/configuration.ts index 77dc42b..e7e34cc 100644 --- a/src/internal/machine/configuration.ts +++ b/src/internal/machine/configuration.ts @@ -84,7 +84,7 @@ const historyFromSnapshot = ( const active = new Set() const values = new Map() for (const activePath of entry.active) { - if (active.has(activePath) || !Object.prototype.hasOwnProperty.call(entry.values, activePath)) { + if (active.has(activePath)) { throw new Error(`Machine snapshot contains invalid remembered state "${activePath}"`) } const node = getNode(machine, activePath) @@ -96,9 +96,15 @@ const historyFromSnapshot = ( throw new Error(`Machine snapshot contains invalid remembered value for "${activePath}"`) } active.add(activePath) - values.set(activePath, decodeStateValueSync(machine, node, entry.values[activePath])) + const hasValue = Object.prototype.hasOwnProperty.call(entry.values, activePath) + if (node.schema === undefined) { + if (hasValue) throw new Error(`Machine snapshot contains a value for structural state "${activePath}"`) + } else { + if (!hasValue) throw new Error(`Machine snapshot omits remembered value for "${activePath}"`) + values.set(activePath, decodeStateValueSync(machine, node, entry.values[activePath])) + } } - if (!active.has(historyNode.parent) || Object.keys(entry.values).length !== active.size) { + if (!active.has(historyNode.parent) || Object.keys(entry.values).length !== values.size) { throw new Error(`Machine snapshot contains incomplete history record "${path}"`) } history.set(path, { @@ -138,7 +144,6 @@ const historyFromSnapshotEffect = Effect.fnUntraced(function*( const node = machine.stateNodes.byPath.get(activePath) if ( active.has(activePath) || node === undefined || node.type === "history" || node.type === "choice" || - !Object.prototype.hasOwnProperty.call(entry.values, activePath) || !(isPathInSubtree(activePath, historyNode.parent) || getPathToRoot(machine, historyNode.parent).includes(activePath)) ) { @@ -152,15 +157,39 @@ const historyFromSnapshotEffect = Effect.fnUntraced(function*( ) } active.add(activePath) - values.set( - activePath, - yield* decodeBoundary(machine, getStateNodeSchema(node), entry.values[activePath], { - boundary: "history", - state: activePath - }) - ) + const hasValue = Object.prototype.hasOwnProperty.call(entry.values, activePath) + if (node.schema === undefined) { + if (hasValue) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: activePath, + cause: Cause.die(new Error(`Machine snapshot contains a value for structural state "${activePath}"`)) + }) + ) + } + } else { + if (!hasValue) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: activePath, + cause: Cause.die(new Error(`Machine snapshot omits remembered value for "${activePath}"`)) + }) + ) + } + values.set( + activePath, + yield* decodeBoundary(machine, getStateNodeSchema(node), entry.values[activePath], { + boundary: "history", + state: activePath + }) + ) + } } - if (!active.has(historyNode.parent) || Object.keys(entry.values).length !== active.size) { + if (!active.has(historyNode.parent) || Object.keys(entry.values).length !== values.size) { return yield* Effect.fail( new MachineSchemaDecodeError({ machineId: machine.id, @@ -212,7 +241,7 @@ const historyToSnapshot = ( entries[path] = { mode: record.mode, active, - values: Object.fromEntries(active.map((path) => [path, record.values.get(path)])) + values: Object.fromEntries(record.values) } } return entries @@ -329,7 +358,9 @@ export const getParentValues = ( const paths = getPathToRoot(machine, path) for (let index = 0; index < paths.length - 1; index++) { const parent = paths[index]! - parents[parent] = getActiveValue(configuration, parent) + if (configuration.values.has(parent)) { + parents[parent] = configuration.values.get(parent) + } } return parents } @@ -340,7 +371,7 @@ export const getParentValue = ( path: string ): unknown => { const parent = getNode(machine, path).parent - return parent === undefined ? undefined : getActiveValue(configuration, parent) + return parent === undefined ? undefined : configuration.values.get(parent) } export const getInitialEntryPaths = ( @@ -368,7 +399,7 @@ export const snapshotFromPath = ( const node = getNode(machine, path) const snapshot: Record = { path, - value: getActiveValue(configuration, path) + value: configuration.values.get(path) } if (node.type === "compound") { const child = node.children.find((child) => configuration.active.has(child)) @@ -438,9 +469,14 @@ export const configurationFromSnapshot = ( const visit = (current: Machine.AtomicSnapshot): void => { const node = getNode(machine, String(current.path)) - const value = decodeStateValueSync(machine, node, current.value) active.add(node.path) - values.set(node.path, value) + if (node.schema === undefined) { + if (current.value !== undefined) { + throw new Error(`Machine structural snapshot "${node.path}" cannot contain a value`) + } + } else { + values.set(node.path, decodeStateValueSync(machine, node, current.value)) + } if (node.type === "compound") { if (!hasProperty(current, "state") || !isSnapshot(current.state)) { throw new Error(`Machine expected compound snapshot "${node.path}" to include an active child state`) @@ -520,9 +556,14 @@ export const configurationFromSnapshotEffect = Effect.fnUntraced(function*( current: Machine.AtomicSnapshot ) { const node = getNode(machine, String(current.path)) - const value = yield* decodeStateValue(machine, node, current.value) active.add(node.path) - values.set(node.path, value) + if (node.schema === undefined) { + if (current.value !== undefined) { + throw new Error(`Machine structural snapshot "${node.path}" cannot contain a value`) + } + } else { + values.set(node.path, yield* decodeStateValue(machine, node, current.value)) + } if (node.type === "compound") { if (!hasProperty(current, "state") || !isSnapshot(current.state)) { throw new Error(`Machine expected compound snapshot "${node.path}" to include an active child state`) @@ -645,7 +686,9 @@ export const captureHistory = ( } const values = new Map() for (const path of active) { - values.set(path, getActiveValue(current, path)) + if (current.values.has(path)) { + values.set(path, current.values.get(path)) + } } history.set(node.path, { mode, @@ -722,6 +765,15 @@ export const configurationFromTargetPathEffect = Effect.fnUntraced(function*( for (const currentPath of paths) { const currentNode = getNode(machine, currentPath) active.add(currentPath) + if (currentNode.schema === undefined) { + const supplied = currentPath === node.path ? + value + : providedValues !== undefined && hasOwn(providedValues, currentPath) ? + providedValues[currentPath] + : undefined + if (supplied !== undefined) throw new Error(`Machine structural target "${currentPath}" cannot contain a value`) + continue + } if (currentPath === node.path) { values.set(currentPath, yield* decodeStateValue(machine, currentNode, value)) } else if (providedValues !== undefined && hasOwn(providedValues, currentPath)) { @@ -783,6 +835,12 @@ export const configurationFromTargetSnapshotEffect = Effect.fnUntraced(function* for (const ancestor of paths.slice(0, -1)) { const node = getNode(machine, ancestor) active.add(ancestor) + if (node.schema === undefined) { + if (providedValues !== undefined && hasOwn(providedValues, ancestor)) { + throw new Error(`Machine structural target "${ancestor}" cannot contain a value`) + } + continue + } if (providedValues !== undefined && hasOwn(providedValues, ancestor)) { values.set(ancestor, yield* decodeStateValue(machine, node, providedValues[ancestor])) } else if (current.values.has(ancestor)) { @@ -816,6 +874,9 @@ export const configurationFromTargetSnapshotEffect = Effect.fnUntraced(function* if (!isPathInSubtree(providedPath, child)) continue const providedNode = getNode(machine, providedPath) active.add(providedPath) + if (providedNode.schema === undefined) { + throw new Error(`Machine structural target "${providedPath}" cannot contain a value`) + } values.set(providedPath, yield* decodeStateValue(machine, providedNode, providedValue)) } } @@ -885,6 +946,15 @@ const configurationFromTargetPathSync = ( for (const currentPath of paths) { const currentNode = getNode(machine, currentPath) active.add(currentPath) + if (currentNode.schema === undefined) { + const supplied = currentPath === node.path ? + value + : providedValues !== undefined && hasOwn(providedValues, currentPath) ? + providedValues[currentPath] + : undefined + if (supplied !== undefined) throw new Error(`Machine structural target "${currentPath}" cannot contain a value`) + continue + } if (currentPath === node.path) { values.set(currentPath, decodeStateValueSync(machine, currentNode, value)) } else if (providedValues !== undefined && hasOwn(providedValues, currentPath)) { @@ -932,6 +1002,12 @@ const configurationFromTargetSnapshotSync = ( for (const ancestor of paths.slice(0, -1)) { const node = getNode(machine, ancestor) active.add(ancestor) + if (node.schema === undefined) { + if (providedValues !== undefined && hasOwn(providedValues, ancestor)) { + throw new Error(`Machine structural target "${ancestor}" cannot contain a value`) + } + continue + } if (providedValues !== undefined && hasOwn(providedValues, ancestor)) { values.set(ancestor, decodeStateValueSync(machine, node, providedValues[ancestor])) } else if (current.values.has(ancestor)) { @@ -960,6 +1036,9 @@ const configurationFromTargetSnapshotSync = ( if (!isPathInSubtree(providedPath, child)) continue const providedNode = getNode(machine, providedPath) active.add(providedPath) + if (providedNode.schema === undefined) { + throw new Error(`Machine structural target "${providedPath}" cannot contain a value`) + } values.set(providedPath, decodeStateValueSync(machine, providedNode, providedValue)) } } @@ -1098,7 +1177,7 @@ export const resolveFinalOutputEffect: < ) { const node = getNode(machine, path) const output = getStateConfigByPath(machine, path)?.output?.({ - state: getActiveValue(configuration, path), + state: configuration.values.get(path), parent: getParentValue(machine, configuration, path), parents: getParentValues(machine, configuration, path), event, @@ -1251,7 +1330,7 @@ const resolveFinalOutputSync = { const node = getNode(machine, path) const output = getStateConfigByPath(machine, path)?.output?.({ - state: getActiveValue(configuration, path), + state: configuration.values.get(path), parent: getParentValue(machine, configuration, path), parents: getParentValues(machine, configuration, path), event, diff --git a/src/internal/machine/executionPlan.ts b/src/internal/machine/executionPlan.ts index 0f71735..88aef1b 100644 --- a/src/internal/machine/executionPlan.ts +++ b/src/internal/machine/executionPlan.ts @@ -100,6 +100,12 @@ const compileIndexedExecutionDescriptor = ( if (node.type === "choice" || node.type === "history") { return undefined } + // Structural active states deliberately use the generic semantic + // reference until the indexed value table represents absent values as an + // explicit capability rather than an `undefined` slot. + if (node.schema === undefined) { + return undefined + } if (node.type === "atomic" || node.type === "final") { leafPaths.push(node.path) } diff --git a/src/internal/machine/invocation.ts b/src/internal/machine/invocation.ts index 037b312..0b44a69 100644 --- a/src/internal/machine/invocation.ts +++ b/src/internal/machine/invocation.ts @@ -109,7 +109,7 @@ export const startAll = ( .filter((path) => configuration.active.has(path)) .flatMap((path) => resolve(Configuration.getStateConfigByPath(machine, path), { - state: Configuration.getActiveValue(configuration, path), + state: configuration.values.get(path), parent: Configuration.getParentValue(machine, configuration, path), parents: Configuration.getParentValues(machine, configuration, path), event diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index 86cff20..322e664 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -278,10 +278,14 @@ type FromMethodKind = "leaf" | "nested" const withFrom = ) => unknown>( method: Method, - kind: FromMethodKind + kind: FromMethodKind, + valued: boolean ): Method & { readonly from: (...args: ReadonlyArray) => unknown } => { Object.defineProperty(method, "from", { value: (...args: ReadonlyArray) => { + if (!valued) { + return method(undefined, ...args) + } const omitted = args.length === 0 || (kind === "nested" && args.length === 1 && typeof args[0] === "function") const input = omitted ? {} : args[0] const rest = omitted ? args : args.slice(1) @@ -312,7 +316,8 @@ const makeSnapshotBuilder = ( builder[key] = withFrom( (value: unknown, selector?: (builder: unknown) => unknown) => makeSnapshotForNode(definition, key, value, selector, options), - node.states === undefined ? "leaf" : "nested" + node.states === undefined ? "leaf" : "nested", + node.schema !== undefined ) } return builder @@ -339,14 +344,18 @@ const makeParallelSnapshotBuilder = ( } const path = options.prefix === "" ? key : `${options.prefix}.${key}` const node = Topology.getStateNodeDefinition(path, definition) - builder[key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { - const nextRegions: Record = {} - for (const regionKey of Object.keys(regions)) { - nextRegions[regionKey] = regions[regionKey] - } - nextRegions[key] = makeSnapshotForNode(definition, key, value, selector, options) - return makeParallelSnapshotBuilder(states, options, nextRegions) - }, node.states === undefined ? "leaf" : "nested") + builder[key] = withFrom( + (value: unknown, selector?: (builder: unknown) => unknown) => { + const nextRegions: Record = {} + for (const regionKey of Object.keys(regions)) { + nextRegions[regionKey] = regions[regionKey] + } + nextRegions[key] = makeSnapshotForNode(definition, key, value, selector, options) + return makeParallelSnapshotBuilder(states, options, nextRegions) + }, + node.states === undefined ? "leaf" : "nested", + node.schema !== undefined + ) } return builder } @@ -525,13 +534,25 @@ const makeLocalTargetChildBuilder = ( builder[child.key] = () => Topology.makeChoiceTarget(child.path, parent.path, values) continue } - builder[child.key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { - if (child.type === "atomic" || child.type === "final") { - return makeTargetWithValues(child.path, value, values) - } - if (child.type === "parallel") { - if (source !== child.path && !source.startsWith(`${child.path}.`)) { - return makeParallelTarget(states, child, value, selector, values) + builder[child.key] = withFrom( + (value: unknown, selector?: (builder: unknown) => unknown) => { + if (child.type === "atomic" || child.type === "final") { + return makeTargetWithValues(child.path, value, values) + } + if (child.type === "parallel") { + if (source !== child.path && !source.startsWith(`${child.path}.`)) { + return makeParallelTarget(states, child, value, selector, values) + } + if (selector === undefined) { + throw new Error(`Machine expected target "${child.path}" builder to provide an active child state`) + } + return selector(makeLocalTargetChildBuilder( + states, + stateNodes, + child.path, + child.schema === undefined ? values : extendTargetValues(values, child.path, value), + source + )) } if (selector === undefined) { throw new Error(`Machine expected target "${child.path}" builder to provide an active child state`) @@ -540,21 +561,13 @@ const makeLocalTargetChildBuilder = ( states, stateNodes, child.path, - extendTargetValues(values, child.path, value), + child.schema === undefined ? values : extendTargetValues(values, child.path, value), source )) - } - if (selector === undefined) { - throw new Error(`Machine expected target "${child.path}" builder to provide an active child state`) - } - return selector(makeLocalTargetChildBuilder( - states, - stateNodes, - child.path, - extendTargetValues(values, child.path, value), - source - )) - }, child.type === "atomic" || child.type === "final" ? "leaf" : "nested") + }, + child.type === "atomic" || child.type === "final" ? "leaf" : "nested", + child.schema !== undefined + ) } return builder } @@ -569,12 +582,19 @@ const makeLocalTargetBuilder = ( return {} } const builder = makeLocalTargetChildBuilder(states, stateNodes, scope, undefined, source) as Record - builder.with = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { - if (selector === undefined) { - throw new Error(`Machine expected target "${scope}" builder to provide an active child state`) - } - return selector(makeLocalTargetChildBuilder(states, stateNodes, scope, { [scope]: value }, source)) - }, "nested") + const scopeNode = getTargetBuilderNode(stateNodes, scope) + if (scopeNode.schema !== undefined) { + builder.with = withFrom( + (value: unknown, selector?: (builder: unknown) => unknown) => { + if (selector === undefined) { + throw new Error(`Machine expected target "${scope}" builder to provide an active child state`) + } + return selector(makeLocalTargetChildBuilder(states, stateNodes, scope, { [scope]: value }, source)) + }, + "nested", + true + ) + } return builder } @@ -610,12 +630,31 @@ const makeBranchTargetNodeBuilder = ( ): unknown => { const node = getTargetBuilderNode(stateNodes, path) if (node.type === "atomic" || node.type === "final") { - return withFrom((value: unknown) => makeTargetWithValues(node.path, value, values), "leaf") + return withFrom( + (value: unknown) => makeTargetWithValues(node.path, value, values), + "leaf", + node.schema !== undefined + ) } - const builder = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { - if (node.type === "parallel") { - if (source !== node.path && !source.startsWith(`${node.path}.`)) { - return makeParallelTarget(states, node, value, selector, values) + const builder = withFrom( + (value: unknown, selector?: (builder: unknown) => unknown) => { + if (node.type === "parallel") { + if (source !== node.path && !source.startsWith(`${node.path}.`)) { + return makeParallelTarget(states, node, value, selector, values) + } + if (selector === undefined) { + throw new Error(`Machine expected target "${node.path}" builder to provide an active child state`) + } + const nextBuilder: Record = {} + addBranchTargetChildren( + nextBuilder, + states, + stateNodes, + node.path, + node.schema === undefined ? values : extendTargetValues(values, node.path, value), + source + ) + return selector(nextBuilder) } if (selector === undefined) { throw new Error(`Machine expected target "${node.path}" builder to provide an active child state`) @@ -626,25 +665,14 @@ const makeBranchTargetNodeBuilder = ( states, stateNodes, node.path, - extendTargetValues(values, node.path, value), + node.schema === undefined ? values : extendTargetValues(values, node.path, value), source ) return selector(nextBuilder) - } - if (selector === undefined) { - throw new Error(`Machine expected target "${node.path}" builder to provide an active child state`) - } - const nextBuilder: Record = {} - addBranchTargetChildren( - nextBuilder, - states, - stateNodes, - node.path, - extendTargetValues(values, node.path, value), - source - ) - return selector(nextBuilder) - }, "nested") as unknown as Record + }, + "nested", + node.schema !== undefined + ) as unknown as Record if (node.type !== "parallel" || source === node.path || source.startsWith(`${node.path}.`)) { addBranchTargetChildren(builder, states, stateNodes, node.path, values, source) } diff --git a/src/internal/machine/planner.ts b/src/internal/machine/planner.ts index 4a8f99e..7750227 100644 --- a/src/internal/machine/planner.ts +++ b/src/internal/machine/planner.ts @@ -18,7 +18,6 @@ import { configurationFromHistoryRecord, getActiveLeafPathFrom, getActiveLeafPaths, - getActiveValue, getHistoryRecord, getInitialEntryPaths, getLeafPath, @@ -201,66 +200,76 @@ const completeHistoryConfiguration = ( if (node.initial === undefined) { throw new Error(`Machine shallow history expected compound state "${path}" to have an initial child`) } - const initializer = machine.handlers[path]?.initial - if (initializer === undefined) { - throw new Error(`Machine shallow history requires an initial value implementation for state "${path}"`) - } + const child = getNode(machine, node.initial) const current = { active, values, outputs: configuration.outputs, history: configuration.history } as ActiveConfiguration - const initialized = collectStateInitializer(machine, initializer, { - state: getActiveValue(current, path), - parent: getParentValue(machine, current, path), - parents: getParentValues(machine, current, path), - event - }) - const child = getNode(machine, node.initial) active.add(child.path) - values.set(child.path, decodeStateValueSync(machine, child, initialized.value)) - commands.push(...initialized.commands) - raisedEvents.push(...initialized.raisedEvents) - emittedEvents.push(...initialized.emittedEvents) + if (child.schema !== undefined) { + const initializer = machine.handlers[path]?.initial + if (initializer === undefined) { + throw new Error(`Machine shallow history requires an initial value implementation for state "${path}"`) + } + const initialized = collectStateInitializer(machine, initializer, { + state: current.values.get(path), + parent: getParentValue(machine, current, path), + parents: getParentValues(machine, current, path), + event + }) + values.set(child.path, decodeStateValueSync(machine, child, initialized.value)) + commands.push(...initialized.commands) + raisedEvents.push(...initialized.raisedEvents) + emittedEvents.push(...initialized.emittedEvents) + } changed = true } if (node.type === "parallel") { const missing = node.children.filter((childPath) => !active.has(childPath)) if (missing.length > 0) { - const initializer = machine.handlers[path]?.initial - if (initializer === undefined) { - throw new Error(`Machine shallow history requires an initial value implementation for state "${path}"`) - } + const valuedMissing = missing.filter((childPath) => getNode(machine, childPath).schema !== undefined) const current = { active, values, outputs: configuration.outputs, history: configuration.history } as ActiveConfiguration - const initialized = collectStateInitializer(machine, initializer, { - state: getActiveValue(current, path), + const initializer = valuedMissing.length === 0 ? undefined : machine.handlers[path]?.initial + if (valuedMissing.length > 0 && initializer === undefined) { + throw new Error(`Machine shallow history requires an initial value implementation for state "${path}"`) + } + const initialized = initializer === undefined ? undefined : collectStateInitializer(machine, initializer, { + state: current.values.get(path), parent: getParentValue(machine, current, path), parents: getParentValues(machine, current, path), event }) - if (typeof initialized.value !== "object" || initialized.value === null) { + if (initialized !== undefined && (typeof initialized.value !== "object" || initialized.value === null)) { throw new Error(`Machine parallel state initializer for "${path}" must return its region values`) } for (const childPath of missing) { const child = getNode(machine, childPath) - if (!Object.prototype.hasOwnProperty.call(initialized.value, child.key)) { - throw new Error(`Machine parallel state initializer for "${path}" must return region "${child.key}"`) - } active.add(child.path) - values.set( - child.path, - decodeStateValueSync(machine, child, (initialized.value as Record)[child.key]) - ) + if (child.schema !== undefined) { + if ( + initialized === undefined || + !Object.prototype.hasOwnProperty.call(initialized.value as object, child.key) + ) { + throw new Error(`Machine parallel state initializer for "${path}" must return region "${child.key}"`) + } + values.set( + child.path, + decodeStateValueSync(machine, child, (initialized.value as Record)[child.key]) + ) + } + } + if (initialized !== undefined) { + commands.push(...initialized.commands) + raisedEvents.push(...initialized.raisedEvents) + emittedEvents.push(...initialized.emittedEvents) } - commands.push(...initialized.commands) - raisedEvents.push(...initialized.raisedEvents) - emittedEvents.push(...initialized.emittedEvents) changed = true } } @@ -495,7 +504,7 @@ const makeStateActionContext = < path: string, event: Machine.LifecycleEvent ): Machine.StateActionContext => ({ - state: getActiveValue(configuration, path) as Machine.StateByIdentifier, + state: configuration.values.get(path) as Machine.StateByIdentifier, parent: getParentValue(machine, configuration, path) as Machine.ParentStateValue, parents: getParentValues(machine, configuration, path) as Machine.ParentStateValues, event @@ -514,7 +523,7 @@ const makeTransitionContext = < event: Machine.EventByTag, snapshot: Machine.Snapshot ): Machine.HandlerContext => ({ - state: getActiveValue(configuration, path) as Machine.StateByIdentifier, + state: configuration.values.get(path) as Machine.StateByIdentifier, parent: getParentValue(machine, configuration, path) as Machine.ParentStateValue, parents: getParentValues(machine, configuration, path) as Machine.ParentStateValues, event, @@ -535,7 +544,7 @@ const makeDoneContext = < output: unknown, snapshot: Machine.Snapshot ): Machine.DoneContext => ({ - state: getActiveValue(configuration, path) as Machine.StateByIdentifier, + state: configuration.values.get(path) as Machine.StateByIdentifier, parent: getParentValue(machine, configuration, path) as Machine.ParentStateValue, parents: getParentValues(machine, configuration, path) as Machine.ParentStateValues, event, @@ -629,7 +638,7 @@ const selectAlwaysTransitions = < Machine.AlwaysContext> >, context: { - state: getActiveValue(configuration, path) as Machine.StateByIdentifier< + state: configuration.values.get(path) as Machine.StateByIdentifier< States, Machine.StateIdentifier >, @@ -919,7 +928,9 @@ const choicesFromTarget = ( return } if (typeof current !== "object" || current === null || !("path" in current) || !("value" in current)) return - values[String(current.path)] = current.value + if (current.value !== undefined) { + values[String(current.path)] = current.value + } if ("state" in current) visit(current.state) if ("states" in current && typeof current.states === "object" && current.states !== null) { for (const state of Object.values(current.states)) visit(state) diff --git a/src/internal/machine/serialization.ts b/src/internal/machine/serialization.ts index 6c3bcd2..8248cd0 100644 --- a/src/internal/machine/serialization.ts +++ b/src/internal/machine/serialization.ts @@ -31,7 +31,7 @@ const EncodedSnapshotSchema = Schema.Struct({ _tag: Schema.Literal("MachineSnapshot"), active: Schema.Array(Schema.Struct({ path: Schema.String, - value: Schema.Unknown + value: Schema.optional(Schema.Unknown) })), completed: Schema.optional(Schema.Array(Schema.Struct({ path: Schema.String, @@ -220,13 +220,17 @@ export const encodeSnapshot = ( const path of Array.from(configuration.active).sort((left, right) => compareDocumentOrder(machine, left, right)) ) { const node = getNode(machine, path) - active.push({ - path, - value: yield* encodeBoundary(machine, getStateNodeSchema(node), getActiveValue(configuration, path), { - boundary: "state", - state: path + if (node.schema === undefined) { + active.push({ path }) + } else { + active.push({ + path, + value: yield* encodeBoundary(machine, getStateNodeSchema(node), getActiveValue(configuration, path), { + boundary: "state", + state: path + }) }) - }) + } } const completed: Array = [] @@ -290,7 +294,6 @@ export const encodeSnapshot = ( const stateNode = machine.stateNodes.byPath.get(path) if ( stateNode === undefined || stateNode.type === "history" || stateNode.type === "choice" || - !record.values.has(path) || !(isPathInSubtree(path, record.parent) || getPathToRoot(machine, record.parent).includes(path)) ) { return yield* Effect.fail( @@ -302,14 +305,23 @@ export const encodeSnapshot = ( }) ) } - encodedValues[path] = yield* encodeBoundary( - machine, - getStateNodeSchema(stateNode), - record.values.get(path), - { boundary: "history", state: path } - ) + if (stateNode.schema === undefined) { + if (record.values.has(path)) { + throw new Error(`Machine history record contains a value for structural state "${path}"`) + } + } else { + if (!record.values.has(path)) { + throw new Error(`Machine history record omits value for "${path}"`) + } + encodedValues[path] = yield* encodeBoundary( + machine, + getStateNodeSchema(stateNode), + record.values.get(path), + { boundary: "history", state: path } + ) + } } - if (record.values.size !== record.active.size) { + if (Object.keys(encodedValues).length !== record.values.size) { return yield* Effect.fail( new MachineSchemaEncodeError({ machineId: machine.id, @@ -356,13 +368,19 @@ export const decodeSnapshot = ( } const node = getNode(machine, entry.path) active.add(entry.path) - values.set( - entry.path, - yield* decodeEncodedBoundary(machine, getStateNodeSchema(node), entry.value, { - boundary: "state", - state: entry.path - }) - ) + const hasValue = Object.prototype.hasOwnProperty.call(entry, "value") + if (node.schema === undefined) { + if (hasValue) throw new Error(`Machine encoded snapshot contains a value for structural state "${entry.path}"`) + } else { + if (!hasValue) throw new Error(`Machine encoded snapshot omits value for state "${entry.path}"`) + values.set( + entry.path, + yield* decodeEncodedBoundary(machine, getStateNodeSchema(node), entry.value, { + boundary: "state", + state: entry.path + }) + ) + } } const history = new Map() @@ -397,7 +415,6 @@ export const decodeSnapshot = ( const stateNode = machine.stateNodes.byPath.get(path) if ( stateNode === undefined || stateNode.type === "history" || stateNode.type === "choice" || - !Object.prototype.hasOwnProperty.call(encodedRecord.values, path) || !(isPathInSubtree(path, historyNode.parent) || getPathToRoot(machine, historyNode.parent).includes(path)) ) { return yield* Effect.fail( @@ -410,15 +427,23 @@ export const decodeSnapshot = ( ) } rememberedActive.add(path) - rememberedValues.set( - path, - yield* decodeEncodedBoundary(machine, getStateNodeSchema(stateNode), encodedRecord.values[path], { - boundary: "history", - state: path - }) - ) + const hasValue = Object.prototype.hasOwnProperty.call(encodedRecord.values, path) + if (stateNode.schema === undefined) { + if (hasValue) { + throw new Error(`Machine encoded history contains a value for structural state "${path}"`) + } + } else { + if (!hasValue) throw new Error(`Machine encoded history omits value for state "${path}"`) + rememberedValues.set( + path, + yield* decodeEncodedBoundary(machine, getStateNodeSchema(stateNode), encodedRecord.values[path], { + boundary: "history", + state: path + }) + ) + } } - if (Object.keys(encodedRecord.values).length !== rememberedActive.size) { + if (Object.keys(encodedRecord.values).length !== rememberedValues.size) { return yield* Effect.fail( new MachineSchemaDecodeError({ machineId: machine.id, diff --git a/src/internal/machine/stateDefinition.ts b/src/internal/machine/stateDefinition.ts index abefba0..929770f 100644 --- a/src/internal/machine/stateDefinition.ts +++ b/src/internal/machine/stateDefinition.ts @@ -2,10 +2,10 @@ import * as Schema from "effect/Schema" import * as SchemaAST from "effect/SchemaAST" export const StateNodePropertyPolicy = { - atomic: ["schema", "type"], - final: ["schema", "type", "output"], - compound: ["schema", "type", "initial", "states"], - parallel: ["schema", "type", "output", "states"], + atomic: ["schema", "type", "annotations"], + final: ["schema", "type", "output", "annotations"], + compound: ["schema", "type", "initial", "states", "annotations"], + parallel: ["schema", "type", "output", "states", "annotations"], history: ["type", "history", "annotations"], choice: ["type", "annotations"] } as const @@ -152,18 +152,22 @@ const assertAllowedProperties = ( } } -const validatePseudoAnnotations = ( +const validateSchemaLessAnnotations = ( boundary: StateDefinitionBoundary, path: string, annotations: unknown ): void => { - assertPlainRecord(boundary, `${path}.annotations`, annotations, "pseudo-state annotations") + assertPlainRecord(boundary, `${path}.annotations`, annotations, "schema-less state annotations") for (const property of Reflect.ownKeys(annotations)) { if (!(PseudoStateAnnotationProperties as ReadonlyArray).includes(property)) { - fail(boundary, `${path}.annotations`, `pseudo-state annotations cannot declare property "${String(property)}"`) + fail( + boundary, + `${path}.annotations`, + `schema-less state annotations cannot declare property "${String(property)}"` + ) } if (typeof annotations[property] !== "string") { - fail(boundary, `${path}.annotations.${String(property)}`, "pseudo-state annotation values must be strings") + fail(boundary, `${path}.annotations.${String(property)}`, "schema-less state annotation values must be strings") } } } @@ -211,7 +215,7 @@ const validateStateTree = ( fail(boundary, path, `${kind} states must be declared below an active parent state`) } if (hasOwn(node, "annotations")) { - validatePseudoAnnotations(boundary, path, node.annotations) + validateSchemaLessAnnotations(boundary, path, node.annotations) } if (kind === "history" && hasOwn(node, "history") && node.history !== "shallow" && node.history !== "deep") { fail(boundary, `${path}.history`, "history must be \"shallow\" or \"deep\"") @@ -219,8 +223,15 @@ const validateStateTree = ( continue } - if (!hasOwn(node, "schema") || !isTaggedSchema(node.schema)) { - fail(boundary, `${path}.schema`, "active states must declare an Effect Schema with a required PropertyKey _tag") + const valued = hasOwn(node, "schema") + if (valued && !isTaggedSchema(node.schema)) { + fail(boundary, `${path}.schema`, "state schemas must decode to an object with a required PropertyKey _tag") + } + if (valued && hasOwn(node, "annotations")) { + fail(boundary, `${path}.annotations`, "schema-backed states must declare annotations on their schema") + } + if (!valued && hasOwn(node, "annotations")) { + validateSchemaLessAnnotations(boundary, path, node.annotations) } if (kind === "atomic" && hasOwn(node, "type") && node.type !== "active") { fail(boundary, `${path}.type`, "atomic state type must be \"active\"") diff --git a/src/internal/machine/topology.ts b/src/internal/machine/topology.ts index 8fd28aa..33a351c 100644 --- a/src/internal/machine/topology.ts +++ b/src/internal/machine/topology.ts @@ -70,7 +70,7 @@ interface NormalizedStateNodeDefinitionBase { type NormalizedStateNodeDefinition = | (NormalizedStateNodeDefinitionBase & { readonly type: "atomic" - readonly schema: Machine.TaggedSchema + readonly schema: Machine.TaggedSchema | undefined readonly output: undefined readonly history: undefined readonly initial: undefined @@ -78,7 +78,7 @@ type NormalizedStateNodeDefinition = }) | (NormalizedStateNodeDefinitionBase & { readonly type: "compound" - readonly schema: Machine.TaggedSchema + readonly schema: Machine.TaggedSchema | undefined readonly output: undefined readonly history: undefined readonly initial: string @@ -86,7 +86,7 @@ type NormalizedStateNodeDefinition = }) | (NormalizedStateNodeDefinitionBase & { readonly type: "parallel" - readonly schema: Machine.TaggedSchema + readonly schema: Machine.TaggedSchema | undefined readonly output: Schema.Top | undefined readonly history: undefined readonly initial: undefined @@ -94,7 +94,7 @@ type NormalizedStateNodeDefinition = }) | (NormalizedStateNodeDefinitionBase & { readonly type: "final" - readonly schema: Machine.TaggedSchema + readonly schema: Machine.TaggedSchema | undefined readonly output: Schema.Top | undefined readonly history: undefined readonly initial: undefined @@ -154,9 +154,10 @@ export const getStateNodeDefinition = ( states: undefined } } - if (!hasProperty(definition, "schema") || !Schema.isSchema(definition.schema)) { - throw new Error(`Machine.make expected state "${path}" to be a tagged schema or state node config`) - } + const schema = hasProperty(definition, "schema") && Schema.isSchema(definition.schema) + ? definition.schema as Machine.TaggedSchema + : undefined + const annotations = schema === undefined ? definition.annotations : Schema.resolveAnnotations(schema) if (definition.type === "parallel" && !hasProperty(definition, "states")) { throw new Error(`Machine.make expected parallel state "${path}" to declare child regions`) } @@ -167,9 +168,9 @@ export const getStateNodeDefinition = ( } if (definition.type === "parallel") { return { - schema: definition.schema, + schema, output: Schema.isSchema(definition.output) ? definition.output : undefined, - annotations: Schema.resolveAnnotations(definition.schema), + annotations, type: "parallel", history: undefined, initial: undefined, @@ -180,9 +181,9 @@ export const getStateNodeDefinition = ( throw new Error(`Machine.make expected compound state "${path}" to declare an initial child`) } return { - schema: definition.schema, + schema, output: undefined, - annotations: Schema.resolveAnnotations(definition.schema), + annotations, type: "compound", history: undefined, initial: definition.initial, @@ -192,18 +193,18 @@ export const getStateNodeDefinition = ( const output = Schema.isSchema(definition.output) ? definition.output : undefined return definition.type === "final" ? { - schema: definition.schema, + schema, output, - annotations: Schema.resolveAnnotations(definition.schema), + annotations, type: "final", history: undefined, initial: undefined, states: undefined } : { - schema: definition.schema, + schema, output: undefined, - annotations: Schema.resolveAnnotations(definition.schema), + annotations, type: "atomic", history: undefined, initial: undefined, @@ -405,7 +406,7 @@ export const makeTarget = < readonly snapshot?: Machine.SnapshotByIdentifier readonly values?: Partial< { - readonly [AncestorStateId in Machine.StateIdentifier]: Machine.StateByIdentifier< + readonly [AncestorStateId in Machine.ValuedStateIdentifier]: Machine.StateByIdentifier< States, AncestorStateId > @@ -445,7 +446,9 @@ export const getSnapshotByPath = ( return Option.none() } if (parents !== undefined) { - parents[snapshot.path] = snapshot.value + if (snapshot.value !== undefined) { + parents[snapshot.path] = snapshot.value + } } if (hasProperty(snapshot, "state") && isSnapshot(snapshot.state)) { return getSnapshotByPath(snapshot.state, path, parents) @@ -473,7 +476,7 @@ export const getNode = (machine: Machine.Any, path: string): Machine.StateNode = export const getStateNodeSchema = (node: Machine.StateNode): Machine.TaggedSchema => { if (node.schema === undefined) { - throw new Error(`Machine pseudo-state "${node.path}" has no active value schema`) + throw new Error(`Machine state "${node.path}" has no value schema`) } return node.schema } diff --git a/src/unstable/reactivity/AtomMachine.ts b/src/unstable/reactivity/AtomMachine.ts index 88277d4..799974b 100644 --- a/src/unstable/reactivity/AtomMachine.ts +++ b/src/unstable/reactivity/AtomMachine.ts @@ -291,6 +291,13 @@ type SnapshotIdentifier = SnapshotNode extends infer Node ? Node extends { readonly path: infer Path extends string } ? Path : never : never +type ValuedSnapshotIdentifier = SnapshotNode extends infer Node ? + Node extends { readonly path: infer Path extends string; readonly value: infer Value } ? + [Value] extends [undefined] ? never + : Path + : never + : never + type SnapshotValueByIdentifier> = SnapshotNode extends infer Node ? Node extends { readonly path: Path; readonly value: infer Value } ? Value : never : never @@ -338,7 +345,7 @@ export const select: < Error, Output, StartError, - const Path extends SnapshotIdentifier + const Path extends ValuedSnapshotIdentifier >(self: MachineAtom, path: Path) => Atom.Atom< AsyncResult.AsyncResult>, StartError | Error> > = internal.select @@ -384,7 +391,7 @@ export const selectSnapshot: < export const selectChild: < Child extends Machine.ChildMachine.Any, StartError, - const Path extends SnapshotIdentifier> + const Path extends ValuedSnapshotIdentifier> >(self: ChildMachineAtom, path: Path) => Atom.Atom< AsyncResult.AsyncResult< Option.Option, Path>>, diff --git a/test/internal/machine/strategyDifferential.test.ts b/test/internal/machine/strategyDifferential.test.ts index d88da3d..ea2e4c3 100644 --- a/test/internal/machine/strategyDifferential.test.ts +++ b/test/internal/machine/strategyDifferential.test.ts @@ -194,6 +194,24 @@ describe("machine planner and runtime strategies", () => { }) })) + it.effect("fails closed to the generic planner for schema-less active states", () => + Effect.gen(function*() { + const states = Machine.defineStates({ Idle: {} }) + const machine = Machine.make({ + states: states.states, + events: [], + initial: () => states.initial.Idle.from() + }).handle({ Idle: {} }) + + assert.strictEqual(ExecutionPlan.selectExecutionPlanForTesting(machine, "auto").strategy, "generic") + yield* verifyPlannerStrategies({ + machine, + events: [], + expected: "generic", + label: "schema-less fallback" + }) + })) + it("falls back to the generic planner for unknown state semantics", () => { const machine = makeFlatMachine() const config = machine.handlers.Count as Machine.Machine.AnyStateConfig & Record diff --git a/test/machine/StateDefinition.test.ts b/test/machine/StateDefinition.test.ts index a29af55..ab315c4 100644 --- a/test/machine/StateDefinition.test.ts +++ b/test/machine/StateDefinition.test.ts @@ -43,6 +43,43 @@ const makeFromUnknownStates = (states: unknown): unknown => }) describe("exact state-definition runtime validation", () => { + it("accepts schema-less active states without confusing them with pseudo-states", () => { + const states = Machine.defineStates({ + Idle: { + annotations: { title: "Idle", description: "No state-local data" } + }, + Flow: { + initial: "Waiting", + states: { + Waiting: {}, + Done: { type: "final", output: Schema.String } + } + }, + Regions: { + type: "parallel", + states: { + left: {}, + right: {} + } + } + }) + const machine = Machine.make({ + states: states.states, + events: [], + initial: () => states.initial.Idle.from() + }) + + const nodes = Machine.stateNodes(machine) + assert.strictEqual(nodes.find(({ path }) => path === "Idle")?.schema, undefined) + assert.deepStrictEqual(nodes.find(({ path }) => path === "Idle")?.annotations, { + title: "Idle", + description: "No state-local data" + }) + assert.strictEqual(nodes.find(({ path }) => path === "Flow")?.type, "compound") + assert.strictEqual(nodes.find(({ path }) => path === "Flow.Done")?.type, "final") + assert.strictEqual(nodes.find(({ path }) => path === "Regions")?.type, "parallel") + }) + it("recognizes Effect schemas before inspecting config properties", () => { const AnnotatedIdle = Idle.annotate({ title: "Idle", @@ -244,4 +281,31 @@ describe("exact state-definition runtime validation", () => { "must be strings" ) }) + + it("rejects malformed schema-less active states at the runtime boundary", () => { + const invalidTrees: ReadonlyArray = [ + [{ Invalid: { schema: undefined } }, "Invalid.schema", "required PropertyKey _tag"], + [{ Invalid: { states: { Child: {} } } }, "Invalid.initial", "must declare an initial child"], + [{ Invalid: { type: "parallel" } }, "Invalid.states", "must declare child regions"], + [ + { Invalid: { annotations: { executable: "no" } } }, + "Invalid.annotations", + "cannot declare property" + ], + [ + { Invalid: { annotations: { title: 1 } } }, + "Invalid.annotations.title", + "must be strings" + ] + ] + + for (const [states, path, detail] of invalidTrees) { + expectDefinitionError( + () => Machine.defineStates(states as Machine.Machine.StateSchemas), + "Machine.defineStates", + path, + detail + ) + } + }) }) diff --git a/test/machine/StructuralStates.test.ts b/test/machine/StructuralStates.test.ts new file mode 100644 index 0000000..ddc96d9 --- /dev/null +++ b/test/machine/StructuralStates.test.ts @@ -0,0 +1,334 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Option, Schema } from "effect" +import { Machine } from "../../src/index.js" + +class Loading extends Schema.TaggedClass("StructuralLoading")("Loading", { + url: Schema.String +}) {} +class Ready extends Schema.TaggedClass("StructuralReady")("Ready", { + duration: Schema.Number +}) {} +class Playing extends Schema.TaggedClass("StructuralPlaying")("Playing", { + position: Schema.Number +}) {} +class Audible extends Schema.TaggedClass("StructuralAudible")("Audible", { + volume: Schema.Number +}) {} +class Muted extends Schema.TaggedClass("StructuralMuted")("Muted", { + volume: Schema.Number +}) {} + +class SourceSelected extends Schema.TaggedClass("StructuralSourceSelected")("SourceSelected", { + url: Schema.String +}) {} +class Loaded extends Schema.TaggedClass("StructuralLoaded")("Loaded", { + duration: Schema.Number +}) {} +class Play extends Schema.TaggedClass("StructuralPlay")("Play", {}) {} +class Mute extends Schema.TaggedClass("StructuralMute")("Mute", { + volume: Schema.Number +}) {} +class Edit extends Schema.TaggedClass("StructuralEdit")("Edit", { + draft: Schema.String +}) {} +class Leave extends Schema.TaggedClass("StructuralLeave")("Leave", {}) {} +class ResumeShallow extends Schema.TaggedClass("StructuralResumeShallow")("ResumeShallow", {}) {} +class ResumeDeep extends Schema.TaggedClass("StructuralResumeDeep")("ResumeDeep", {}) {} +class Editing extends Schema.TaggedClass("StructuralEditing")("Editing", { + draft: Schema.String +}) {} + +const States = Machine.defineStates({ + player: { + type: "parallel", + annotations: { title: "Player" }, + states: { + transport: { + initial: "Empty", + states: { + Empty: {}, + Loading, + Ready: { + schema: Ready, + initial: "Paused", + states: { + Paused: {}, + Playing + } + } + } + }, + settings: { + initial: "Audible", + states: { + Audible, + Muted + } + } + } + } +}) + +const initial = States.initial.player.from((player) => + player + .transport.from((transport) => transport.Empty.from()) + .settings.from((settings) => settings.Audible.from({ volume: 1 })) +) + +const makeMachine = () => + Machine.make({ + states: States.states, + events: [SourceSelected, Loaded, Play, Mute], + initial: () => initial + }).handle({ + player: { + states: { + transport: { + states: { + Empty: { + on: { + SourceSelected: ({ event, state, target }) => { + assert.strictEqual(state, undefined) + return target.local.Loading.from({ url: event.url }) + } + } + }, + Loading: { + on: { + Loaded: ({ event, state, target }) => { + assert.strictEqual(state._tag, "Loading") + return target.local.Ready.from( + { duration: event.duration }, + (ready) => ready.Paused.from() + ) + } + } + }, + Ready: { + states: { + Paused: { + on: { + Play: ({ parent, state, target }) => { + assert.strictEqual(state, undefined) + return target.local.Playing.from({ position: Math.min(0, parent.duration) }) + } + } + }, + Playing: { + on: { + Mute: ({ event, target }) => target.branch.player.settings.Muted.from({ volume: event.volume }) + } + } + } + } + } + } + } + } + }) + +const HistoryStates = Machine.defineStates({ + flow: { + initial: "section", + states: { + section: { + initial: "Idle", + states: { + Idle: {}, + Editing + } + }, + recent: { type: "history" }, + exact: { type: "history", history: "deep" } + } + }, + away: {} +}) + +const historyFallback = () => + HistoryStates.initial.flow.from((flow) => flow.section.from((section) => section.Idle.from())) + +const historyMachine = Machine.make({ + states: HistoryStates.states, + events: [Edit, Leave, ResumeShallow, ResumeDeep], + initial: historyFallback +}).handle({ + flow: { + history: { + recent: { default: historyFallback }, + exact: { default: historyFallback } + }, + on: { + Leave: ({ target }) => target.full.away.from() + }, + states: { + section: { + states: { + Idle: { + on: { + Edit: ({ event, target }) => target.local.Editing.from({ draft: event.draft }) + } + } + } + } + } + }, + away: { + on: { + ResumeShallow: ({ target }) => target.history.flow.recent(), + ResumeDeep: ({ target }) => target.history.flow.exact() + } + } +}) + +const FinalStates = Machine.defineStates({ + Done: { + type: "final", + output: Schema.String + } +}) + +describe("structural active states", () => { + it.effect("constructs structural atomic, compound, and parallel snapshots without values", () => + Effect.gen(function*() { + const planned = yield* Machine.planInitial(makeMachine()) + const snapshot = planned.state + assert.deepStrictEqual(snapshot, { + path: "player", + value: undefined, + states: { + transport: { + path: "player.transport", + value: undefined, + state: { + path: "player.transport.Empty", + value: undefined + } + }, + settings: { + path: "player.settings", + value: undefined, + state: { + path: "player.settings.Audible", + value: new Audible({ volume: 1 }) + } + } + } + }) + + assert.isTrue(States.matches(snapshot, "player.transport")) + assert.isTrue(States.matches(snapshot, "player.transport.Empty")) + assert.deepStrictEqual( + States.get(snapshot, "player.settings.Audible"), + Option.some(new Audible({ volume: 1 })) + ) + const transport = States.getSnapshot(snapshot, "player.transport") + assert(Option.isSome(transport)) + assert.strictEqual(transport.value.value, undefined) + })) + + it.effect("transitions structural to valued, valued to structural, and across parallel regions", () => + Effect.gen(function*() { + const machine = makeMachine() + const started = yield* Machine.planInitial(machine) + + const loading = yield* Machine.plan(machine, started.state, new SourceSelected({ url: "/song.mp3" })) + assert.deepStrictEqual( + States.get(loading.next, "player.transport.Loading"), + Option.some(new Loading({ url: "/song.mp3" })) + ) + + const paused = yield* Machine.plan(machine, loading.next, new Loaded({ duration: 120 })) + assert.isTrue(States.matches(paused.next, "player.transport.Ready.Paused")) + assert.deepStrictEqual( + States.getWithParents(paused.next, "player.transport.Ready"), + Option.some({ value: new Ready({ duration: 120 }), parents: {} }) + ) + + const playing = yield* Machine.plan(machine, paused.next, new Play({})) + assert.deepStrictEqual( + States.getWithParents(playing.next, "player.transport.Ready.Playing"), + Option.some({ + value: new Playing({ position: 0 }), + parents: { "player.transport.Ready": new Ready({ duration: 120 }) } + }) + ) + + const muted = yield* Machine.plan(machine, playing.next, new Mute({ volume: 0 })) + assert.isTrue(States.matches(muted.next, "player.transport.Ready.Playing")) + assert.deepStrictEqual( + States.get(muted.next, "player.settings.Muted"), + Option.some(new Muted({ volume: 0 })) + ) + })) + + it.effect("encodes active structural paths without inventing values", () => + Effect.gen(function*() { + const machine = makeMachine() + const started = yield* Machine.planInitial(machine) + const encoded = yield* Machine.encodeSnapshot(machine, started.state) + + assert.deepStrictEqual(encoded.active, [ + { path: "player" }, + { path: "player.transport" }, + { path: "player.transport.Empty" }, + { path: "player.settings" }, + { path: "player.settings.Audible", value: { _tag: "Audible", volume: 1 } } + ]) + + const decoded = yield* Machine.decodeSnapshot(machine, encoded) + assert.deepStrictEqual(decoded, started.state) + + const encodedWithStructuralValue = structuredClone(encoded) as any + encodedWithStructuralValue.active[0].value = { _tag: "Invented" } + const decodeError = yield* Machine.decodeSnapshot(machine, encodedWithStructuralValue).pipe(Effect.flip) + assert.instanceOf(decodeError, Machine.MachineSchemaDecodeError) + + const snapshotWithStructuralValue = { ...started.state, value: { _tag: "Invented" } } as any + const encodeError = yield* Machine.encodeSnapshot(machine, snapshotWithStructuralValue).pipe(Effect.flip) + assert.instanceOf(encodeError, Machine.MachineSchemaEncodeError) + })) + + it.effect("restores structural control through shallow and deep history", () => + Effect.gen(function*() { + const started = yield* Machine.planInitial(historyMachine) + const editing = yield* Machine.plan(historyMachine, started.state, new Edit({ draft: "saved" })) + const away = yield* Machine.plan(historyMachine, editing.next, new Leave({})) + + const shallow = yield* Machine.plan(historyMachine, away.next, new ResumeShallow({})) + assert.isTrue(HistoryStates.matches(shallow.next, "flow.section.Idle")) + assert.isTrue(Option.isNone(HistoryStates.get(shallow.next, "flow.section.Editing"))) + + const deep = yield* Machine.plan(historyMachine, away.next, new ResumeDeep({})) + assert.deepStrictEqual( + HistoryStates.get(deep.next, "flow.section.Editing"), + Option.some(new Editing({ draft: "saved" })) + ) + assert.deepStrictEqual(deep.next.history, away.next.history) + })) + + it.effect("keeps final output independent from a state value schema", () => + Effect.gen(function*() { + const machine = Machine.make({ + states: FinalStates.states, + events: [], + initial: () => FinalStates.initial.Done.from() + }).handle({ + Done: { + output: ({ state }) => { + assert.strictEqual(state, undefined) + return "complete" + } + } + }) + + const planned = yield* Machine.planInitial(machine) + assert.isTrue(planned.done) + assert.strictEqual(planned.output, "complete") + assert.deepStrictEqual(planned.state, { + path: "Done", + value: undefined, + completed: [{ path: "Done", output: "complete" }] + }) + })) +}) diff --git a/typetest/machine/Inspection.tst.ts b/typetest/machine/Inspection.tst.ts index 5373cc9..c12c085 100644 --- a/typetest/machine/Inspection.tst.ts +++ b/typetest/machine/Inspection.tst.ts @@ -44,7 +44,7 @@ describe("Machine inspection", () => { const inspect = (node: Machine.Machine.StateNode<"state">) => { switch (node.type) { case "atomic": - expect(node.schema).type.toBe() + expect(node.schema).type.toBe() expect(node.output).type.toBe() expect(node.history).type.toBe() expect(node.children).type.toBe() @@ -52,7 +52,7 @@ describe("Machine inspection", () => { expect(node.parent).type.toBe<"state" | undefined>() break case "compound": - expect(node.schema).type.toBe() + expect(node.schema).type.toBe() expect(node.output).type.toBe() expect(node.history).type.toBe() expect(node.children).type.toBe>() @@ -60,7 +60,7 @@ describe("Machine inspection", () => { expect(node.parent).type.toBe<"state" | undefined>() break case "parallel": - expect(node.schema).type.toBe() + expect(node.schema).type.toBe() expect(node.output).type.toBe() expect(node.history).type.toBe() expect(node.children).type.toBe>() @@ -68,7 +68,7 @@ describe("Machine inspection", () => { expect(node.parent).type.toBe<"state" | undefined>() break case "final": - expect(node.schema).type.toBe() + expect(node.schema).type.toBe() expect(node.output).type.toBe() expect(node.history).type.toBe() expect(node.children).type.toBe() diff --git a/typetest/machine/StructuralStates.tst.ts b/typetest/machine/StructuralStates.tst.ts new file mode 100644 index 0000000..1ab5ebd --- /dev/null +++ b/typetest/machine/StructuralStates.tst.ts @@ -0,0 +1,207 @@ +import { type Option, Schema } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" + +class Loading extends Schema.TaggedClass("StructuralTypeLoading")("Loading", { + url: Schema.String +}) {} +class Ready extends Schema.TaggedClass("StructuralTypeReady")("Ready", { + duration: Schema.Number +}) {} +class Playing extends Schema.TaggedClass("StructuralTypePlaying")("Playing", { + position: Schema.Number +}) {} +class Audible extends Schema.TaggedClass("StructuralTypeAudible")("Audible", { + volume: Schema.Number +}) {} +class Muted extends Schema.TaggedClass("StructuralTypeMuted")("Muted", { + volume: Schema.Number +}) {} +class Select extends Schema.TaggedClass