From 8e44b2f5ce12e482959ce060e02d045fe5d3c6f5 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 6 Sep 2026 14:24:45 +0200 Subject: [PATCH 1/4] feat: model machines with one typed root --- packages/effect-machine/src/Machine.ts | 1178 ++-- .../src/internal/machine/atom.ts | 6 +- .../src/internal/machine/cluster.ts | 8 +- .../src/internal/machine/configuration.ts | 16 +- .../src/internal/machine/executionPlan.ts | 161 +- .../src/internal/machine/machine.ts | 290 +- .../src/internal/machine/planner.ts | 97 +- .../src/internal/machine/process.ts | 24 +- .../src/internal/machine/protocol.ts | 12 +- .../src/internal/machine/readiness.ts | 1 + .../src/internal/machine/serialization.ts | 2 + .../src/internal/machine/stateDefinition.ts | 39 +- .../src/internal/machine/targetBuilder.ts | 15 +- .../src/internal/machine/topology.ts | 6 +- .../internal/testing/machine/finiteModel.ts | 45 +- .../src/internal/testing/machine/format.ts | 3 +- .../src/internal/testing/machine/probe.ts | 8 +- .../testing/machine/referenceModel.ts | 42 +- .../src/internal/testing/machine/trace.ts | 8 +- .../internal/testing/machine/verification.ts | 26 +- .../effect-machine/src/testing/MachineTest.ts | 125 +- .../src/unstable/cluster/ClusterMachine.ts | 21 +- .../src/unstable/reactivity/AtomMachine.ts | 108 +- packages/effect-machine/test/examples/can.ts | 10 +- .../machine/ProtocolValidation.test.ts | 25 +- .../test/internal/machine/activities.test.ts | 123 +- .../test/internal/machine/protocol.test.ts | 28 +- .../internal/machine/rootStrategies.test.ts | 106 + .../machine/strategyDifferential.test.ts | 938 +-- .../machine/support/strategyDifferential.ts | 5 +- .../machine/ActivityLifecycleModel.test.ts | 408 +- .../test/machine/Annotations.test.ts | 57 +- .../machine/AnnotationsVisualization.test.ts | 70 +- .../test/machine/Choice.test.ts | 657 +- .../test/machine/DeepHandlers.test.ts | 185 +- .../test/machine/DynamicChildren.test.ts | 239 +- .../test/machine/History.test.ts | 671 +- .../test/machine/InitialEntry.test.ts | 253 +- .../test/machine/Inspection.test.ts | 94 +- .../test/machine/Invoke.test.ts | 335 +- .../test/machine/LiveInspection.test.ts | 119 +- .../test/machine/LocalTargetWith.test.ts | 154 +- .../test/machine/Machine.test.ts | 6092 ++++++++++------- .../test/machine/MachineReferences.test.ts | 236 +- .../test/machine/ProtocolOwnership.test.ts | 39 +- .../test/machine/PublicPrototype.test.ts | 8 +- .../test/machine/Resume.test.ts | 649 +- .../effect-machine/test/machine/Root.test.ts | 242 + .../test/machine/RuntimeDifferential.test.ts | 340 +- .../test/machine/Scheduling.test.ts | 44 +- .../machine/SnapshotCodecAdversarial.test.ts | 309 +- .../test/machine/SnapshotContext.test.ts | 269 +- .../test/machine/StateDefinition.test.ts | 246 +- .../test/machine/StateUpdate.test.ts | 664 +- .../test/machine/StructuralStates.test.ts | 283 +- .../test/machine/Totality.test.ts | 30 +- .../test/machine/Visualization.test.ts | 355 +- .../test/machine/visualization/mermaid.ts | 4 +- .../test/machine/visualization/text.ts | 6 +- .../test/testing/Coverage.test.ts | 156 +- .../test/testing/Exploration.test.ts | 118 +- .../test/testing/FiniteModel.test.ts | 12 +- .../test/testing/Invariant.test.ts | 45 +- .../test/testing/MachineTest.test.ts | 176 +- .../effect-machine/test/testing/Probe.test.ts | 109 +- .../test/testing/ReferenceModel.test.ts | 140 +- .../test/testing/Runtime.test.ts | 176 +- .../test/testing/TraceValues.test.ts | 27 +- .../test/testing/Verification.test.ts | 612 +- .../unstable/cluster/ClusterMachine.test.ts | 164 +- .../unstable/reactivity/AtomMachine.test.ts | 496 +- .../typetest/machine/Activities.tst.ts | 23 +- .../typetest/machine/Annotations.tst.ts | 131 +- .../typetest/machine/Choice.tst.ts | 168 +- .../typetest/machine/ConsumerTypes.tst.ts | 48 +- .../typetest/machine/DeepHandlers.tst.ts | 539 +- .../typetest/machine/DynamicChildren.tst.ts | 96 +- .../typetest/machine/EventByTag.tst.ts | 50 +- .../typetest/machine/EventConstructors.tst.ts | 70 +- .../typetest/machine/History.tst.ts | 917 +-- .../typetest/machine/InitialEntry.tst.ts | 105 +- .../typetest/machine/Inspection.tst.ts | 147 +- .../typetest/machine/Machine.tst.ts | 2001 +++--- .../typetest/machine/MachineReferences.tst.ts | 196 +- .../typetest/machine/Readiness.tst.ts | 165 +- .../typetest/machine/Resume.tst.ts | 31 +- .../typetest/machine/Root.tst.ts | 72 + .../typetest/machine/SnapshotContext.tst.ts | 148 +- .../typetest/machine/StateDefinition.tst.ts | 207 +- .../typetest/machine/StateUpdate.tst.ts | 176 +- .../typetest/machine/StructuralStates.tst.ts | 205 +- .../typetest/testing/Coverage.tst.ts | 32 +- .../typetest/testing/Exploration.tst.ts | 42 +- .../typetest/testing/Invariant.tst.ts | 31 +- .../typetest/testing/MachineTest.tst.ts | 62 +- .../typetest/testing/Probe.tst.ts | 31 +- .../typetest/testing/Verification.tst.ts | 10 +- .../unstable/cluster/ClusterMachine.tst.ts | 164 +- .../unstable/reactivity/AtomMachine.tst.ts | 262 +- 99 files changed, 14099 insertions(+), 10795 deletions(-) create mode 100644 packages/effect-machine/test/internal/machine/rootStrategies.test.ts create mode 100644 packages/effect-machine/test/machine/Root.test.ts create mode 100644 packages/effect-machine/typetest/machine/Root.tst.ts diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index d3a31a2..66579dd 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -162,6 +162,9 @@ export interface Machine< */ readonly states: States + /** Root descriptor for topology and snapshot access. */ + readonly root: State, Machine.StateNodeConfig>> + /** * Events accepted through public machine input boundaries. * @@ -282,15 +285,15 @@ export interface Definition< * **Example** * * ```ts - * const counter = definition.handle({ + * const counter = definition.handle({ states: { * Count: { * on: { * Increment: (to) => - * to.full.Count().resolve(({ event, state, target }) => + * to.branch.Count().resolve(({ event, state, target }) => * target.decoded(new Count({ value: state.value + event.by }))) * } * } - * }) + * } }) * ``` * * @since 0.4.0 @@ -835,29 +838,6 @@ type ValidateStateNodeWithoutChildren = "i : "output" extends keyof Node ? StateDefinitionError<"Only final and parallel states can declare output"> : unknown -type DefineStateTreeInput = { - readonly [Key in keyof States]: DefineStateNodeInput -} - -type DefineStateNodeInput = Node extends Machine.TaggedSchema ? Node - : Node extends Machine.HistoryStateNodeConfig ? Machine.HistoryStateNodeConfig - : Node extends Machine.ChoiceStateNodeConfig ? Machine.ChoiceStateNodeConfig - : Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? - Omit & { readonly states: DefineStateTreeInput } - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? Omit & { - readonly initial: ActiveStateKey | ChoiceStateKey - readonly states: DefineStateTreeInput - } - : Node - -type ValidateDefinedStates = [States] extends - [Machine.ValidateStateSchemas] ? [] - : [validation: Machine.ValidateStateSchemas] - -type InvalidDefinedStateTreeInput = [States] extends - [Machine.ValidateStateSchemas] ? never - : States & Machine.ValidateStateSchemas - type ReusableStateNodeConfig = | Machine.AtomicStateNodeConfig | Machine.CompoundStateNodeConfig @@ -869,23 +849,73 @@ type ReusableStateValidation = Node extends ReusableStateNodeConfig ? Vali type ValidateDefinedState = [Node] extends [ReusableStateValidation] ? [] : [validation: ReusableStateValidation] -type InvalidDefinedStateInput = [Node] extends [ReusableStateValidation] ? never - : Node & ReusableStateValidation +const StateTypeId = "~effect/Machine/State" -interface StateConstructor { - ( - node: Node & DefineStateNodeInput>, - ..._validation: ValidateDefinedState> - ): Node - (node: InvalidDefinedStateInput): never +/** An immutable state descriptor, usable as a root or a nested state. + * @category models + * @since 0.32.0 + */ +export interface State extends Machine.StateAccessors<{ readonly "": Node }> { + readonly [StateTypeId]: typeof StateTypeId + readonly node: Node +} + +type NormalizeState = Node extends + { readonly [StateTypeId]: typeof StateTypeId; readonly node: infer Config } ? Config + : Node extends Machine.TaggedSchema ? Node + : "fields" extends keyof Node ? NormalizeStateFields + : Node extends { readonly states: infer Children } ? + Children extends Readonly> ? Node : NormalizeStateFields + : Node + +type NormalizeStateFields = { + readonly [Key in keyof Node as Key extends "fields" ? "schema" : Key]: Key extends "fields" ? + Node[Key] extends Schema.Struct.Fields ? Schema.TaggedStruct : never + : Key extends "states" ? + { readonly [Child in keyof Node[Key]]: NormalizeState> } + : Node[Key] } -interface StatesConstructor { - ( - states: States & DefineStateTreeInput>, - ..._validation: ValidateDefinedStates> - ): Machine.DefinedStates - (states: InvalidDefinedStateTreeInput): never +type NormalizedState = Extract, Machine.StateNodeConfig> +type StateFieldsValidation = Node extends { readonly [StateTypeId]: typeof StateTypeId } | Machine.TaggedSchema ? + unknown : + & (Node extends { readonly fields: infer Fields } ? + "schema" extends keyof Node ? StateDefinitionError<"Declare fields or schema, not both", ""> + : Fields extends Schema.Struct.Fields ? + "_tag" extends keyof Fields ? StateDefinitionError<"State fields cannot declare _tag", ""> : unknown + : StateDefinitionError<"State fields must be schema fields", ""> + : unknown) + & (Node extends { readonly states: infer Children } ? { + readonly states: { + readonly [Key in keyof Children]: StateFieldsValidation + } + } : + unknown) + +interface StateInput { + readonly schema?: Machine.TaggedSchema + readonly fields?: Schema.Struct.Fields + readonly type?: "active" | "parallel" | "final" | "history" | "choice" + readonly initial?: string + readonly states?: Readonly< + Record< + string, + Machine.TaggedSchema | StateInput | { + readonly [StateTypeId]: typeof StateTypeId + readonly node: Machine.StateNodeConfig + } + > + > + readonly history?: "shallow" | "deep" + readonly output?: Schema.Top + readonly annotations?: Machine.SchemaLessStateAnnotations +} + +interface StateConstructor { + ( + node: Node & StateFieldsValidation>, + ..._validation: ValidateDefinedState>> + ): State> } type EventProtocolError = { @@ -912,12 +942,6 @@ type ValidateInputEventProtocol< > = [DuplicateInput] extends [never] ? unknown : EventProtocolError<"Public event tags must be unique", DuplicateInput> -type ValidateEmittedEventProtocol< - EmittedEvents extends ReadonlyArray, - DuplicateEmitted extends PropertyKey = DuplicateEventTag -> = [DuplicateEmitted] extends [never] ? unknown - : EventProtocolError<"Emitted event tags must be unique", DuplicateEmitted> - type ValidateInternalEventProtocol< InputEvents extends ReadonlyArray, InternalEvents extends ReadonlyArray, @@ -954,7 +978,7 @@ type FromCallable, Result> = Arguments : (...args: Arguments) => Result : (...args: Arguments) => Result -type FromMethod, Result> = { +type FromMethod, out Result> = { /** * Constructs the selected state from its schema make input while the * machine plans the resulting configuration. The input may be omitted when @@ -1224,33 +1248,28 @@ type InitialParallelBuilder< } type FullSnapshotBuilderWithPrefix< - States extends Machine.StateSchemas, - Prefix extends string = "" -> = - & { - readonly [Key in ActiveStateKey]: FullSnapshotMethod - } - & { - readonly [Key in ChoiceStateKey]: () => Machine.ChoiceTargetInstruction> - } + in out States extends Machine.StateSchemas, + in out Prefix extends string = "" +> = { + readonly [Key in ActiveStateKey | ChoiceStateKey]: Key extends ActiveStateKey + ? FullSnapshotMethod + : () => Machine.ChoiceTargetInstruction> +} type FullSnapshotMethod< - States extends Machine.StateSchemas, - StateId extends ActiveStateKey, - Prefix extends string -> = Machine.NodeSchema extends never ? FromMethod< - FullSnapshotFromArguments, - FullSnapshotResult - > - : - & DecodedMethod< - FullSnapshotArguments, - FullSnapshotResult - > - & FromMethod< + in out States extends Machine.StateSchemas, + in out StateId extends ActiveStateKey, + in out Prefix extends string +> = { + readonly [ + /** Constructs this node using schema make input or an already decoded value. */ + Method in Machine.NodeSchema extends never ? "from" : "from" | "decoded" + ]: Method extends "from" ? FromCallable< FullSnapshotFromArguments, - FullSnapshotResult + Machine.StateConstruction> > + : (...args: FullSnapshotArguments) => FullSnapshotResult +} type FullSnapshotArguments< States extends Machine.StateSchemas, @@ -1409,23 +1428,20 @@ type HistorySnapshotResult< : never type HistorySnapshotMethod< - States extends Machine.StateSchemas, - StateId extends ActiveStateKey, - Prefix extends string, - Owner extends string -> = Machine.NodeSchema extends never ? FromMethod< - HistorySnapshotFromArguments, - HistorySnapshotResult - > - : - & DecodedMethod< - HistorySnapshotArguments, - HistorySnapshotResult - > - & FromMethod< + in out States extends Machine.StateSchemas, + in out StateId extends ActiveStateKey, + in out Prefix extends string, + in out Owner extends string +> = { + readonly [Key in Machine.NodeSchema extends never ? "from" : "from" | "decoded"]: Key extends + "from" ? FromCallable< HistorySnapshotFromArguments, - HistorySnapshotResult - > + Machine.StateConstruction> + > : + ( + ...args: HistorySnapshotArguments + ) => HistorySnapshotResult +} type HistorySnapshotWithPrefix< States extends Machine.StateSchemas, @@ -1434,7 +1450,8 @@ type HistorySnapshotWithPrefix< > = { readonly [Key in ActiveStateKey]: Owner extends | Machine.JoinPath - | `${Machine.JoinPath}.${string}` ? HistorySnapshotResult + | (Machine.JoinPath extends "" ? string : `${Machine.JoinPath}.${string}`) ? + HistorySnapshotResult : never }[ActiveStateKey] @@ -1446,7 +1463,7 @@ type HistorySnapshotBuilderWithPrefix< readonly [ Key in ActiveStateKey as Owner extends | Machine.JoinPath - | `${Machine.JoinPath}.${string}` ? Key + | (Machine.JoinPath extends "" ? string : `${Machine.JoinPath}.${string}`) ? Key : never ]: HistorySnapshotMethod } @@ -1458,7 +1475,8 @@ type HistorySnapshotRegions< > = { readonly [Key in ActiveStateKey]: Owner extends | Machine.JoinPath - | `${Machine.JoinPath}.${string}` ? HistorySnapshotResult + | (Machine.JoinPath extends "" ? string : `${Machine.JoinPath}.${string}`) ? + HistorySnapshotResult : FullSnapshotResult } @@ -1474,7 +1492,8 @@ type HistoryParallelBuilder< & { readonly [Key in Remaining]: Owner extends | Machine.JoinPath - | `${Machine.JoinPath}.${string}` ? NodeBuilderMethod< + | (Machine.JoinPath extends "" ? string : `${Machine.JoinPath}.${string}`) ? + NodeBuilderMethod< States[Key], HistorySnapshotArguments, HistoryParallelBuilder< @@ -1518,9 +1537,7 @@ type HistoryParallelBuilder< > } -type ParentPath = Path extends `${infer Parent}.${infer Child}` - ? Child extends `${string}.${string}` ? `${Parent}.${ParentPath}` : Parent - : never +type ParentPath = Machine.ImmediateParentStateIdentifier type IsCompoundNode = Node extends { readonly type: "parallel" } ? false : Node extends { readonly states: Machine.StateSchemas } ? true @@ -1600,7 +1617,7 @@ 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}` ? NestedTargetMethod< + Source extends Path | (Path extends "" ? string : `${Path}.${string}`) ? NestedTargetMethod< Node, LocalTargetBuilderWithPrefix, LocalTargetResultWithPrefix, @@ -1715,7 +1732,7 @@ type BranchTargetMethod< 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}` ? + Source extends Path | (Path extends "" ? string : `${Path}.${string}`) ? & NestedTargetMethod< Node, BranchTargetBuilderWithPrefix, @@ -1788,20 +1805,26 @@ 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 +type RequiresInitialValue = Machine.NodeSchema extends never ? false + : {} extends NodeMakeInput ? false + : true + +type HasRequiredActiveChild = { + readonly [Key in ActiveStateKey]: RequiresInitialValue extends true ? Key : never }[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 } ? - | (HasValuedActiveChild extends true ? Extract> : never) - | InitializerClosuresForChildren +> = Node extends { readonly states: infer Declared extends Machine.StateSchemas } ? + string extends keyof Declared ? never : + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + | (HasRequiredActiveChild extends true ? Extract> : never) + | InitializerClosuresForChildren : Node extends { readonly states: infer Children extends Machine.StateSchemas; readonly initial: infer Initial } ? - | (Initial extends ActiveStateKey ? Machine.NodeSchema extends never ? never - : Extract> + | (Initial extends ActiveStateKey ? + RequiresInitialValue extends true ? Extract> : never : never) | (Initial extends ActiveStateKey ? InitializerClosureForNode< AllStates, @@ -1810,6 +1833,7 @@ type InitializerClosureForNode< > : never) : never + : never type InitializerClosuresForChildren< AllStates extends Machine.StateSchemas, @@ -2483,7 +2507,7 @@ export declare namespace ChildMachine { ExcludeCompatibleRuntime< Exclude | Machine.Services>, MachineRuntimeRequirement>, Machine.Event, - Machine.Emit + Machine.EmittedEvent >, Scope.Scope > @@ -2721,6 +2745,7 @@ export declare namespace Machine { /** @internal */ readonly [MachineTypeId]: TypeCarrier readonly states: StateSchemas + readonly root: Pick, typeof StateTypeId | "node"> readonly events: EventProtocol.Any<"public"> readonly internalEvents: EventProtocol.Any<"internal"> readonly emittedEvents: EventProtocol.Any<"emitted"> @@ -2841,9 +2866,6 @@ export declare namespace Machine { */ export type EmittedEvents = M[typeof MachineTypeId]["emittedEvents"] - /** @deprecated Use {@link EmittedEvents}. */ - export type Emits = EmittedEvents - /** * Extracts state paths with implemented output handlers. * @@ -3030,10 +3052,7 @@ export declare namespace Machine { * @category utility types * @since 0.4.0 */ - export type EmittedEvent = EmittedEventOf> - - /** @deprecated Use {@link EmittedEvent}. */ - export type Emit = EmittedEvent + export type EmittedEvent = EmittedEventOf> /** * A schema whose decoded value contains a `_tag` discriminator. @@ -3073,9 +3092,6 @@ export declare namespace Machine { "title" | "description" | "documentation" > - /** @deprecated Use {@link SchemaLessStateAnnotations}. */ - export type PseudoStateAnnotations = SchemaLessStateAnnotations - /** * Configuration accepted for an atomic object state node. * @@ -3271,21 +3287,16 @@ export declare namespace Machine { type InitialBuilder = InitialSnapshotBuilderWithPrefix /** - * State definitions and snapshot access helpers returned by `states`. + * Typed snapshot access shared by state descriptors. * * **Details** * - * The `states` property is the original state tree and can be passed directly - * to `make`. The remaining helpers match and read snapshots for the same - * state tree. + * The helpers match and read snapshots for the same captured topology. * * @category models * @since 0.4.0 */ - export interface DefinedStates { - /** Captured state tree supplied to {@link states}. */ - readonly states: States - + export interface StateAccessors { /** * Checks and preserves one active state path from this definition. * @@ -3314,7 +3325,10 @@ export declare namespace Machine { const Path extends ValuedStateIdentifier >( snapshot: SnapshotByIdentifier, - path: Path & (Path extends NoInfer | `${NoInfer}.${string}` ? unknown : never) + path: + & Path + & (Path extends NoInfer | (NoInfer extends "" ? string : `${NoInfer}.${string}`) ? unknown + : never) ): Option.Option> } @@ -3351,7 +3365,10 @@ export declare namespace Machine { const Path extends StateIdentifier >( snapshot: SnapshotByIdentifier, - path: Path & (Path extends NoInfer | `${NoInfer}.${string}` ? unknown : never) + path: + & Path + & (Path extends NoInfer | (NoInfer extends "" ? string : `${NoInfer}.${string}`) ? unknown + : never) ): Option.Option> } @@ -3373,7 +3390,10 @@ export declare namespace Machine { const Path extends StateIdentifier >( snapshot: SnapshotByIdentifier, - path: Path & (Path extends NoInfer | `${NoInfer}.${string}` ? unknown : never) + path: + & Path + & (Path extends NoInfer | (NoInfer extends "" ? string : `${NoInfer}.${string}`) ? unknown + : never) ): boolean } } @@ -3817,17 +3837,17 @@ export declare namespace Machine { export type EnsureChoiceImplementations< States extends StateSchemas, UnhandledStates extends StateIdentifier - > = [ChoiceIdentifier] extends [never] ? unknown - : [MissingChoiceImplementations] extends [never] ? unknown - : { - readonly "~effect/Machine/MissingChoiceImplementation": MissingChoiceImplementations - } + > = IsAny extends true ? unknown : [ChoiceIdentifier] extends [never] ? unknown + : [MissingChoiceImplementations] extends [never] ? unknown + : { + readonly "~effect/Machine/MissingChoiceImplementation": MissingChoiceImplementations + } /** @internal Readiness proof required by planning and managed execution. */ export type EnsureHistoryImplementations< States extends StateSchemas, UnhandledStates extends StateIdentifier - > = + > = IsAny extends true ? unknown : & ([HistoryIdentifier] extends [never] ? unknown : [MissingHistoryImplementations] extends [never] ? unknown : { @@ -3835,6 +3855,20 @@ export declare namespace Machine { }) & EnsureChoiceImplementations + /** Owners whose declared default descendants require supplied values. */ + export type RequiredInitializers = string extends keyof States ? never : + "" extends keyof States ? States[""] extends { readonly "~effect/Machine/ExplicitInitial": true } ? never + : InitializerClosureForNode : + never + + /** Proof that initialization can construct every required default value. */ + export type EnsureInitialImplementations< + States extends StateSchemas, + UnhandledStates extends StateIdentifier + > = [Extract, UnhandledStates>] extends [never] ? unknown : { + readonly "~effect/Machine/MissingInitialImplementation": Extract, UnhandledStates> + } + /** * Extracts a state-tree node by state path. * @@ -3844,7 +3878,15 @@ export declare namespace Machine { export type NodeByIdentifier< States extends StateSchemas, StateId extends StateIdentifier - > = StateId extends `${infer Head}.${infer Rest}` + > = string extends keyof States ? NodeByNamedIdentifier + : "" extends keyof States ? StateId extends "" ? States[""] + : States[""] extends { readonly states: infer Children extends StateSchemas } + ? NodeByIdentifier>> : + never + : NodeByNamedIdentifier + + type NodeByNamedIdentifier = StateId extends + `${infer Head}.${infer Rest}` ? Head extends keyof States ? States[Head] extends { readonly states: infer Children extends StateSchemas } ? Rest extends StateIdentifier ? NodeByIdentifier : never @@ -3892,9 +3934,6 @@ export declare namespace Machine { */ export type EmittedEventOf> = Emits[number]["Type"] - /** @deprecated Use {@link EmittedEventOf}. */ - export type EmitOf> = EmittedEventOf - /** * Event values received by lifecycle callbacks. * @@ -3920,8 +3959,11 @@ export declare namespace Machine { * @category utility types * @since 0.4.0 */ - export type ParentStateIdentifier = StateId extends `${infer Parent}.${infer Child}` - ? Parent | (Child extends `${string}.${string}` ? `${Parent}.${ParentStateIdentifier}` : never) + export type ParentStateIdentifier = StateId extends "" ? never + : "" | NamedParentStateIdentifier + + type NamedParentStateIdentifier = StateId extends `${infer Parent}.${infer Child}` + ? Parent | (Child extends `${string}.${string}` ? `${Parent}.${NamedParentStateIdentifier}` : never) : never /** @@ -3933,7 +3975,8 @@ export declare namespace Machine { export type ImmediateParentStateIdentifier = StateId extends `${infer Head}.${infer Tail}` ? Tail extends `${string}.${string}` ? `${Head}.${ImmediateParentStateIdentifier}` : Head - : never + : StateId extends "" ? never + : "" /** * Maps every parent state path of a state identifier to its decoded value. @@ -4016,7 +4059,7 @@ export declare namespace Machine { States extends StateSchemas, Children extends StateSchemas, Prefix extends StateIdentifier - > = { + > = Prefix extends "" ? TerminalOutput : { readonly [Key in ActiveStateKey]: DirectFinalCompletionOutput< States, Extract, StateIdentifier> @@ -4187,6 +4230,7 @@ export declare namespace Machine { * @since 0.4.0 */ export interface EncodedSnapshot { + readonly version: 2 readonly _tag: "MachineSnapshot" readonly active: ReadonlyArray readonly completed?: ReadonlyArray @@ -4381,7 +4425,7 @@ export declare namespace Machine { * @category utility types * @since 0.4.0 */ - export type RootStateIdentifier = StateId extends `${infer Root}.${string}` ? Root : StateId + export type RootStateIdentifier = StateId extends string ? "" : never /** * Extracts the public snapshot shape that contains a final state path. @@ -4443,7 +4487,7 @@ export declare namespace Machine { * @category models * @since 0.4.0 */ - export interface StateConstruction { + export interface StateConstruction { readonly [Topology.StateConstructionTypeId]: Result } @@ -4626,7 +4670,12 @@ export declare namespace Machine { export type HistoryDefaultTargetBuilder< States extends StateSchemas, Owner extends StateIdentifier - > = HistorySnapshotBuilderWithPrefix + > = HistorySnapshotMethod< + { readonly "": Extract, StateNodeConfig> }, + "" & ActiveStateKey<{ readonly "": Extract, StateNodeConfig> }>, + "", + Owner + > /** * Builder for source-local transition targets. @@ -4663,7 +4712,7 @@ export declare namespace Machine { Source extends StateNodeIdentifier > = BranchTargetBuilderForRoot< States, - Extract, ActiveStateKey>, + Extract<"" extends keyof States ? "" : RootStateIdentifier, ActiveStateKey>, Source > @@ -4687,8 +4736,8 @@ export declare namespace Machine { * @since 0.4.0 */ export interface TargetBuilder< - States extends StateSchemas, - Source extends StateNodeIdentifier + in out States extends StateSchemas, + in out Source extends StateNodeIdentifier > { /** * Selects an explicitly targetless transition. @@ -4820,16 +4869,15 @@ export declare namespace Machine { Node, Path extends string, Scope extends "local" | "branch" - > = Node extends { readonly states: StateSchemas } ? NodeSchema extends never ? {} - : { - readonly update: SelectionValue< - StateUpdateBuilder>>, - Path, - "update", - Scope - > - } - : {} + > = Node extends { readonly type: "final" } ? {} : NodeSchema extends never ? {} + : { + readonly update: SelectionValue< + StateUpdateBuilder>>, + Path, + "update", + Scope + > + } /** @internal */ type BranchUpdateSelectionPath< @@ -4872,34 +4920,18 @@ export declare namespace Machine { & InitialSelectionMethod : SelectionMethod - type FullTargetSelector = { - readonly [Key in Extract, keyof FullTargetBuilder>]: FullSelectionNode< - States, - States[Key], - Extract>, - FullTargetBuilder[Key] - > - } + type RootBuilder = Builder extends { readonly "": infer Root } ? Root : never - type BranchTargetSelector< - States extends StateSchemas, - Source extends StateNodeIdentifier, - Root extends string = Source extends `${infer Head}.${string}` ? Head : Source - > = Root extends ActiveStateKey ? Root extends keyof BranchTargetBuilder ? { - readonly [Key in Root]: - & SelectionNode< - States, - States[Key], - Key, - "branch", - BranchTargetBuilder[Key] - > - & (Source extends ChoiceIdentifier ? {} - : Source extends `${Key}.${infer Rest}` ? BranchUpdateSelectionPath - : StateUpdateSelectionForNode) - } - : {} - : {} + type FullTargetSelector = + & SelectionValue>, "", "state", "full"> + & InitialSelectionMethod>, "", "full"> + + type BranchTargetSelector> = + States[""] extends { readonly states: infer Children extends StateSchemas } ? + & SelectionTreeWithPrefix>> + & (Source extends ChoiceIdentifier ? {} + : Omit, "update">) + : {} type LocalTargetSelector< States extends StateSchemas, @@ -4958,6 +4990,22 @@ export declare namespace Machine { States extends StateSchemas, Source extends StateNodeIdentifier > { + /** Replaces this handler owner's value without reentry. */ + /** Selects the current handler owner for a value update without reentry. */ + readonly self: Source extends ValuedStateIdentifier + ? StateUpdateSelectionForNode, Source, "branch"> + : {} + /** Addresses the root and its retained value from any active descendant. */ + readonly root: "" extends ActiveStateKey ? + & SelectionNode< + States, + States[""], + "", + "branch", + (BranchTargetBuilder extends { readonly "": infer Builder } ? Builder : never) + > + & StateUpdateSelectionForNode + : never /** Handles the trigger without selecting a destination. */ readonly none: SelectionValue["none"], never, "none", "local"> /** Selects a destination or updates the nearest active compound scope. */ @@ -4967,7 +5015,9 @@ export declare namespace Machine { /** Selects a complete destination under any top-level state. */ readonly full: FullTargetSelector /** Restores a shallow or deep history pseudo-state. */ - readonly history: HistorySelectionTree> + readonly history: States[""] extends { readonly states: infer Children extends StateSchemas } + ? HistorySelectionTree>> + : {} } /** Definition-time selector that can choose only a valid top-level initial entry. */ @@ -5518,7 +5568,7 @@ export declare namespace Machine { ExcludeCompatibleRuntime< Exclude | Services>, internalRuntime.MachineRuntime>, Event, - Emit + EmittedEvent >, Output, | InitialError @@ -5606,7 +5656,7 @@ export declare namespace Machine { export type InvokeEmits = Invoke extends { readonly [InvokeTypeId]: { readonly emits: Types.Covariant } } ? Emitted - : Invoke extends { readonly child: ChildMachine } ? Emit + : Invoke extends { readonly child: ChildMachine } ? EmittedEvent : never /** Public parent inputs required by an invoked child machine. */ @@ -5840,7 +5890,7 @@ export declare namespace Machine { Selection > = ( context: TransitionResolveContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => SelectionKind extends "none" ? undefined : SelectedTargetResult | undefined export type DeclinableTransitionResolver< @@ -5850,7 +5900,7 @@ export declare namespace Machine { Selection > = ( context: TransitionResolveContext & DeclineCapability, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => | (SelectionKind extends "none" ? undefined : SelectedTargetResult | undefined) | Declined @@ -5864,7 +5914,7 @@ export declare namespace Machine { Owner extends ValuedStateIdentifier > = ( context: StateUpdateResolveContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => StateUpdate /** @internal */ @@ -5876,7 +5926,7 @@ export declare namespace Machine { Owner extends ValuedStateIdentifier > = ( context: StateUpdateResolveContext & DeclineCapability, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => StateUpdate | Declined /** One named destination declared by a branching transition. */ @@ -5944,7 +5994,7 @@ export declare namespace Machine { Branches extends Readonly> > = ( context: TransitionBranchesResolveContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => BranchSelectionResult export type DeclinableTransitionBranchesResolver< @@ -5954,7 +6004,7 @@ export declare namespace Machine { Branches extends Readonly> > = ( context: TransitionBranchesResolveContext & DeclineCapability, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => BranchSelectionResult | Declined /** @internal Type evidence retained by a transition authored through its bound selector. */ @@ -6047,6 +6097,67 @@ export declare namespace Machine { > } + type ConstructionCallbacks = { + readonly [ + Method in Extract as Builder[Method] extends (...args: infer Args) => unknown + ? Args extends readonly [unknown?] ? Method : never : + never + ]: Builder[Method] extends (...args: infer Args) => unknown ? ( + value: (context: Context) => Args[0] + ) => Result : + never + } + + type GuardedTransition< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateNodeIdentifier, + Context, + Reenter extends boolean, + Selection extends TargetSelection + > = + & ConstructionCallbacks< + Omit, + SelectionBuilder, + BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + SelectedTargetResult | Declined, + "declinable" + > + > + & (SelectionSupportsDefaultConstruction extends true ? BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + SelectedTargetResult | Declined, + "declinable" + > + : {}) + & { + readonly resolve: ( + resolve: TransitionResolver, + options?: TransitionRequiredOptions + ) => BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + SelectedTargetResult | Declined, + "declinable" + > + } + /** * A selected transition target with target-specific resolver operations. * @@ -6054,7 +6165,7 @@ export declare namespace Machine { * * ```ts * Reset: (to) => - * to.full.Ready().resolve( + * to.branch.Ready().resolve( * ({ target }) => target.from(), * { reenter: true } * ) @@ -6075,6 +6186,27 @@ export declare namespace Machine { Selection extends TargetSelection > = & Selection + & ("declinable" extends Acceptance ? { + /** Declines this candidate before construction when the predicate is false. */ + readonly guard: ( + predicate: (context: Omit) => boolean + ) => GuardedTransition + } : + {}) + & ConstructionCallbacks< + Omit, + SelectionBuilder, + BuiltTransition< + States, + Events, + Emits, + StateId, + Context, + Reenter, + SelectedTargetResult, + "required" + > + > & (SelectionSupportsDefaultConstruction extends true ? BuiltTransition< States, Events, @@ -6158,7 +6290,7 @@ export declare namespace Machine { & (( resolve: ( context: UpdatingTransitionResolveContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => CombinedTarget>, States, Owner>, options?: TransitionRequiredOptions ) => BuiltTransition< @@ -6174,7 +6306,7 @@ export declare namespace Machine { & ("declinable" extends Acceptance ? ( resolve: ( context: UpdatingTransitionResolveContext & DeclineCapability, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => CombinedTarget>, States, Owner> | Declined, options: TransitionDeclinableOptions ) => BuiltTransition< @@ -6264,26 +6396,44 @@ export declare namespace Machine { Selection extends TargetSelection > = & Selection - & StateUpdateTransitionRequired - & ("declinable" extends Acceptance ? StateUpdateTransitionDeclinable< - States, - Events, - Emits, - StateId, - Context, - Reenter, - Selection - > - : {}) + & ConstructionCallbacks< + Omit< + StateUpdateResolveContext, ValuedStateIdentifier>>, + "owner" + >, + SelectionBuilder, + BuiltTransition, "required"> + > + & { + readonly resolve: + & StateUpdateTransitionRequired + & ("declinable" extends Acceptance ? StateUpdateTransitionDeclinable< + States, + Events, + Emits, + StateId, + Context, + Reenter, + Selection + > + : {}) + } /** @internal Type evidence retained by a machine initial-entry declaration. */ export interface InitialBuilderEvidence { readonly [InitialBuilderTypeId]: Types.Covariant } + /** Decoded input shared by initial construction callbacks. */ + export interface InitialContext { + /** Decoded value supplied when the machine is started. */ + readonly input: Input + } + /** A selected machine initial entry with its exact resolver target. */ export type InitialTransitionTarget> = & Selection + & ConstructionCallbacks, SelectionBuilder, InitialBuilderEvidence> & (SelectionSupportsDefaultConstruction extends true ? InitialBuilderEvidence : {}) & { /** Lazily constructs the selected initial state from decoded machine input. */ @@ -6320,6 +6470,42 @@ export declare namespace Machine { to: InitialSelector ) => InitialBuilderEvidence> + /** Initializes the root and its declared default descendants. */ + export type RootInitialBuilderInput = ( + root: InitialTransitionTarget, "", "initial", "initial">> + ) => InitialBuilderEvidence> + + interface RootConfigurationSelection extends + TargetSelection< + FullSnapshotMethod<{ readonly "": Root }, "" & ActiveStateKey<{ readonly "": Root }>, "">, + "", + "state", + "initial" + > + {} + + /** Explicitly constructs a complete starting configuration. */ + export type RootConfigurationBuilderInput = ( + root: + & ConstructionCallbacks< + InitialContext, + SelectionBuilder>, + InitialBuilderEvidence> + > + & { + readonly resolve: ( + resolve: ( + context: { + /** Decoded startup input. */ + readonly input: Input + /** Builder for the complete root configuration. */ + readonly target: SelectionBuilder> + } + ) => ConstructionResult> + ) => InitialBuilderEvidence> + } + ) => InitialBuilderEvidence> + type TransitionSelectorNode< States extends StateSchemas, Events extends ReadonlyArray, @@ -6434,46 +6620,56 @@ export declare namespace Machine { > } - /** Selector supplied to inline transition declarations. */ - export type TransitionSelector< - States extends StateSchemas, - Events extends ReadonlyArray, - Emits extends ReadonlyArray, - StateId extends StateNodeIdentifier, - Context, - Reenter extends boolean, - Acceptance extends TransitionAcceptance - > = - & TransitionSelectorNode< + type TransitionSelectorTargets< + in out States extends StateSchemas, + in out Events extends ReadonlyArray, + in out Emits extends ReadonlyArray, + in out StateId extends StateNodeIdentifier, + in out Context, + in out Reenter extends boolean, + in out Acceptance extends TransitionAcceptance + > = { + readonly [Key in keyof TargetSelector]: TransitionSelectorNode< States, Events, Emits, StateId, Context, - Reenter, + Key extends "self" ? false : Reenter, Acceptance, - TargetSelector + TargetSelector[Key] > - & { - /** Declares a closed set of named destinations for one resolver. */ - readonly branches: >>( - branches: Branches & ValidateTransitionBranchRecord> - ) => { - /** Resolves exactly one declared branch after this transition is selected. */ - readonly resolve: - & TransitionBranchesResolveRequired - & ("declinable" extends Acceptance ? TransitionBranchesResolveDeclinable< - States, - Events, - Emits, - StateId, - Context, - Reenter, - Branches - > - : {}) - } + } + + /** Selector supplied to inline transition declarations. */ + export interface TransitionSelector< + in out States extends StateSchemas, + in out Events extends ReadonlyArray, + in out Emits extends ReadonlyArray, + in out StateId extends StateNodeIdentifier, + in out Context, + in out Reenter extends boolean, + in out Acceptance extends TransitionAcceptance + > extends TransitionSelectorTargets { + /** Declares a closed set of named destinations for one resolver. */ + readonly branches: >>( + branches: Branches & ValidateTransitionBranchRecord> + ) => { + /** Resolves exactly one declared branch after this transition is selected. */ + readonly resolve: + & TransitionBranchesResolveRequired + & ("declinable" extends Acceptance ? TransitionBranchesResolveDeclinable< + States, + Events, + Emits, + StateId, + Context, + Reenter, + Branches + > + : {}) } + } /** Inline transition declaration accepted by state and invocation handlers. */ export type TransitionBuilderInput< @@ -6974,7 +7170,7 @@ export declare namespace Machine { Error | ActionError>, Services, InitialError, - Emit, + EmittedEvent, EventOf>, never, | RequiredInvokeChannel, "done"> @@ -7010,9 +7206,9 @@ export declare namespace Machine { * ```ts * invoke: (from) => * from.effect("load-user", () => loadUser).onDone((to) => - * to.full.Ready().resolve(({ output, target }) => target.from({ user: output })) + * to.branch.Ready().resolve(({ output, target }) => target.from({ user: output })) * ).onFailure((to) => - * to.full.Failed().resolve(({ error, target }) => target.from({ error })) + * to.branch.Failed().resolve(({ error, target }) => target.from({ error })) * ) * ``` * @@ -7238,14 +7434,14 @@ export declare namespace Machine { * **Example** (State actions, events, and invocation) * * ```ts - * machine.handle({ + * machine.handle({ states: { * Loading: { * entry: (_, enqueue) => enqueue.emit({ _tag: "Started" }), * invoke: (from) => - * from.effect("load", () => load).onDone((to) => to.full.Ready()), - * on: { Cancel: (to) => to.full.Idle() } + * from.effect("load", () => load).onDone((to) => to.branch.Ready()), + * on: { Cancel: (to) => to.branch.Idle() } * } - * }) + * } }) * ``` * * @inlineType ActiveOutputHandlerConfig @@ -7267,12 +7463,12 @@ export declare namespace Machine { /** Runs synchronously when the state is entered and may enqueue commands. */ readonly entry?: ( context: StateActionContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => StateActionResult /** Runs synchronously before the state is exited and may enqueue commands. */ readonly exit?: ( context: StateActionContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => StateActionResult /** Starts state-owned Effect, Stream, timer, logic, or child lifecycles. */ readonly invoke?: InvokeBuilderInput @@ -7423,15 +7619,15 @@ export declare namespace Machine { ParentEvents extends ReadonlyArray = readonly [] > = ( context: StateInitializeContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => SnapshotBuilderComplete, boolean> /** Context used only when a history node has no previously captured record. */ export interface HistoryDefaultContext< - States extends StateSchemas, + in out States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, - ParentId extends StateIdentifier + in out ParentId extends StateIdentifier > { /** Lifecycle event that attempted to restore this history node. */ readonly event: LifecycleEvent @@ -7455,7 +7651,7 @@ export declare namespace Machine { ParentId extends StateIdentifier > = ( context: HistoryDefaultContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => | CompleteSnapshotContaining | StateConstruction> @@ -7536,7 +7732,7 @@ export declare namespace Machine { /** Runs synchronously when the final state is entered and may enqueue commands. */ readonly entry?: ( context: StateActionContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => StateActionResult readonly exit?: never readonly always?: never @@ -7589,21 +7785,27 @@ export declare namespace Machine { : never : never - type HandlerNodeByPath = Path extends - `${infer Head}.${infer Rest}` ? Head extends keyof States ? States[Head] extends { - readonly states: infer Children extends StateSchemas - } ? HandlerNodeByPath - : never + type HandlerNodeByPath = "" extends keyof States ? + Path extends "" ? States[""] + : States[""] extends { readonly states: infer Children extends StateSchemas } ? HandlerNodeByPath : never + : Path extends `${infer Head}.${infer Rest}` ? Head extends keyof States ? States[Head] extends { + readonly states: infer Children extends StateSchemas + } ? HandlerNodeByPath + : never + : never : Path extends keyof States ? States[Path] : never // Resolve only the supplied branch for a flattened state-node path. Keeping // this recursion on the finite path avoids recursively expanding an open // generic handler config. - type HandlerConfigAtPath = Path extends `${infer Head}.${infer Rest}` ? - Head extends keyof Config ? HandlerConfigAtPath, Rest> - : never + type HandlerConfigAtPath = [Config] extends [never] ? never : + "" extends keyof Config ? Path extends "" ? Config[""] + : HandlerConfigAtPath, Path> + : Path extends `${infer Head}.${infer Rest}` ? + Head extends keyof Config ? HandlerConfigAtPath, Rest> + : never : Path extends keyof Config ? Config[Path] : never @@ -7762,7 +7964,7 @@ export declare namespace Machine { States extends StateSchemas, Prefix extends string, Config, - UnknownKeys extends string = Exclude, Extract> + UnknownKeys extends string = Exclude, ActiveStateKey | ChoiceStateKey> > = [UnknownKeys] extends [never] ? unknown : { readonly [Key in UnknownKeys]: HandlerValidationError< "Handler tree contains a state key that does not exist", @@ -7931,7 +8133,8 @@ export declare namespace Machine { StateId extends StateIdentifier, Config > = [HistoryIdentifier] extends [never] ? unknown - : StateId extends RequiredHistoryInitializers ? "initialize" extends keyof Config ? unknown : { + : StateId extends RequiredHistoryInitializers | RequiredInitializers ? + "initialize" extends keyof Config ? unknown : { readonly initialize: HandlerValidationError< "State requires initialize for shallow history restoration", StateId @@ -7992,10 +8195,10 @@ export declare namespace Machine { Emits extends ReadonlyArray, StateId extends string, Config, - Incompatible = IncompatibleRuntime< + Incompatible = keyof Config extends never ? never : IncompatibleRuntime< ConfigServices>, EventOf, - EmitOf + EmittedEventOf > > = [Incompatible] extends [never] ? unknown : HandlerValidationError<"Handler config requires an incompatible machine runtime", StateId, Incompatible> @@ -8020,6 +8223,8 @@ export declare namespace Machine { NodeConfig, AvailableOutputStates > extends infer Validation ? unknown extends Validation ? never + : "" extends keyof AllStates ? StateId extends "" ? { readonly ""?: Validation } + : { readonly ""?: { readonly states: HandlerValidationAtPath } } : HandlerValidationAtPath : never @@ -8056,11 +8261,82 @@ export declare namespace Machine { & HandlerUnknownStateKeyValidation & HandlerTreeNodeValidations + type RootInitialTargetValidation = + RequiredInitializersForTargetPath> extends + infer Required ? Types.UnionToIntersection< + Required extends string ? + HandlerConfigAtPath<{ readonly "": Config }, Required> extends infer NodeConfig ? + [NodeConfig] extends [never] ? RootInitializeError + : "initialize" extends keyof NodeConfig ? never + : RootInitializeError + : never : + unknown + > : + unknown + + type RootInitializeError = Path extends "" ? { + readonly initialize: HandlerValidationError< + "State requires initialize because a transition enters its declared initial configuration", + Path + > + } : + { + readonly states: HandlerValidationAtPath + }> + } + + type RootHandlerValidation< + States extends StateSchemas, + Events extends ReadonlyArray, + InputEvents extends ReadonlyArray, + Emits extends ReadonlyArray, + Config, + OutputStates extends StateIdentifier + > = + & HandlerNodeValidation< + States, + States[""], + Events, + InputEvents, + Emits, + Extract<"", StateNodeIdentifier>, + Config, + OutputStates + > + & RootInitialTargetValidation + & Types.UnionToIntersection< + Exclude, ""> extends infer Path ? + Path extends StateNodeIdentifier ? + HandlerConfigAtPath<{ readonly "": Config }, Path> extends infer NodeConfig ? + [NodeConfig] extends [never] ? never : + HandlerNodeValidation< + States, + HandlerNodeByPath, + Events, + InputEvents, + Emits, + Path, + NodeConfig, + OutputStates + > extends infer Validation ? unknown extends Validation ? never + : { readonly states: HandlerValidationAtPath } + : never : + never : + never : + never + > + type HandlerHasRequiredInitial< AllStates extends StateSchemas, StateId extends StateIdentifier, Config - > = StateId extends RequiredHistoryInitializers ? "initialize" extends keyof Config ? true : false : true + > = StateId extends RequiredHistoryInitializers | RequiredInitializers + ? "initialize" extends keyof Config ? true : false + : true type HandlerHasRequiredHistoryDefaults = Node extends { readonly states: infer Children extends StateSchemas @@ -8087,7 +8363,8 @@ export declare namespace Machine { Node, StateId extends StateIdentifier, Config - > = [HistoryIdentifier | ChoiceIdentifier] extends [never] ? StateId + > = [HistoryIdentifier | ChoiceIdentifier | RequiredInitializers] extends [never] ? + StateId : HandlerHasRequiredInitial extends true ? HandlerHasRequiredHistoryDefaults extends true ? HandlerHasRequiredChoices extends true ? StateId : never @@ -8160,7 +8437,7 @@ export declare namespace Machine { ExcludeCompatibleRuntime< R | HandlerTreeEvidence["services"], EventOf, - EmitOf + EmittedEventOf >, InitialE, InitialR, @@ -8195,21 +8472,23 @@ export declare namespace Machine { ParentEvents extends ReadonlyArray > { < - const Config extends HandlerTree< - States, - States, - Events, - Emits, - E, - R, - InputEvents, - ParentEvents, - "" - > + const Config >( config: & Config - & HandlerTreeValidation< + & (Config extends (...args: never[]) => unknown ? never : unknown) + & HandlerNode< + States, + States[""], + Events, + Emits, + E, + R, + InputEvents, + ParentEvents, + Extract<"", StateNodeIdentifier> + > + & RootHandlerValidation< States, Events, InputEvents, @@ -8217,15 +8496,10 @@ export declare namespace Machine { NoInfer, | OutputStates | Extract< - HandlerTreeEvidence>["outputState"], + HandlerTreeEvidence }>["outputState"], StateIdentifier > > - & ([StateIdentifier] extends [UnhandledStates] ? HandlerInitialTargetValidation< - States, - NoInfer - > - : unknown) ): HandleTreeResult< States, Events, @@ -8241,7 +8515,7 @@ export declare namespace Machine { OutputStates, InputEvents, ParentEvents, - Config + { readonly "": Config } > } @@ -8301,11 +8575,11 @@ export declare namespace Machine { > { readonly entry?: ( context: StateActionContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => StateActionResult readonly exit?: ( context: StateActionContext, - enqueue: Enqueue, EmitOf> + enqueue: Enqueue, EmittedEventOf> ) => StateActionResult readonly invoke?: StoredInvokeDefinition readonly always?: TransitionConfig< @@ -8355,9 +8629,9 @@ export declare namespace Machine { > } -type StateSource = Machine.DefinedStates | Machine.Any +type StateSource = State | Machine.Any -type StateSchemasOf = Source extends Machine.DefinedStates ? States +type StateSchemasOf = Source extends State ? { readonly "": Node } : Source extends Machine.Any ? Machine.States : never @@ -8370,6 +8644,29 @@ type StateSchemasOf = Source extends Machine.Defined */ export type Snapshot = Machine.Snapshot> +/** Type utilities for logical snapshots, including snapshots carried by atom bridges. */ +/** Typed traversal of root and child logical snapshots. + * @category utility types + * @since 0.32.0 + */ +export declare namespace Snapshot { + /** Every active node represented by a logical snapshot type. */ + export type Node = State extends { readonly path: string; readonly value: unknown } ? + | State + | (State extends { readonly state: infer Child } ? Node + : State extends { readonly states: infer Regions } ? Node + : never) : + never + /** Every valid absolute path in a logical snapshot type. */ + export type Path = Node extends infer Current + ? Current extends { readonly path: infer Path extends string } ? Path : never + : never + /** The node at one absolute path. Runtime access can still be inactive. */ + export type At> = Node extends infer Current + ? Current extends { readonly path: Path } ? Current : never + : never +} + /** * Extracts the decoded value owned by a schema-backed state path. * @@ -8389,7 +8686,7 @@ export type Value< * * The source may be the object returned by {@link states} or a machine * definition. This is the type-level counterpart of - * `DefinedStates.getSnapshot`. + * `State.getSnapshot`. * * @category utility types * @since 0.18.0 @@ -8474,10 +8771,10 @@ export const isFinal: < * } * }) * - * const States = Machine.states({ + * const States = Machine.state({ initial: "slot1", states: { * slot1: TradingSlot, * slot2: TradingSlot - * }) + * } }) * ``` * * @category constructors @@ -8485,47 +8782,8 @@ export const isFinal: < */ export const state: StateConstructor = internal.state as StateConstructor -/** - * Defines the complete state tree while preserving literal state paths. - * - * **When to use** - * - * Use when you want to pass a state tree to `make` and also get typed - * snapshot matching and access helpers. - * - * **Details** - * - * The returned `states` property is an immutable structural capture of the - * supplied tree. - * `Machine.make` derives its initial target selector from that tree and - * enforces compound and parallel initial-state rules. Active nodes may omit - * `schema` when they own no value and still participate fully in targeting, - * matching, and snapshots. - * - * **Example** (Atomic initial snapshot) - * - * ```ts - * import { Schema } from "effect" - * import { Machine } from "@typeonce/effect-machine" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * - * const States = Machine.states({ idle: Idle }) - * - * Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: (to) => to.idle().resolve(({ target }) => target.from()) - * }) - * ``` - * - * @category constructors - * @since 0.4.0 - */ -export const states: StatesConstructor = internal.states - type MakeConfig< - States extends Machine.StateSchemas, + Root extends Machine.StateNodeConfig, InputEvents extends ReadonlyArray, Emits extends ReadonlyArray, Input extends Schema.Top, @@ -8536,8 +8794,8 @@ type MakeConfig< > = { /** Stable definition identifier used by inspection and visualization. */ readonly id?: string - /** State topology and value schemas, normally supplied by `Machine.states`. */ - readonly states: States & DefineStateTreeInput> + /** State topology and value schemas, captured by a `Machine.state` descriptor. */ + readonly root: { readonly [StateTypeId]: typeof StateTypeId; readonly node: Root } /** Public events accepted by independently running machine references. */ readonly events: & Machine.EventProtocol<"public", InputEvents> @@ -8555,12 +8813,10 @@ type MakeConfig< readonly parent?: ParentDeclaration /** Schema used to decode input before initial-state construction. */ readonly input?: Input - /** Target-first declaration that constructs the initial active configuration. */ - readonly initial: unknown } type MakeResult< - States extends Machine.StateSchemas, + Root extends Machine.StateNodeConfig, InputEvents extends ReadonlyArray, Emits extends ReadonlyArray, Input extends Schema.Top, @@ -8569,23 +8825,29 @@ type MakeResult< InternalEvents extends ReadonlyArray, ParentDeclaration extends Parent.Any | undefined > = Definition< - States, + { readonly "": Root }, readonly [...InputEvents, ...InternalEvents], Input, InitialE, InitialR, - Machine.FinalStateFromDefinition, - Machine.TerminalOutput, + Machine.FinalStateFromDefinition<{ readonly "": Root }>, + Machine.TerminalOutput<{ readonly "": Root }>, Emits, InputEvents, Machine.ParentEventsOf > +type RootInitialization = + & { readonly initialConfiguration?: never } + & (Machine.NodeSchema extends never ? { readonly initial?: Machine.RootInitialBuilderInput } + : {} extends NodeMakeInput ? { readonly initial?: Machine.RootInitialBuilderInput } + : { readonly initial: Machine.RootInitialBuilderInput }) + /** @inline */ interface Make { - /** @param config Complete schema-first machine definition. */ + /** @param config Complete starting configuration, including root and descendant values. */ < - const States extends Machine.StateSchemas, + const Root extends Machine.StateNodeConfig, const InputEvents extends ReadonlyArray, const Emits extends ReadonlyArray = readonly [], const Input extends Schema.Top = typeof Schema.Void, @@ -8595,16 +8857,29 @@ interface Make { const ParentDeclaration extends Parent.Any | undefined = undefined >( config: - & Omit< - MakeConfig, - "initial" - > - & { readonly initial: Machine.InitialBuilderInput }, - ..._validation: ValidateDefinedStates> - ): MakeResult - /** @param config Invalid state tree retained only to report its validation error at the call site. */ + & MakeConfig + & { + /** Mutually exclusive with a complete initial configuration. */ + readonly initial?: never + /** Constructs the complete initial root configuration, overriding child defaults. */ + readonly initialConfiguration: Machine.RootConfigurationBuilderInput< + NoInfer, + Input["Type"] + > + } + ): MakeResult< + Root & { readonly "~effect/Machine/ExplicitInitial": true }, + InputEvents, + Emits, + Input, + InitialE, + InitialR, + InternalEvents, + ParentDeclaration + > + /** @param config Complete schema-first machine definition. */ < - const States extends Machine.StateSchemas, + const Root extends Machine.StateNodeConfig, const InputEvents extends ReadonlyArray, const Emits extends ReadonlyArray = readonly [], const Input extends Schema.Top = typeof Schema.Void, @@ -8614,12 +8889,21 @@ interface Make { const ParentDeclaration extends Parent.Any | undefined = undefined >( config: - & Omit< - MakeConfig, - "states" + & MakeConfig + & Exclude< + RootInitialization, Input["Type"]>, + { readonly initialConfiguration: unknown } > - & { readonly states: InvalidDefinedStateTreeInput } - ): never + ): MakeResult< + Root, + InputEvents, + Emits, + Input, + InitialE, + InitialR, + InternalEvents, + ParentDeclaration + > } /** @@ -8661,30 +8945,30 @@ interface Make { * by: Schema.Number * }) {} * - * const States = Machine.states({ Count }) - * const Events = Machine.events(Increment) + * const States = Machine.state({ initial: "Count", states: { Count } }) + * const Events = Machine.eventsFromSchemas(Increment) * * const counter = Machine.make({ - * states: States.states, + * root: States, * events: Events, - * initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) - * }).handle({ + * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Count.decoded(new Count({ value: 0 })))) + * }).handle({ states: { * Count: { * on: { * Increment: (to) => - * to.full.Count().resolve(({ event, state, target }) => + * to.branch.Count().resolve(({ event, state, target }) => * target.decoded(new Count({ value: state.value + event.by }))) * } * } - * }) + * } }) * ``` * - * @see {@link states} for typed state-tree helpers. + * @see {@link state} for typed state-tree helpers. * @inlineType MakeConfig * @category constructors * @since 0.4.0 */ -export const make: Make = internal.make +export const make: Make = internal.make as unknown as Make /** * Extracts the decoded event union carried by an event protocol descriptor. @@ -8708,7 +8992,7 @@ export type EventOf = Machine.EventO * **Example** * * ```ts - * export const Event = Machine.events( + * export const Event = Machine.eventsFromSchemas( * Schema.TaggedUnion({ * Increment: { by: Schema.Number }, * Reset: {} @@ -8721,10 +9005,41 @@ export type EventOf = Machine.EventO * @category constructors * @since 0.10.0 */ -export const events: { - >( - ...schemas: Schemas & ValidateInputEventProtocol> - ): Machine.EventProtocol<"public", readonly [...Schemas]> +type EventFieldsSchemas>> = keyof Cases extends never + ? readonly [] + : readonly [ + Extract< + Schema.TaggedUnion<{ readonly [Tag in keyof Cases & string]: Schema.TaggedStruct }>, + Machine.TaggedSchema + > + ] +type ValidateEventFields = { + readonly [Tag in keyof Cases]: "_tag" extends keyof Cases[Tag] + ? EventProtocolError<"Event fields cannot declare _tag", Extract> + : unknown +} + +/** Defines a public event protocol from tagged field records. + * + * ```ts + * const Events = Machine.events({ Increment: { by: Schema.Number }, Reset: {} }) + * const increment = Events.Increment({ by: 1 }) + * ``` + * + * Constructors defer schema validation until delivery. Use eventsFromSchemas + * when importing existing tagged schemas or protocol descriptors. + * @category constructors + * @since 0.32.0 + */ +export const events: >>( + cases: Cases & ValidateEventFields> & ValidateEventProtocolBuilder<"public", EventFieldsSchemas> +) => Machine.EventProtocol<"public", EventFieldsSchemas> = internal.eventsFromFields as any + +/** Imports existing schemas and protocols without rebuilding their contracts. + * @category constructors + * @since 0.32.0 + */ +export const eventsFromSchemas: { >>( ...inputs: Inputs & ValidateEventProtocolBuilder<"public", Inputs> ): Machine.EventProtocol<"public", Machine.EventProtocolInputSchemasOf<"public", Inputs>> @@ -8768,7 +9083,7 @@ export const optionalParent: >( - ...schemas: Schemas & ValidateInternalEventProtocol> - ): Machine.EventProtocol<"internal", readonly [...Schemas]> +export const internalEvents: >>( + cases: + & Cases + & ValidateEventFields> + & ValidateEventProtocolBuilder<"internal", EventFieldsSchemas> +) => Machine.EventProtocol<"internal", EventFieldsSchemas> = internal.internalEventsFromFields as any + +/** Imports existing schemas and protocols without rebuilding their contracts. + * @category constructors + * @since 0.32.0 + */ +export const internalEventsFromSchemas: { >>( ...inputs: Inputs & ValidateEventProtocolBuilder<"internal", Inputs> ): Machine.EventProtocol<"internal", Machine.EventProtocolInputSchemasOf<"internal", Inputs>> @@ -8801,7 +9124,7 @@ export const internalEvents: { * **Example** * * ```ts - * const Emitted = Machine.emittedEvents( + * const Emitted = Machine.emittedEventsFromSchemas( * Schema.TaggedUnion({ * Saved: { id: Schema.String } * }) @@ -8812,10 +9135,18 @@ export const internalEvents: { * @category constructors * @since 0.10.0 */ -export const emittedEvents: { - >( - ...schemas: Schemas & ValidateEmittedEventProtocol> - ): Machine.EventProtocol<"emitted", readonly [...Schemas]> +export const emittedEvents: >>( + cases: + & Cases + & ValidateEventFields> + & ValidateEventProtocolBuilder<"emitted", EventFieldsSchemas> +) => Machine.EventProtocol<"emitted", EventFieldsSchemas> = internal.emittedEventsFromFields as any + +/** Imports existing schemas and protocols without rebuilding their contracts. + * @category constructors + * @since 0.32.0 + */ +export const emittedEventsFromSchemas: { >>( ...inputs: Inputs & ValidateEventProtocolBuilder<"emitted", Inputs> ): Machine.EventProtocol<"emitted", Machine.EventProtocolInputSchemasOf<"emitted", Inputs>> @@ -8854,12 +9185,12 @@ export const emittedEvents: { * import { Machine } from "@typeonce/effect-machine" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: (to) => to.Idle().resolve(({ target }) => target.from()) - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const encoded = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) @@ -8936,12 +9267,12 @@ export const encodeSnapshot: < * import { Machine } from "@typeonce/effect-machine" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: (to) => to.Idle().resolve(({ target }) => target.from()) - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const roundTrip = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) @@ -9045,12 +9376,12 @@ type ValidateTransitionBranchRecord = [keyof Branches] extends [never] * import { Machine } from "@typeonce/effect-machine" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: (to) => to.Idle().resolve(({ target }) => target.from()) - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const initialState = Effect.map(Machine.planInitial(machine), (plan) => plan.state) * ``` @@ -9102,7 +9433,7 @@ export const planInitial: < readonly initialEntryPaths: ReadonlyArray> readonly state: Machine.Snapshot readonly commands: ReadonlyArray - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly microsteps: ReadonlyArray<{ readonly next: Machine.Snapshot readonly event: Machine.EventOf | InitialEvent @@ -9115,7 +9446,7 @@ export const planInitial: < > readonly commands: ReadonlyArray readonly raisedEvents: ReadonlyArray> - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly exitPaths: ReadonlyArray readonly entryPaths: ReadonlyArray readonly changed: boolean @@ -9327,13 +9658,13 @@ export const enabled: < * import { Machine } from "@typeonce/effect-machine" * import { Effect, Schema } from "effect" * - * const internalEvents = Machine.internalEvents(Schema.TaggedStruct("Loaded", {})) + * const internalEvents = Machine.internalEventsFromSchemas(Schema.TaggedStruct("Loaded", {})) * const machine = Machine.make({ - * states: { Idle: {} }, - * events: Machine.events(), + * root: Machine.state({ initial: "Idle", states: { Idle: {} } }), + * events: Machine.eventsFromSchemas(), * internalEvents, - * initial: (to) => to.Idle() - * }).handle({ Idle: { on: { Loaded: (to) => to.none } } }) + * initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.from())) + * }).handle({ states: { Idle: { on: { Loaded: (to) => to.none } } } }) * * export const canLoad = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) @@ -9450,20 +9781,20 @@ export const can: { * class Off extends Schema.TaggedClass("Off")("Off", {}) {} * class On extends Schema.TaggedClass("On")("On", {}) {} * class Toggle extends Schema.TaggedClass("Toggle")("Toggle", {}) {} - * const States = Machine.states({ Off, On }) + * const States = Machine.state({ initial: "Off", states: { Off, On } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(Toggle), - * initial: (to) => to.Off().resolve(({ target }) => target.from()) - * }).handle({ + * root: States, + * events: Machine.eventsFromSchemas(Toggle), + * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Off.from())) + * }).handle({ states: { * Off: { * on: { * Toggle: (to) => - * to.full.On().resolve(({ target }) => target.from()) + * to.branch.On().resolve(({ target }) => target.from()) * } * }, * On: {} - * }) + * } }) * * const nextState = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) @@ -9515,9 +9846,10 @@ export const plan: < event: Machine.EventInputOf ) => Effect.Effect< & { + readonly event: Machine.EventOf readonly next: Machine.Snapshot readonly commands: ReadonlyArray - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly microsteps: ReadonlyArray<{ readonly next: Machine.Snapshot readonly event: Machine.EventOf | InitialEvent @@ -9530,7 +9862,7 @@ export const plan: < > readonly commands: ReadonlyArray readonly raisedEvents: ReadonlyArray> - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly exitPaths: ReadonlyArray readonly entryPaths: ReadonlyArray readonly changed: boolean @@ -9808,7 +10140,7 @@ export const prepare: < ExcludeCompatibleRuntime< ExecutionServices, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf > > > = internal.prepare as any @@ -9842,12 +10174,12 @@ export const prepare: < * import { Machine } from "@typeonce/effect-machine" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: (to) => to.Idle().resolve(({ target }) => target.from()) - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const state = Effect.gen(function*() { * const ref = yield* Machine.start(machine) @@ -9918,7 +10250,7 @@ export const start: < ExcludeCompatibleRuntime< ExecutionServices, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf > > = internal.start as any @@ -9953,12 +10285,12 @@ export const start: < * import { Machine } from "@typeonce/effect-machine" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: (to) => to.Idle().resolve(({ target }) => target.from()) - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const resumed = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) @@ -10025,6 +10357,6 @@ export const resume: < ExcludeCompatibleRuntime< ExecutionServices, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf > > = internal.resume as any diff --git a/packages/effect-machine/src/internal/machine/atom.ts b/packages/effect-machine/src/internal/machine/atom.ts index d349ea2..352b2e1 100644 --- a/packages/effect-machine/src/internal/machine/atom.ts +++ b/packages/effect-machine/src/internal/machine/atom.ts @@ -383,7 +383,6 @@ const makeChildFromRefAtom = snapshot.state)), result: makeChildRuntimeResultAtom(snapshot), send, stop, @@ -496,7 +495,6 @@ const makeFromRefAtom = ( const result = { ref, snapshot, - state: Atom.mapResult(snapshot, (snapshot) => snapshot.state), result: makeRuntimeResultAtom(snapshot), send, stop, @@ -767,7 +765,7 @@ export const can = (event: unknown) => { type MachineResumeRequirementsOf = MachineResumeRequirements< Machine.Machine.Services, Machine.Machine.Event, - Machine.Machine.Emit + Machine.Machine.EmittedEvent > type EnsureMachineExecutable = IsAny> extends true ? { @@ -827,7 +825,7 @@ export const make: { InitialR, R, Machine.Machine.EventOf, - Machine.Machine.EmitOf + Machine.Machine.EmittedEventOf > > & EnsureExecutable diff --git a/packages/effect-machine/src/internal/machine/cluster.ts b/packages/effect-machine/src/internal/machine/cluster.ts index 8538cb0..9c47fc1 100644 --- a/packages/effect-machine/src/internal/machine/cluster.ts +++ b/packages/effect-machine/src/internal/machine/cluster.ts @@ -98,7 +98,7 @@ type SendRpc> = Rpc.R type MachineEvents = Machine.Machine.InputEvents -type MachineEmits = Machine.Machine.Emits +type MachineEmits = Machine.Machine.EmittedEvents const hasInvokes = (machine: Machine.Machine.Any): boolean => Reflect.ownKeys(machine.handlers).some((key) => machine.handlers[key as string]?.invoke !== undefined) @@ -225,7 +225,7 @@ export const make = < | ExcludeCompatibleRuntime< Machine.ExecutionServices, Machine.Machine.EventOf, - Machine.Machine.EmitOf + Machine.Machine.EmittedEventOf > | Machine.Machine.SnapshotDecodingServices | Machine.Machine.SnapshotEncodingServices @@ -261,7 +261,7 @@ export const make = < | ExcludeCompatibleRuntime< Machine.ExecutionServices, Machine.Machine.EventOf, - Machine.Machine.EmitOf + Machine.Machine.EmittedEventOf > | Machine.Machine.SnapshotDecodingServices | Machine.Machine.SnapshotEncodingServices @@ -283,7 +283,7 @@ export const make = < Effect.mapError((error) => reject("PersistenceFailure", String(error.cause))) ) let current: Machine.Machine.Snapshot | undefined - const emitted: Array>> = [] + const emitted: Array>> = [] if (Option.isSome(loaded.checkpoint)) { const checkpoint = loaded.checkpoint.value diff --git a/packages/effect-machine/src/internal/machine/configuration.ts b/packages/effect-machine/src/internal/machine/configuration.ts index 35ba97e..4b20a15 100644 --- a/packages/effect-machine/src/internal/machine/configuration.ts +++ b/packages/effect-machine/src/internal/machine/configuration.ts @@ -292,7 +292,8 @@ const pathToRootCache = new WeakMap Object.prototype.hasOwnProperty.call(u, key) -export const isDescendantOf = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`) +export const isDescendantOf = (path: string, ancestor: string): boolean => + path !== ancestor && (ancestor === "" || path.startsWith(`${ancestor}.`)) export const isPathInSubtree = (path: string, ancestor: string): boolean => path === ancestor || isDescendantOf(path, ancestor) @@ -1168,7 +1169,10 @@ export const isActiveFinalNode = ( const node = getNode(machine, path) if (node.type === "compound") { const child = getActiveChildPath(machine, configuration, path) - return child !== undefined && isDirectFinalPath(machine, child) + return child !== undefined && + (path === "" + ? !hasCompletionHandler(machine, child) && isActiveFinalNode(machine, configuration, child) + : isDirectFinalPath(machine, child)) } if (node.type === "parallel") { for (const child of node.children) { @@ -1269,7 +1273,9 @@ export const completeActiveFinalNodeEffect: < const node = getNode(machine, path) if (node.type === "compound") { const child = getActiveChildPath(machine, configuration, path) - if (child === undefined || !isDirectFinalPath(machine, child)) { + if ( + child === undefined || (path === "" ? hasCompletionHandler(machine, child) : !isDirectFinalPath(machine, child)) + ) { return undefined } const childCompletion = yield* completeActiveFinalNodeEffect( @@ -1403,7 +1409,9 @@ const completeActiveFinalNodeSync = readonly nodes: ReadonlyArray readonly indexByPath: ReadonlyMap readonly parentIndices: ReadonlyArray @@ -140,12 +142,6 @@ 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) } @@ -210,7 +206,20 @@ const compileIndexedExecutionDescriptor = ( dispatchByLeaf.set(leafIndex, dispatch) } return { - flat: nodes.every((node) => node.parent === undefined && (node.type === "atomic" || node.type === "final")), + valued: Object.freeze(nodes.map((node) => node.schema !== undefined)), + // A structural root with direct leaves has the same event kernel as an + // atomic root. Root behavior and targets that can rebuild the root retain + // the hierarchical planner so their entry and exit boundaries stay explicit. + flat: nodes.every((node) => node.parent === undefined && (node.type === "atomic" || node.type === "final")) || ( + nodes[0]?.path === "" && nodes[0].type === "compound" && nodes[0].schema === undefined && + (machine.handlers[""] === undefined || Reflect.ownKeys(machine.handlers[""]).length === 0) && + nodes.slice(1).every((node) => node.parent === "" && (node.type === "atomic" || node.type === "final")) && + [...transitionsByPath.values()].every((events) => + [...events.values()].every((transition) => + transition.targets !== undefined && transition.targets.every((path) => path !== "") + ) + ) + ), nodes, indexByPath, parentIndices, @@ -276,7 +285,7 @@ const ownedIndexedStateFromActive = ( const index = descriptor.indexByPath.get(path) if (index === undefined) throw new Error(`Machine expected indexed active path "${path}"`) active[index] = 1 - values[index] = configuration.values.get(path) + if (descriptor.valued[index]) values[index] = configuration.values.get(path) } for (const [path, output] of configuration.outputs) { const index = descriptor.indexByPath.get(path) @@ -306,7 +315,7 @@ const activeConfigurationFromIndexedState = ( if (configuration.active[index] !== 1) continue const path = descriptor.nodes[index]!.path active.add(path) - values.set(path, configuration.values[index]) + if (descriptor.valued[index]) values.set(path, configuration.values[index]) } for (const index of configuration.completedOrder) { if (configuration.completed[index] === 1) { @@ -324,7 +333,7 @@ const snapshotFromIndexedStateStatePath = ( const node = descriptor.nodes[index]! const snapshot: Record = { path: node.path, - value: configuration.values[index] + value: descriptor.valued[index] ? configuration.values[index] : undefined } if (node.type === "compound") { const childIndex = descriptor.childIndices[index]!.find((childIndex) => configuration.active[childIndex] === 1) @@ -383,7 +392,9 @@ const makeIndexedTransitionContext = ( const parentIndex = descriptor.parentIndices[sourceIndex]! const ancestors: Record = {} for (const ancestorIndex of descriptor.ancestorIndices[sourceIndex]!) { - ancestors[descriptor.nodes[ancestorIndex]!.path] = configuration.values[ancestorIndex] + if (descriptor.valued[ancestorIndex]) { + ancestors[descriptor.nodes[ancestorIndex]!.path] = configuration.values[ancestorIndex] + } } return { ...resolveMachineReferences(machine, machineReferences), @@ -426,6 +437,7 @@ export interface ExecutionMicrostep { } export interface ExecutionMacrostep { + readonly event: unknown readonly next: State readonly commands: ReadonlyArray readonly emittedEvents: ReadonlyArray @@ -564,11 +576,10 @@ const collectIndexedEvaluatedTransition = ( const index = descriptor.indexByPath.get(stateUpdate.path) const node = index === undefined ? undefined : descriptor.nodes[index] if ( - index === undefined || node === undefined || state.active[index] !== 1 || node.schema === undefined || - (node.type !== "compound" && node.type !== "parallel") + index === undefined || node === undefined || state.active[index] !== 1 || node.schema === undefined ) { throw new Error( - `Machine state update owner "${stateUpdate.path}" must be an active valued compound or parallel state` + `Machine state update owner "${stateUpdate.path}" must be an active valued state` ) } return { @@ -794,6 +805,7 @@ const planIndexedFlatState = ( throw new Error("Machine reached a terminal indexed configuration without a completed root output") } return { + event: decoded, next: ownedIndexedStateFromActive(descriptor, completed), commands: commands ?? emptyExecutionValues, emittedEvents: emittedEvents ?? emptyExecutionValues, @@ -820,7 +832,15 @@ const planIndexedFlatState = ( }, transition.evaluate ) - const target = transitionResult.state + const update = isStateUpdate(transitionResult.state) ? transitionResult.state : undefined + const target = update === undefined ? transitionResult.state : undefined + if (update !== undefined) { + const node = descriptor.nodes[sourceIndex]! + if (update.path !== sourcePath || node.schema === undefined) { + throw new Error(`Machine state update owner "${update.path}" must be the active valued state`) + } + updateOwnedIndexedValue(current, sourceIndex, decodeStateValueSync(machine, node, update.value)) + } validateDeclaredTransitionTarget( sourcePath, { type: "event", event: event._tag }, @@ -871,7 +891,7 @@ const planIndexedFlatState = ( branchKey: transitionResult.branchKey, target: target === undefined ? undefined : getTargetNodePath(target as any), resolvedTarget: target === undefined ? undefined : getTargetNodePath(target as any), - updates: [] + updates: update === undefined ? [] : [update.path] }] } : undefined), @@ -901,6 +921,7 @@ const planIndexedFlatState = ( const raised = raisedEvents?.[raisedIndex] if (raised === undefined) { return { + event: decoded, next: current, commands: commands ?? emptyExecutionValues, emittedEvents: emittedEvents ?? emptyExecutionValues, @@ -935,6 +956,7 @@ const planIndexedState = ( input ) return { + event: planned.event, next: ownedIndexedStateFromActive(descriptor, planned.next), commands: planned.commands, emittedEvents: planned.emittedEvents, @@ -966,6 +988,7 @@ const planIndexedState = ( throw new Error("Machine reached a terminal indexed configuration without a completed root output") } return { + event: decoded, next: ownedIndexedStateFromActive(descriptor, completed), commands: [], emittedEvents: [], @@ -979,6 +1002,7 @@ const planIndexedState = ( const selections = selectIndexedEventTransitions(machine, descriptor, configuration, decoded, machineReferences) if (selections.length === 0) { return { + event: decoded, next: configuration, commands: [], emittedEvents: [], @@ -1021,6 +1045,7 @@ const planIndexedState = ( throw new Error("Machine reached a terminal indexed configuration without a completed root output") } return { + event: decoded, next: current, commands, emittedEvents, @@ -1034,6 +1059,7 @@ const planIndexedState = ( const raised = raisedEvents[raisedIndex] if (raised === undefined) { return { + event: decoded, next: current, commands, emittedEvents, @@ -1103,43 +1129,74 @@ const makeIndexedExecutionPlan = ( snapshot: (state) => snapshotFromIndexedState(indexed, state as OwnedIndexedState), plan: (state, event, retainMicrosteps = false, machineReferences) => planIndexedState(machine, indexed, state as OwnedIndexedState, event, retainMicrosteps, machineReferences), - initial: (args, machineReferences) => { - const inputArgs = machine.input === undefined - ? args - : args.length === 0 - ? (decodeInputSync(machine, machine.input, undefined), args) - : [decodeInputSync(machine, machine.input, args[0])] - const initial = machine.initial(...inputArgs as any) - const normalized = normalizeConfigurationSync(machine, initial as Machine.Snapshot) - const active = machineReferences === undefined ? normalized : withMachineReferences(normalized, machineReferences) - validateInitialConfiguration(machine, active) - const completed = completeConfigurationSync(machine, active, InitialEvent).configuration - const configuration = ownedIndexedStateFromActive(indexed, completed) - const state = snapshotFromIndexedState(indexed, configuration) - const done = isActiveFinalConfiguration(machine, completed) - if (!done) { - return { - state, - configuration, - activeConfiguration: completed, - initialEntryPaths: getInitialEntryPaths(machine, completed), - done: false, - output: undefined + // Initializers may enqueue commands and emissions. Managed startup owns that + // work through the generic initial planner; indexed event execution remains valid. + ...(Object.values(machine.handlers as Record).some((config) => + "initialize" in config + ) ? + {} : + { + initial: (args: ReadonlyArray, machineReferences?: PlanningMachineReferences) => { + const inputArgs = machine.input === undefined + ? args + : args.length === 0 + ? (decodeInputSync(machine, machine.input, undefined), args) + : [decodeInputSync(machine, machine.input, args[0])] + const initial = machine.initial(...inputArgs as any) + const resolved = isInitialTarget(initial) + ? resolveInitialTarget( + machine, + { + active: new Set(), + values: new Map(), + outputs: new Map(), + history: new Map(), + ...(machineReferences === undefined ? {} : { machineReferences }) + }, + initial, + InitialEvent + ).target + : initial + const normalized = isInitialTarget(initial) + ? normalizeTargetConfigurationSync(machine, { + active: new Set(), + values: new Map(), + outputs: new Map(), + history: new Map() + }, resolved) + : normalizeConfigurationSync(machine, resolved as Machine.Snapshot) + const active = machineReferences === undefined + ? normalized + : withMachineReferences(normalized, machineReferences) + if (machine.initialDefinition.selection.kind === "initial") validateInitialConfiguration(machine, active) + const completed = completeConfigurationSync(machine, active, InitialEvent).configuration + const configuration = ownedIndexedStateFromActive(indexed, completed) + const state = snapshotFromIndexedState(indexed, configuration) + const done = isActiveFinalConfiguration(machine, completed) + if (!done) { + return { + state, + configuration, + activeConfiguration: completed, + initialEntryPaths: getInitialEntryPaths(machine, completed), + done: false, + output: undefined + } + } + const root = getRootPath(machine, completed) + if (!completed.outputs.has(root)) { + throw new Error("Machine reached a terminal configuration without a completed root output") + } + return { + state, + configuration, + activeConfiguration: completed, + initialEntryPaths: getInitialEntryPaths(machine, completed), + done: true, + output: completed.outputs.get(root) + } } - } - const root = getRootPath(machine, completed) - if (!completed.outputs.has(root)) { - throw new Error("Machine reached a terminal configuration without a completed root output") - } - return { - state, - configuration, - activeConfiguration: completed, - initialEntryPaths: getInitialEntryPaths(machine, completed), - done: true, - output: completed.outputs.get(root) - } - } + }) }) export type ExecutionPlanStrategy = "generic" | "indexed-flat" | "indexed-hierarchical" | "auto" diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index 0227a2f..46939dc 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -3,7 +3,7 @@ import * as Inspectable from "effect/Inspectable" import * as Option from "effect/Option" import { Prototype as PipeablePrototype } from "effect/Pipeable" import { hasProperty } from "effect/Predicate" -import type * as Schema from "effect/Schema" +import * as Schema from "effect/Schema" import type * as Scope from "effect/Scope" import type * as Stream from "effect/Stream" import type { @@ -23,6 +23,7 @@ import type { ParentMode, RuntimeOutcome, SpawnOptions, + State, StoppedError } from "../../Machine.js" import * as Activities from "./activities.js" @@ -78,34 +79,6 @@ type SpawnResult > -type DefineStateTreeInput = States -type ValidateDefinedStates = [States] extends - [Machine.ValidateStateSchemas] ? [] - : [validation: Machine.ValidateStateSchemas] -type InvalidDefinedStateTreeInput = [States] extends - [Machine.ValidateStateSchemas] ? never - : States & Machine.ValidateStateSchemas -type ReusableStateNodeConfig = - | Machine.AtomicStateNodeConfig - | Machine.CompoundStateNodeConfig - | Machine.ParallelStateNodeConfig -interface StateConstructor { - (node: Node): Node -} -interface StatesConstructor { - ( - states: States, - ..._validation: ValidateDefinedStates> - ): Machine.DefinedStates - (states: InvalidDefinedStateTreeInput): never -} -type ValidateInputEventProtocol> = InputEvents extends - ReadonlyArray ? unknown : never -type ValidateInternalEventProtocol< - InputEvents extends ReadonlyArray, - InternalEvents extends ReadonlyArray -> = InputEvents | InternalEvents extends ReadonlyArray ? unknown : never - const Proto = { ...Inspectable.BaseProto, ...PipeablePrototype, @@ -123,6 +96,7 @@ const makeWithHandlers = ( ): Machine.Any => { const machine = Object.create(Proto) machine.states = self.states + machine.root = self.root machine.events = self.events machine.internalEvents = self.internalEvents machine.emittedEvents = self.emittedEvents @@ -165,7 +139,7 @@ type DirectTransitionDescriptor = { readonly [TransitionBuilderDescriptorTypeId]: typeof TransitionBuilderDescriptorTypeId readonly type: "direct" readonly selection: Topology.TargetSelection - readonly resolve?: (context: any, enqueue: unknown) => unknown + readonly resolver?: (context: any, enqueue: unknown) => unknown readonly reenter: boolean readonly declinable: boolean } @@ -209,7 +183,7 @@ const makeDirectTransitionDescriptor = ( resolve: ((context: any, enqueue: unknown) => unknown) | undefined, options: unknown ): DirectTransitionDescriptor => { - const shared: Omit = { + const shared: Omit = { [TransitionBuilderDescriptorTypeId]: TransitionBuilderDescriptorTypeId, type: "direct", selection: plainTargetSelection(selection), @@ -217,12 +191,34 @@ const makeDirectTransitionDescriptor = ( } return resolve === undefined ? Object.freeze(shared) - : Object.freeze({ ...shared, resolve }) + : Object.freeze({ ...shared, resolver: resolve }) +} + +const guardedSelection = (selection: Topology.TargetSelection, predicate: (context: any) => boolean): object => { + const wrap = (resolve: (context: any, enqueue: unknown) => unknown, options?: unknown) => + makeDirectTransitionDescriptor( + selection, + (context, enqueue) => predicate(context) ? resolve(context, enqueue) : Topology.makeDeclined(), + { ...transitionOptions(options), declinable: true } + ) + return Object.freeze({ + ...wrap(( + context + ) => (selection.kind === "none" ? Topology.makeNoTarget() : constructSelectedTarget(context.target))), + from: (value: (context: any) => unknown) => wrap((context) => context.target.from(value(context))), + decoded: (value: (context: any) => unknown) => wrap((context) => context.target.decoded(value(context))), + resolve: wrap + }) } const decorateTransitionSelection = (selection: Topology.TargetSelection): Topology.TargetSelection => Object.freeze({ ...selection, + guard: (predicate: (context: any) => boolean) => guardedSelection(selection, predicate), + from: (value: (context: any) => unknown) => + makeDirectTransitionDescriptor(selection, (context) => context.target.from(value(context)), undefined), + decoded: (value: (context: any) => unknown) => + makeDirectTransitionDescriptor(selection, (context) => context.target.decoded(value(context)), undefined), resolve: (resolve: (context: any, enqueue: unknown) => unknown, options?: unknown) => makeDirectTransitionDescriptor(selection, resolve, options), reenter: () => makeDirectTransitionDescriptor(selection, undefined, { reenter: true }), @@ -243,14 +239,16 @@ const decorateTransitionSelection = (selection: Topology.TargetSelection): Topol } }) -const decorateStateUpdateSelection = (selection: Topology.TargetSelection): Topology.TargetSelection => { - const update = ( - resolve: (context: any, enqueue: unknown) => unknown, - options?: unknown - ) => makeDirectTransitionDescriptor(selection, resolve, options) - Object.assign(update, selection) - return Object.freeze(update) as unknown as Topology.TargetSelection -} +const decorateStateUpdateSelection = (selection: Topology.TargetSelection): Topology.TargetSelection => + Object.freeze({ + ...selection, + from: (value: (context: any) => unknown) => + makeDirectTransitionDescriptor(selection, (context) => context.owner.from(value(context)), undefined), + decoded: (value: (context: any) => unknown) => + makeDirectTransitionDescriptor(selection, (context) => context.owner.decoded(value(context)), undefined), + resolve: (resolve: (context: any, enqueue: unknown) => unknown, options?: unknown) => + makeDirectTransitionDescriptor(selection, resolve, options) + }) const noneTransitionSelection = decorateTransitionSelection(Topology.noneTargetSelection) @@ -267,6 +265,10 @@ const makeInitialBuilderDescriptor = ( const decorateInitialSelection = (selection: Topology.TargetSelection): Topology.TargetSelection => Object.freeze({ ...selection, + from: (value: (context: any) => unknown) => + makeInitialBuilderDescriptor(selection, (context) => context.target.from(value(context))), + decoded: (value: (context: any) => unknown) => + makeInitialBuilderDescriptor(selection, (context) => context.target.decoded(value(context))), resolve: (resolve: (context: any) => unknown) => makeInitialBuilderDescriptor(selection, resolve) }) @@ -293,6 +295,12 @@ const decorateInitialSelectorNode = (node: unknown): unknown => { const decorateTransitionSelectorNode = (node: unknown): unknown => { if (Topology.isTargetSelection(node)) { + if (node.kind === "state" && node.scope === "full" && hasProperty(node, "initial")) { + return Object.freeze({ + ...decorateTransitionSelection(node), + initial: decorateTransitionSelection(node.initial as Topology.TargetSelection) + }) + } if (node.kind === "update") return decorateStateUpdateSelection(node) return node === Topology.noneTargetSelection ? noneTransitionSelection : decorateTransitionSelection(node) } @@ -361,7 +369,7 @@ const normalizeTransitionBuilder = ( } return { target: () => descriptor.selection, - resolve: descriptor.resolve, + resolve: descriptor.resolver, reenter: descriptor.reenter, declinable: descriptor.declinable } @@ -431,7 +439,7 @@ const makeSelectionNode = ( } if ( scope === "branch" && source !== undefined && node.schema !== undefined && - (source === path || source.startsWith(`${path}.`)) && + (source === path || Configuration.isDescendantOf(source, path)) && getTargetBuilderNode(stateNodes, source).type !== "choice" ) { Object.defineProperty(method, "update", { @@ -440,6 +448,9 @@ const makeSelectionNode = ( }) } } + if (node.type === "atomic" && scope === "branch" && source === path && node.schema !== undefined) { + Object.defineProperty(method, "update", { value: makeStateUpdateSelection(path, "branch"), enumerable: true }) + } return method } @@ -464,15 +475,12 @@ const makeTargetSelector = ( stateNodes: Machine.StateNodes, source: string ): unknown => { - const full: Record = {} - for (const node of stateNodes.byPath.values()) { - if (node.parent === undefined && node.type !== "history" && node.type !== "choice") { - full[node.key] = makeSelectionNode(stateNodes, node.path, "full") - } - } + const full = Object.assign({}, makeSelectionValue("state", "", "full"), { + initial: makeSelectionValue("initial", "", "full") + }) const branch: Record = {} - const root = getTargetBuilderNode(stateNodes, source.split(".")[0]!) - branch[root.key] = makeSelectionNode(stateNodes, root.path, "branch", source) + const root = getTargetBuilderNode(stateNodes, stateNodes.roots[0]!) + addSelectionChildren(branch, stateNodes, root.path, "branch", source) const local: Record = {} const localScope = getLocalTargetScope(stateNodes, source) if (localScope !== undefined) { @@ -487,10 +495,15 @@ const makeTargetSelector = ( } return { none: Topology.noneTargetSelection, + self: getTargetBuilderNode(stateNodes, source).schema === undefined || + getTargetBuilderNode(stateNodes, source).type === "final" + ? {} + : { update: makeStateUpdateSelection(source, "branch") }, + root: makeSelectionNode(stateNodes, root.path, "branch", source), local, branch, full, - history: makeHistorySelectionTree(stateNodes, undefined) + history: makeHistorySelectionTree(stateNodes, "") } } @@ -581,6 +594,7 @@ const getSelectionBuilder = ( let parts = selection.path!.split(".") if (selection.kind === "history") { builder = target.history + if (selection.path !== "") parts.unshift("") } else if (selection.scope === "local") { builder = target.local const scope = getLocalTargetScope(stateNodes, source) @@ -589,11 +603,12 @@ const getSelectionBuilder = ( builder = builder.with parts = [] } else { - parts = selection.path!.slice(scope.length + 1).split(".") + parts = (scope === "" ? selection.path! : selection.path!.slice(scope.length + 1)).split(".") } } } else if (selection.scope === "branch") { builder = target.branch + if (selection.path !== "") parts.unshift("") } else { builder = target.full } @@ -646,7 +661,7 @@ const validateResolvedSelection = ( (selectedNode?.type === "compound" || selectedNode?.type === "parallel") if ( resultPath === undefined || - (resultPath !== selection.path && !(acceptsDescendant && resultPath.startsWith(`${selection.path}.`))) + (resultPath !== selection.path && !(acceptsDescendant && Configuration.isDescendantOf(resultPath, selection.path!))) ) { throw new Error( `Machine transition resolver selected "${selection.path}" but constructed "${resultPath ?? ""}"` @@ -1082,7 +1097,7 @@ const flattenHandlers = ( const makeHandle = (self: Definition.Any): Definition.Any["handle"] => ((config: Record) => { const handlers: Record = Object.create(null) - flattenHandlers(handlers, self.stateNodes, self.states, "", config) + flattenHandlers(handlers, self.stateNodes, self.states, "", { "": config }) return makeWithHandlers(self, handlers) }) as Definition.Any["handle"] @@ -1123,18 +1138,6 @@ export const isFinal = < state: Machine.Snapshot ): state is Machine.SnapshotContainingFinal => internalPlanner.isFinal(machine as any, state) -const makeInitialSelector = (stateNodes: Machine.StateNodes): unknown => { - const selector: Record = {} - for (const node of stateNodes.byPath.values()) { - if (node.parent === undefined && node.type !== "history" && node.type !== "choice") { - selector[node.key] = node.type === "atomic" || node.type === "final" - ? makeSelectionMethod("state", node.path, "initial") - : Object.freeze({ initial: makeSelectionValue("initial", node.path, "initial") }) - } - } - return Object.freeze(selector) -} - const getInitialSelectionBuilder = ( initialBuilder: Record, selection: Topology.TargetSelection @@ -1152,17 +1155,18 @@ const getInitialSelectionBuilder = ( const captureInitialBranch = ( definition: unknown, - stateNodes: Machine.StateNodes, - initialBuilder: Record + initialBuilder: Record, + configuration: boolean ): { readonly selection: Topology.TargetSelection readonly resolve?: (context: any) => unknown readonly builder: Record } => { - if (typeof definition !== "function") { + if (definition !== undefined && typeof definition !== "function") { throw new Error("Machine initial definition must be a target-first callback") } - const result = definition(decorateInitialSelectorNode(makeInitialSelector(stateNodes))) + const selector = decorateInitialSelection(makeSelectionValue(configuration ? "state" : "initial", "", "initial")) + const result = definition === undefined ? selector : definition(selector) let selection: Topology.TargetSelection let resolve: ((context: any) => unknown) | undefined if (Topology.isTargetSelection(result)) { @@ -1198,13 +1202,17 @@ const validateInitialSelection = (result: unknown, selection: Topology.TargetSel const compileInitial = ( definition: unknown, states: Machine.StateTree, - stateNodes: Machine.StateNodes + stateNodes: Machine.StateNodes, + configuration = false ): { readonly initial: (input?: unknown) => unknown readonly definition: Machine.InitialDefinition } => { - const initialBuilder = makeSnapshotBuilder(states, { mode: "initial", prefix: "" }) as Record - const branch = captureInitialBranch(definition, stateNodes, initialBuilder) + const rootNode = getTargetBuilderNode(stateNodes, "") + const initialBuilder = configuration ? + makeSnapshotBuilder(states, { mode: "full", prefix: "" }) as Record + : { "": withFrom((value: unknown) => Topology.makeInitialTarget("", value), "leaf", rootNode.schema !== undefined) } + const branch = captureInitialBranch(definition, initialBuilder, configuration) return { initial: (input?: unknown) => { const result = branch.resolve === undefined @@ -1220,62 +1228,35 @@ const compileInitial = ( } } -export const state: StateConstructor = ((node: Node): Node => { - StateDefinition.validateStateDefinitions({ state: node }, "Machine.state") - return StateDefinition.captureStateDefinitions({ state: node }).state -}) as StateConstructor +export const state = (node: unknown): State => { + const captured = StateDefinition.captureRoot(node) as Machine.StateNodeConfig + const access = makeStateHelpers() + return Object.freeze({ ...access, "~effect/Machine/State": "~effect/Machine/State", node: captured }) +} -export const states: StatesConstructor = (( - states: States -): Machine.DefinedStates => { - StateDefinition.validateStateDefinitions(states, "Machine.states") - const captured = StateDefinition.captureStateDefinitions(states) +const makeStateHelpers = (): Machine.StateAccessors<{ readonly "": Machine.StateNodeConfig }> => { return { - states: captured, - path: ((path: string) => path) as Machine.DefinedStates["path"], + path: ((path: string) => path) as Machine.StateAccessors<{ readonly "": Machine.StateNodeConfig }>["path"], get: ((snapshot: Machine.AtomicSnapshot, path: string) => Topology.getSnapshotByPath(snapshot, path).pipe( Option.map((snapshot) => snapshot.value) - )) as Machine.DefinedStates["get"], + )) as Machine.StateAccessors<{ readonly "": Machine.StateNodeConfig }>["get"], getWithParents: ((snapshot, path) => { const parents: Record = {} return Topology.getSnapshotByPath(snapshot, path, parents).pipe( Option.map((snapshot) => ({ value: snapshot.value, parents })) ) - }) as Machine.DefinedStates["getWithParents"], - getSnapshot: Topology.getSnapshotByPath as unknown as Machine.DefinedStates["getSnapshot"], + }) as Machine.StateAccessors<{ readonly "": Machine.StateNodeConfig }>["getWithParents"], + getSnapshot: Topology.getSnapshotByPath as unknown as Machine.StateAccessors< + { readonly "": Machine.StateNodeConfig } + >["getSnapshot"], matches: ((snapshot: Machine.AtomicSnapshot, path: string) => - Option.isSome(Topology.getSnapshotByPath(snapshot, path))) as Machine.DefinedStates["matches"] + Option.isSome(Topology.getSnapshotByPath(snapshot, path))) as Machine.StateAccessors< + { readonly "": Machine.StateNodeConfig } + >["matches"] } -}) as StatesConstructor - -type MakeConfig< - States extends Machine.StateSchemas, - InputEvents extends ReadonlyArray, - Emits extends ReadonlyArray, - Input extends Schema.Top, - InitialE, - InitialR, - InternalEvents extends ReadonlyArray, - ParentDeclaration extends Parent.Any | undefined -> = { - readonly id?: string - readonly states: States & DefineStateTreeInput> - readonly events: - & Machine.EventProtocol<"public", InputEvents> - & ValidateInputEventProtocol> - readonly internalEvents?: - & Machine.EventProtocol<"internal", InternalEvents> - & ValidateInternalEventProtocol< - NoInfer, - NoInfer - > - readonly emittedEvents?: Machine.EventProtocol<"emitted", Emits> - readonly parent?: ParentDeclaration - readonly input?: Input - readonly initial: unknown } type MakeResult< @@ -1300,40 +1281,7 @@ type MakeResult< Machine.ParentEventsOf > -interface Make { - < - const States extends Machine.StateSchemas, - const InputEvents extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - InitialE = never, - InitialR = never, - const InternalEvents extends ReadonlyArray = readonly [], - const ParentDeclaration extends Parent.Any | undefined = undefined - >( - config: MakeConfig, - ..._validation: ValidateDefinedStates> - ): MakeResult - < - const States extends Machine.StateSchemas, - const InputEvents extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - InitialE = never, - InitialR = never, - const InternalEvents extends ReadonlyArray = readonly [], - const ParentDeclaration extends Parent.Any | undefined = undefined - >( - config: - & Omit< - MakeConfig, - "states" - > - & { readonly states: InvalidDefinedStateTreeInput } - ): never -} - -export const make: Make = (< +export const make = < const States extends Machine.StateSchemas, const InputEvents extends ReadonlyArray, const Emits extends ReadonlyArray = readonly [], @@ -1345,7 +1293,8 @@ export const make: Make = (< >( config: { readonly id?: string - readonly states: States + readonly root: State + readonly initialConfiguration?: unknown readonly events: Machine.EventProtocol<"public", InputEvents> readonly internalEvents?: Machine.EventProtocol<"internal", InternalEvents> readonly emittedEvents?: Machine.EventProtocol<"emitted", Emits> @@ -1354,10 +1303,10 @@ export const make: Make = (< readonly initial: unknown } ): MakeResult => { - StateDefinition.validateStateDefinitions(config.states, "Machine.make") - const states = StateDefinition.captureStateDefinitions(config.states) + const states = Object.freeze({ "": config.root.node }) const self = Object.create(Proto) self.states = states + self.root = config.root self.events = config.events self.internalEvents = config.internalEvents ?? Protocol.makeEventProtocol("internal", [] as const) self.emittedEvents = config.emittedEvents ?? Protocol.makeEventProtocol("emitted", [] as const) @@ -1365,7 +1314,12 @@ export const make: Make = (< self.input = config.input self.id = config.id self.stateNodes = Topology.compileStateNodes(states) - const compiledInitial = compileInitial(config.initial, states, self.stateNodes) + const compiledInitial = compileInitial( + config.initialConfiguration ?? config.initial, + states, + self.stateNodes, + config.initialConfiguration !== undefined + ) self.initial = compiledInitial.initial self.initialDefinition = compiledInitial.definition self.makeTargetBuilder = makeTargetBuilder(states, self.stateNodes) @@ -1373,7 +1327,7 @@ export const make: Make = (< self.handle = makeHandle(self) Protocol.setProtocol(self) return self -}) as Make +} const flattenEventProtocolInputs = ( kind: Kind, @@ -1393,6 +1347,22 @@ export const events = > +const eventFieldSchemas = ( + cases: Readonly> +): ReadonlyArray => { + for (const [tag, fields] of Object.entries(cases)) { + if (Object.hasOwn(fields, "_tag")) throw new Error(`Machine event "${tag}" fields cannot declare _tag`) + } + return Object.keys(cases).length === 0 ? [] : [Schema.TaggedUnion(cases)] +} + +export const eventsFromFields = (cases: Readonly>) => + Protocol.makeEventProtocol("public", eventFieldSchemas(cases)) +export const internalEventsFromFields = (cases: Readonly>) => + Protocol.makeEventProtocol("internal", eventFieldSchemas(cases)) +export const emittedEventsFromFields = (cases: Readonly>) => + Protocol.makeEventProtocol("emitted", eventFieldSchemas(cases)) + const makeParent = < const Mode extends ParentMode, const Events extends ReadonlyArray @@ -1543,7 +1513,7 @@ export const planInitial: < readonly initialEntryPaths: ReadonlyArray> readonly state: Machine.Snapshot readonly commands: ReadonlyArray - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly microsteps: ReadonlyArray<{ readonly next: Machine.Snapshot readonly event: Machine.EventOf | InitialEventModel @@ -1556,7 +1526,7 @@ export const planInitial: < > readonly commands: ReadonlyArray readonly raisedEvents: ReadonlyArray> - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly exitPaths: ReadonlyArray readonly entryPaths: ReadonlyArray readonly changed: boolean @@ -1721,7 +1691,7 @@ export const plan: < & { readonly next: Machine.Snapshot readonly commands: ReadonlyArray - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly microsteps: ReadonlyArray<{ readonly next: Machine.Snapshot readonly event: Machine.EventOf | InitialEventModel @@ -1734,7 +1704,7 @@ export const plan: < > readonly commands: ReadonlyArray readonly raisedEvents: ReadonlyArray> - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly exitPaths: ReadonlyArray readonly entryPaths: ReadonlyArray readonly changed: boolean @@ -1955,7 +1925,7 @@ export const start: < ExcludeCompatibleRuntime< ExecutionServices, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf > > = internalProcess.start as any @@ -2008,6 +1978,6 @@ export const resume: < ExcludeCompatibleRuntime< ExecutionServices, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf > > = internalProcess.resume as any diff --git a/packages/effect-machine/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts index 024290b..f2caf4d 100644 --- a/packages/effect-machine/src/internal/machine/planner.ts +++ b/packages/effect-machine/src/internal/machine/planner.ts @@ -57,6 +57,7 @@ import { isStateUpdate, isTarget, makeChoiceTarget, + makeStateInput, makeTarget, TargetSnapshotTypeId } from "./topology.js" @@ -331,7 +332,9 @@ const completeHistoryConfiguration = ( if (child.schema !== undefined) { const initializer = machine.handlers[path]?.initialize if (initializer === undefined) { - throw new Error(`Machine shallow history requires an initial value implementation for state "${path}"`) + values.set(child.path, decodeStateValueSync(machine, child, makeStateInput({}))) + changed = true + continue } const initialized = collectStateInitializer(machine, initializer, { ...resolveMachineReferences(machine, current), @@ -361,9 +364,6 @@ const completeHistoryConfiguration = ( machineReferences: configuration.machineReferences } as ActiveConfiguration const initializer = valuedMissing.length === 0 ? undefined : machine.handlers[path]?.initialize - 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, { ...resolveMachineReferences(machine, current), state: current.values.get(path), @@ -379,8 +379,11 @@ const completeHistoryConfiguration = ( const child = getNode(machine, childPath) active.add(child.path) if (child.schema !== undefined) { + if (initialized === undefined) { + values.set(child.path, decodeStateValueSync(machine, child, makeStateInput({}))) + continue + } if ( - initialized === undefined || initializedValues === undefined || !Object.prototype.hasOwnProperty.call(initializedValues, child.key) ) { throw new Error(`Machine parallel state initializer for "${path}" must return region "${child.key}"`) @@ -455,6 +458,7 @@ function resolveHistoryTarget( const completed = completeHistoryConfiguration(machine, restored, event) const snapshot = snapshotFromConfigurationAtPath(machine, completed.configuration, target.parent) const values = Object.fromEntries(completed.configuration.values) + if (target.parent !== "" && configuration.active.has("")) delete values[""] return { target: makeTarget(target.parent as any, snapshot.value as any, { snapshot: snapshot as any, @@ -474,7 +478,7 @@ function resolveHistoryTarget( } const collected = collectTransition(machine, fallback, { event, - target: getTargetBuilder(machine, target.parent).full, + target: getTargetBuilder(machine, target.parent).full[""], owner: target.parent }) if (collected.state === undefined || isHistoryTarget(collected.state) || !isSnapshot(collected.state)) { @@ -518,6 +522,7 @@ function resolveHistoryTarget( } const snapshot = snapshotFromConfigurationAtPath(machine, fallbackConfiguration, target.parent) const values = Object.fromEntries(fallbackConfiguration.values) + if (target.parent !== "" && configuration.active.has("")) delete values[""] return { target: makeTarget(target.parent as any, snapshot.value as any, { snapshot: snapshot as any, @@ -752,7 +757,7 @@ const collectStateActions = < ) => { const commands: Array = [] const raisedEvents: Array> = [] - const emittedEvents: Array> = [] + const emittedEvents: Array> = [] for (const path of paths) { const collected = collectStateAction< Machine.StateActionContext>, @@ -771,7 +776,7 @@ const collectStateActions = < ) commands.push(...collected.commands) raisedEvents.push(...collected.raisedEvents) - emittedEvents.push(...collected.emittedEvents as ReadonlyArray>) + emittedEvents.push(...collected.emittedEvents as ReadonlyArray>) } return { commands, emittedEvents, raisedEvents } } @@ -1083,7 +1088,7 @@ export const validateDeclaredTransitionTarget = ( const actual = typeof target === "object" && target !== null && "path" in target ? String(target.path) : "" - if (!declaredTargets.some((path) => actual === path || actual.startsWith(`${path}.`))) { + if (!declaredTargets.some((path) => actual === path || (path === "" || actual.startsWith(`${path}.`)))) { const triggerLabel = trigger.type === "event" ? String(trigger.event) : trigger.type throw new Error( `Machine transition from "${sourcePath}" on "${triggerLabel}" returned target "${actual}" outside declared targets: ${ @@ -1373,10 +1378,9 @@ const collectEvaluatedTransition = < ? (() => { const node = getNode(machine, stateUpdate.path) if ( - !state.active.has(node.path) || node.schema === undefined || - (node.type !== "compound" && node.type !== "parallel") + !state.active.has(node.path) || node.schema === undefined ) { - throw new Error(`Machine state update owner "${node.path}" must be an active valued compound or parallel state`) + throw new Error(`Machine state update owner "${node.path}" must be an active valued state`) } return { path: node.path, @@ -1694,13 +1698,17 @@ export const planInitialSync = < : args.length === 0 ? (decodeInputSync(machine, machine.input, undefined), args) : [decodeInputSync(machine, machine.input, args[0])] as [...Machine.InputArgs] - const state = machine.initial(...inputArgs) + const initial = machine.initial(...inputArgs) const emptyConfiguration: ActiveConfiguration = { active: new Set(), values: new Map(), outputs: new Map(), history: new Map() } + const rootResolution = isInitialTarget(initial) + ? resolveInitialTarget(machine, emptyConfiguration, initial, InitialEvent) + : undefined + const state = rootResolution?.target ?? initial const initialChoice = choiceFromTarget(state) const choiceResolution = initialChoice === undefined ? undefined @@ -1716,9 +1724,11 @@ export const planInitialSync = < const initialHistoryChoiceTransitions: Array = [] const resolvedInitialTargets: Array = [] for ( - const target of choiceResolution === undefined + const target of choiceResolution !== undefined + ? [choiceResolution.target, ...choiceResolution.additionalTargets] + : rootResolution === undefined ? [] - : [choiceResolution.target, ...choiceResolution.additionalTargets] + : [rootResolution.target] ) { if (!isHistoryTarget(target)) { resolvedInitialTargets.push(target) @@ -1731,7 +1741,7 @@ export const planInitialSync = < initialHistoryEmittedEvents.push(...history.emittedEvents) initialHistoryChoiceTransitions.push(...history.transitions) } - let resolvedConfiguration = choiceResolution === undefined + let resolvedConfiguration = resolvedInitialTargets.length === 0 ? normalizeConfigurationSync(machine, state as Machine.Snapshot) : normalizeTargetConfigurationSync( machine, @@ -1748,18 +1758,21 @@ export const planInitialSync = < ) } const configuration: ActiveConfiguration = resolvedConfiguration - validateInitialConfiguration(machine, configuration) + if (machine.initialDefinition.selection.kind === "initial") validateInitialConfiguration(machine, configuration) const startingState = snapshotFromConfiguration(machine, configuration) const initialEntryPaths = getInitialEntryPaths(machine, configuration) const commands = [ + ...(rootResolution?.commands ?? []), ...(choiceResolution?.commands ?? []), ...initialHistoryActions ] const raisedEvents = [ + ...(rootResolution?.raisedEvents ?? []), ...(choiceResolution?.raisedEvents ?? []), ...initialHistoryRaisedEvents ] const emittedEvents = [ + ...(rootResolution?.emittedEvents ?? []), ...(choiceResolution?.emittedEvents ?? []), ...initialHistoryEmittedEvents ] @@ -1777,21 +1790,27 @@ export const planInitialSync = < [...entry.commands], [...raisedEvents, ...entry.raisedEvents] as Array>, [...emittedEvents, ...entry.emittedEvents], - choiceResolution === undefined ? [] : [{ - next: configuration, - event: InitialEvent, - transitions: [...choiceResolution.transitions, ...initialHistoryChoiceTransitions], - commands: [...choiceResolution.commands, ...initialHistoryActions], - raisedEvents: [ - ...choiceResolution.raisedEvents, - ...initialHistoryRaisedEvents - ] as ReadonlyArray>, - emittedEvents: [...choiceResolution.emittedEvents, ...initialHistoryEmittedEvents], - exitPaths: [], - entryPaths: [], - changed: false, - stabilize: false - }] + choiceResolution === undefined && (rootResolution?.transitions.length ?? 0) === 0 && + initialHistoryChoiceTransitions.length === 0 ? + [] : + [{ + next: configuration, + event: InitialEvent, + transitions: [ + ...(rootResolution?.transitions ?? []), + ...(choiceResolution?.transitions ?? []), + ...initialHistoryChoiceTransitions + ], + commands, + raisedEvents: [ + ...raisedEvents + ] as ReadonlyArray>, + emittedEvents, + exitPaths: [], + entryPaths: [], + changed: false, + stabilize: false + }] ) const planned = { @@ -1802,7 +1821,7 @@ export const planInitialSync = < ...commands, ...settled.commands ], - emittedEvents: settled.emittedEvents as ReadonlyArray>, + emittedEvents: settled.emittedEvents as ReadonlyArray>, microsteps: settled.microsteps.map((step) => ({ next: snapshotFromConfiguration(machine, step.next), event: step.event, @@ -2195,6 +2214,7 @@ const macrostepConfiguration = < throw new Error("Machine reached a terminal configuration without a completed root output") } return { + event: decodedEvent, next: completed.configuration, commands: [], emittedEvents: [], @@ -2213,6 +2233,7 @@ const macrostepConfiguration = < ) if (selections.length === 0) { return { + event: decodedEvent, next: configuration, commands: [], emittedEvents: [], @@ -2231,7 +2252,10 @@ const macrostepConfiguration = < const raisedEvents = [...step.raisedEvents] const emittedEvents = [...step.emittedEvents] const microsteps = [step] - return settle(machine, step.next, decodedEvent as any, commands, raisedEvents, emittedEvents, microsteps) + return { + ...settle(machine, step.next, decodedEvent as any, commands, raisedEvents, emittedEvents, microsteps), + event: decodedEvent + } } const snapshotMacrostep = < @@ -2242,9 +2266,10 @@ const snapshotMacrostep = < Output >( machine: Machine.Any, - settled: MacrostepPlan -): MacrostepPlan, Event, E, R, Output> => { + settled: MacrostepPlan & { readonly event: unknown } +): MacrostepPlan, Event, E, R, Output> & { readonly event: unknown } => { const planned = { + event: settled.event, next: snapshotFromConfiguration(machine, settled.next), commands: settled.commands, emittedEvents: settled.emittedEvents, diff --git a/packages/effect-machine/src/internal/machine/process.ts b/packages/effect-machine/src/internal/machine/process.ts index 603fd79..b83777f 100644 --- a/packages/effect-machine/src/internal/machine/process.ts +++ b/packages/effect-machine/src/internal/machine/process.ts @@ -41,6 +41,7 @@ const runSequentialDiscard = ( const acknowledgedPlan = ( planned: { + readonly event: unknown readonly next: unknown readonly commands: ReadonlyArray readonly emittedEvents: ReadonlyArray @@ -60,6 +61,7 @@ const acknowledgedPlan = ( }, snapshot: (state: unknown) => unknown ): unknown => ({ + event: planned.event, next: snapshot(planned.next), commands: planned.commands, emittedEvents: planned.emittedEvents, @@ -388,7 +390,7 @@ const makeProcessLogic: < ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf >, Output, | InitialE @@ -460,7 +462,7 @@ const makeProcessLogic: < ? undefined : CommandRuntime.runEmittedEvents( planned.emittedEvents, - CommandRuntime.makeLiveRuntime, Machine.EmitOf>(machine, scope) + CommandRuntime.makeLiveRuntime, Machine.EmittedEventOf>(machine, scope) ) const result = Effect.succeed({ state: planned.state, @@ -522,7 +524,7 @@ const makeProcessLogic: < // so the Effect scheduler remains responsible for cooperative yield. let configuration: Configuration.ActiveConfiguration | undefined let pendingMessage: Option.Option>> = Option.none() - let liveRuntime: Runtime, Machine.EmitOf> | undefined + let liveRuntime: Runtime, Machine.EmittedEventOf> | undefined while (terminal === undefined) { const message = Option.isSome(pendingMessage) ? pendingMessage.value : yield* receiveMessage pendingMessage = Option.none() @@ -554,7 +556,7 @@ const makeProcessLogic: < current = next if (planned.emittedEvents.length > 0) { yield* CommandRuntime.runEmittedEvents( - planned.emittedEvents as ReadonlyArray>, + planned.emittedEvents as ReadonlyArray>, liveRuntime ??= CommandRuntime.makeLiveRuntime(machine, context) ) } @@ -630,7 +632,7 @@ const makeProcessLogic: < // worker can continue draining an already queued batch. configuration = undefined let pendingMessage: Option.Option>> = Option.none() - let liveRuntime: Runtime, Machine.EmitOf> | undefined + let liveRuntime: Runtime, Machine.EmittedEventOf> | undefined // Match the compact non-invoke loop while retaining state-scoped // child lifecycle work at the same ordered Effect boundaries. @@ -678,7 +680,7 @@ const makeProcessLogic: < current = next if (planned.emittedEvents.length > 0) { yield* CommandRuntime.runEmittedEvents( - planned.emittedEvents as ReadonlyArray>, + planned.emittedEvents as ReadonlyArray>, liveRuntime ??= CommandRuntime.makeLiveRuntime(machine, context) ) } @@ -732,7 +734,7 @@ const makeProcessLogic: < ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf >, Output, | InitialE @@ -772,7 +774,7 @@ export const toProcessLogic: < ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf >, Output, | InitialE @@ -878,7 +880,7 @@ export const start: < ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf > > = (machine, ...args) => internalRuntime.startProcess( @@ -922,7 +924,7 @@ export const prepare: < ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf > > > = (machine, ...args) => @@ -957,7 +959,7 @@ export const resume: < ExcludeCompatibleRuntime< Exclude, internalRuntime.MachineRuntime>, Machine.EventOf, - Machine.EmitOf + Machine.EmittedEventOf > > = (machine, snapshot) => internalRuntime.startProcess( diff --git a/packages/effect-machine/src/internal/machine/protocol.ts b/packages/effect-machine/src/internal/machine/protocol.ts index 4d08deb..e6b7571 100644 --- a/packages/effect-machine/src/internal/machine/protocol.ts +++ b/packages/effect-machine/src/internal/machine/protocol.ts @@ -496,16 +496,16 @@ export const decodeEventSync = >( machine: Machine.Any, event: unknown -): Effect.Effect, MachineSchemaDecodeError> => { +): Effect.Effect, MachineSchemaDecodeError> => { const protocol = getProtocolSchemas(machine) if (isEventConstruction(event)) { return decodeEventConstruction(machine, protocol, event, "emission") as Effect.Effect< - Machine.EmitOf, + Machine.EmittedEventOf, MachineSchemaDecodeError > } const eventName = getEventName(event) - return decodeBoundary>( + return decodeBoundary>( machine, protocol.emit, event, @@ -516,13 +516,13 @@ export const decodeEmit = >( machine: Machine.Any, event: unknown -): Machine.EmitOf => { +): Machine.EmittedEventOf => { const protocol = getProtocolSchemas(machine) if (isEventConstruction(event)) { - return decodeEventConstructionSync(machine, protocol, event, "emission") as Machine.EmitOf + return decodeEventConstructionSync(machine, protocol, event, "emission") as Machine.EmittedEventOf } const eventName = getEventName(event) - return decodeBoundarySync>( + return decodeBoundarySync>( machine, protocol.emit, event, diff --git a/packages/effect-machine/src/internal/machine/readiness.ts b/packages/effect-machine/src/internal/machine/readiness.ts index 16549b2..c298401 100644 --- a/packages/effect-machine/src/internal/machine/readiness.ts +++ b/packages/effect-machine/src/internal/machine/readiness.ts @@ -8,3 +8,4 @@ export type EnsureExecutable< > = & Machine.Machine.EnsureOutputImplementations & Machine.Machine.EnsureHistoryImplementations + & Machine.Machine.EnsureInitialImplementations diff --git a/packages/effect-machine/src/internal/machine/serialization.ts b/packages/effect-machine/src/internal/machine/serialization.ts index 4a722d9..d97245d 100644 --- a/packages/effect-machine/src/internal/machine/serialization.ts +++ b/packages/effect-machine/src/internal/machine/serialization.ts @@ -29,6 +29,7 @@ import { getNode, getStateNodeSchema } from "./topology.js" const EncodedSnapshotSchema = Schema.Struct({ _tag: Schema.Literal("MachineSnapshot"), + version: Schema.Literal(2), active: Schema.Array(Schema.Struct({ path: Schema.String, value: Schema.optionalKey(Schema.Json) @@ -412,6 +413,7 @@ export const encodeSnapshot = ( const encoded: Machine.EncodedSnapshot = { _tag: "MachineSnapshot" as const, + version: 2, active, ...(completed.length === 0 ? {} : { completed }), ...(Object.keys(history).length === 0 ? {} : { history }) diff --git a/packages/effect-machine/src/internal/machine/stateDefinition.ts b/packages/effect-machine/src/internal/machine/stateDefinition.ts index 9f89de5..4cc12d9 100644 --- a/packages/effect-machine/src/internal/machine/stateDefinition.ts +++ b/packages/effect-machine/src/internal/machine/stateDefinition.ts @@ -18,7 +18,7 @@ export type AllowedStateNodeProperty = (typeof State export type AllowedPseudoStateAnnotationProperty = (typeof PseudoStateAnnotationProperties)[number] -export type StateDefinitionBoundary = "Machine.state" | "Machine.states" | "Machine.make" +export type StateDefinitionBoundary = "Machine.state" | "Machine.make" const hasOwn = (value: object, key: PropertyKey): boolean => Object.prototype.hasOwnProperty.call(value, key) @@ -192,13 +192,14 @@ const validateStateTree = ( boundary: StateDefinitionBoundary, states: unknown, parent: string, - allowPseudoStates: boolean + allowPseudoStates: boolean, + root = false ): void => { const treePath = parent === "" ? "" : parent assertPlainRecord(boundary, treePath, states, "state trees") for (const key of Reflect.ownKeys(states)) { - const path = validateStateKey(boundary, parent, key) + const path = root && key === "" ? "" : validateStateKey(boundary, parent, key) const node = states[key] // Effect schemas have their own runtime properties. They are complete @@ -358,3 +359,35 @@ const captureStateTree = (states: Readonly>): Readonly(states: States): States => captureStateTree(states as Readonly>) as States + +/** Root capture keeps descriptor helpers out of the compiled topology. */ +export const captureRoot = (input: unknown, tag = ""): unknown => { + if (Schema.isSchema(input)) return input + if (typeof input !== "object" || input === null) return input + if ( + "~effect/Machine/State" in input && input["~effect/Machine/State"] === "~effect/Machine/State" && "node" in input + ) { + return input.node + } + const config = input as Readonly> + const copy: Record = { ...config } + if (hasOwn(config, "fields")) { + if (hasOwn(config, "schema")) fail("Machine.state", tag, "declare fields or schema, not both") + assertPlainRecord("Machine.state", tag, config.fields, "state fields") + if (hasOwn(config.fields, "_tag")) fail("Machine.state", tag, "state fields cannot declare _tag") + copy.schema = Schema.TaggedStruct(tag, config.fields as Schema.Struct.Fields) + delete copy.fields + } + if (hasOwn(config, "states")) { + assertPlainRecord("Machine.state", tag, config.states, "child states") + const children: Record = Object.create(null) + for (const key of Reflect.ownKeys(config.states)) { + validateStateKey("Machine.state", tag, key) + children[key as string] = captureRoot(config.states[key], key as string) + } + copy.states = children + } + if (tag !== "") return copy + validateStateTree("Machine.state", { "": copy }, "", false, true) + return captureStateDefinitions({ "": copy })[""] +} diff --git a/packages/effect-machine/src/internal/machine/targetBuilder.ts b/packages/effect-machine/src/internal/machine/targetBuilder.ts index 97d00d5..ec68053 100644 --- a/packages/effect-machine/src/internal/machine/targetBuilder.ts +++ b/packages/effect-machine/src/internal/machine/targetBuilder.ts @@ -235,7 +235,10 @@ const getTargetBuilderDefinition = ( let children = states let path = "" let definition: Machine.TaggedSchema | Machine.StateNodeConfig | undefined - for (const key of targetPath.split(".")) { + const segments = Object.hasOwn(states, "") && targetPath !== "" + ? ["", ...targetPath.split(".")] + : targetPath.split(".") + for (const key of segments) { if (!hasProperty(children, key)) { throw new Error(`Machine expected state path "${targetPath}" to exist`) } @@ -310,7 +313,7 @@ const makeLocalTargetChildBuilder = ( return makeTargetWithValues(child.path, value, values) } if (child.type === "parallel") { - if (source !== child.path && !source.startsWith(`${child.path}.`)) { + if (source !== child.path && !(child.path === "" || source.startsWith(`${child.path}.`))) { return makeParallelTarget(states, child, value, selector, values) } if (selector === undefined) { @@ -412,7 +415,7 @@ const makeBranchTargetNodeBuilder = ( const builder = withFrom( (value: unknown, selector?: (builder: unknown) => unknown) => { if (node.type === "parallel") { - if (source !== node.path && !source.startsWith(`${node.path}.`)) { + if (source !== node.path && !(node.path === "" || source.startsWith(`${node.path}.`))) { return makeParallelTarget(states, node, value, selector, values) } if (selector === undefined) { @@ -447,7 +450,7 @@ const makeBranchTargetNodeBuilder = ( node.schema !== undefined ) as unknown as Record withInitial(builder, node.path, node.schema !== undefined, values) - if (node.type !== "parallel" || source === node.path || source.startsWith(`${node.path}.`)) { + if (node.type !== "parallel" || source === node.path || node.path === "" || source.startsWith(`${node.path}.`)) { addBranchTargetChildren(builder, states, stateNodes, node.path, values, source) } return builder @@ -458,7 +461,7 @@ const makeBranchTargetBuilder = ( stateNodes: Machine.StateNodes, source: string ): unknown => { - const rootPath = source.split(".")[0]! + const rootPath = stateNodes.roots[0]! const root = getTargetBuilderNode(stateNodes, rootPath) return { [root.key]: makeBranchTargetNodeBuilder(states, stateNodes, root.path, undefined, source) @@ -488,7 +491,7 @@ const makeHistoryTargetBuilder = ( const getParentPathRuntime = (path: string): string => { const separator = path.lastIndexOf(".") if (separator < 0) { - throw new Error(`Machine expected history state "${path}" to have an active parent`) + return "" } return path.slice(0, separator) } diff --git a/packages/effect-machine/src/internal/machine/topology.ts b/packages/effect-machine/src/internal/machine/topology.ts index bdfc083..6a18005 100644 --- a/packages/effect-machine/src/internal/machine/topology.ts +++ b/packages/effect-machine/src/internal/machine/topology.ts @@ -374,7 +374,7 @@ export const compileStateNodes = (states: Machine.StateSchemas): Machine.StateNo if (key.includes(".")) { throw new Error(`Machine state keys cannot contain ".": "${key}"`) } - const path = parent === undefined ? key : `${parent}.${key}` + const path = parent === undefined || parent === "" ? key : `${parent}.${key}` const definition = getStateNodeDefinition(path, tree[key]!) let node: Machine.StateNode let childStates: Machine.StateTree | undefined @@ -401,7 +401,7 @@ export const compileStateNodes = (states: Machine.StateSchemas): Machine.StateNo history: undefined, parent, children: [], - initial: `${path}.${definition.initial}` + initial: path === "" ? definition.initial : `${path}.${definition.initial}` } childStates = definition.states break @@ -627,7 +627,7 @@ export const getSnapshotByPath = ( if (snapshot.path === path) { return Option.some(snapshot) } - if (!path.startsWith(`${snapshot.path}.`)) { + if (snapshot.path !== "" && !path.startsWith(`${snapshot.path}.`)) { return Option.none() } if (parents !== undefined) { diff --git a/packages/effect-machine/src/internal/testing/machine/finiteModel.ts b/packages/effect-machine/src/internal/testing/machine/finiteModel.ts index d97ea80..9c2f88f 100644 --- a/packages/effect-machine/src/internal/testing/machine/finiteModel.ts +++ b/packages/effect-machine/src/internal/testing/machine/finiteModel.ts @@ -1352,7 +1352,7 @@ const selectDefinitionTarget = ( const sourceRoot = byPath.get(source)!.root if (selected.root !== sourceRoot) { const root = target.split(".")[0]! - return selector.full[root]() + return selector.branch[root]() } const selectable = selectableDefinitionTarget(source, target, byPath) let current = selector.branch @@ -1452,17 +1452,18 @@ const makeHandlers = ( ? Object.fromEntries(node.states.flatMap((child) => { if (child._tag !== "History") return [] return [[child.key, { - default: ({ target }: { readonly target: Record }) => { - const fallback = byPath.get(child.fallback)! - const parts = child.fallback.split(".") - const completeRoot = selectSnapshot(target, fallback.root, byPath, parts, 0) - if (findSnapshot(completeRoot, path) === undefined) { - throw new Error( - `MachineTest.compileModel could not construct history fallback for "${path}.${child.key}"` - ) - } - return completeRoot - } + default: ({ target: root }: { readonly target: any }) => + root.from((target: Record) => { + const fallback = byPath.get(child.fallback)! + const parts = child.fallback.split(".") + const completeRoot = selectSnapshot(target, fallback.root, byPath, parts, 0) + if (findSnapshot(completeRoot, path) === undefined) { + throw new Error( + `MachineTest.compileModel could not construct history fallback for "${path}.${child.key}"` + ) + } + return completeRoot + }) }]] })) : {} @@ -1511,23 +1512,21 @@ export const compileModel = (model: FiniteModel): Machine.Machine.Any => { const flattened = validateModel(model) const byPath = new Map(flattened.map((state) => [state.path, state])) const stateTree = makeStateTree(model.roots, undefined) - const defined = Machine.states(stateTree as any) + const defined = Machine.state({ initial: model.initial, states: stateTree } as any) const eventSchemas = model.events.map((event) => Schema.TaggedStruct(event, {})) const initial = byPath.get(model.initial)! const machine = Machine.make({ - states: defined.states as any, - events: Machine.events(...eventSchemas) as any, - initial: (to: Record) => { - const selected = to[initial.path] - const selection = typeof selected === "function" ? selected() : selected.initial - return selection.resolve(({ target }: { readonly target: any }) => - selectSnapshot({ [initial.path]: target }, initial.path, byPath, [initial.path], 0) as any - ) - } + root: defined, + events: Machine.eventsFromSchemas(...eventSchemas) as any } as any) const transitions = new Map(model.transitions.map((transition) => [ `${transition.source}\u0000${triggerKey(transition.trigger)}`, transition ])) - return machine.handle(makeHandlers(model.roots, undefined, byPath, transitions) as any) as Machine.Machine.Any + return machine.handle( + { + initialize: ({ builder }: any) => builder.decoded(stateValue(initial)), + states: makeHandlers(model.roots, undefined, byPath, transitions) + } as any + ) as Machine.Machine.Any } diff --git a/packages/effect-machine/src/internal/testing/machine/format.ts b/packages/effect-machine/src/internal/testing/machine/format.ts index d0281b4..b425521 100644 --- a/packages/effect-machine/src/internal/testing/machine/format.ts +++ b/packages/effect-machine/src/internal/testing/machine/format.ts @@ -53,7 +53,8 @@ const canonicalize = (value: unknown, active: WeakSet): unknown => { export const formatValue = (value: unknown): string => JSON.stringify(canonicalize(value, new WeakSet())) -const formatConfiguration = (paths: ReadonlyArray): string => `[${paths.join(", ")}]` +const formatConfiguration = (paths: ReadonlyArray): string => + `[${paths.map((path) => path === "" ? "(root)" : path).join(", ")}]` const formatMicrosteps = (microsteps: ReadonlyArray>): Array => microsteps.map((microstep, index) => { diff --git a/packages/effect-machine/src/internal/testing/machine/probe.ts b/packages/effect-machine/src/internal/testing/machine/probe.ts index ef74931..ab5448c 100644 --- a/packages/effect-machine/src/internal/testing/machine/probe.ts +++ b/packages/effect-machine/src/internal/testing/machine/probe.ts @@ -27,14 +27,14 @@ export const probe = ( machine: M, ref: Machine.MachineRef< Machine.Machine.Snapshot>, - Machine.Machine.InputEvent, + Machine.Machine.EventInput>, Error, Output > ): Effect.Effect, ProbeUnavailableError> => { const acknowledged = (ref as Runtime.MachineRef< Machine.Machine.Snapshot>, - Machine.Machine.InputEvent, + Machine.Machine.EventInput>, Error, Output >)[Runtime.acknowledgedSend] @@ -61,12 +61,12 @@ export const probe = ( ) => boolean ) => ({ _tag: "Until", predicate } as const) }), - sendAndAwait: (event: Machine.Machine.InputEvent) => + sendAndAwait: (event: Machine.Machine.EventInput>) => acknowledged.call(ref, event).pipe( Effect.map(({ after, before, plan }) => { const eventPlan = plan as ProbePlan return Object.freeze({ - event, + event: eventPlan.event, before, plan: eventPlan, after, diff --git a/packages/effect-machine/src/internal/testing/machine/referenceModel.ts b/packages/effect-machine/src/internal/testing/machine/referenceModel.ts index 76ecefc..bba832a 100644 --- a/packages/effect-machine/src/internal/testing/machine/referenceModel.ts +++ b/packages/effect-machine/src/internal/testing/machine/referenceModel.ts @@ -573,10 +573,7 @@ const runtimeTargetPath = (index: ModelIndex, transition: FiniteTransition): str if (target.node._tag === "Choice") { return runtimeTargetPath(index, { ...transition, target: resolveChoicePath(index, target.path) }) } - // A same-root branch builder identifies the concrete initialized leaf. A - // full builder replaces the root with a complete snapshot and identifies - // that snapshot's root even when it contains initialized descendants. - if (source.root !== target.root) return target.path + // Branch builders preserve the deepest concrete selection within their declared bound. const initial = (path: string): string => { const current = getState(index, path) @@ -603,7 +600,7 @@ const runtimeTargetPath = (index: ModelIndex, transition: FiniteTransition): str const next = target.path.slice(current.path.length + 1).split(".")[0]! return inspect(`${current.path}.${next}`) } - return inspect(source.root) + return inspect(source.root === target.root ? source.root : target.root) } const entryChoicePath = (index: ModelIndex, path: string): string | undefined => { @@ -916,8 +913,6 @@ const transitionRecord = (index: ModelIndex, transition: FiniteTransition): Refe ? transition.target : transition.target === undefined ? runtimeTargetPath(index, transition) - : getState(index, transition.source).root !== getState(index, transition.target).root - ? transition.target : entryChoicePath(index, transition.target) ?? runtimeTargetPath(index, transition), resolvedTarget: transition.target === undefined ? runtimeTargetPath(index, transition) @@ -1269,7 +1264,7 @@ const projectState = (snapshot: unknown): ActualStateProjection => { const values: Record = {} const visit = (current: unknown): void => { if (!isRecord(current)) return - if (typeof current.path === "string") { + if (typeof current.path === "string" && current.path !== "") { activePaths.push(current.path) values[current.path] = current.value } @@ -1282,7 +1277,9 @@ const projectState = (snapshot: unknown): ActualStateProjection => { return { activePaths, values, - completions: isRecord(snapshot) && Array.isArray(snapshot.completed) ? snapshot.completed : [], + completions: isRecord(snapshot) && Array.isArray(snapshot.completed) + ? snapshot.completed.filter((completion) => !isRecord(completion) || completion.path !== "") + : [], history: isRecord(snapshot) && isRecord(snapshot.history) ? snapshot.history : {} } } @@ -1402,7 +1399,13 @@ export const verifyModelTrace = ( add( location, `${field}.history`, - historyOrderIndependent(expected.history), + historyOrderIndependent( + Object.fromEntries( + Object.entries(expected.history).map(( + [path, record] + ) => [path, { ...record, active: ["", ...record.active] }]) + ) + ), historyOrderIndependent(projected.history) ) } @@ -1422,26 +1425,26 @@ export const verifyModelTrace = ( add( initialLocation, "initial.startingConfiguration", - reference.initial.startingState.activePaths, + ["", ...reference.initial.startingState.activePaths], get(actualInitial, "startingConfiguration") ) add( initialLocation, "initial.initialEntryPaths", - reference.initial.initialEntryPaths, + ["", ...reference.initial.initialEntryPaths], get(actualInitial, "initialEntryPaths") ) add( initialLocation, "initial.plan.initialEntryPaths", - reference.initial.initialEntryPaths, + ["", ...reference.initial.initialEntryPaths], get(actualInitialPlan, "initialEntryPaths") ) compareState(initialLocation, "initial.plan.state", reference.initial.state, get(actualInitialPlan, "state")) add( initialLocation, "initial.configuration", - reference.initial.state.activePaths, + ["", ...reference.initial.state.activePaths], get(actualInitial, "configuration") ) add( @@ -1463,7 +1466,7 @@ export const verifyModelTrace = ( add(location, "step.index", expected.index, get(actual, "index")) add(location, "step.event", expected.event, eventTag(get(actual, "event"))) compareState(location, "step.before", expected.before, get(actual, "before")) - add(location, "step.beforeConfiguration", expected.before.activePaths, get(actual, "beforeConfiguration")) + add(location, "step.beforeConfiguration", ["", ...expected.before.activePaths], get(actual, "beforeConfiguration")) const actualMicrosteps = array(get(actualPlan, "microsteps")) add(location, "step.plan.microsteps.length", expected.microsteps.length, actualMicrosteps.length) @@ -1490,14 +1493,19 @@ export const verifyModelTrace = ( compareState(location, "step.plan.next", expected.after, get(actualPlan, "next")) compareState(location, "step.after", expected.after, get(actual, "after")) - add(location, "step.afterConfiguration", expected.after.activePaths, get(actual, "afterConfiguration")) + add(location, "step.afterConfiguration", ["", ...expected.after.activePaths], get(actual, "afterConfiguration")) add(location, "step.plan.done", expected.done, get(actualPlan, "done")) add(location, "step.plan.output", expected.output, get(actualPlan, "output")) } const finalLocation: ModelVerificationLocation = { phase: "final" } compareState(finalLocation, "trace.final", reference.final, get(actualTrace, "final")) - add(finalLocation, "trace.finalConfiguration", reference.final.activePaths, get(actualTrace, "finalConfiguration")) + add( + finalLocation, + "trace.finalConfiguration", + ["", ...reference.final.activePaths], + get(actualTrace, "finalConfiguration") + ) return mismatches.length === 0 ? Effect.void diff --git a/packages/effect-machine/src/internal/testing/machine/trace.ts b/packages/effect-machine/src/internal/testing/machine/trace.ts index 8b73529..7b17de2 100644 --- a/packages/effect-machine/src/internal/testing/machine/trace.ts +++ b/packages/effect-machine/src/internal/testing/machine/trace.ts @@ -57,7 +57,7 @@ export const rawConfigurationPaths = ( export const appendTrace = ( machine: ReadyMachine, trace: Trace, - event: Machine.Machine.InputEvent, + event: Machine.Machine.EventInput>, scenario: Scenario ): Effect.Effect, RunFailure, M>, RunServices> => { const index = trace.steps.length @@ -83,13 +83,13 @@ export const appendTrace = ( index, before, beforeConfiguration, - event, + event: plan.event, plan, after: plan.next, afterConfiguration: rawConfigurationPaths(machine, plan.next) } return { - scenario, + scenario: { ...scenario, events: [...trace.steps.map((step) => step.event), plan.event] }, initial: trace.initial, steps: [...trace.steps, step], final: plan.next, @@ -135,7 +135,7 @@ export const run: ( configuration: rawConfigurationPaths(machine, initial.state) } let trace: Trace = { - scenario, + scenario: { ...scenario, events: [] }, initial: initialTrace, steps: [], final: initial.state, diff --git a/packages/effect-machine/src/internal/testing/machine/verification.ts b/packages/effect-machine/src/internal/testing/machine/verification.ts index 049834a..05f44e6 100644 --- a/packages/effect-machine/src/internal/testing/machine/verification.ts +++ b/packages/effect-machine/src/internal/testing/machine/verification.ts @@ -325,7 +325,7 @@ const targetWithinSelection = ( return selection.kind === "state" && (selection.scope === "local" || selection.scope === "branch") && (selectedNode?.type === "compound" || selectedNode?.type === "parallel") && - target.startsWith(`${selection.path}.`) + (selection.path === "" || target.startsWith(`${selection.path}.`)) } const finiteTagValues = (ast: SchemaAST.AST): ReadonlyArray | undefined => { @@ -490,8 +490,8 @@ export const coverage = ( for (const trace of traces) { scenarioEvents += trace.scenario.events.length if (trace.scenario.events.length === 0) emptyScenarios += 1 - for (const event of trace.scenario.events) { - const tag = event._tag + for (const step of trace.steps) { + const tag = step.event._tag eventCounts.set(tag, (eventCounts.get(tag) ?? 0) + 1) } @@ -1218,7 +1218,10 @@ export const verify = ( if (node.type === "final") return true if (node.type === "compound") { const child = node.children.find((candidate) => active.has(candidate)) - return child !== undefined && byPath.get(child)?.type === "final" + return child !== undefined && (path === "" + ? !definitions.some((definition) => definition.source === child && definition.trigger.type === "done") && + isCompletedControl(child, active) + : byPath.get(child)?.type === "final") } if (node.type === "parallel") { return node.children.length > 0 && node.children.every((child) => isCompletedControl(child, active)) @@ -1730,6 +1733,21 @@ export const verify = ( const initialLocation: VerificationLocation = { eventIndex: undefined } const starting = inspectSnapshot(trace.initial.startingState, initialLocation, "initial starting state") if (selected.has("definitions")) { + if (initialDefinition.selection.kind === "initial") { + for (const path of starting.paths) { + const node = byPath.get(path) + if (node?.type !== "compound") continue + const child = node.children.find((child) => starting.active.has(child)) + if (byPath.get(node.initial ?? "")?.type !== "choice" && child !== node.initial) { + add( + "definitions.initial", + initialLocation, + `initial state "${path}" must enter declared child "${node.initial}"`, + path + ) + } + } + } const startingRoots = starting.paths.filter((path) => byPath.get(path)?.parent === undefined) const routedRoot = startingRoots.length === 1 && startingRoots[0] !== initialDefinition.target ? initialChoiceRouteRoot() diff --git a/packages/effect-machine/src/testing/MachineTest.ts b/packages/effect-machine/src/testing/MachineTest.ts index 380867a..fb4032a 100644 --- a/packages/effect-machine/src/testing/MachineTest.ts +++ b/packages/effect-machine/src/testing/MachineTest.ts @@ -159,11 +159,11 @@ type RootReadyMachine = * @since 0.4.0 */ export type Scenario = Machine.Machine.InputSchema extends typeof Schema.Void ? { - readonly events: ReadonlyArray> + readonly events: ReadonlyArray>> } : { readonly input: InputValue - readonly events: ReadonlyArray> + readonly events: ReadonlyArray>> } /** @@ -238,22 +238,19 @@ export interface Scenarios { * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} * class Reset extends Schema.TaggedClass("Reset")("Reset", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(Reset), - * initial: { - * target: (to) => to.Idle(), - * resolve: ({ target }) => target.from() - * } - * }).handle({ + * root: States, + * events: Machine.eventsFromSchemas(Reset), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) + * }).handle({ states: { * Idle: { * on: { * Reset: (to) => - * to.full.Idle().resolve(({ target }) => target.from()) + * to.branch.Idle().resolve(({ target }) => target.from()) * } * } - * }) + * } }) * * const generated = MachineTest.scenarios(machine, { maxEvents: 5 }) * ``` @@ -301,7 +298,7 @@ export interface Microstep< > readonly commands: ReadonlyArray readonly raisedEvents: ReadonlyArray> - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly exitPaths: ReadonlyArray readonly entryPaths: ReadonlyArray readonly changed: boolean @@ -319,7 +316,7 @@ export type InitialPlan = readonly initialEntryPaths: ReadonlyArray> readonly state: Machine.Machine.Snapshot> readonly commands: ReadonlyArray - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly microsteps: ReadonlyArray< Microstep | Machine.Machine.Services> > @@ -334,9 +331,10 @@ export type InitialPlan = */ export type EventPlan = & { + readonly event: Machine.Machine.InputEvent readonly next: Machine.Machine.Snapshot> readonly commands: ReadonlyArray - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly microsteps: ReadonlyArray> } & PlanCompletion @@ -405,9 +403,10 @@ export type ProbeMicrostep = Omit, "transitio */ export type ProbePlan = & { + readonly event: Machine.Machine.InputEvent readonly next: Machine.Machine.Snapshot> readonly commands: ReadonlyArray - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly microsteps: ReadonlyArray> } & PlanCompletion @@ -447,13 +446,13 @@ export interface Probe { readonly machine: M readonly ref: Machine.MachineRef< Machine.Machine.Snapshot>, - Machine.Machine.InputEvent, + Machine.Machine.EventInput>, Error, Output > readonly sendAndAwait: ( - event: Machine.Machine.InputEvent - ) => Effect.Effect, Error | Machine.StoppedError> + event: Machine.Machine.EventInput> + ) => Effect.Effect, Error | Machine.MachineSchemaDecodeError | Machine.StoppedError> /** Constructors for asynchronous observation after a causal command. */ readonly await: { readonly none: RuntimeTest.RuntimeAwait< @@ -497,15 +496,12 @@ export { ProbeUnavailableError } from "../internal/testing/machine/probe.js" * import { MachineTest } from "@typeonce/effect-machine/testing" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: { - * target: (to) => to.Idle(), - * resolve: ({ target }) => target.from() - * } - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const program = Effect.gen(function*() { * const ref = yield* Machine.start(machine) @@ -520,7 +516,7 @@ export const probe: ( machine: ReadyMachine, ref: Machine.MachineRef< Machine.Machine.Snapshot>, - Machine.Machine.InputEvent, + Machine.Machine.EventInput>, Error, Output > @@ -1111,15 +1107,12 @@ export const Invariant: { * class Count extends Schema.TaggedClass("Count")("Count", { * value: Schema.Number * }) {} - * const States = Machine.states({ Count }) + * const States = Machine.state({ initial: "Count", states: { Count } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: { - * target: (to) => to.Count(), - * resolve: ({ target }) => target.decoded(new Count({ value: 0 })) - * } - * }).handle({ Count: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Count.decoded(new Count({ value: 0 })))) + * }).handle({ states: { Count: {} } }) * * const nonNegative = MachineTest.invariants(machine).state( * "count is non-negative", @@ -1446,23 +1439,20 @@ export type ExploreOptions("Increment")("Increment", {}) {} - * const States = Machine.states({ Count }) + * const States = Machine.state({ initial: "Count", states: { Count } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(Increment), - * initial: { - * target: (to) => to.Count(), - * resolve: ({ target }) => target.decoded(new Count({ value: 0 })) - * } - * }).handle({ + * root: States, + * events: Machine.eventsFromSchemas(Increment), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Count.decoded(new Count({ value: 0 })))) + * }).handle({ states: { * Count: { * on: { * Increment: (to) => - * to.full.Count().resolve(({ state, target }) => + * to.branch.Count().resolve(({ state, target }) => * target.decoded(new Count({ value: state.value + 1 }))) * } * } - * }) + * } }) * * const explored = MachineTest.explore(machine, { * events: ({ snapshot }) => snapshot.value.value < 2 ? [new Increment({})] : [], @@ -1589,7 +1579,7 @@ export type RunFailure = readonly scenario: Scenario readonly phase: "event" readonly eventIndex: number - readonly event: Machine.Machine.InputEvent + readonly event: Machine.Machine.EventInput> readonly initial: InitialTrace readonly steps: ReadonlyArray> readonly cause: Cause @@ -1638,15 +1628,12 @@ export type RunServices = IsAny< * import { MachineTest } from "@typeonce/effect-machine/testing" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: { - * target: (to) => to.Idle(), - * resolve: ({ target }) => target.from() - * } - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const trace = MachineTest.run(machine, { events: [] }) * ``` @@ -1907,15 +1894,12 @@ export interface Coverage { * import { MachineTest } from "@typeonce/effect-machine/testing" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: { - * target: (to) => to.Idle(), - * resolve: ({ target }) => target.from() - * } - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const report = Effect.map( * MachineTest.run(machine, { events: [] }), @@ -1970,7 +1954,7 @@ export interface ObservedGraphMicrostep { readonly event: Machine.Machine.Event | Machine.InitialEvent readonly transitions: Microstep["transitions"] readonly raisedEvents: ReadonlyArray> - readonly emittedEvents: ReadonlyArray> + readonly emittedEvents: ReadonlyArray> readonly exitPaths: ReadonlyArray> readonly entryPaths: ReadonlyArray> readonly changed: boolean @@ -2136,15 +2120,12 @@ export interface VerifyOptions { * import { MachineTest } from "@typeonce/effect-machine/testing" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: { - * target: (to) => to.Idle(), - * resolve: ({ target }) => target.from() - * } - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const checked = Effect.gen(function*() { * const trace = yield* MachineTest.run(machine, { events: [] }) diff --git a/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts b/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts index 20c7f67..90d1fb1 100644 --- a/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts +++ b/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts @@ -192,7 +192,7 @@ type SendRpc> = Rpc.R type MachineEvents = Machine.Machine.InputEvents -type MachineEmits = Machine.Machine.Emits +type MachineEmits = Machine.Machine.EmittedEvents /** * Cluster adapter for one machine definition and entity type. @@ -232,7 +232,7 @@ export interface ClusterMachine< */ readonly toLayer: (options?: { readonly enqueue?: ( - event: Machine.Machine.EmitOf> + event: Machine.Machine.EmittedEventOf> ) => Effect.Effect }) => Layer.Layer } @@ -241,7 +241,7 @@ type MachineServices = | ExcludeCompatibleRuntime< Machine.ExecutionServices | Machine.Machine.InitialServices>, Machine.Machine.Event, - Machine.Machine.Emit + Machine.Machine.EmittedEvent > | Machine.Machine.SnapshotDecodingServices> | Machine.Machine.SnapshotEncodingServices> @@ -354,15 +354,12 @@ export const layerMemory: Layer.Layer = internal.layerMemory * import { ClusterMachine } from "@typeonce/effect-machine/cluster" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: { - * target: (to) => to.Idle(), - * resolve: ({ target }) => target.from() - * } - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const adapter = ClusterMachine.make("IdleMachine", machine, { version: "1" }) * ``` @@ -433,7 +430,7 @@ export const make: < | ExcludeCompatibleRuntime< Machine.ExecutionServices, Machine.Machine.EventOf, - Machine.Machine.EmitOf + Machine.Machine.EmittedEventOf > | Machine.Machine.SnapshotDecodingServices | Machine.Machine.SnapshotEncodingServices diff --git a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts index e585f13..d495ac4 100644 --- a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts +++ b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts @@ -105,17 +105,6 @@ export interface MachineAtom, StartError> > - /** - * Atom containing the state value from the latest runtime snapshot. - * - * This preserves the historical behavior of exposing a state even when the - * runtime snapshot reports a terminal error. Use `result` when runtime - * failures must be represented in the atom failure channel. - * - * @since 0.4.0 - */ - readonly state: Atom.Atom> - /** * Atom containing the current state, with startup and runtime failures in * one typed failure channel. @@ -246,17 +235,7 @@ export interface ChildMachineAtom > - /** - * Atom containing the current child state when active. - * - * Runtime failures retain the last successful state. Use `result` when they - * must be represented in the atom failure channel. - * - * @since 0.4.0 - */ - readonly state: Atom.Atom< - AsyncResult.AsyncResult>>, StartError> - > + /** * Atom containing the current child state when active, with startup and * runtime failures in one typed failure channel. @@ -319,16 +298,8 @@ export type ChildOf< Child extends Machine.ChildMachine.Any > = ChildMachineAtom> -type SnapshotNode = State extends Machine.Machine.AtomicSnapshot ? - | State - | (State extends { readonly state: infer Child } ? SnapshotNode - : State extends { readonly states: infer Regions } ? SnapshotNode - : never) - : never - -type SnapshotIdentifier = SnapshotNode extends infer Node ? - Node extends { readonly path: infer Path extends string } ? Path : never - : never +type SnapshotNode = Machine.Snapshot.Node +type SnapshotIdentifier = Machine.Snapshot.Path type ValuedSnapshotIdentifier = SnapshotNode extends infer Node ? Node extends { readonly path: infer Path extends string; readonly value: infer Value } ? @@ -345,7 +316,7 @@ type SnapshotByIdentifier> = Snaps Node extends { readonly path: Path } ? Node : never : never -type ChildState = RefState> +type ChildState = ChildSnapshot type ChildSnapshot = Machine.Machine.Snapshot< Machine.Machine.States @@ -420,15 +391,12 @@ type EnsureValuedSelectorPath = [Path] extends [Valu * class Count extends Schema.TaggedClass("Count")("Count", { * value: Schema.Number * }) {} - * const States = Machine.states({ Count }) + * const States = Machine.state({ initial: "Count", states: { Count } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: { - * target: (to) => to.Count(), - * resolve: ({ target }) => target.decoded(new Count({ value: 0 })) - * } - * }).handle({ Count: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Count.decoded(new Count({ value: 0 })))) + * }).handle({ states: { Count: {} } }) * const machineAtom = AtomMachine.make(machine) * * const countAtom = AtomMachine.select(machineAtom, "Count") @@ -553,15 +521,18 @@ export const selectSnapshot: { * @since 0.4.0 */ export const selectChild: { - < - Child extends Machine.ChildMachine.Any = never, - StartError = never, - const Path extends ValuedSnapshotIdentifier> = ValuedSnapshotIdentifier> - >(path: Path): + (path: NoInfer): & SelectorProjection<"selectChild", Path> - & ((self: ChildMachineAtom) => Atom.Atom< + & (< + Child extends Machine.ChildMachine.Any, + StartError + >( + self: ChildMachineAtom & EnsureValuedSelectorPath, Path> + ) => Atom.Atom< AsyncResult.AsyncResult< - Option.Option, Path>>, + Option.Option< + SnapshotValueByIdentifier, Extract>>> + >, StartError | RefError> > >) @@ -658,15 +629,12 @@ export const selectSnapshotChild: { * import { AtomMachine } from "@typeonce/effect-machine/reactivity" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: { - * target: (to) => to.Idle(), - * resolve: ({ target }) => target.from() - * } - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) + * }).handle({ states: { Idle: {} } }) * const machineAtom = AtomMachine.make(machine) * * const isIdleAtom = AtomMachine.matches(machineAtom, "Idle") @@ -791,7 +759,7 @@ type MachineRequirementsOf = MachineRequirements< Machine.Machine.InitialServices, Machine.Machine.Services, Machine.Machine.Event, - Machine.Machine.Emit + Machine.Machine.EmittedEvent > type MissingBoundRequirements = Exclude< @@ -810,7 +778,7 @@ type EnsureBoundRequirements = IsAny = MachineResumeRequirements< Machine.Machine.Services, Machine.Machine.Event, - Machine.Machine.Emit + Machine.Machine.EmittedEvent > type MissingBoundResumeRequirements = Exclude< @@ -1051,7 +1019,7 @@ export interface Bound { * const processAtoms = AtomMachine.family(processMachine, { * atoms: { * ready: AtomMachine.matches("Ready"), - * state: (machine) => machine.state, + * state: (machine) => machine.result, * send: (machine) => machine.send * } * }) @@ -1099,7 +1067,7 @@ export const family: < * child: (plantId: string) => Plant(plantId), * atoms: { * broken: AtomMachine.matchesChild("Broken"), - * state: (child) => child.state, + * state: (child) => child.result, * send: (child) => child.send * } * }) @@ -1112,7 +1080,10 @@ export const familyChild: < Key, Parent extends MachineAtom | ChildMachineAtom, Child extends Machine.ChildMachine.Any, - const Projections extends FamilyProjectionRecord, ChildFamilySelectorProjection> + const Projections extends FamilyProjectionRecord< + ChildOf>, + ChildFamilySelectorProjection> + > >( parent: Parent, options: { @@ -1136,15 +1107,12 @@ export const familyChild: < * import { AtomMachine } from "@typeonce/effect-machine/reactivity" * * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.states({ Idle }) + * const States = Machine.state({ initial: "Idle", states: { Idle } }) * const machine = Machine.make({ - * states: States.states, - * events: Machine.events(), - * initial: { - * target: (to) => to.Idle(), - * resolve: ({ target }) => target.from() - * } - * }).handle({ Idle: {} }) + * root: States, + * events: Machine.eventsFromSchemas(), + * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) + * }).handle({ states: { Idle: {} } }) * * const machineAtom = AtomMachine.make(machine) * ``` @@ -1191,7 +1159,7 @@ export const make: { InitialR, R, Machine.Machine.EventOf, - Machine.Machine.EmitOf + Machine.Machine.EmittedEventOf > > & EnsureExecutable diff --git a/packages/effect-machine/test/examples/can.ts b/packages/effect-machine/test/examples/can.ts index d4f69e1..98fec5e 100644 --- a/packages/effect-machine/test/examples/can.ts +++ b/packages/effect-machine/test/examples/can.ts @@ -1,13 +1,13 @@ import { Machine } from "@typeonce/effect-machine" import { Effect, Schema } from "effect" -const internalEvents = Machine.internalEvents(Schema.TaggedStruct("Loaded", {})) +const internalEvents = Machine.internalEventsFromSchemas(Schema.TaggedStruct("Loaded", {})) const machine = Machine.make({ - states: { Idle: {} }, - events: Machine.events(), + root: Machine.state({ initial: "Idle", states: { Idle: {} } }), + events: Machine.eventsFromSchemas(), internalEvents, - initial: (to) => to.Idle() -}).handle({ Idle: { on: { Loaded: (to) => to.none } } }) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.from())) +}).handle({ states: { Idle: { on: { Loaded: (to) => to.none } } } }) export const canLoad = Effect.gen(function*() { const initial = yield* Machine.planInitial(machine) diff --git a/packages/effect-machine/test/internal/machine/ProtocolValidation.test.ts b/packages/effect-machine/test/internal/machine/ProtocolValidation.test.ts index bb3fc34..f61a232 100644 --- a/packages/effect-machine/test/internal/machine/ProtocolValidation.test.ts +++ b/packages/effect-machine/test/internal/machine/ProtocolValidation.test.ts @@ -9,26 +9,31 @@ describe("event validation across execution strategies", () => { it.effect(`rejects mutated decoded events in the ${strategy} planner`, () => Effect.gen(function*() { const Set = Schema.TaggedStruct("Set", { value: Schema.Int }) - const events = Machine.events(Set) + const events = Machine.eventsFromSchemas(Set) let retained: unknown const machine = Machine.make({ - states: { Ready: Schema.TaggedStruct("Ready", {}) }, + root: Machine.state({ initial: "Ready", states: { Ready: Schema.TaggedStruct("Ready", {}) } }), events, - initial: (to) => to.Ready().resolve(({ target }) => target.decoded({ _tag: "Ready" })) + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Ready.decoded({ _tag: "Ready" }))) }).handle({ - Ready: { - on: { - Set: (to) => - to.none.resolve(({ event }) => { - retained = event - }) + states: { + Ready: { + on: { + Set: (to) => + to.none.resolve(({ event }) => { + retained = event + }) + } } } }) const initial = yield* Machine.planInitial(machine) const selected = ExecutionPlan.selectExecutionPlanForTesting(machine, strategy) assert.strictEqual(selected.strategy, strategy === "auto" ? "indexed-flat" : "generic") - const state = selected.plan.fromConfiguration(Configuration.normalizeConfigurationSync(machine, initial.state)) + const state = selected.plan.fromConfiguration( + Configuration.normalizeConfigurationSync>(machine, initial.state) + ) selected.plan.plan(state, events.Set({ value: 1 }), true) Object.assign(retained as object, { value: "invalid" }) assert.throws(() => selected.plan.plan(state, retained, true), Machine.MachineSchemaDecodeError) diff --git a/packages/effect-machine/test/internal/machine/activities.test.ts b/packages/effect-machine/test/internal/machine/activities.test.ts index e3c395e..f940df5 100644 --- a/packages/effect-machine/test/internal/machine/activities.test.ts +++ b/packages/effect-machine/test/internal/machine/activities.test.ts @@ -13,61 +13,64 @@ class WorkSucceeded extends Schema.TaggedClass("WorkSucceeded")(" class WorkFailed extends Schema.TaggedClass("WorkFailed")("WorkFailed", {}) {} class LoadTimedOut extends Schema.TaggedClass("LoadTimedOut")("LoadTimedOut", {}) {} -const childStates = Machine.states({ ChildIdle }) +const childStates = Machine.state({ initial: "ChildIdle", states: { ChildIdle } }) const childMachine = Machine.make({ id: "document-worker", - states: childStates.states, - events: Machine.events(), - initial: (to) => to.ChildIdle().resolve(({ target }) => target.decoded(new ChildIdle({}))) + root: childStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.ChildIdle.decoded(new ChildIdle({})))) }) const child = Machine.child("child", childMachine) let dynamicFactoryEvaluations = 0 const timerDuration = "10 seconds" -const activityStates = Machine.states({ Loading, Dynamic }) +const activityStates = Machine.state({ initial: "Loading", states: { Loading, Dynamic } }) const activityMachine = Machine.make({ id: "activity-inspection", - states: activityStates.states, - events: Machine.events(WorkSucceeded, WorkFailed, LoadTimedOut), - initial: (to) => to.Loading().resolve(({ target }) => target.decoded(new Loading({}))) + root: activityStates, + events: Machine.eventsFromSchemas(WorkSucceeded, WorkFailed, LoadTimedOut), + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Loading.decoded(new Loading({})))) }).handle({ - Loading: { - invoke: ( - from - ) => [ - from.logic("poll-server", { - address: Machine.childAddress("poll-server"), - logic: Machine.logic({ - initial: undefined, - run: () => Effect.never + states: { + Loading: { + invoke: ( + from + ) => [ + from.logic("poll-server", { + address: Machine.childAddress("poll-server"), + logic: Machine.logic({ + initial: undefined, + run: () => Effect.never + }) + }), + from.effect("load-document", () => Effect.fail("unavailable").pipe(Effect.as(1))).onDone((to) => to.none) + .onFailure((to) => to.none), + from.timer("load-timeout", timerDuration).onDone((to) => to.none), + from.stream("updates", () => Stream.empty).onDone((to) => to.none), + from.child(child) + ] + }, + Dynamic: { + invoke: (from) => + from.logic("context-owned", { + address: Machine.childAddress("context-owned"), + logic: () => { + dynamicFactoryEvaluations++ + return Machine.logic({ initial: undefined, run: () => Effect.never }) + } }) - }), - from.effect("load-document", () => Effect.fail("unavailable").pipe(Effect.as(1))).onDone((to) => to.none) - .onFailure((to) => to.none), - from.timer("load-timeout", timerDuration).onDone((to) => to.none), - from.stream("updates", () => Stream.empty).onDone((to) => to.none), - from.child(child) - ] - }, - Dynamic: { - invoke: (from) => - from.logic("context-owned", { - address: Machine.childAddress("context-owned"), - logic: () => { - dynamicFactoryEvaluations++ - return Machine.logic({ initial: undefined, run: () => Effect.never }) - } - }) + } } }) const renderActivityMachine = makeTextRenderer< typeof activityMachine, - Machine.Machine.Snapshot + Machine.Snapshot >(Machine) const renderMermaidActivityMachine = makeMermaidRenderer< typeof activityMachine, - Machine.Machine.Snapshot + Machine.Snapshot >(Machine) const machine = { @@ -105,7 +108,7 @@ const machine = { describe("machine activity metadata", () => { it("inspects all public helper descriptors without retaining runtime values", () => { const expected: ReadonlyArray< - Machine.Machine.ActivityDefinition> + Machine.Machine.ActivityDefinition> > = [ { source: "Loading", @@ -166,12 +169,15 @@ describe("machine activity metadata", () => { Effect.sync(() => { const id = `generated-timer-${idSuffix}` const generated = Machine.make({ - states: activityStates.states, - events: Machine.events(LoadTimedOut), - initial: (to) => to.Loading().resolve(({ target }) => target.decoded(new Loading({}))) + root: activityStates, + events: Machine.eventsFromSchemas(LoadTimedOut), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Loading.decoded(new Loading({})))) }).handle({ - Loading: { - invoke: (from) => from.timer(id, durationMillis).onDone((to) => to.none) + states: { + Loading: { + invoke: (from) => from.timer(id, durationMillis).onDone((to) => to.none) + } } }) const definition = Machine.activityDefinitions(generated)[0] @@ -263,19 +269,24 @@ describe("machine activity metadata", () => { it("renders activities beneath their owning state", () => { assert.strictEqual( - renderActivityMachine(activityMachine, { path: "Loading" as const, value: new Loading({}) }), + renderActivityMachine(activityMachine, { + path: "" as const, + value: undefined, + state: { path: "Loading" as const, value: new Loading({}) } + }), [ "activity-inspection", "● active ○ inactive ◇ transition ┄ branch → target ◆ activity", "", - "├─ ● Loading", - "│ ├─ ◆ process: poll-server", - "│ ├─ ◆ effect: load-document [success: dynamic, failure: dynamic]", - "│ ├─ ◆ timer: load-timeout [10s]", - "│ ├─ ◆ stream: updates", - "│ └─ ◆ machine: child → document-worker", - "└─ ○ Dynamic", - " └─ ◆ process: context-owned", + "└─ ● (root) [compound, initial: Loading]", + " ├─ ● Loading", + " │ ├─ ◆ process: poll-server", + " │ ├─ ◆ effect: load-document [success: dynamic, failure: dynamic]", + " │ ├─ ◆ timer: load-timeout [10s]", + " │ ├─ ◆ stream: updates", + " │ └─ ◆ machine: child → document-worker", + " └─ ○ Dynamic", + " └─ ◆ process: context-owned", "", "Candidate events: none" ].join("\n") @@ -284,13 +295,17 @@ describe("machine activity metadata", () => { it("renders state-owned activities inside their Mermaid state", () => { const rendered = renderMermaidActivityMachine(activityMachine, { - path: "Loading" as const, - value: new Loading({}) + path: "" as const, + value: undefined, + state: { + path: "Loading" as const, + value: new Loading({}) + } }) assert.include( rendered, - "state_0: process / poll-server · effect / load-document · timer / load-timeout (10s) · stream / updates · machine / child → document-worker" + "state_1: process / poll-server · effect / load-document · timer / load-timeout (10s) · stream / updates · machine / child → document-worker" ) assert.notInclude(rendered, "success: dynamic") assert.notInclude(rendered, "note right of") diff --git a/packages/effect-machine/test/internal/machine/protocol.test.ts b/packages/effect-machine/test/internal/machine/protocol.test.ts index 7e7b6ad..8f72855 100644 --- a/packages/effect-machine/test/internal/machine/protocol.test.ts +++ b/packages/effect-machine/test/internal/machine/protocol.test.ts @@ -10,25 +10,22 @@ const InternalEvent = Schema.TaggedStruct("InternalEvent", { value: Schema.Strin describe("machine protocols", () => { it("rejects forged, misclassified, and overlapping event descriptors", () => { - const states = Machine.states({ ProtocolIdle }) - const initial = (to: Machine.Machine.InitialSelector) => - to.ProtocolIdle().resolve(() => ({ path: "ProtocolIdle" as const, value: new ProtocolIdle({}) })) + const states = Machine.state({ initial: "ProtocolIdle", states: { ProtocolIdle } }) assert.throws( - () => Machine.make({ states: states.states, events: [PublicEvent] as any, initial }), + () => Machine.make({ root: states, events: [PublicEvent] as any }), /expected an event protocol/ ) assert.throws( - () => Machine.make({ states: states.states, events: Machine.internalEvents(PublicEvent) as any, initial }), + () => Machine.make({ root: states, events: Machine.internalEventsFromSchemas(PublicEvent) as any }), /expected a public event protocol/ ) assert.throws( () => Machine.make({ - states: states.states, - events: Machine.events(PublicEvent), - internalEvents: Machine.internalEvents(PublicEvent) as any, - initial + root: states, + events: Machine.eventsFromSchemas(PublicEvent), + internalEvents: Machine.internalEventsFromSchemas(PublicEvent) as any }), /must be disjoint/ ) @@ -36,13 +33,14 @@ describe("machine protocols", () => { it.effect("keeps the complete event protocol private across handler clones", () => Effect.gen(function*() { - const states = Machine.states({ ProtocolIdle }) + const states = Machine.state({ initial: "ProtocolIdle", states: { ProtocolIdle } }) const machine = Machine.make({ - states: states.states, - events: Machine.events(PublicEvent), - internalEvents: Machine.internalEvents(InternalEvent), - initial: (to) => to.ProtocolIdle().resolve(({ target }) => target.decoded(new ProtocolIdle({}))) - }).handle({}) + root: states, + events: Machine.eventsFromSchemas(PublicEvent), + internalEvents: Machine.internalEventsFromSchemas(InternalEvent), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.ProtocolIdle.decoded(new ProtocolIdle({})))) + }).handle({ states: {} }) assert.strictEqual(Object.hasOwn(machine, "eventSchemas"), false) assert.deepStrictEqual( diff --git a/packages/effect-machine/test/internal/machine/rootStrategies.test.ts b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts new file mode 100644 index 0000000..3494397 --- /dev/null +++ b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts @@ -0,0 +1,106 @@ +import { it } from "@effect/vitest" +import { Schema } from "effect" +import { Machine } from "../../../src/index.js" +import { verifyPlannerStrategies } from "./support/strategyDifferential.js" + +it.effect("constructs schema defaults throughout a parallel root", () => { + const machine = Machine.make({ + root: Machine.state({ + type: "parallel", + states: { + Left: { schema: Schema.TaggedStruct("Left", {}), initial: "Idle", states: { Idle: { fields: {} } } }, + Right: { fields: {} } + } + }), + events: Machine.events({ Noop: {} }) + }) + return verifyPlannerStrategies({ + machine, + expected: "indexed-hierarchical", + label: "default construction", + events: [{ _tag: "Noop" }] + }) +}) + +it.effect("compares structural leaves, reentry, raised events and completion with generic planning", () => { + const machine = Machine.make({ + root: Machine.state({ initial: "Idle", states: { Idle: {}, Busy: {}, Done: { type: "final" } } }), + events: Machine.events({ Begin: {}, Reenter: {}, Finish: {}, Complete: {} }) + }).handle({ + states: { + Idle: { on: { Begin: (to) => to.local.Busy() } }, + Busy: { + on: { + Reenter: (to) => to.none.reenter(), + Finish: (to) => + to.none.resolve((_, enqueue) => { + enqueue.raise({ _tag: "Complete" }) + }), + Complete: (to) => to.local.Done() + } + } + } + }) + return verifyPlannerStrategies({ + machine, + expected: "indexed-flat", + label: "structural leaves", + events: [ + { _tag: "Begin" }, + { _tag: "Reenter" }, + { _tag: "Finish" } + ] + }) +}) + +it.effect("keeps flat root updates and retained snapshots equal to generic planning", () => { + const root = Machine.state({ fields: { count: Schema.Number } }) + const machine = Machine.make({ + root, + events: Machine.events({ Increment: { by: Schema.Number }, Noop: {}, Reenter: {} }), + initial: (root) => root.from(() => ({ count: 0 })) + }).handle({ + on: { + Increment: (to) => to.self.update.from(({ current, event }) => ({ count: current.count + event.by })), + Noop: (to) => to.none, + Reenter: (to) => to.none.reenter() + } + }) + return verifyPlannerStrategies({ + machine, + expected: "indexed-flat", + label: "root updates", + events: [ + { _tag: "Increment", by: 2 }, + { _tag: "Noop" }, + { _tag: "Reenter" }, + { _tag: "Increment", by: 3 } + ] + }) +}) + +it.effect("keeps root values and child transitions equal to generic planning", () => { + const root = Machine.state({ fields: { count: Schema.Number }, initial: "Idle", states: { Idle: {}, Busy: {} } }) + const machine = Machine.make({ + root, + events: Machine.events({ Increment: { by: Schema.Number }, Start: {}, Stop: {} }), + initial: (root) => root.from(() => ({ count: 0 })) + }).handle({ + on: { Increment: (to) => to.self.update.from(({ current, event }) => ({ count: current.count + event.by })) }, + states: { + Idle: { on: { Start: (to) => to.local.Busy() } }, + Busy: { on: { Stop: (to) => to.local.Idle() } } + } + }) + return verifyPlannerStrategies({ + machine, + expected: "indexed-hierarchical", + label: "root children", + events: [ + { _tag: "Increment", by: 2 }, + { _tag: "Start" }, + { _tag: "Increment", by: 3 }, + { _tag: "Stop" } + ] + }) +}) diff --git a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts index 4fa0842..3dca378 100644 --- a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts +++ b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts @@ -28,25 +28,32 @@ class Select extends Schema.TaggedClass("CoverageSelect")("Select", { }) {} const branchMachine = Machine.make({ - states: StartupStates.states, - events: Machine.events(Select), - initial: (to) => to.count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: StartupStates, + events: Machine.eventsFromSchemas(Select), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.count.decoded(new Count({ value: 0 })))) }).handle({ - count: { - on: { - Select: (to) => - to.branches({ - negative: { target: to.none }, - zero: { target: to.none }, - positive: { target: to.none } - }).resolve(({ event, select }) => - event.value < 0 - ? select.negative() - : event.value === 0 - ? select.zero() - : select.positive() - ) + states: { + count: { + on: { + Select: (to) => + to.branches({ + negative: { target: to.none }, + zero: { target: to.none }, + positive: { target: to.none } + }).resolve(({ event, select }) => + event.value < 0 + ? select.negative() + : event.value === 0 + ? select.zero() + : select.positive() + ) + } } } }) @@ -105,25 +115,29 @@ const ChoiceEvent = Schema.Struct({ _tag: Schema.Union([Schema.Literal("Alpha"), Schema.Literal("Beta")]) }) const OpenEvent = Schema.Struct({ _tag: Schema.String }) -const EventStates = Machine.states({ count: Count }) +const EventStates = Machine.state({ initial: "count", states: { count: Count } }) const finiteEventMachine = Machine.make({ - states: EventStates.states, - events: Machine.events(TickEvent, ChoiceEvent), - initial: (to) => to.count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: EventStates, + events: Machine.eventsFromSchemas(TickEvent, ChoiceEvent), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.count.decoded(new Count({ value: 0 })))) }).handle({ - count: { - on: { - [Tick]: (to) => to.none, - Alpha: (to) => to.none, - Beta: (to) => to.none + states: { + count: { + on: { + [Tick]: (to) => to.none, + Alpha: (to) => to.none, + Beta: (to) => to.none + } } } }) const openEventMachine = Machine.make({ - states: EventStates.states, - events: Machine.events(OpenEvent), - initial: (to) => to.count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) -}).handle({ count: {} }) + root: EventStates, + events: Machine.eventsFromSchemas(OpenEvent), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.count.decoded(new Count({ value: 0 })))) +}).handle({ states: { count: {} } }) const event = (_tag: string): { readonly _tag: string } => ({ _tag }) @@ -340,8 +354,8 @@ describe("MachineTest observed graph", () => { ) const nodes = Array.from(observed.graph) - const zero = nodes.find(([, node]) => (node.snapshot.value as Count).value === 0)! - const one = nodes.find(([, node]) => (node.snapshot.value as Count).value === 1)! + const zero = nodes.find(([, node]) => (node.snapshot.state.value as Count).value === 0)! + const one = nodes.find(([, node]) => (node.snapshot.state.value as Count).value === 1)! assert.notStrictEqual(zero[1].id, one[1].id) const shortest = Graph.dijkstra(observed.graph, { source: zero[0], @@ -395,7 +409,7 @@ describe("MachineTest observed graph", () => { }) const observed = yield* MachineTest.observedGraph(machine, [rememberedA, rememberedB]) const outside = Array.from(observed.graph, ([, node]) => node).filter( - ({ configuration }) => configuration.length === 1 && configuration[0] === "outside" + ({ configuration }) => configuration.length === 2 && configuration[0] === "" && configuration[1] === "outside" ) assert.strictEqual(outside.length, 2) diff --git a/packages/effect-machine/test/testing/Exploration.test.ts b/packages/effect-machine/test/testing/Exploration.test.ts index 947b8ed..ef8625a 100644 --- a/packages/effect-machine/test/testing/Exploration.test.ts +++ b/packages/effect-machine/test/testing/Exploration.test.ts @@ -15,45 +15,51 @@ class Select extends Schema.TaggedClass("VerificationSelect")("Select", value: Schema.Int }) {} -const NavigationStates = Machine.states({ - off: Off, - app: { - schema: App, - initial: "one", - states: { - one: One, - two: Two +const NavigationStates = Machine.state({ + initial: "off", + states: { + off: Off, + app: { + schema: App, + initial: "one", + states: { + one: One, + two: Two + } } } }) const navigationMachine = Machine.make({ - states: NavigationStates.states, - events: Machine.events(Go), - initial: (to) => to.off().resolve(({ target }) => target.decoded(new Off({}))) + root: NavigationStates, + events: Machine.eventsFromSchemas(Go) }).handle({ - off: { - on: { - Go: (to) => - to.full.app().resolve(({ target }) => target.decoded(new App({}), (app) => app.two.decoded(new Two({})))) + states: { + off: { + on: { + Go: (to) => + to.branch.app().resolve(({ target }) => target.decoded(new App({}), (app) => app.two.decoded(new Two({})))) + } } } }) const raisedNavigationMachine = Machine.make({ - states: NavigationStates.states, - events: Machine.events(Go), - initial: (to) => to.off().resolve(({ target }) => target.decoded(new Off({}))) + root: NavigationStates, + events: Machine.eventsFromSchemas(Go), + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.off.decoded(new Off({})))) }).handle({ - off: { - always: (to) => - to.full.app().resolve(({ target }) => target.decoded(new App({}), (app) => app.one.decoded(new One({})))), - on: { - Go: (to) => - to.full.app().resolve(({ target }) => target.decoded(new App({}), (app) => app.one.decoded(new One({})))) + states: { + off: { + always: (to) => + to.branch.app().resolve(({ target }) => target.decoded(new App({}), (app) => app.one.decoded(new One({})))), + on: { + Go: (to) => + to.branch.app().resolve(({ target }) => target.decoded(new App({}), (app) => app.one.decoded(new One({})))) + } } } }) -const CounterStates = Machine.states({ counter: Counter }) +const CounterStates = Machine.state({ initial: "counter", states: { counter: Counter } }) const counterMachine = Machine.make({ - states: CounterStates.states, - events: Machine.events(Increment, Noop), - initial: (to) => to.counter().resolve(({ target }) => target.decoded(new Counter({ count: 0 }))) + root: CounterStates, + events: Machine.eventsFromSchemas(Increment, Noop), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.counter.decoded(new Counter({ count: 0 })))) }).handle({ - counter: { - on: { - Increment: (to) => - to.full.counter().resolve(({ state, target }) => target.decoded(new Counter({ count: state.count + 1 }))), - Noop: (to) => to.none + states: { + counter: { + on: { + Increment: (to) => + to.branch.counter().resolve(({ state, target }) => target.decoded(new Counter({ count: state.count + 1 }))), + Noop: (to) => to.none + } } } }) const conditionalMachine = Machine.make({ - states: CounterStates.states, - events: Machine.events(Select), - initial: (to) => to.counter().resolve(({ target }) => target.decoded(new Counter({ count: 0 }))) + root: CounterStates, + events: Machine.eventsFromSchemas(Select), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.counter.decoded(new Counter({ count: 0 })))) }).handle({ - counter: { - on: { - Select: (to) => - to.branches({ - negative: { target: to.none }, - zero: { target: to.full.counter() }, - positive: { target: to.none } - }).resolve(({ event, select }) => - event.value < 0 - ? select.negative() - : event.value === 0 - ? select.zero.decoded(new Counter({ count: 0 })) - : select.positive() - ) + states: { + counter: { + on: { + Select: (to) => + to.branches({ + negative: { target: to.none }, + zero: { target: to.branch.counter() }, + positive: { target: to.none } + }).resolve(({ event, select }) => + event.value < 0 + ? select.negative() + : event.value === 0 + ? select.zero.decoded(new Counter({ count: 0 })) + : select.positive() + ) + } } } }) const invokedMachine = Machine.make({ - states: CounterStates.states, - events: Machine.events(), - initial: (to) => to.counter().resolve(({ target }) => target.decoded(new Counter({ count: 0 }))) + root: CounterStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.counter.decoded(new Counter({ count: 0 })))) }).handle({ - counter: { - invoke: ( - from - ) => [ - from.effect("first", () => Effect.succeed(1)).onDone((to) => to.none), - from.effect("second", () => Effect.succeed(2)).onDone((to) => to.none) - ] + states: { + counter: { + invoke: ( + from + ) => [ + from.effect("first", () => Effect.succeed(1)).onDone((to) => to.none), + from.effect("second", () => Effect.succeed(2)).onDone((to) => to.none) + ] + } } }) class StartupA extends Schema.TaggedClass("StartupA")("StartupA", {}) {} class StartupB extends Schema.TaggedClass("StartupB")("StartupB", {}) {} -const RoutedStartupStates = Machine.states({ - a: { - schema: StartupA, - initial: "route", - states: { - route: { type: "choice" }, - second: { type: "choice" } - } - }, - b: StartupB +const RoutedStartupStates = Machine.state({ + initial: "b", + states: { + a: { + schema: StartupA, + initial: "route", + states: { + route: { type: "choice" }, + second: { type: "choice" } + } + }, + b: StartupB + } }) const routedStartupMachine = Machine.make({ - states: RoutedStartupStates.states, - events: Machine.events(), - initial: (to) => to.a.initial.resolve(({ target }) => target.decoded(new StartupA({}), (a) => a.route())) + root: RoutedStartupStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.a.decoded(new StartupA({}), (a) => a.route()))) }).handle({ - a: { - states: { - route: { - choice: (to) => to.local.second().resolve(({ target }) => target()) - }, - second: { - choice: (to) => to.full.b().resolve(({ target }) => target.decoded(new StartupB({}))) + states: { + a: { + states: { + route: { + choice: (to) => to.local.second().resolve(({ target }) => target()) + }, + second: { + choice: (to) => to.branch.b().resolve(({ target }) => target.decoded(new StartupB({}))) + } } - } - }, - b: {} + }, + b: {} + } }) class ChoiceFlow extends Schema.TaggedClass("ChoiceFlow")("ChoiceFlow", {}) {} class ChoiceReady extends Schema.TaggedClass("ChoiceReady")("ChoiceReady", {}) {} class ChoiceRouted extends Schema.TaggedClass("ChoiceRouted")("ChoiceRouted", {}) {} -const ChoiceResolutionStates = Machine.states({ - flow: { - schema: ChoiceFlow, - initial: "ready", - states: { - ready: ChoiceReady, - first: { type: "choice" }, - second: { type: "choice" }, - routed: ChoiceRouted +const ChoiceResolutionStates = Machine.state({ + initial: "flow", + states: { + flow: { + schema: ChoiceFlow, + initial: "ready", + states: { + ready: ChoiceReady, + first: { type: "choice" }, + second: { type: "choice" }, + routed: ChoiceRouted + } } } }) const choiceResolutionMachine = Machine.make({ - states: ChoiceResolutionStates.states, - events: Machine.events(Route), - initial: (to) => - to.flow.initial.resolve(({ target }) => - target.decoded(new ChoiceFlow({}), (flow) => flow.ready.decoded(new ChoiceReady({}))) + root: ChoiceResolutionStates, + events: Machine.eventsFromSchemas(Route), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => to.flow.decoded(new ChoiceFlow({}), (flow) => flow.ready.decoded(new ChoiceReady({})))) ) }).handle({ - flow: { - states: { - ready: { - on: { - Route: (to) => to.local.first().resolve(({ target }) => target()) - } - }, - first: { - choice: (to) => to.local.second().resolve(({ target }) => target()) - }, - second: { - choice: (to) => to.local.routed().resolve(({ target }) => target.decoded(new ChoiceRouted({}))) - }, - routed: {} + states: { + flow: { + states: { + ready: { + on: { + Route: (to) => to.local.first().resolve(({ target }) => target()) + } + }, + first: { + choice: (to) => to.local.second().resolve(({ target }) => target()) + }, + second: { + choice: (to) => to.local.routed().resolve(({ target }) => target.decoded(new ChoiceRouted({}))) + }, + routed: {} + } } } }) const reentryMachine = Machine.make({ - states: NavigationStates.states, - events: Machine.events(Restart), - initial: (to) => - to.app.initial.resolve(({ target }) => - target.decoded( - new App({}), - (app) => app.one.decoded(new One({})) - ) + root: NavigationStates, + events: Machine.eventsFromSchemas(Restart), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => to.app.decoded(new App({}), (app) => app.one.decoded(new One({})))) ) }).handle({ - app: { - on: { - Restart: (to) => - to.full.app().resolve(({ target }) => target.decoded(new App({}), (app) => app.one.decoded(new One({}))), { - reenter: true - }) + states: { + app: { + on: { + Restart: (to) => + to.branch.app().resolve(({ target }) => target.decoded(new App({}), (app) => app.one.decoded(new One({}))), { + reenter: true + }) + } } } }) @@ -214,28 +239,33 @@ class Dashboard extends Schema.TaggedClass("Dashboard")("Dashboard", class Left extends Schema.TaggedClass("Left")("Left", {}) {} class Right extends Schema.TaggedClass("Right")("Right", {}) {} -const ParallelStates = Machine.states({ - dashboard: { - schema: Dashboard, - type: "parallel", - states: { - left: Left, - right: Right +const ParallelStates = Machine.state({ + initial: "dashboard", + states: { + dashboard: { + schema: Dashboard, + type: "parallel", + states: { + left: Left, + right: Right + } } } }) const parallelMachine = Machine.make({ - states: ParallelStates.states, - events: Machine.events(), - initial: (to) => - to.dashboard.initial.resolve(({ target }) => - target.decoded( - new Dashboard({}), - (dashboard) => dashboard.left.decoded(new Left({})).right.decoded(new Right({})) + root: ParallelStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.dashboard.decoded( + new Dashboard({}), + (dashboard) => dashboard.left.decoded(new Left({})).right.decoded(new Right({})) + ) ) ) -}).handle({ dashboard: {} }) +}).handle({ states: { dashboard: {} } }) class Workspace extends Schema.TaggedClass("Workspace")("Workspace", {}) {} class Editor extends Schema.TaggedClass("Editor")("Editor", {}) {} @@ -246,202 +276,224 @@ class Away extends Schema.TaggedClass("Away")("Away", {}) {} class Leave extends Schema.TaggedClass("Leave")("Leave", {}) {} class Resume extends Schema.TaggedClass("Resume")("Resume", {}) {} -const HistoryStates = Machine.states({ - workspace: { - schema: Workspace, - initial: "editor", - states: { - editor: { - schema: Editor, - initial: "editing", - states: { - editing: Editing +const HistoryStates = Machine.state({ + initial: "away", + states: { + workspace: { + schema: Workspace, + initial: "editor", + states: { + editor: { + schema: Editor, + initial: "editing", + states: { + editing: Editing + } + }, + recent: { + type: "history" + }, + exact: { + type: "history", + history: "deep" } - }, - recent: { - type: "history" - }, - exact: { - type: "history", - history: "deep" } - } - }, - away: Away + }, + away: Away + } }) const historyMachine = Machine.make({ - states: HistoryStates.states, - events: Machine.events(Leave, Resume), - initial: (to) => - to.workspace.initial.resolve(({ target }) => - target.decoded( - new Workspace({}), - (workspace) => + root: HistoryStates, + events: Machine.eventsFromSchemas(Leave, Resume), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.workspace.decoded(new Workspace({}), (workspace) => workspace.editor.decoded( new Editor({}), (editor) => editor.editing.decoded(new Editing({ revision: 1 })) - ) + )) ) ) }).handle({ - workspace: { - history: { - recent: { - default: ({ target }) => - target.workspace.decoded( - new Workspace({}), - (workspace) => - workspace.editor.decoded( - new Editor({}), - (editor) => editor.editing.decoded(new Editing({ revision: 0 })) - ) - ) + states: { + workspace: { + history: { + recent: { + default: ({ target }) => + target.from((to) => + to.workspace.decoded(new Workspace({}), (workspace) => + workspace.editor.decoded( + new Editor({}), + (editor) => editor.editing.decoded(new Editing({ revision: 0 })) + )) + ) + }, + exact: { + default: ({ target }) => + target.from((to) => + to.workspace.decoded(new Workspace({}), (workspace) => + workspace.editor.decoded( + new Editor({}), + (editor) => editor.editing.decoded(new Editing({ revision: 0 })) + )) + ) + } }, - exact: { - default: ({ target }) => - target.workspace.decoded( - new Workspace({}), - (workspace) => - workspace.editor.decoded( - new Editor({}), - (editor) => editor.editing.decoded(new Editing({ revision: 0 })) - ) - ) + on: { + Leave: (to) => to.branch.away().resolve(({ target }) => target.decoded(new Away({}))) + }, + states: { + editor: { + initialize: ({ builder }) => builder.decoded(new Editing({ revision: 0 })) + } } }, - on: { - Leave: (to) => to.full.away().resolve(({ target }) => target.decoded(new Away({}))) - }, - states: { - editor: { - initialize: ({ builder }) => builder.decoded(new Editing({ revision: 0 })) + away: { + on: { + Resume: (to) => to.history.workspace.exact.resolve(({ target }) => target()) } } - }, - away: { - on: { - Resume: (to) => to.history.workspace.exact.resolve(({ target }) => target()) - } } }) -const StructuralHistoryStates = Machine.states({ - workspace: { - initial: "editor", - states: { - editor: { - initial: "idle", - states: { idle: {} } - }, - exact: { type: "history", history: "deep" } - } - }, - away: {} +const StructuralHistoryStates = Machine.state({ + initial: "away", + states: { + workspace: { + initial: "editor", + states: { + editor: { + initial: "idle", + states: { idle: {} } + }, + exact: { type: "history", history: "deep" } + } + }, + away: {} + } }) const structuralHistoryInitial = () => ({ - path: "workspace" as const, + path: "" as const, value: undefined, state: { - path: "workspace.editor" as const, + path: "workspace" as const, value: undefined, - state: { path: "workspace.editor.idle" as const, value: undefined } + state: { + path: "workspace.editor" as const, + value: undefined, + state: { path: "workspace.editor.idle" as const, value: undefined } + } } }) const structuralHistoryMachine = Machine.make({ - states: StructuralHistoryStates.states, - events: Machine.events(Leave), - initial: (to) => - to.workspace.initial.resolve(({ target }) => - target.from((workspace) => workspace.editor.from((editor) => editor.idle.from())) + root: StructuralHistoryStates, + events: Machine.eventsFromSchemas(Leave), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => to.workspace.from((workspace) => workspace.editor.from((editor) => editor.idle.from()))) ) }).handle({ - workspace: { - history: { - exact: { default: structuralHistoryInitial } - }, - on: { - Leave: (to) => to.full.away().resolve(({ target }) => target.from()) + states: { + workspace: { + history: { + exact: { default: structuralHistoryInitial } + }, + on: { + Leave: (to) => to.branch.away().resolve(({ target }) => target.from()) + } } } }) class Finished extends Schema.TaggedClass("Finished")("Finished", {}) {} -const CompletionStates = Machine.states({ - finished: { - schema: Finished, - type: "final", - output: Schema.String +const CompletionStates = Machine.state({ + initial: "finished", + states: { + finished: { + schema: Finished, + type: "final", + output: Schema.String + } } }) const completionMachine = Machine.make({ - states: CompletionStates.states, - events: Machine.events(), - initial: (to) => to.finished().resolve(({ target }) => target.decoded(new Finished({}))) + root: CompletionStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.finished.decoded(new Finished({})))) }).handle({ - finished: { - output: () => "complete" + states: { + finished: { + output: () => "complete" + } } }) class Workflow extends Schema.TaggedClass("Workflow")("Workflow", {}) {} class Archived extends Schema.TaggedClass("Archived")("Archived", {}) {} -const DoneTransitionStates = Machine.states({ - workflow: { - schema: Workflow, - initial: "finished", - states: { - finished: { - schema: Finished, - type: "final", - output: Schema.String +const DoneTransitionStates = Machine.state({ + initial: "workflow", + states: { + workflow: { + schema: Workflow, + initial: "finished", + states: { + finished: { + schema: Finished, + type: "final", + output: Schema.String + } } - } - }, - archived: Archived + }, + archived: Archived + } }) const doneTransitionMachine = Machine.make({ - states: DoneTransitionStates.states, - events: Machine.events(), - initial: (to) => - to.workflow.initial.resolve(({ target }) => - target.decoded( - new Workflow({}), - (workflow) => workflow.finished.decoded(new Finished({})) + root: DoneTransitionStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.workflow.decoded(new Workflow({}), (workflow) => workflow.finished.decoded(new Finished({}))) ) ) }).handle({ - workflow: { - onDone: (to) => to.full.archived().resolve(({ target }) => target.decoded(new Archived({}))), - states: { - finished: { - output: () => "workflow-output" + states: { + workflow: { + onDone: (to) => to.branch.archived().resolve(({ target }) => target.decoded(new Archived({}))), + states: { + finished: { + output: () => "workflow-output" + } } } } }) const nestedCompletionMachine = Machine.make({ - states: DoneTransitionStates.states, - events: Machine.events(), - initial: (to) => - to.workflow.initial.resolve(({ target }) => - target.decoded( - new Workflow({}), - (workflow) => workflow.finished.decoded(new Finished({})) + root: DoneTransitionStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.workflow.decoded(new Workflow({}), (workflow) => workflow.finished.decoded(new Finished({}))) ) ) }).handle({ - workflow: { - states: { - finished: { - output: () => "nested-output" + states: { + workflow: { + states: { + finished: { + output: () => "nested-output" + } } } } @@ -458,7 +510,7 @@ describe("MachineTest.verify", () => { const valuedState = { ...trace, - final: { ...trace.final, value: { _tag: "Invented" } } + final: { ...trace.final, state: { ...trace.final.state, value: { _tag: "Invented" } } } } as unknown as typeof trace const stateError = yield* MachineTest.verify(structuralHistoryMachine, valuedState, { laws: ["configuration"] @@ -516,7 +568,7 @@ describe("MachineTest.verify", () => { assert.strictEqual(updateMicrostep.changed, false) assert.deepStrictEqual(updateMicrostep.exitPaths, []) assert.deepStrictEqual(updateMicrostep.entryPaths, []) - assert.strictEqual((updated.final.value as Counter).count, 1) + assert.strictEqual((updated.final.state.value as Counter).count, 1) yield* MachineTest.verify(counterMachine, updated) const noop = yield* MachineTest.run(counterMachine, { events: [new Noop({})] }) @@ -599,7 +651,7 @@ describe("MachineTest.verify", () => { ...trace, final: { ...final, - states: { left: final.states.left } + state: { ...final.state, states: { left: final.state.states.left } } } } as typeof trace const omittedError = yield* MachineTest.verify(parallelMachine, omitted, { @@ -611,7 +663,7 @@ describe("MachineTest.verify", () => { ...trace, final: { ...final, - states: { left: final.states.left, right: final.states.left } + state: { ...final.state, states: { left: final.state.states.left, right: final.state.states.left } } } } as typeof trace const duplicateError = yield* MachineTest.verify(parallelMachine, duplicate, { @@ -836,7 +888,7 @@ describe("MachineTest.verify", () => { ...step.plan, microsteps: [{ ...microstep, - transitions: [{ ...transition, target: "app.two" }] + transitions: [{ ...transition, target: "off" }] }] } }] @@ -848,7 +900,7 @@ describe("MachineTest.verify", () => { const violation = error.violations.find(({ law }) => law === "definitions.selection") assert.strictEqual(violation?.eventIndex, 0) assert.strictEqual(violation?.microstepIndex, 0) - assert.strictEqual(violation?.path, "app.two") + assert.strictEqual(violation?.path, "off") })) it.effect("rejects invalid named-branch evidence", () => @@ -917,7 +969,7 @@ describe("MachineTest.verify", () => { Effect.gen(function*() { const trace = yield* MachineTest.run(routedStartupMachine, { events: [] }) - assert.deepStrictEqual(trace.initial.startingConfiguration, ["b"]) + assert.deepStrictEqual(trace.initial.startingConfiguration, ["", "b"]) assert.deepStrictEqual( trace.initial.plan.microsteps[0]?.transitions.map(({ branchIndex, source, target, trigger }) => ({ branchIndex, @@ -949,7 +1001,7 @@ describe("MachineTest.verify", () => { }).pipe(Effect.flip) const violation = error.violations.find(({ law }) => law === "definitions.initial") assert.strictEqual(violation?.eventIndex, undefined) - assert.strictEqual(violation?.path, "app") + assert.strictEqual(violation?.path, "") })) it.effect("binds resolved targets to direct and chained choice evidence", () => diff --git a/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts b/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts index 318fc39..efcca39 100644 --- a/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts +++ b/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts @@ -36,15 +36,23 @@ class Changed extends Schema.TaggedClass("Changed")("Changed", { value: Schema.Number }) {} -const CounterStates = Machine.states({ - Count, - Done: { schema: Done, type: "final" } +const CounterStates = Machine.state({ + initial: "Count", + states: { + Count, + Done: { schema: Done, type: "final" } + } }) const UnsupportedChildMachine = Machine.make({ - states: { Count }, - events: Machine.events(), - initial: (to) => to.Count().resolve(() => ({ path: "Count" as const, value: new Count({ value: 0 }) })) + root: Machine.state({ initial: "Count", states: { Count } }), + events: Machine.eventsFromSchemas(), + initialConfiguration: (to) => + to.resolve(() => ({ + path: "" as const, + value: undefined, + state: { path: "Count" as const, value: new Count({ value: 0 }) } + })) }) const UnsupportedChild = Machine.child("unsupported", UnsupportedChildMachine) @@ -57,46 +65,51 @@ const makeCounter = (state: { }) => Machine.make({ id: "Counter", - states: CounterStates.states, - events: Machine.events(Increment, Fail, Finish, RaiseFromAction, SpawnFromAction), - emittedEvents: Machine.emittedEvents(Changed), - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: CounterStates, + events: Machine.eventsFromSchemas(Increment, Fail, Finish, RaiseFromAction, SpawnFromAction), + emittedEvents: Machine.emittedEventsFromSchemas(Changed), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) }).handle({ - Count: { - entry: () => { - state.initialEntries += 1 + states: { + Count: { + entry: () => { + state.initialEntries += 1 + }, + on: { + Increment: (to) => + to.branch.Count().resolve(({ event, state: current, target }, enqueue) => { + state.actions += 1 + state.inFlight += 1 + state.maxInFlight = Math.max(state.maxInFlight, state.inFlight) + state.inFlight -= 1 + const value = current.value + event.by + enqueue.emit(new Changed({ value })) + return target.decoded(new Count({ value })) + }), + Fail: (to) => + to.branch.Count().resolve(({ state: current, target }, enqueue) => { + enqueue.emit(new Changed({ value: 999 })) + return target.decoded(current) + }), + Finish: (to) => + to.branch.Done().resolve(({ state: current, target }) => + target.decoded(new Done({ value: current.value })) + ), + RaiseFromAction: (to) => + to.branch.Count().resolve(({ state: current, target }, enqueue) => { + enqueue.raise(new Increment({ by: 1, block: false })) + return target.decoded(current) + }), + SpawnFromAction: (to) => + to.branch.Count().resolve(({ state: current, target }, enqueue) => { + enqueue.stop(UnsupportedChild) + return target.decoded(current) + }) + } }, - on: { - Increment: (to) => - to.full.Count().resolve(({ event, state: current, target }, enqueue) => { - state.actions += 1 - state.inFlight += 1 - state.maxInFlight = Math.max(state.maxInFlight, state.inFlight) - state.inFlight -= 1 - const value = current.value + event.by - enqueue.emit(new Changed({ value })) - return target.decoded(new Count({ value })) - }), - Fail: (to) => - to.full.Count().resolve(({ state: current, target }, enqueue) => { - enqueue.emit(new Changed({ value: 999 })) - return target.decoded(current) - }), - Finish: (to) => - to.full.Done().resolve(({ state: current, target }) => target.decoded(new Done({ value: current.value }))), - RaiseFromAction: (to) => - to.full.Count().resolve(({ state: current, target }, enqueue) => { - enqueue.raise(new Increment({ by: 1, block: false })) - return target.decoded(current) - }), - SpawnFromAction: (to) => - to.full.Count().resolve(({ state: current, target }, enqueue) => { - enqueue.stop(UnsupportedChild) - return target.decoded(current) - }) - } - }, - Done: {} + Done: {} + } }) const storageKey = (entityType: string, entityId: string): string => `${entityType}\u0000${entityId}` @@ -266,8 +279,9 @@ describe("ClusterMachine", () => { assert.strictEqual(state.actions, 1) assert.deepStrictEqual(emitted, [new Changed({ value: 2 })]) assert.deepStrictEqual(storage.entries.get(storageKey("CounterEntity", "counter-1"))?.snapshot, { + version: 2, _tag: "MachineSnapshot", - active: [{ path: "Count" as const, value: { _tag: "Count", value: "2" } }] + active: [{ path: "" }, { path: "Count" as const, value: { _tag: "Count", value: "2" } }] }) yield* TestClock.adjust(5000) @@ -278,6 +292,8 @@ describe("ClusterMachine", () => { assert.strictEqual(state.initialEntries, 1) assert.strictEqual(state.actions, 2) assert.deepStrictEqual(storage.entries.get(storageKey("CounterEntity", "counter-1"))?.snapshot.active, [{ + path: "" + }, { path: "Count" as const, value: { _tag: "Count", value: "5" } }]) @@ -311,6 +327,8 @@ describe("ClusterMachine", () => { assert.strictEqual(state.maxInFlight, 1) assert.strictEqual(state.actions, 2) assert.deepStrictEqual(storage.entries.get(storageKey("SerializedCounter", "counter-1"))?.snapshot.active, [{ + path: "" + }, { path: "Count" as const, value: { _tag: "Count", value: "2" } }]) @@ -358,17 +376,20 @@ describe("ClusterMachine", () => { typeof value === "object" && value !== null && "_tag" in value && value._tag === "OpaqueState" ) ) - const opaqueStates = Machine.states({ OpaqueState }) + const opaqueStates = Machine.state({ initial: "OpaqueState", states: { OpaqueState } }) const resource: { self?: unknown } = {} resource.self = resource const opaqueMachine = Machine.make({ - states: opaqueStates.states, - events: Machine.events(Fail), - initial: (to) => to.OpaqueState().resolve(({ target }) => target.decoded({ _tag: "OpaqueState", resource })) + root: opaqueStates, + events: Machine.eventsFromSchemas(Fail), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.OpaqueState.decoded({ _tag: "OpaqueState", resource }))) }).handle({ - OpaqueState: { - on: { - Fail: (to) => to.full.OpaqueState().resolve(({ state: current, target }) => target.decoded(current)) + states: { + OpaqueState: { + on: { + Fail: (to) => to.branch.OpaqueState().resolve(({ state: current, target }) => target.decoded(current)) + } } } }) @@ -417,7 +438,7 @@ describe("ClusterMachine", () => { assert.isAtLeast(storage.loads, 2) assert.strictEqual(state.actions, 1) assert.strictEqual(storage.commits, 1) - assert.deepStrictEqual(storage.entries.get(key)?.snapshot.active, [{ + assert.deepStrictEqual(storage.entries.get(key)?.snapshot.active, [{ path: "" }, { path: "Count" as const, value: { _tag: "Count", value: "1" } }]) @@ -484,16 +505,18 @@ describe("ClusterMachine", () => { version: "1", requestId: firstId, snapshot: { + version: 2, _tag: "MachineSnapshot", - active: [{ path: "Count" as const, value: { _tag: "Count", value: "1" } }] + active: [{ path: "" }, { path: "Count" as const, value: { _tag: "Count", value: "1" } }] } } const second: ClusterMachine.Checkpoint = { ...first, requestId: secondId, snapshot: { + version: 2, _tag: "MachineSnapshot", - active: [{ path: "Count" as const, value: { _tag: "Count", value: "2" } }] + active: [{ path: "" }, { path: "Count" as const, value: { _tag: "Count", value: "2" } }] } } @@ -519,8 +542,9 @@ describe("ClusterMachine", () => { machineId: "Other", version: "1", snapshot: { + version: 2, _tag: "MachineSnapshot", - active: [{ path: "Count" as const, value: { _tag: "Count", value: "0" } }] + active: [{ path: "" }, { path: "Count" as const, value: { _tag: "Count", value: "0" } }] }, reason: "MachineIdMismatch" }, @@ -529,8 +553,9 @@ describe("ClusterMachine", () => { machineId: "Counter", version: "0", snapshot: { + version: 2, _tag: "MachineSnapshot", - active: [{ path: "Count" as const, value: { _tag: "Count", value: "0" } }] + active: [{ path: "" }, { path: "Count" as const, value: { _tag: "Count", value: "0" } }] }, reason: "VersionMismatch" }, @@ -538,7 +563,11 @@ describe("ClusterMachine", () => { entityType: "InvalidSnapshotCounter", machineId: "Counter", version: "1", - snapshot: { _tag: "MachineSnapshot", active: [{ path: "Missing" as const, value: {} }] }, + snapshot: { + version: 2, + _tag: "MachineSnapshot", + active: [{ path: "" }, { path: "Missing" as const, value: {} }] + }, reason: "InvalidCheckpoint" } ] @@ -583,6 +612,8 @@ describe("ClusterMachine", () => { const client = makeClient("counter-1") assertAccepted(yield* client.send(new Finish({}))) assert.deepStrictEqual(storage.entries.get(storageKey("FinalCounter", "counter-1"))?.snapshot.active, [{ + path: "" + }, { path: "Done" as const, value: { _tag: "Done", value: "0" } }]) @@ -591,6 +622,8 @@ describe("ClusterMachine", () => { assert.strictEqual(state.actions, 0) assert.strictEqual(storage.commits, 2) assert.deepStrictEqual(storage.entries.get(storageKey("FinalCounter", "counter-1"))?.snapshot.active, [{ + path: "" + }, { path: "Done" as const, value: { _tag: "Done", value: "0" } }]) @@ -614,15 +647,18 @@ describe("ClusterMachine", () => { it.effect("rejects machines with invoke configurations", () => Effect.gen(function*() { - const states = Machine.states({ Count }) + const states = Machine.state({ initial: "Count", states: { Count } }) const invoked = Machine.make({ id: "Invoked", - states: states.states, - events: Machine.events(Increment), - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: states, + events: Machine.eventsFromSchemas(Increment), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) }).handle({ - Count: { - invoke: (from) => from.effect("child", () => Effect.void).onDone((to) => to.none) + states: { + Count: { + invoke: (from) => from.effect("child", () => Effect.void).onDone((to) => to.none) + } } }) const bridge = ClusterMachine.make("InvokedCounter", invoked, { version: "1" }) diff --git a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts index 5cef39b..161ef8a 100644 --- a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts +++ b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts @@ -36,9 +36,12 @@ class Online extends Schema.TaggedClass("Online")("Online", {}) {} class Offline extends Schema.TaggedClass("Offline")("Offline", {}) {} -const CounterStates = Machine.states({ - Count, - Done: { schema: Done, type: "final" } +const CounterStates = Machine.state({ + initial: "Count", + states: { + Count, + Done: { schema: Done, type: "final" } + } }) const makeRegistry = Effect.acquireRelease( @@ -66,37 +69,43 @@ const waitForResult = ( const makeCounterMachine = () => Machine.make({ - states: CounterStates.states, - events: Machine.events(Finish), - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: CounterStates, + events: Machine.eventsFromSchemas(Finish), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) }).handle({ - Count: { - on: { - Finish: (to) => - to.full.Count().resolve(({ state, event, target }) => - target.decoded(new Count({ value: state.value + event.by })) - ) - } - }, - Done: {} + states: { + Count: { + on: { + Finish: (to) => + to.branch.Count().resolve(({ state, event, target }) => + target.decoded(new Count({ value: state.value + event.by })) + ) + } + }, + Done: {} + } }) const makeInputCounterMachine = () => Machine.make({ - states: CounterStates.states, - events: Machine.events(Finish), + root: CounterStates, + events: Machine.eventsFromSchemas(Finish), input: Schema.Number, - initial: (to) => to.Count().resolve(({ input, target }) => target.decoded(new Count({ value: input }))) + initialConfiguration: (root) => + root.resolve(({ input, target }) => target.from((to) => to.Count.decoded(new Count({ value: input })))) }).handle({ - Count: { - on: { - Finish: (to) => - to.full.Count().resolve(({ state, event, target }) => - target.decoded(new Count({ value: state.value + event.by })) - ) - } - }, - Done: {} + states: { + Count: { + on: { + Finish: (to) => + to.branch.Count().resolve(({ state, event, target }) => + target.decoded(new Count({ value: state.value + event.by })) + ) + } + }, + Done: {} + } }) const forceGc = async () => { @@ -138,18 +147,20 @@ describe("AtomMachine", () => { Effect.scoped(Effect.gen(function*() { class Idle extends Schema.TaggedClass("AtomPreparedIdle")("Idle", {}) {} class ReadyEmission extends Schema.TaggedClass("AtomPreparedReady")("ReadyEmission", {}) {} - const states = Machine.states({ Idle }) - const Emissions = Machine.emittedEvents(ReadyEmission) + const states = Machine.state({ initial: "Idle", states: { Idle } }) + const Emissions = Machine.emittedEventsFromSchemas(ReadyEmission) const machine = Machine.make({ - states: states.states, - events: Machine.events(), + root: states, + events: Machine.eventsFromSchemas(), emittedEvents: Emissions, - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) }).handle({ - Idle: { - entry: (_, enqueue) => { - enqueue.emit(Emissions.ReadyEmission()) - return undefined + states: { + Idle: { + entry: (_, enqueue) => { + enqueue.emit(Emissions.ReadyEmission()) + return undefined + } } } }) @@ -187,33 +198,39 @@ describe("AtomMachine", () => { const invokeStarts = yield* Ref.make(0) const invokeStopped = yield* Deferred.make() const machine = Machine.make({ - states: CounterStates.states, - events: Machine.events(Finish), - initial: (to) => - to.Count().resolve(({ target }) => { + root: CounterStates, + events: Machine.eventsFromSchemas(Finish), + initialConfiguration: (root) => + root.resolve(({ target }) => { initialCalls += 1 - return target.decoded(new Count({ value: 0 })) + return target.from((to) => to.Count.decoded(new Count({ value: 0 }))) }) }).handle({ - Count: { - invoke: (from) => - from.logic("active", { - address: Machine.childAddress("active"), - logic: Machine.logic({ - initial: () => Ref.update(invokeStarts, (n) => n + 1).pipe(Effect.as(undefined)), - run: () => Effect.never.pipe(Effect.onInterrupt(() => Deferred.succeed(invokeStopped, void 0))) - }) - }), - on: { - Finish: (to) => - to.full.Count().resolve(({ event, state, target }) => - target.decoded(new Count({ value: state.value + event.by })) - ) - } - }, - Done: {} + states: { + Count: { + invoke: (from) => + from.logic("active", { + address: Machine.childAddress("active"), + logic: Machine.logic({ + initial: () => Ref.update(invokeStarts, (n) => n + 1).pipe(Effect.as(undefined)), + run: () => Effect.never.pipe(Effect.onInterrupt(() => Deferred.succeed(invokeStopped, void 0))) + }) + }), + on: { + Finish: (to) => + to.branch.Count().resolve(({ event, state, target }) => + target.decoded(new Count({ value: state.value + event.by })) + ) + } + }, + Done: {} + } + }) + const bridge = AtomMachine.resume(machine, { + path: "" as const, + value: undefined, + state: { path: "Count" as const, value: new Count({ value: 5 }) } }) - const bridge = AtomMachine.resume(machine, { path: "Count" as const, value: new Count({ value: 5 }) }) const canFinish = AtomMachine.can(new Finish({ by: 1 }))(bridge) const firstRegistry = AtomRegistry.make() const secondRegistry = AtomRegistry.make() @@ -225,7 +242,7 @@ describe("AtomMachine", () => { assert.strictEqual(first.sessionId, firstAgain.sessionId) assert.strictEqual(first, firstAgain) assert.notStrictEqual(first, second) - assert.deepStrictEqual(yield* AtomRegistry.getResult(firstRegistry, bridge.state), { + assert.deepStrictEqual((yield* AtomRegistry.getResult(firstRegistry, bridge.result)).state, { path: "Count" as const, value: new Count({ value: 5 }) }) @@ -251,20 +268,23 @@ describe("AtomMachine", () => { const childMachine = makeCounterMachine() const Child = Machine.child("counter", childMachine) const parent = Machine.make({ - states: { Count, ValueRead }, - events: Machine.events(Finish, ReadValue), - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: Machine.state({ initial: "Count", states: { Count, ValueRead } }), + events: Machine.eventsFromSchemas(Finish, ReadValue), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) }).handle({ - Count: { - on: { - Finish: (to) => - to.full.ValueRead().resolve(({ target }) => target.decoded(new ValueRead({ value: "active" }))) - } - }, - ValueRead: { - invoke: (from) => from.child(Child).onDone((to) => to.none), - on: { - ReadValue: (to) => to.full.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + states: { + Count: { + on: { + Finish: (to) => + to.branch.ValueRead().resolve(({ target }) => target.decoded(new ValueRead({ value: "active" }))) + } + }, + ValueRead: { + invoke: (from) => from.child(Child).onDone((to) => to.none), + on: { + ReadValue: (to) => to.branch.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + } } } }) @@ -302,18 +322,18 @@ describe("AtomMachine", () => { Stream.runCollect, Effect.forkScoped ) - yield* mount(registry, childAtoms.state) + yield* mount(registry, childAtoms.result) yield* Effect.sync(() => registry.set(parentAtoms.send, new Finish({ by: 0 }))) - yield* waitForResult(registry, parentAtoms.state, (state) => state.path === "ValueRead") + yield* waitForResult(registry, parentAtoms.result, (state) => state.state.path === "ValueRead") yield* Fiber.join(childChange) assert(Option.isSome(yield* parentRef.child(Child))) assert(Option.isSome(yield* parentRef.child(Alias))) assert(Option.isNone(yield* parentRef.child(Impostor))) - assert(Option.isNone(yield* AtomRegistry.getResult(registry, impostorAtoms.state))) + assert(Option.isNone(yield* AtomRegistry.getResult(registry, impostorAtoms.result))) - const initial = yield* waitForResult(registry, childAtoms.state, Option.isSome) + const initial = yield* waitForResult(registry, childAtoms.result, Option.isSome) assert(Option.isSome(initial)) - assert.strictEqual(initial.value.value.value, 0) + assert.strictEqual(initial.value.state.value.value, 0) const selectedInitial = yield* waitForResult(registry, selectedCount, Option.isSome) assert(Option.isSome(selectedInitial)) assert.strictEqual(selectedInitial.value.value, 0) @@ -328,8 +348,8 @@ describe("AtomMachine", () => { yield* Effect.sync(() => registry.set(childAtoms.send, new Finish({ by: 2 }))) const updated = yield* waitForResult( registry, - childAtoms.state, - (state) => Option.isSome(state) && state.value.value.value === 2 + childAtoms.result, + (state) => Option.isSome(state) && state.value.state.value.value === 2 ) assert(Option.isSome(updated)) const selectedUpdated = yield* waitForResult( @@ -361,14 +381,17 @@ describe("AtomMachine", () => { const childMachine = makeCounterMachine() const Child = Machine.childFamily(childMachine) const parent = Machine.make({ - states: { Count }, - events: Machine.events(), - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: Machine.state({ initial: "Count", states: { Count } }), + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) }).handle({ - Count: { - invoke: (from) => - from.effect("spawn-counter", ({ children }) => children.spawn(Child("dynamic"))).onDone((to) => to.none) - .onFailure((to) => to.none) + states: { + Count: { + invoke: (from) => + from.effect("spawn-counter", ({ children }) => children.spawn(Child("dynamic"))).onDone((to) => to.none) + .onFailure((to) => to.none) + } } }) const parentAtoms = AtomMachine.make(parent) @@ -378,7 +401,7 @@ describe("AtomMachine", () => { const selected = AtomMachine.selectChild(childAtoms, "Count") const matches = AtomMachine.matchesChild(childAtoms, "Count") - yield* mount(registry, childAtoms.state) + yield* mount(registry, childAtoms.result) const active = yield* waitForResult(registry, selected, Option.isSome) assert(Option.isSome(active)) assert.strictEqual(active.value.value, 0) @@ -395,14 +418,17 @@ describe("AtomMachine", () => { const childMachine = makeCounterMachine() const Child = Machine.childFamily(childMachine) const parent = Machine.make({ - states: { Count }, - events: Machine.events(), - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: Machine.state({ initial: "Count", states: { Count } }), + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) }).handle({ - Count: { - invoke: (from) => - from.effect("spawn-counter", ({ children }) => children.spawn(Child("dynamic"))).onDone((to) => to.none) - .onFailure((to) => to.none) + states: { + Count: { + invoke: (from) => + from.effect("spawn-counter", ({ children }) => children.spawn(Child("dynamic"))).onDone((to) => to.none) + .onFailure((to) => to.none) + } } }) const parentAtoms = AtomMachine.make(parent) @@ -443,7 +469,7 @@ describe("AtomMachine", () => { const machine = makeInputCounterMachine() const atoms = AtomMachine.family(machine, { atoms: { - state: (machine) => machine.state + state: (machine) => machine.result } }) const equalFirst = new FamilyKey({ id: "same" }) @@ -452,13 +478,14 @@ describe("AtomMachine", () => { const plainSecond = { id: "same" } const plainDifferent = { id: "different" } const anyInputMachine = Machine.make({ - states: CounterStates.states, - events: Machine.events(), + root: CounterStates, + events: Machine.eventsFromSchemas(), input: Schema.Any, - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) - }).handle({ Count: {}, Done: {} }) + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) + }).handle({ states: { Count: {}, Done: {} } }) const anyInputAtoms = AtomMachine.family(anyInputMachine, { - atoms: { state: (machine) => machine.state } + atoms: { state: (machine) => machine.result } }) assert.strictEqual(anyInputAtoms.state(equalFirst), anyInputAtoms.state(equalSecond)) @@ -503,10 +530,10 @@ describe("AtomMachine", () => { atoms: { canFinish: AtomMachine.can(new Finish({ by: 1 })), count: AtomMachine.select("Count"), - equal: (machine) => machine.state.pipe(Atom.withEquality(() => true)), + equal: (machine) => machine.result.pipe(Atom.withEquality(() => true)), ref: (machine) => machine.ref, send: (machine) => machine.send, - state: (machine) => machine.state + state: (machine) => machine.result } }) const send = atoms.send(4) @@ -525,7 +552,7 @@ describe("AtomMachine", () => { assert.strictEqual(updated.value.value, 9) const secondRegistry = AtomRegistry.make() - assert.strictEqual((yield* AtomRegistry.getResult(secondRegistry, state)).value.value, 4) + assert.strictEqual((yield* AtomRegistry.getResult(secondRegistry, state)).state.value.value, 4) secondRegistry.dispose() const ref = yield* AtomRegistry.getResult(registry, atoms.ref(4)) @@ -548,15 +575,19 @@ describe("AtomMachine", () => { assert.deepStrictEqual(initial, { status: "active", state: { - path: "Count" as const, - value: new Count({ value: 0 }) + path: "" as const, + value: undefined, + state: { + path: "Count" as const, + value: new Count({ value: 0 }) + } } }) yield* Effect.sync(() => registry.set(bridge.send, new Finish({ by: 2 }))) - const state = yield* waitForResult(registry, bridge.state, (state) => state.value.value === 2) - assert.deepStrictEqual(state, { + const state = yield* waitForResult(registry, bridge.result, (state) => state.state.value.value === 2) + assert.deepStrictEqual(state.state, { path: "Count" as const, value: new Count({ value: 2 }) }) @@ -570,16 +601,16 @@ describe("AtomMachine", () => { const second = makeCounter(2) assert.notStrictEqual(first, second) - yield* mount(registry, first.state) - yield* mount(registry, second.state) - assert.strictEqual((yield* AtomRegistry.getResult(registry, first.state)).value.value, 1) - assert.strictEqual((yield* AtomRegistry.getResult(registry, second.state)).value.value, 2) + yield* mount(registry, first.result) + yield* mount(registry, second.result) + assert.strictEqual((yield* AtomRegistry.getResult(registry, first.result)).state.value.value, 1) + assert.strictEqual((yield* AtomRegistry.getResult(registry, second.result)).state.value.value, 2) const bound = AtomMachine.bind(Atom.runtime(Layer.empty)) const makeBoundCounter = bound.factory(makeInputCounterMachine()) const boundCounter = makeBoundCounter(3) - yield* mount(registry, boundCounter.state) - assert.strictEqual((yield* AtomRegistry.getResult(registry, boundCounter.state)).value.value, 3) + yield* mount(registry, boundCounter.result) + assert.strictEqual((yield* AtomRegistry.getResult(registry, boundCounter.result)).state.value.value, 3) assert.strictEqual( yield* AtomRegistry.getResult(registry, AtomMachine.can(new Finish({ by: 1 }))(boundCounter)), true @@ -629,33 +660,39 @@ describe("AtomMachine", () => { accept: Schema.Boolean }) {} class Complete extends Schema.TaggedClass("AtomCanComplete")("Complete", {}) {} - const events = Machine.events(Check, Complete) - const states = Machine.states({ - CanIdle, - CanDone: { schema: CanDone, type: "final" } + const events = Machine.eventsFromSchemas(Check, Complete) + const states = Machine.state({ + initial: "CanIdle", + states: { + CanIdle, + CanDone: { schema: CanDone, type: "final" } + } }) let requiredResolverCalls = 0 let declinableResolverCalls = 0 const machine = Machine.make({ - states: states.states, + root: states, events, - initial: (to) => to.CanIdle().resolve(({ target }) => target.decoded(new CanIdle({}))) + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.CanIdle.decoded(new CanIdle({})))) }).handle({ - CanIdle: { - on: { - Check: (to) => - to.none.resolve(({ event, decline }) => { - declinableResolverCalls++ - return event.accept ? undefined : decline() - }, { declinable: true }), - Complete: (to) => - to.full.CanDone().resolve(({ target }) => { - requiredResolverCalls++ - return target.decoded(new CanDone({})) - }) - } - }, - CanDone: {} + states: { + CanIdle: { + on: { + Check: (to) => + to.none.resolve(({ event, decline }) => { + declinableResolverCalls++ + return event.accept ? undefined : decline() + }, { declinable: true }), + Complete: (to) => + to.branch.CanDone().resolve(({ target }) => { + requiredResolverCalls++ + return target.decoded(new CanDone({})) + }) + } + }, + CanDone: {} + } }) const registry = yield* makeRegistry const bridge = AtomMachine.make(machine) @@ -718,19 +755,22 @@ describe("AtomMachine", () => { class Published extends Schema.TaggedClass("AtomCanPublished")("Published", { value: Schema.Number }) {} - const emissions = Machine.emittedEvents(Published) + const emissions = Machine.emittedEventsFromSchemas(Published) const startupMachine = Machine.make({ - states: CounterStates.states, - events: Machine.events(Finish), + root: CounterStates, + events: Machine.eventsFromSchemas(Finish), emittedEvents: emissions, - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) }).handle({ - Count: { - entry: (_, enqueue) => { - enqueue.emit(emissions.Published({ value: "invalid" } as never)) - } - }, - Done: {} + states: { + Count: { + entry: (_, enqueue) => { + enqueue.emit(emissions.Published({ value: "invalid" } as never)) + } + }, + Done: {} + } }) const registry = yield* makeRegistry const startupBridge = AtomMachine.make(startupMachine) @@ -743,17 +783,20 @@ describe("AtomMachine", () => { class Begin extends Schema.TaggedClass("AtomCanBegin")("Begin", {}) {} const failure = new Error("runtime failed") const faultMachine = Machine.make({ - states: { FaultIdle, FaultLoading }, - events: Machine.events(Begin), - initial: (to) => to.FaultIdle().resolve(({ target }) => target.decoded(new FaultIdle({}))) + root: Machine.state({ initial: "FaultIdle", states: { FaultIdle, FaultLoading } }), + events: Machine.eventsFromSchemas(Begin), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.FaultIdle.decoded(new FaultIdle({})))) }).handle({ - FaultIdle: { - on: { - Begin: (to) => to.full.FaultLoading().resolve(({ target }) => target.decoded(new FaultLoading({}))) + states: { + FaultIdle: { + on: { + Begin: (to) => to.branch.FaultLoading().resolve(({ target }) => target.decoded(new FaultLoading({}))) + } + }, + FaultLoading: { + invoke: (from) => from.effect("fail", () => Effect.die(failure)) } - }, - FaultLoading: { - invoke: (from) => from.effect("fail", () => Effect.die(failure)) } }) const faultBridge = AtomMachine.make(faultMachine) @@ -772,53 +815,60 @@ describe("AtomMachine", () => { it.effect("selects compound and parallel state paths from the bridge snapshot", () => Effect.scoped(Effect.gen(function*() { - const states = Machine.states({ - Ready: { - schema: Ready, - type: "parallel", - states: { - editor: { - schema: Editor, - initial: "Editing", - states: { - Editing, - Saving - } - }, - network: { - schema: Network, - initial: "Online", - states: { - Online, - Offline + const states = Machine.state({ + initial: "Ready", + states: { + Ready: { + schema: Ready, + type: "parallel", + states: { + editor: { + schema: Editor, + initial: "Editing", + states: { + Editing, + Saving + } + }, + network: { + schema: Network, + initial: "Online", + states: { + Online, + Offline + } } } } } }) const machine = Machine.make({ - states: states.states, - events: Machine.events(), - initial: (to) => - to.Ready.initial.resolve(({ target }) => - target.decoded(new Ready({}), (ready) => - ready - .editor.decoded(new Editor({}), (editor) => editor.Editing.decoded(new Editing({}))) - .network.decoded(new Network({}), (network) => network.Online.decoded(new Online({})))) + root: states, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.Ready.decoded(new Ready({}), (ready) => + ready + .editor.decoded(new Editor({}), (editor) => editor.Editing.decoded(new Editing({}))) + .network.decoded(new Network({}), (network) => network.Online.decoded(new Online({})))) + ) ) }).handle({ - Ready: { - states: { - editor: { - states: { - Editing: {}, - Saving: {} - } - }, - network: { - states: { - Online: {}, - Offline: {} + states: { + Ready: { + states: { + editor: { + states: { + Editing: {}, + Saving: {} + } + }, + network: { + states: { + Online: {}, + Offline: {} + } } } } @@ -887,8 +937,12 @@ describe("AtomMachine", () => { assert.deepStrictEqual(snapshot, { status: "stopped", state: { - path: "Count" as const, - value: new Count({ value: 0 }) + path: "" as const, + value: undefined, + state: { + path: "Count" as const, + value: new Count({ value: 0 }) + } } }) }))) @@ -925,27 +979,33 @@ describe("AtomMachine", () => { Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry const machine = Machine.make({ - states: { - Count, - Done: { - schema: Done, - type: "final", - output: Schema.Number + root: Machine.state({ + initial: "Count", + states: { + Count, + Done: { + schema: Done, + type: "final", + output: Schema.Number + } } - }, - events: Machine.events(Finish), - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 1 }))) + }), + events: Machine.eventsFromSchemas(Finish), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 1 })))) }).handle({ - Count: { - on: { - Finish: (to) => - to.full.Done().resolve(({ state, event, target }) => - target.decoded(new Done({ value: state.value + event.by })) - ) + states: { + Count: { + on: { + Finish: (to) => + to.branch.Done().resolve(({ state, event, target }) => + target.decoded(new Done({ value: state.value + event.by })) + ) + } + }, + Done: { + output: ({ state }) => state.value } - }, - Done: { - output: ({ state }) => state.value } }) const bridge = AtomMachine.make(machine) @@ -957,9 +1017,13 @@ describe("AtomMachine", () => { assert.deepStrictEqual(snapshot, { status: "done", state: { - path: "Done" as const, - value: new Done({ value: 4 }), - completed: [{ path: "Done" as const, output: 4 }] + path: "" as const, + value: undefined, + state: { + path: "Done" as const, + value: new Done({ value: 4 }) + }, + completed: [{ path: "Done" as const, output: 4 }, { path: "" as const, output: 4 }] }, output: 4 }) diff --git a/packages/effect-machine/typetest/machine/Activities.tst.ts b/packages/effect-machine/typetest/machine/Activities.tst.ts index 06849cf..3da7e6f 100644 --- a/packages/effect-machine/typetest/machine/Activities.tst.ts +++ b/packages/effect-machine/typetest/machine/Activities.tst.ts @@ -6,17 +6,20 @@ class Loading extends Schema.TaggedClass("Loading")("Loading", {}) {} class Dynamic extends Schema.TaggedClass("Dynamic")("Dynamic", {}) {} class TimedOut extends Schema.TaggedClass("TimedOut")("TimedOut", {}) {} -const States = Machine.states({ Loading, Dynamic }) +const States = Machine.state({ initial: "Loading", states: { Loading, Dynamic } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(TimedOut), - initial: (to) => to.Loading().resolve(({ target }) => (target.decoded(new Loading({})))) + root: States, + events: Machine.eventsFromSchemas(TimedOut), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.Loading.decoded(new Loading({}))))) }).handle({ - Loading: { - invoke: (from) => from.timer("timeout", "1 second").onDone((to) => to.none) - }, - Dynamic: { - invoke: (from) => from.timer("dynamic", () => "2 seconds" as const).onDone((to) => to.none) + states: { + Loading: { + invoke: (from) => from.timer("timeout", "1 second").onDone((to) => to.none) + }, + Dynamic: { + invoke: (from) => from.timer("dynamic", () => "2 seconds" as const).onDone((to) => to.none) + } } }) @@ -24,7 +27,7 @@ describe("Machine activity inspection", () => { it("preserves source path and activity kind unions", () => { const definition = Machine.activityDefinitions(machine)[0]! - expect(definition.source).type.toBe<"Loading" | "Dynamic">() + expect(definition.source).type.toBe<"" | "Loading" | "Dynamic">() expect(definition.type).type.toBe<"process" | "effect" | "stream" | "timer" | "machine">() }) diff --git a/packages/effect-machine/typetest/machine/Annotations.tst.ts b/packages/effect-machine/typetest/machine/Annotations.tst.ts index 0916932..d4d729b 100644 --- a/packages/effect-machine/typetest/machine/Annotations.tst.ts +++ b/packages/effect-machine/typetest/machine/Annotations.tst.ts @@ -12,24 +12,27 @@ const AnnotatedWorkflow = Workflow.annotate({ designOwner: "editor-platform" }) -const States = Machine.states({ - Workflow: { - schema: AnnotatedWorkflow, - initial: "Idle", - states: { - Idle, - Routing: { - type: "choice", - annotations: { - title: "Select persistence route", - description: "Routes according to the current document" - } - }, - Recent: { - type: "history", - annotations: { - title: "Previous workflow state", - documentation: "https://example.test/docs/history" +const States = Machine.state({ + initial: "Workflow", + states: { + Workflow: { + schema: AnnotatedWorkflow, + initial: "Idle", + states: { + Idle, + Routing: { + type: "choice", + annotations: { + title: "Select persistence route", + description: "Routes according to the current document" + } + }, + Recent: { + type: "history", + annotations: { + title: "Previous workflow state", + documentation: "https://example.test/docs/history" + } } } } @@ -37,12 +40,14 @@ const States = Machine.states({ }) const machine = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.Workflow.initial.resolve(( + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(( { target } - ) => (target.decoded(new Workflow({}), (workflow) => workflow.Idle.decoded(new Idle({}))))) + ) => (target.from((to) => + to.Workflow.decoded(new Workflow({}), (workflow) => workflow.Idle.decoded(new Idle({}))) + ))) }) describe("Machine state annotations", () => { @@ -56,53 +61,65 @@ describe("Machine state annotations", () => { }) it("limits pseudo-state annotations to descriptive metadata", () => { - expect(Machine.states).type.not.toBeCallableWith({ - Workflow: { - schema: Workflow, - initial: "Idle", - states: { - Idle, - Invalid: { type: "choice", annotations: { arbitrary: () => 1 } } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Workflow", + states: { + Workflow: { + schema: Workflow, + initial: "Idle", + states: { + Idle, + Invalid: { type: "choice", annotations: { arbitrary: () => 1 } } + } } } }) - expect(Machine.states).type.not.toBeCallableWith({ - Workflow: { - schema: Workflow, - initial: "Idle", - states: { - Idle, - Invalid: { type: "history", annotations: { title: () => "Executable" } } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Workflow", + states: { + Workflow: { + schema: Workflow, + initial: "Idle", + states: { + Idle, + Invalid: { type: "history", annotations: { title: () => "Executable" } } + } } } }) }) it("keeps schema-backed APIs unavailable to annotated pseudo-states", () => { - expect(Machine.states).type.not.toBeCallableWith({ - Workflow: { - schema: Workflow, - initial: "Idle", - states: { - Idle, - Invalid: { - type: "choice", - schema: Idle, - annotations: { title: "Still a pseudo-state" } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Workflow", + states: { + Workflow: { + schema: Workflow, + initial: "Idle", + states: { + Idle, + Invalid: { + type: "choice", + schema: Idle, + annotations: { title: "Still a pseudo-state" } + } } } } }) - expect(Machine.states).type.not.toBeCallableWith({ - Workflow: { - schema: Workflow, - initial: "Idle", - states: { - Idle, - Invalid: { - type: "history", - states: { Idle }, - annotations: { title: "Still a pseudo-state" } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Workflow", + states: { + Workflow: { + schema: Workflow, + initial: "Idle", + states: { + Idle, + Invalid: { + type: "history", + states: { Idle }, + annotations: { title: "Still a pseudo-state" } + } } } } diff --git a/packages/effect-machine/typetest/machine/Choice.tst.ts b/packages/effect-machine/typetest/machine/Choice.tst.ts index 5697551..cb2415f 100644 --- a/packages/effect-machine/typetest/machine/Choice.tst.ts +++ b/packages/effect-machine/typetest/machine/Choice.tst.ts @@ -6,51 +6,58 @@ class Flow extends Schema.TaggedClass("Flow")("Flow", { score: Schema.Numb class Approved extends Schema.TaggedClass("Approved")("Approved", {}) {} class Rejected extends Schema.TaggedClass("Rejected")("Rejected", {}) {} -const States = Machine.states({ - Flow: { - schema: Flow, - initial: "Routing", - states: { - Routing: { type: "choice" }, - Approved, - Rejected +const States = Machine.state({ + initial: "Flow", + states: { + Flow: { + schema: Flow, + initial: "Routing", + states: { + Routing: { type: "choice" }, + Approved, + Rejected + } } } }) describe("Machine choice pseudo-states", () => { it("separates active, choice, and state-node identifiers", () => { - expect>().type.toBe< - "Flow" | "Flow.Approved" | "Flow.Rejected" + expect>().type.toBe< + "" | "Flow" | "Flow.Approved" | "Flow.Rejected" >() - expect>().type.toBe<"Flow.Routing">() - expect>().type.toBe< - "Flow" | "Flow.Routing" | "Flow.Approved" | "Flow.Rejected" + expect>().type.toBe<"Flow.Routing">() + expect>().type.toBe< + "" | "Flow" | "Flow.Routing" | "Flow.Approved" | "Flow.Rejected" >() - expect["path"]>().type.not.toBe<"Flow.Routing">() + expect["path"]>().type.not.toBe<"Flow.Routing">() }) it("exposes only choice context and requires implementation before planning", () => { const incomplete = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.Flow.initial.resolve(({ target }) => (target.decoded(new Flow({ score: 80 }), (flow) => flow.Routing()))) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(( + { target } + ) => (target.from((to) => to.Flow.decoded(new Flow({ score: 80 }), (flow) => flow.Routing())))) }) expect(Machine.planInitial).type.not.toBeCallableWith(incomplete) const complete = incomplete.handle({ - Flow: { - states: { - Routing: { - choice: (to) => - to.local.Approved().resolve((context) => { - expect(to.local.Approved()).type.not.toHaveProperty("reenter") - expect(context).type.not.toHaveProperty("state") - expect(context.containingState).type.toBe() - expect(context.ancestors.Flow).type.toBe() - expect(context.event).type.toBe>() - return context.target.decoded(new Approved({})) - }) + states: { + Flow: { + states: { + Routing: { + choice: (to) => + to.local.Approved().resolve((context) => { + expect(to.local.Approved()).type.not.toHaveProperty("reenter") + expect(context).type.not.toHaveProperty("state") + expect(context.containingState).type.toBe() + expect(context.ancestors.Flow).type.toBe() + expect(context.event).type.toBe>() + return context.target.decoded(new Approved({})) + }) + } } } } @@ -60,20 +67,24 @@ describe("Machine choice pseudo-states", () => { it("rejects Effects returned by choice resolvers", () => { const machine = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.Flow.initial.resolve(({ target }) => (target.decoded(new Flow({ score: 80 }), (flow) => flow.Routing()))) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(( + { target } + ) => (target.from((to) => to.Flow.decoded(new Flow({ score: 80 }), (flow) => flow.Routing())))) }) machine.handle({ - Flow: { - states: { - Routing: { - choice: (to) => - to.local.Approved().resolve(({ target }) => - // @ts-expect-error! - Effect.succeed(target.decoded(new Approved({}))) - ) + states: { + Flow: { + states: { + Routing: { + choice: (to) => + to.local.Approved().resolve(({ target }) => + // @ts-expect-error! + Effect.succeed(target.decoded(new Approved({}))) + ) + } } } } @@ -89,20 +100,25 @@ describe("Machine choice pseudo-states", () => { { type: "choice", output: Schema.String } ] as const for (const choice of invalidDefinitions) { - expect(Machine.states).type.not.toBeCallableWith({ - Flow: { - schema: Flow, - initial: "Approved", - states: { Approved, choice } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Flow", + states: { + Flow: { + schema: Flow, + initial: "Approved", + states: { Approved, choice } + } } }) } const base = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.Flow.initial.resolve(({ target }) => (target.decoded(new Flow({ score: 80 }), (flow) => flow.Routing()))) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(( + { target } + ) => (target.from((to) => to.Flow.decoded(new Flow({ score: 80 }), (flow) => flow.Routing())))) }) const invalidHandlers = [ { entry: () => undefined }, @@ -117,51 +133,59 @@ describe("Machine choice pseudo-states", () => { { states: {} } ] as const for (const invalid of invalidHandlers) { - expect(base.handle).type.not.toBeCallableWith({ Flow: { states: { Routing: invalid } } }) + expect(base.handle).type.not.toBeCallableWith({ states: { Flow: { states: { Routing: invalid } } } }) } }) it("validates the selected choice result", () => { const base = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.Flow.initial.resolve(({ target }) => (target.decoded(new Flow({ score: 80 }), (flow) => flow.Routing()))) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(( + { target } + ) => (target.from((to) => to.Flow.decoded(new Flow({ score: 80 }), (flow) => flow.Routing())))) }) base.handle({ - Flow: { - states: { - Routing: { - choice: (to) => - to.local.Rejected().resolve(({ target: selectedTarget }) => { - expect(selectedTarget.decoded).type.not.toBeCallableWith(new Approved({})) - return selectedTarget.decoded(new Rejected({})) - }) + states: { + Flow: { + states: { + Routing: { + choice: (to) => + to.local.Rejected().resolve(({ target: selectedTarget }) => { + expect(selectedTarget.decoded).type.not.toBeCallableWith(new Approved({})) + return selectedTarget.decoded(new Rejected({})) + }) + } } } } }) expect(base.handle).type.not.toBeCallableWith({ - Flow: { - states: { - Routing: { choice: () => undefined } + states: { + Flow: { + states: { + Routing: { choice: () => undefined } + } } } }) const declinableChoice = null as unknown as Machine.Machine.TransitionConfig< - typeof States.states, + { readonly "": typeof States.node }, readonly [], readonly [], "Flow.Routing", - Machine.Machine.ChoiceContext, + Machine.Machine.ChoiceContext<{ readonly "": typeof States.node }, readonly [], readonly [], "Flow.Routing">, false, "declinable" > expect(base.handle).type.not.toBeCallableWith({ - Flow: { - states: { - Routing: { choice: declinableChoice } + states: { + Flow: { + states: { + Routing: { choice: declinableChoice } + } } } }) diff --git a/packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts b/packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts index 195968f..bf7d0e3 100644 --- a/packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts +++ b/packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts @@ -7,12 +7,15 @@ class InSession extends Schema.TaggedClass("ConsumerTypesInSession")( role: Schema.Literals(["offerer", "proposer"]) }) {} -const States = Machine.states({ - root: { - initial: "Idle", - states: { - Idle: {}, - InSession +const States = Machine.state({ + initial: "root", + states: { + root: { + initial: "Idle", + states: { + Idle: {}, + InSession + } } } }) @@ -23,34 +26,37 @@ const StartupInput = Schema.Struct({ }) const definition = Machine.make({ - states: States.states, - events: Machine.events(), + root: States, + events: Machine.eventsFromSchemas(), input: StartupInput, - initial: (to) => - to.root.initial.resolve(({ input, target }) => { + initialConfiguration: (root) => + root.resolve(({ input, target }) => { expect(input).type.toBe() - return target.from((root) => root.Idle.from()) + return target.from((to) => to.root.from((root) => root.Idle.from())) }) }) const machine = definition.handle({ - root: { - states: { - Idle: {}, - InSession: {} + states: { + root: { + states: { + Idle: {}, + InSession: {} + } } } }) const voidMachine = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => to.root.initial.resolve(({ target }) => target.from((root) => root.Idle.from())) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.root.from((root) => root.Idle.from()))) }) describe("consumer type extractors", () => { it("extracts complete snapshots from defined states and machines", () => { - expect>().type.toBe>() + expect>().type.toBe>() expect>().type.toBe>() }) @@ -66,10 +72,10 @@ describe("consumer type extractors", () => { it("extracts path-rooted snapshots including structural states", () => { expect>().type.toBe< - Machine.Machine.SnapshotByIdentifier + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "root"> >() expect>().type.toBe< - Machine.Machine.SnapshotByIdentifier + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "root.Idle"> >() // @ts-expect-error! diff --git a/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts b/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts index 45698f0..515b835 100644 --- a/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts +++ b/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts @@ -19,95 +19,98 @@ class DeepActionService extends Context.Service()("ty class DeepFailure extends Data.TaggedError("DeepFailure")<{}> {} class DeepActionFailure extends Data.TaggedError("DeepActionFailure")<{}> {} -const DeepStates = Machine.states({ - Root: { - schema: Root, - initial: "L1", - states: { - L1: { - schema: Branch, - initial: "L2", - states: { - L2: { - schema: Branch, - initial: "L3", - states: { - L3: { - schema: Branch, - initial: "L4", - states: { - L4: { - schema: Branch, - initial: "L5", - states: { - L5: { - schema: Branch, - initial: "L6", - states: { - L6: { - schema: Branch, - initial: "L7", - states: { - L7: { - schema: Branch, - initial: "L8", - states: { - L8: { - schema: Branch, - initial: "L9", - states: { - L9: { - schema: Branch, - initial: "L10", - states: { - L10: { - schema: Branch, - initial: "Hub", - states: { - Hub: { - schema: Hub, - initial: "Route", - states: { - Route: { type: "choice" }, - Idle, - Done: { - schema: Done, - type: "final", - output: Schema.String - }, - Work: { - schema: Work, - type: "parallel", - output: Schema.Struct({ - left: Schema.String, - right: Schema.Number - }), - states: { - left: { - schema: LeftRegion, - initial: "LeftDone", - states: { - LeftDone: { - schema: LeftDone, - type: "final", - output: Schema.String +const DeepStates = Machine.state({ + initial: "Root", + states: { + Root: { + schema: Root, + initial: "L1", + states: { + L1: { + schema: Branch, + initial: "L2", + states: { + L2: { + schema: Branch, + initial: "L3", + states: { + L3: { + schema: Branch, + initial: "L4", + states: { + L4: { + schema: Branch, + initial: "L5", + states: { + L5: { + schema: Branch, + initial: "L6", + states: { + L6: { + schema: Branch, + initial: "L7", + states: { + L7: { + schema: Branch, + initial: "L8", + states: { + L8: { + schema: Branch, + initial: "L9", + states: { + L9: { + schema: Branch, + initial: "L10", + states: { + L10: { + schema: Branch, + initial: "Hub", + states: { + Hub: { + schema: Hub, + initial: "Route", + states: { + Route: { type: "choice" }, + Idle, + Done: { + schema: Done, + type: "final", + output: Schema.String + }, + Work: { + schema: Work, + type: "parallel", + output: Schema.Struct({ + left: Schema.String, + right: Schema.Number + }), + states: { + left: { + schema: LeftRegion, + initial: "LeftDone", + states: { + LeftDone: { + schema: LeftDone, + type: "final", + output: Schema.String + } } - } - }, - right: { - schema: RightRegion, - initial: "RightDone", - states: { - RightDone: { - schema: RightDone, - type: "final", - output: Schema.Number + }, + right: { + schema: RightRegion, + initial: "RightDone", + states: { + RightDone: { + schema: RightDone, + type: "final", + output: Schema.Number + } } } } - } - }, - recent: { type: "history" } + }, + recent: { type: "history" } + } } } } @@ -136,33 +139,34 @@ const DeepStates = Machine.states({ const deepHistoryFallback = ( target: Machine.Machine.HistoryDefaultTargetBuilder< - typeof DeepStates.states, + { readonly "": typeof DeepStates.node }, "Root.L1.L2.L3.L4.L5.L6.L7.L8.L9.L10.Hub" > ) => - target.Root.decoded( - new Root({}), - (root) => - root.L1.decoded(new Branch({}), (l1) => - l1.L2.decoded(new Branch({}), (l2) => - l2.L3.decoded(new Branch({}), (l3) => - l3.L4.decoded(new Branch({}), (l4) => - l4.L5.decoded(new Branch({}), (l5) => - l5.L6.decoded(new Branch({}), (l6) => - l6.L7.decoded(new Branch({}), (l7) => - l7.L8.decoded(new Branch({}), (l8) => - l8.L9.decoded(new Branch({}), (l9) => - l9.L10.decoded(new Branch({}), (l10) => - l10.Hub.decoded(new Hub({}), (hub) => - hub.Idle.decoded(new Idle({}))))))))))))) + target.from((tree) => + tree.Root.decoded( + new Root({}), + (root) => + root.L1.decoded(new Branch({}), (l1) => + l1.L2.decoded(new Branch({}), (l2) => + l2.L3.decoded(new Branch({}), (l3) => + l3.L4.decoded(new Branch({}), (l4) => + l4.L5.decoded(new Branch({}), (l5) => + l5.L6.decoded(new Branch({}), (l6) => + l6.L7.decoded(new Branch({}), (l7) => + l7.L8.decoded(new Branch({}), (l8) => + l8.L9.decoded(new Branch({}), (l9) => + l9.L10.decoded(new Branch({}), (l10) => + l10.Hub.decoded(new Hub({}), (hub) => hub.Idle.decoded(new Idle({}))))))))))))) + ) ) const makeDeepMachine = () => Machine.make({ - states: DeepStates.states, - events: Machine.events(Advance), - initial: (to) => - to.Root.initial.resolve((): never => { + root: DeepStates, + events: Machine.eventsFromSchemas(Advance), + initialConfiguration: (to) => + to.resolve((): never => { throw new Error("type-only") }) }) @@ -251,109 +255,113 @@ describe("deep handler trees", () => { class NarrowService extends Context.Service()("types/deep/NarrowService") {} class NarrowFailure extends Data.TaggedError("NarrowFailure")<{}> {} - const States = Machine.states({ - n0: { - schema: NarrowNode, - initial: "n1", - states: { - n1: { - schema: NarrowNode, - initial: "n2", - states: { - n2: { - schema: NarrowNode, - initial: "n3", - states: { - n3: { - schema: NarrowNode, - initial: "n4", - states: { - n4: { - schema: NarrowNode, - initial: "n5", - states: { - n5: { - schema: NarrowNode, - initial: "n6", - states: { - n6: { - schema: NarrowNode, - initial: "n7", - states: { - n7: { - schema: NarrowNode, - initial: "n8", - states: { - n8: { - schema: NarrowNode, - initial: "n9", - states: { - n9: { - schema: NarrowNode, - initial: "n10", - states: { - n10: { - schema: NarrowNode, - initial: "n11", - states: { - n11: { - schema: NarrowNode, - initial: "n12", - states: { - n12: { - schema: NarrowNode, - initial: "n13", - states: { - n13: { - schema: NarrowNode, - initial: "n14", - states: { - n14: { - schema: NarrowNode, - initial: "n15", - states: { - n15: { - schema: NarrowNode, - initial: "n16", - states: { - n16: { - schema: NarrowNode, - initial: "n17", - states: { - n17: { - schema: NarrowNode, - initial: "n18", - states: { - n18: { - schema: NarrowNode, - initial: "n19", - states: { - n19: { - schema: NarrowNode, - initial: "n20", - states: { - n20: { - schema: NarrowNode, - initial: "n21", - states: { - n21: { - schema: NarrowNode, - initial: "n22", - states: { - n22: { - schema: NarrowNode, - initial: "n23", - states: { - n23: { - schema: NarrowNode, - initial: "n24", - states: { - n24: { - schema: - NarrowNode, - type: "final", - output: - Schema.String + const States = Machine.state({ + initial: "n0", + states: { + n0: { + schema: NarrowNode, + initial: "n1", + states: { + n1: { + schema: NarrowNode, + initial: "n2", + states: { + n2: { + schema: NarrowNode, + initial: "n3", + states: { + n3: { + schema: NarrowNode, + initial: "n4", + states: { + n4: { + schema: NarrowNode, + initial: "n5", + states: { + n5: { + schema: NarrowNode, + initial: "n6", + states: { + n6: { + schema: NarrowNode, + initial: "n7", + states: { + n7: { + schema: NarrowNode, + initial: "n8", + states: { + n8: { + schema: NarrowNode, + initial: "n9", + states: { + n9: { + schema: NarrowNode, + initial: "n10", + states: { + n10: { + schema: NarrowNode, + initial: "n11", + states: { + n11: { + schema: NarrowNode, + initial: "n12", + states: { + n12: { + schema: NarrowNode, + initial: "n13", + states: { + n13: { + schema: NarrowNode, + initial: "n14", + states: { + n14: { + schema: NarrowNode, + initial: "n15", + states: { + n15: { + schema: NarrowNode, + initial: "n16", + states: { + n16: { + schema: NarrowNode, + initial: "n17", + states: { + n17: { + schema: NarrowNode, + initial: "n18", + states: { + n18: { + schema: NarrowNode, + initial: "n19", + states: { + n19: { + schema: NarrowNode, + initial: "n20", + states: { + n20: { + schema: NarrowNode, + initial: "n21", + states: { + n21: { + schema: NarrowNode, + initial: "n22", + states: { + n22: { + schema: NarrowNode, + initial: "n23", + states: { + n23: { + schema: + NarrowNode, + initial: "n24", + states: { + n24: { + schema: + NarrowNode, + type: "final", + output: Schema + .String + } } } } @@ -405,65 +413,68 @@ describe("deep handler trees", () => { } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.n0.initial.resolve((): never => { + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (to) => + to.resolve((): never => { throw new Error("type-only") }) }).handle({ - n0: { - states: { - n1: { - states: { - n2: { - states: { - n3: { - states: { - n4: { - states: { - n5: { - states: { - n6: { - states: { - n7: { - states: { - n8: { - states: { - n9: { - states: { - n10: { - states: { - n11: { - states: { - n12: { - states: { - n13: { - states: { - n14: { - states: { - n15: { - states: { - n16: { - states: { - n17: { - states: { - n18: { - states: { - n19: { - states: { - n20: { - states: { - n21: { - states: { - n22: { - states: { - n23: { - states: { - n24: { - entry: () => {}, - output: () => - "complete" + states: { + n0: { + states: { + n1: { + states: { + n2: { + states: { + n3: { + states: { + n4: { + states: { + n5: { + states: { + n6: { + states: { + n7: { + states: { + n8: { + states: { + n9: { + states: { + n10: { + states: { + n11: { + states: { + n12: { + states: { + n13: { + states: { + n14: { + states: { + n15: { + states: { + n16: { + states: { + n17: { + states: { + n18: { + states: { + n19: { + states: { + n20: { + states: { + n21: { + states: { + n22: { + states: { + n23: { + states: { + n24: { + entry: + () => {}, + output: () => + "complete" + } } } } diff --git a/packages/effect-machine/typetest/machine/DynamicChildren.tst.ts b/packages/effect-machine/typetest/machine/DynamicChildren.tst.ts index b31489e..af1c66d 100644 --- a/packages/effect-machine/typetest/machine/DynamicChildren.tst.ts +++ b/packages/effect-machine/typetest/machine/DynamicChildren.tst.ts @@ -12,24 +12,28 @@ describe("dynamic child machines", () => { class ParentIdle extends Schema.TaggedClass("DynamicTypesParentIdle")("ParentIdle", {}) {} const Input = Schema.Struct({ id: Schema.String }) - const ParentEvents = Machine.events(ParentNotice) + const ParentEvents = Machine.eventsFromSchemas(ParentNotice) const childMachine = Machine.make({ - states: { ChildIdle }, - events: Machine.events(ChildEvent), + root: Machine.state({ initial: "ChildIdle", states: { ChildIdle } }), + events: Machine.eventsFromSchemas(ChildEvent), input: Input, parent: Machine.parent(ParentEvents), - initial: (to) => to.ChildIdle().resolve(({ input, target }) => target.decoded(new ChildIdle({ id: input.id }))) + initialConfiguration: (root) => + root.resolve(({ input, target }) => target.from((to) => to.ChildIdle.decoded(new ChildIdle({ id: input.id })))) }).handle({ - ChildIdle: { - on: { ChildEvent: (to) => to.none } + states: { + ChildIdle: { + on: { ChildEvent: (to) => to.none } + } } }) const Child = Machine.childFamily(childMachine) const voidChildMachine = Machine.make({ - states: { ChildIdle }, - events: Machine.events(), - initial: (to) => to.ChildIdle().resolve(({ target }) => target.decoded(new ChildIdle({ id: "void" }))) - }).handle({ ChildIdle: {} }) + root: Machine.state({ initial: "ChildIdle", states: { ChildIdle } }), + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.ChildIdle.decoded(new ChildIdle({ id: "void" })))) + }).handle({ states: { ChildIdle: {} } }) const VoidChild = Machine.childFamily(voidChildMachine) it("binds one machine type to runtime ids", () => { @@ -41,48 +45,54 @@ describe("dynamic child machines", () => { it("types statechart-owned dynamic child operations", () => { Machine.make({ - states: { ParentIdle }, - events: Machine.events(ParentNotice, OtherEvent), - initial: (to) => to.ParentIdle().resolve(({ target }) => target.decoded(new ParentIdle({}))) + root: Machine.state({ initial: "ParentIdle", states: { ParentIdle } }), + events: Machine.eventsFromSchemas(ParentNotice, OtherEvent), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.ParentIdle.decoded(new ParentIdle({})))) }).handle({ - ParentIdle: { - invoke: (from) => - from.effect("spawn", ({ children }) => { - expect(children.spawn).type.toBeCallableWith(Child("p-1"), { input: { id: "p-1" } }) - expect(children.spawn).type.not.toBeCallableWith(Child("p-1")) - expect(children.spawn).type.toBeCallableWith(VoidChild("void")) - expect(children.spawn).type.not.toBeCallableWith(VoidChild("void"), { input: { id: "void" } }) - expect(children.spawn).type.not.toBeCallableWith( - Child("erased") as Machine.ChildMachine.Any, - { input: { id: "erased" } } - ) - expect(children.sendTo).type.toBeCallableWith(Child("p-1"), new ChildEvent({})) - expect(children.sendTo).type.not.toBeCallableWith(Child("p-1"), new ParentNotice({})) - expect(children.stop).type.toBeCallableWith(Child("p-1")) + states: { + ParentIdle: { + invoke: (from) => + from.effect("spawn", ({ children }) => { + expect(children.spawn).type.toBeCallableWith(Child("p-1"), { input: { id: "p-1" } }) + expect(children.spawn).type.not.toBeCallableWith(Child("p-1")) + expect(children.spawn).type.toBeCallableWith(VoidChild("void")) + expect(children.spawn).type.not.toBeCallableWith(VoidChild("void"), { input: { id: "void" } }) + expect(children.spawn).type.not.toBeCallableWith( + Child("erased") as Machine.ChildMachine.Any, + { input: { id: "erased" } } + ) + expect(children.sendTo).type.toBeCallableWith(Child("p-1"), new ChildEvent({})) + expect(children.sendTo).type.not.toBeCallableWith(Child("p-1"), new ParentNotice({})) + expect(children.stop).type.toBeCallableWith(Child("p-1")) - const spawned = children.spawn(Child("p-1"), { input: { id: "p-1" } }) - expect>().type.toBe>>() - expect>().type.toBe< - Machine.ChildAlreadyExistsError | Machine.ChildMachine.StartError> - >() - return spawned - }).onDone((to) => to.none).onFailure((to) => to.none) + const spawned = children.spawn(Child("p-1"), { input: { id: "p-1" } }) + expect>().type.toBe>>() + expect>().type.toBe< + Machine.ChildAlreadyExistsError | Machine.ChildMachine.StartError> + >() + return spawned + }).onDone((to) => to.none).onFailure((to) => to.none) + } } }) }) it("rejects a child whose parent protocol is not accepted", () => { Machine.make({ - states: { ParentIdle }, - events: Machine.events(OtherEvent), - initial: (to) => to.ParentIdle().resolve(({ target }) => target.decoded(new ParentIdle({}))) + root: Machine.state({ initial: "ParentIdle", states: { ParentIdle } }), + events: Machine.eventsFromSchemas(OtherEvent), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.ParentIdle.decoded(new ParentIdle({})))) }).handle({ - ParentIdle: { - invoke: (from) => - from.effect("incompatible", ({ children }) => { - expect(children.spawn).type.not.toBeCallableWith(Child("p-1"), { input: { id: "p-1" } }) - return Effect.void - }).onDone((to) => to.none) + states: { + ParentIdle: { + invoke: (from) => + from.effect("incompatible", ({ children }) => { + expect(children.spawn).type.not.toBeCallableWith(Child("p-1"), { input: { id: "p-1" } }) + return Effect.void + }).onDone((to) => to.none) + } } }) }) diff --git a/packages/effect-machine/typetest/machine/EventByTag.tst.ts b/packages/effect-machine/typetest/machine/EventByTag.tst.ts index 0b27a4c..c70e0f8 100644 --- a/packages/effect-machine/typetest/machine/EventByTag.tst.ts +++ b/packages/effect-machine/typetest/machine/EventByTag.tst.ts @@ -31,32 +31,34 @@ describe("Machine.EventByTag", () => { }) it("narrows handler contexts for every finite tag", () => { - const states = Machine.states({ Idle }) + const states = Machine.state({ initial: "Idle", states: { Idle } }) Machine.make({ - states: states.states, - events: Machine.events(FiniteUnion), - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: states, + events: Machine.eventsFromSchemas(FiniteUnion), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }).handle({ - Idle: { - on: { - Alpha: (to) => - to.none.resolve(({ event }) => { - expect(event).type.toBe<{ - readonly _tag: "Alpha" - readonly payload: string - readonly count: number - }>() - return undefined - }), - Beta: (to) => - to.none.resolve(({ event }) => { - expect(event).type.toBe<{ - readonly _tag: "Beta" - readonly payload: string - readonly count: number - }>() - return undefined - }) + states: { + Idle: { + on: { + Alpha: (to) => + to.none.resolve(({ event }) => { + expect(event).type.toBe<{ + readonly _tag: "Alpha" + readonly payload: string + readonly count: number + }>() + return undefined + }), + Beta: (to) => + to.none.resolve(({ event }) => { + expect(event).type.toBe<{ + readonly _tag: "Beta" + readonly payload: string + readonly count: number + }>() + return undefined + }) + } } } }) diff --git a/packages/effect-machine/typetest/machine/EventConstructors.tst.ts b/packages/effect-machine/typetest/machine/EventConstructors.tst.ts index 146386a..c74530e 100644 --- a/packages/effect-machine/typetest/machine/EventConstructors.tst.ts +++ b/packages/effect-machine/typetest/machine/EventConstructors.tst.ts @@ -26,14 +26,14 @@ describe("Machine event constructor collections", () => { value: Schema.String }) - const states = Machine.states({ Idle: {} }) - const events = Machine.events(PublicEvent, SetLabel, FiniteEvent) - const internalEvents = Machine.internalEvents(InternalEvent) + const states = Machine.state({ initial: "Idle", states: { Idle: {} } }) + const events = Machine.eventsFromSchemas(PublicEvent, SetLabel, FiniteEvent) + const internalEvents = Machine.internalEventsFromSchemas(InternalEvent) const machine = Machine.make({ - states: states.states, + root: states, events, internalEvents, - initial: (to) => to.Idle().resolve(({ target }) => (target.from())) + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.from()))) }) it("derives public constructors and their schema make inputs", () => { @@ -72,15 +72,13 @@ describe("Machine event constructor collections", () => { it("keeps public and internal protocol descriptors nominally separate", () => { expect(Machine.make).type.not.toBeCallableWith({ - states: states.states, - events: internalEvents, - initial: (to: Machine.Machine.InitialSelector) => to.Idle() + root: states, + events: internalEvents }) expect(Machine.make).type.not.toBeCallableWith({ - states: states.states, + root: states, events, - internalEvents: events, - initial: (to: Machine.Machine.InitialSelector) => to.Idle() + internalEvents: events }) const Reset = Schema.TaggedStruct("Reset", {}) @@ -89,11 +87,11 @@ describe("Machine event constructor collections", () => { it("keeps open discriminator schemas in the protocol without inventing constructor keys", () => { const OpenEvent = Schema.Struct({ _tag: Schema.String, value: Schema.Number }) - const openEvents = Machine.events(OpenEvent) + const openEvents = Machine.eventsFromSchemas(OpenEvent) const openMachine = Machine.make({ - states: states.states, + root: states, events: openEvents, - initial: (to) => to.Idle().resolve(({ target }) => (target.from())) + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.from()))) }) expect(openEvents.Dynamic).type.toRaiseError() @@ -101,30 +99,38 @@ describe("Machine event constructor collections", () => { }) it("accepts public constructions at machine delivery boundaries", () => { - expect(Machine.plan(machine.handle({ Idle: {} }), { path: "Idle", value: undefined }, events.Reset())).type.not + expect( + Machine.plan(machine.handle({ states: { Idle: {} } }), { + path: "", + value: undefined, + state: { path: "Idle", value: undefined } + }, events.Reset()) + ).type.not .toRaiseError() }) it("accepts internal constructions raised from invocation handlers", () => { expect( machine.handle({ - Idle: { - invoke: ( - from - ) => [ - from.effect("load", () => Effect.succeed("ready")).onDone((to) => - to.none.resolve(({ output }, enqueue) => { - enqueue.raise(internalEvents.Loaded({ value: output })) - return undefined - }) - ), - from.timer("timeout", "1 second").onDone((to) => - to.none.resolve((_, enqueue) => { - enqueue.raise(internalEvents.Failed()) - return undefined - }) - ) - ] + states: { + Idle: { + invoke: ( + from + ) => [ + from.effect("load", () => Effect.succeed("ready")).onDone((to) => + to.none.resolve(({ output }, enqueue) => { + enqueue.raise(internalEvents.Loaded({ value: output })) + return undefined + }) + ), + from.timer("timeout", "1 second").onDone((to) => + to.none.resolve((_, enqueue) => { + enqueue.raise(internalEvents.Failed()) + return undefined + }) + ) + ] + } } }) ).type.not.toRaiseError() diff --git a/packages/effect-machine/typetest/machine/History.tst.ts b/packages/effect-machine/typetest/machine/History.tst.ts index 725f433..78676ed 100644 --- a/packages/effect-machine/typetest/machine/History.tst.ts +++ b/packages/effect-machine/typetest/machine/History.tst.ts @@ -44,92 +44,103 @@ class Editor extends Schema.TaggedClass("Editor")("Editor", {}) {} class Editing extends Schema.TaggedClass("Editing")("Editing", {}) {} class Sidebar extends Schema.TaggedClass("Sidebar")("Sidebar", {}) {} -const States = Machine.states({ - checkout: { - schema: Checkout, - initial: "shipping", - states: { - shipping: Shipping, - payment: { - schema: Payment, - initial: "cardEntry", - states: { - cardEntry: CardEntry, - verifying: Verifying +const States = Machine.state({ + initial: "support", + states: { + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + payment: { + schema: Payment, + initial: "cardEntry", + states: { + cardEntry: CardEntry, + verifying: Verifying + } + }, + recent: { + type: "history" + }, + exact: { + type: "history", + history: "deep" } - }, - recent: { - type: "history" - }, - exact: { - type: "history", - history: "deep" } - } - }, - support: Support + }, + support: Support + } }) -const NestedStates = Machine.states({ - App: { - schema: App, - initial: "Workspace", - states: { - Workspace: { - schema: Workspace, - type: "parallel", - states: { - Editor: { - schema: Editor, - initial: "Editing", - states: { - Editing +const NestedStates = Machine.state({ + initial: "Closed", + states: { + App: { + schema: App, + initial: "Workspace", + states: { + Workspace: { + schema: Workspace, + type: "parallel", + states: { + Editor: { + schema: Editor, + initial: "Editing", + states: { + Editing + } + }, + Sidebar, + resume: { + type: "history", + history: "deep" } - }, - Sidebar, - resume: { - type: "history", - history: "deep" } - } - }, - Settings - } - }, - Closed + }, + Settings + } + }, + Closed + } }) const completeNestedFallback = ( - target: Machine.Machine.HistoryDefaultTargetBuilder + target: Machine.Machine.HistoryDefaultTargetBuilder<{ readonly "": typeof NestedStates.node }, "App.Workspace"> ) => - target.App.decoded( - new App({ session: "fallback" }), - (app) => { - expect(app).type.not.toHaveProperty("Settings") - return app.Workspace.decoded( - new Workspace({}), - (workspace) => - workspace - .Editor.decoded(new Editor({}), (editor) => editor.Editing.decoded(new Editing({}))) - .Sidebar.decoded(new Sidebar({})) - ) - } + target.from((tree) => + tree.App.decoded( + new App({ session: "fallback" }), + (app) => { + expect(app).type.not.toHaveProperty("Settings") + return app.Workspace.decoded( + new Workspace({}), + (workspace) => + workspace + .Editor.decoded(new Editor({}), (editor) => editor.Editing.decoded(new Editing({}))) + .Sidebar.decoded(new Sidebar({})) + ) + } + ) ) const constructedNestedFallback = ( - target: Machine.Machine.HistoryDefaultTargetBuilder, + target: Machine.Machine.HistoryDefaultTargetBuilder<{ readonly "": typeof NestedStates.node }, "App.Workspace">, session: string ) => - target.App.from({ session }, (app) => - app.Workspace.from((workspace) => - workspace - .Editor.from((editor) => editor.Editing.from()) - .Sidebar.from() - )) + target.from((tree) => + tree.App.from({ session }, (app) => + app.Workspace.from((workspace) => + workspace + .Editor.from((editor) => editor.Editing.from()) + .Sidebar.from() + )) + ) describe("Machine history states", () => { it("separates active and history identifiers", () => { - expect>().type.toBe< + expect>().type.toBe< + | "" | "checkout" | "checkout.shipping" | "checkout.payment" @@ -137,24 +148,26 @@ describe("Machine history states", () => { | "checkout.payment.verifying" | "support" >() - expect>().type.toBe< + expect>().type.toBe< "checkout.recent" | "checkout.exact" >() }) it("excludes history pseudo-states from snapshots and initial selectors", () => { - type Snapshot = Machine.Machine.Snapshot + type Snapshot = Machine.Snapshot expect<"checkout.recent">().type.not.toBeAssignableTo() Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => { + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (to) => { expect(to).type.not.toHaveProperty("recent") expect(to).type.not.toHaveProperty("exact") - return to.checkout.initial.resolve(({ target }) => - target.decoded( - new Checkout({ orderId: "order-1" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "Main Street" })) + return to.resolve(({ target }) => + target.from((to) => + to.checkout.decoded( + new Checkout({ orderId: "order-1" }), + (checkout) => checkout.shipping.decoded(new Shipping({ address: "Main Street" })) + ) ) ) } @@ -167,23 +180,26 @@ describe("Machine history states", () => { it("exposes zero-argument history targets without value overrides", () => { const definition = Machine.make({ - states: States.states, - events: Machine.events(Resume), - initial: (to) => to.support().resolve(({ target }) => (target.decoded(new Support({})))) + root: States, + events: Machine.eventsFromSchemas(Resume), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) }) const incomplete = definition.handle({ - support: { - on: { - Resume: (to) => { - expect(to.history.checkout.recent).type.not.toBeAssignableTo<() => unknown>() - expect(to.history.checkout.recent).type.toHaveProperty("resolve") - expect(to.full.checkout).type.not.toHaveProperty("recent") - expect(to.local).type.not.toHaveProperty("recent") - expect(to.branch).type.not.toHaveProperty("recent") - return to.history.checkout.exact.resolve(({ target }) => { - expect(target).type.toBeCallableWith() - return target() - }) + states: { + support: { + on: { + Resume: (to) => { + expect(to.history.checkout.recent).type.not.toBeAssignableTo<() => unknown>() + expect(to.history.checkout.recent).type.toHaveProperty("resolve") + expect(to.branch.checkout).type.not.toHaveProperty("recent") + expect(to.local).type.not.toHaveProperty("recent") + expect(to.branch).type.not.toHaveProperty("recent") + return to.history.checkout.exact.resolve(({ target }) => { + expect(target).type.toBeCallableWith() + return target() + }) + } } } } @@ -193,17 +209,22 @@ describe("Machine history states", () => { }) it("requires typed defaults and only the shallow-dependent initializer", () => { - expect>().type.toBe<"checkout.payment">() + expect>().type.toBe< + "checkout.payment" + >() const definition = Machine.make({ - states: States.states, - events: Machine.events(Resume), - initial: (to) => to.support().resolve(({ target }) => (target.decoded(new Support({})))) + root: States, + events: Machine.eventsFromSchemas(Resume), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) }) const incomplete = definition.handle({ - support: { - on: { - Resume: (to) => to.history.checkout.recent.resolve(({ target }) => target()) + states: { + support: { + on: { + Resume: (to) => to.history.checkout.recent.resolve(({ target }) => target()) + } } } }) @@ -211,35 +232,41 @@ describe("Machine history states", () => { expect(Machine.planInitial).type.not.toBeCallableWith(incomplete) const complete = definition.handle({ - checkout: { - history: { - recent: { - default: ({ owner, target }) => { - expect(owner).type.toBe<"checkout">() - expect(target).type.toBe< - Machine.Machine.HistoryDefaultTargetBuilder - >() - return target.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) + states: { + checkout: { + history: { + recent: { + default: ({ owner, target }) => { + expect(owner).type.toBe<"checkout">() + expect(target).type.toBe< + Machine.Machine.HistoryDefaultTargetBuilder<{ readonly "": typeof States.node }, "checkout"> + >() + return target.from((tree) => + tree.checkout.decoded( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) + ) + ) + } + }, + exact: { + default: ({ target }) => + target.from((tree) => + tree.checkout.decoded( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) + ) + ) } }, - exact: { - default: ({ target }) => - target.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - } - }, - states: { - payment: { - initialize: ({ state, containingState, ancestors, builder }) => { - expect(state).type.toBe() - expect(containingState).type.toBe() - expect(ancestors).type.toBe<{ readonly checkout: Checkout }>() - return builder.decoded(new CardEntry({ cardNumber: `attempt-${state.attempt}` })) + states: { + payment: { + initialize: ({ state, containingState, ancestors, builder }) => { + expect(state).type.toBe() + expect(containingState).type.toBe() + expect(ancestors).type.toBe<{ readonly checkout: Checkout }>() + return builder.decoded(new CardEntry({ cardNumber: `attempt-${state.attempt}` })) + } } } } @@ -251,41 +278,50 @@ describe("Machine history states", () => { it("rejects defaults outside the history parent and wrong initial child values", () => { const machine = Machine.make({ - states: States.states, - events: Machine.events(Resume), - initial: (to) => to.support().resolve(({ target }) => (target.decoded(new Support({})))) + root: States, + events: Machine.eventsFromSchemas(Resume), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) }) machine.handle({ - checkout: { - history: { - recent: { - default: ({ target }) => { - expect(target).type.not.toHaveProperty("support") - return target.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) + states: { + checkout: { + history: { + recent: { + default: ({ target }) => { + expect(target).type.not.toHaveProperty("support") + return target.from((tree) => + tree.checkout.decoded( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) + ) + ) + } + }, + exact: { + default: ({ target }) => + target.from((tree) => + tree.checkout.decoded( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) + ) + ) } - }, - exact: { - default: ({ target }) => - target.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) } } } }) machine.handle({ - checkout: { - states: { - payment: { - initialize: ({ builder }) => { - expect(builder.decoded).type.not.toBeCallableWith(new Verifying({ challengeId: "wrong-child" })) - return builder.decoded(new CardEntry({ cardNumber: "" })) + states: { + checkout: { + states: { + payment: { + initialize: ({ builder }) => { + expect(builder.decoded).type.not.toBeCallableWith(new Verifying({ challengeId: "wrong-child" })) + return builder.decoded(new CardEntry({ cardNumber: "" })) + } } } } @@ -293,38 +329,48 @@ describe("Machine history states", () => { }) expect(machine.handle).type.not.toBeCallableWith({ - checkout: { - states: { - recent: {} + states: { + checkout: { + states: { + recent: {} + } } } }) }) it("accepts only complete root configurations containing a nested history owner", () => { - expect>().type.toBe< - ReturnType + expect>() + .type.toBe< + ReturnType extends Machine.Machine.StateConstruction ? Snapshot + : never >() const machine = Machine.make({ - states: NestedStates.states, - events: Machine.events(Resume), - initial: (to) => to.Closed().resolve(({ target }) => (target.decoded(new Closed({})))) + root: NestedStates, + events: Machine.eventsFromSchemas(Resume), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.Closed.decoded(new Closed({}))))) }) const complete = machine.handle({ - App: { - states: { - Workspace: { - history: { - resume: { - default: ({ owner, target }) => { - expect(owner).type.toBe<"App.Workspace">() - expect(target).type.toBe< - Machine.Machine.HistoryDefaultTargetBuilder - >() - expect(target).type.not.toHaveProperty("Closed") - return completeNestedFallback(target) + states: { + App: { + states: { + Workspace: { + history: { + resume: { + default: ({ owner, target }) => { + expect(owner).type.toBe<"App.Workspace">() + expect(target).type.toBe< + Machine.Machine.HistoryDefaultTargetBuilder< + { readonly "": typeof NestedStates.node }, + "App.Workspace" + > + >() + expect(target).type.not.toHaveProperty("Closed") + return completeNestedFallback(target) + } } } } @@ -335,15 +381,17 @@ describe("Machine history states", () => { expect(Machine.planInitial).type.toBeCallableWith(complete) expect(machine.handle).type.not.toBeCallableWith({ - App: { - states: { - Workspace: { - history: { - resume: { - default: () => ({ - path: "Closed" as const, - value: new Closed({}) - }) + states: { + App: { + states: { + Workspace: { + history: { + resume: { + default: () => ({ + path: "Closed" as const, + value: new Closed({}) + }) + } } } } @@ -352,19 +400,21 @@ describe("Machine history states", () => { }) expect(machine.handle).type.not.toBeCallableWith({ - App: { - states: { - Workspace: { - history: { - resume: { - default: () => ({ - path: "App" as const, - value: new App({ session: "sibling" }), - state: { - path: "App.Settings" as const, - value: new Settings({}) - } - }) + states: { + App: { + states: { + Workspace: { + history: { + resume: { + default: () => ({ + path: "App" as const, + value: new App({ session: "sibling" }), + state: { + path: "App.Settings" as const, + value: new Settings({}) + } + }) + } } } } @@ -373,29 +423,31 @@ describe("Machine history states", () => { }) expect(machine.handle).type.not.toBeCallableWith({ - App: { - states: { - Workspace: { - history: { - resume: { - default: () => ({ - path: "App.Workspace" as const, - value: new Workspace({}), - states: { - Editor: { - path: "App.Workspace.Editor" as const, - value: new Editor({}), - state: { - path: "App.Workspace.Editor.Editing" as const, - value: new Editing({}) + states: { + App: { + states: { + Workspace: { + history: { + resume: { + default: () => ({ + path: "App.Workspace" as const, + value: new Workspace({}), + states: { + Editor: { + path: "App.Workspace.Editor" as const, + value: new Editor({}), + state: { + path: "App.Workspace.Editor.Editing" as const, + value: new Editing({}) + } + }, + Sidebar: { + path: "App.Workspace.Sidebar" as const, + value: new Sidebar({}) } - }, - Sidebar: { - path: "App.Workspace.Sidebar" as const, - value: new Sidebar({}) } - } - }) + }) + } } } } @@ -404,25 +456,27 @@ describe("Machine history states", () => { }) expect(machine.handle).type.not.toBeCallableWith({ - App: { - states: { - Workspace: { - history: { - resume: { - default: () => ({ - path: "App" as const, - value: new App({ session: "missing-region" }), - state: { - path: "App.Workspace" as const, - value: new Workspace({}), - states: { - Sidebar: { - path: "App.Workspace.Sidebar" as const, - value: new Sidebar({}) + states: { + App: { + states: { + Workspace: { + history: { + resume: { + default: () => ({ + path: "App" as const, + value: new App({ session: "missing-region" }), + state: { + path: "App.Workspace" as const, + value: new Workspace({}), + states: { + Sidebar: { + path: "App.Workspace.Sidebar" as const, + value: new Sidebar({}) + } } } - } - }) + }) + } } } } @@ -433,17 +487,20 @@ describe("Machine history states", () => { it("rejects Effects returned by nested history defaults", () => { const machine = Machine.make({ - states: NestedStates.states, - events: Machine.events(Resume), - initial: (to) => to.Closed().resolve(({ target }) => (target.decoded(new Closed({})))) + root: NestedStates, + events: Machine.eventsFromSchemas(Resume), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.Closed.decoded(new Closed({}))))) }) expect(machine.handle).type.not.toBeCallableWith({ - App: { - states: { - Workspace: { - history: { - resume: { - default: () => Effect.succeed(null) + states: { + App: { + states: { + Workspace: { + history: { + resume: { + default: () => Effect.succeed(null) + } } } } @@ -454,26 +511,33 @@ describe("Machine history states", () => { it("requires defaults and shallow initializers in one handler tree", () => { const definition = Machine.make({ - states: States.states, - events: Machine.events(Resume), - initial: (to) => to.support().resolve(({ target }) => (target.decoded(new Support({})))) + root: States, + events: Machine.eventsFromSchemas(Resume), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) }) const afterDefaults = definition.handle({ - checkout: { - history: { - recent: { - default: ({ target }) => - target.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - }, - exact: { - default: ({ target }) => - target.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) + states: { + checkout: { + history: { + recent: { + default: ({ target }) => + target.from((tree) => + tree.checkout.decoded( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) + ) + ) + }, + exact: { + default: ({ target }) => + target.from((tree) => + tree.checkout.decoded( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) + ) + ) + } } } } @@ -481,26 +545,32 @@ describe("Machine history states", () => { expect(Machine.planInitial).type.not.toBeCallableWith(afterDefaults) const complete = definition.handle({ - checkout: { - history: { - recent: { - default: ({ target }) => - target.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) + states: { + checkout: { + history: { + recent: { + default: ({ target }) => + target.from((tree) => + tree.checkout.decoded( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) + ) + ) + }, + exact: { + default: ({ target }) => + target.from((tree) => + tree.checkout.decoded( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) + ) + ) + } }, - exact: { - default: ({ target }) => - target.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - } - }, - states: { - payment: { - initialize: ({ state, builder }) => builder.decoded(new CardEntry({ cardNumber: String(state.attempt) })) + states: { + payment: { + initialize: ({ state, builder }) => builder.decoded(new CardEntry({ cardNumber: String(state.attempt) })) + } } } } @@ -512,49 +582,61 @@ describe("Machine history states", () => { }) it("does not require nested initializers for deep-only history", () => { - const DeepOnlyStates = Machine.states({ - checkout: { - schema: Checkout, - initial: "payment", - states: { - payment: { - schema: Payment, - initial: "cardEntry", - states: { - cardEntry: CardEntry, - verifying: Verifying + const DeepOnlyStates = Machine.state({ + initial: "support", + states: { + checkout: { + schema: Checkout, + initial: "payment", + states: { + payment: { + schema: Payment, + initial: "cardEntry", + states: { + cardEntry: CardEntry, + verifying: Verifying + } + }, + exact: { + type: "history", + history: "deep" } - }, - exact: { - type: "history", - history: "deep" } - } - }, - support: Support + }, + support: Support + } }) - expect>().type.toBe() + expect>().type.toBe< + never + >() const machine = Machine.make({ - states: DeepOnlyStates.states, - events: Machine.events(Resume), - initial: (to) => to.support().resolve(({ target }) => (target.decoded(new Support({})))) + root: DeepOnlyStates, + events: Machine.eventsFromSchemas(Resume), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) }).handle({ - checkout: { - history: { - exact: { - default: () => ({ - path: "checkout", - value: new Checkout({ orderId: "fallback" }), - state: { - path: "checkout.payment", - value: new Payment({ attempt: 1 }), + states: { + checkout: { + history: { + exact: { + default: () => ({ + path: "", + value: undefined, state: { - path: "checkout.payment.cardEntry", - value: new CardEntry({ cardNumber: "" }) + path: "checkout", + value: new Checkout({ orderId: "fallback" }), + state: { + path: "checkout.payment", + value: new Payment({ attempt: 1 }), + state: { + path: "checkout.payment.cardEntry", + value: new CardEntry({ cardNumber: "" }) + } + } } - } - }) + }) + } } } } @@ -564,58 +646,68 @@ describe("Machine history states", () => { }) it("requires an exact region-value map when shallow restoration descends through a parallel state", () => { - const ParallelStates = Machine.states({ - outer: { - schema: Checkout, - initial: "all", - states: { - all: { - schema: Payment, - type: "parallel", - states: { - shipping: Shipping, - card: CardEntry + const ParallelStates = Machine.state({ + initial: "support", + states: { + outer: { + schema: Checkout, + initial: "all", + states: { + all: { + schema: Payment, + type: "parallel", + states: { + shipping: Shipping, + card: CardEntry + } + }, + recent: { + type: "history" } - }, - recent: { - type: "history" } - } - }, - support: Support + }, + support: Support + } }) - expect>().type.toBe<"outer.all">() + expect>().type.toBe< + "outer.all" + >() const machine = Machine.make({ - states: ParallelStates.states, - events: Machine.events(Resume), - initial: (to) => to.support().resolve(({ target }) => (target.decoded(new Support({})))) + root: ParallelStates, + events: Machine.eventsFromSchemas(Resume), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) }) const complete = machine.handle({ - outer: { - history: { - recent: { - default: ({ target }) => - target.outer.decoded( - new Checkout({ orderId: "fallback" }), - (outer) => - outer.all.decoded( - new Payment({ attempt: 1 }), - (all) => - all - .shipping.decoded(new Shipping({ address: "" })) - .card.decoded(new CardEntry({ cardNumber: "" })) + states: { + outer: { + history: { + recent: { + default: ({ target }) => + target.from((tree) => + tree.outer.decoded( + new Checkout({ orderId: "fallback" }), + (outer) => + outer.all.decoded( + new Payment({ attempt: 1 }), + (all) => + all + .shipping.decoded(new Shipping({ address: "" })) + .card.decoded(new CardEntry({ cardNumber: "" })) + ) ) - ) - } - }, - states: { - all: { - initialize: ({ state, builder }) => { - expect(state).type.toBe() - return builder - .shipping.decoded(new Shipping({ address: `attempt-${state.attempt}` })) - .card.decoded(new CardEntry({ cardNumber: "" })) + ) + } + }, + states: { + all: { + initialize: ({ state, builder }) => { + expect(state).type.toBe() + return builder + .shipping.decoded(new Shipping({ address: `attempt-${state.attempt}` })) + .card.decoded(new CardEntry({ cardNumber: "" })) + } } } } @@ -624,15 +716,17 @@ describe("Machine history states", () => { expect(Machine.planInitial).type.toBeCallableWith(complete) expect(machine.handle).type.not.toBeCallableWith({ - outer: { - states: { - all: { - initialize: ({ builder }: Machine.Machine.StateInitializeContext< - typeof ParallelStates.states, - readonly [typeof Resume], - readonly [], - "outer.all" - >) => builder.shipping.decoded(new Shipping({ address: "missing-card" })) + states: { + outer: { + states: { + all: { + initialize: ({ builder }: Machine.Machine.StateInitializeContext< + { readonly "": typeof ParallelStates.node }, + readonly [typeof Resume], + readonly [], + "outer.all" + >) => builder.shipping.decoded(new Shipping({ address: "missing-card" })) + } } } } @@ -640,60 +734,75 @@ describe("Machine history states", () => { }) it("rejects root history nodes and active-state properties on history nodes", () => { - expect(Machine.states).type.not.toBeCallableWith({ - rootHistory: { - type: "history" + expect(Machine.state).type.not.toBeCallableWith({ + initial: "rootHistory", + states: { + rootHistory: { + type: "history" + } } }) - expect(Machine.states).type.not.toBeCallableWith({ - checkout: { - schema: Checkout, - initial: "shipping", - states: { - shipping: Shipping, - history: { - type: "history", - schema: Support + expect(Machine.state).type.not.toBeCallableWith({ + initial: "checkout", + states: { + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + history: { + type: "history", + schema: Support + } } } } }) - expect(Machine.states).type.not.toBeCallableWith({ - checkout: { - schema: Checkout, - initial: "shipping", - states: { - shipping: Shipping, - history: { - type: "history", - states: { - child: Support + expect(Machine.state).type.not.toBeCallableWith({ + initial: "checkout", + states: { + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + history: { + type: "history", + states: { + child: Support + } } } } } }) - expect(Machine.states).type.not.toBeCallableWith({ - checkout: { - schema: Checkout, - initial: "history", - states: { - shipping: Shipping, - history: { - type: "history" + expect(Machine.state).type.not.toBeCallableWith({ + initial: "checkout", + states: { + checkout: { + schema: Checkout, + initial: "history", + states: { + shipping: Shipping, + history: { + type: "history" + } } } } }) - expect(Machine.states).type.not.toBeCallableWith({ - checkout: { - schema: Checkout, - initial: "shipping", - states: { - shipping: Shipping, - history: { - type: "history", - history: "stack" + expect(Machine.state).type.not.toBeCallableWith({ + initial: "checkout", + states: { + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + history: { + type: "history", + history: "stack" + } } } } diff --git a/packages/effect-machine/typetest/machine/InitialEntry.tst.ts b/packages/effect-machine/typetest/machine/InitialEntry.tst.ts index 35e92c4..0a7a27c 100644 --- a/packages/effect-machine/typetest/machine/InitialEntry.tst.ts +++ b/packages/effect-machine/typetest/machine/InitialEntry.tst.ts @@ -8,76 +8,87 @@ class Idle extends Schema.TaggedClass("InitialTypeIdle")("Idle", { count: class Loading extends Schema.TaggedClass("InitialTypeLoading")("Loading", {}) {} class Open extends Schema.TaggedClass("InitialTypeOpen")("Open", {}) {} -const States = Machine.states({ - closed: Closed, - opened: { - schema: Opened, - initial: "idle", - states: { idle: Idle, loading: Loading } +const States = Machine.state({ + initial: "closed", + states: { + closed: Closed, + opened: { + schema: Opened, + initial: "idle", + states: { idle: Idle, loading: Loading } + } } }) const base = Machine.make({ - states: States.states, - events: Machine.events(Open), - initial: (to) => to.closed().resolve(({ target }) => (target.decoded(new Closed({})))) + root: States, + events: Machine.eventsFromSchemas(Open), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.closed.decoded(new Closed({}))))) }) describe("declared initial entry types", () => { it("requires initialize at the handle call that returns an initial target", () => { base.handle({ - closed: { - on: { - Open: (to) => to.full.opened.initial.resolve(({ target }) => target.decoded(new Opened({ id: "team-1" }))) - } - }, - // @ts-expect-error! - opened: {} + states: { + closed: { + on: { + Open: (to) => to.branch.opened.initial.resolve(({ target }) => target.decoded(new Opened({ id: "team-1" }))) + } + }, + // @ts-expect-error! + opened: {} + } }) base.handle({ - closed: { - on: { - Open: (to) => to.full.opened.initial.resolve(({ target }) => target.from({ id: "team-1" })) + states: { + closed: { + on: { + Open: (to) => to.branch.opened.initial.resolve(({ target }) => target.from({ id: "team-1" })) + } + }, + opened: { + initialize: ({ builder }) => builder.from({ count: 0 }) } - }, - opened: { - initialize: ({ builder }) => builder.from({ count: 0 }) } }) base.handle({ - closed: { - on: { - Open: (to) => - to.full.opened().resolve(({ target }) => - target.decoded( - new Opened({ id: "team-1" }), - (opened) => opened.loading.decoded(new Loading({})) + states: { + closed: { + on: { + Open: (to) => + to.branch.opened().resolve(({ target }) => + target.decoded( + new Opened({ id: "team-1" }), + (opened) => opened.loading.decoded(new Loading({})) + ) ) - ) - } - }, - opened: {} + } + }, + opened: {} + } }) }) it("only exposes initial on compound and parallel state builders", () => { base.handle({ - closed: { - on: { - Open: (to) => - to.full.opened().resolve(({ target }) => { - expect(to.full.opened.initial).type.not.toBeAssignableTo<() => unknown>() - expect(target).type.toHaveProperty("initial") - expect(target.initial.decoded).type.not.toBeCallableWith() - expect(target.initial.decoded).type.toBeCallableWith(new Opened({ id: "team-1" })) - expect(target.initial.from).type.toBeCallableWith({ id: "team-1" }) - return target.decoded( - new Opened({ id: "team-1" }), - (opened) => opened.loading.decoded(new Loading({})) - ) - }) + states: { + closed: { + on: { + Open: (to) => + to.branch.opened().resolve(({ target }) => { + expect(to.branch.opened.initial).type.not.toBeAssignableTo<() => unknown>() + expect(target).type.toHaveProperty("initial") + expect(target.initial.decoded).type.not.toBeCallableWith() + expect(target.initial.decoded).type.toBeCallableWith(new Opened({ id: "team-1" })) + expect(target.initial.from).type.toBeCallableWith({ id: "team-1" }) + return target.decoded( + new Opened({ id: "team-1" }), + (opened) => opened.loading.decoded(new Loading({})) + ) + }) + } } } }) diff --git a/packages/effect-machine/typetest/machine/Inspection.tst.ts b/packages/effect-machine/typetest/machine/Inspection.tst.ts index 00afdfe..8644189 100644 --- a/packages/effect-machine/typetest/machine/Inspection.tst.ts +++ b/packages/effect-machine/typetest/machine/Inspection.tst.ts @@ -8,44 +8,57 @@ describe("Machine inspection", () => { class Running extends Schema.TaggedClass("Running")("Running", {}) {} class Reset extends Schema.TaggedClass("Reset")("Reset", {}) {} - const States = Machine.states({ - root: { - schema: Root, - initial: "idle", - states: { - idle: Idle, - recent: { type: "history" } + const States = Machine.state({ + initial: "root", + states: { + root: { + schema: Root, + initial: "idle", + states: { + idle: Idle, + recent: { type: "history" } + } } } }) - const initial: Machine.Machine.Snapshot = { - path: "root", - value: new Root({}), - state: { path: "root.idle", value: new Idle({}) } + const initial: Machine.Snapshot = { + path: "" as const, + value: undefined, + state: { + path: "root", + value: new Root({}), + state: { path: "root.idle", value: new Idle({}) } + } } const machine = Machine.make({ - states: States.states, - events: Machine.events(Reset), - initial: (to) => - to.root.initial.resolve(({ target }) => target.decoded(new Root({}), (root) => root.idle.decoded(new Idle({})))) + root: States, + events: Machine.eventsFromSchemas(Reset), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => to.root.decoded(new Root({}), (root) => root.idle.decoded(new Idle({})))) + ) }).handle({ - root: { - on: { - Reset: (to) => to.none + states: { + root: { + on: { + Reset: (to) => to.none + } } } }) it("exposes one closed operational protocol from prepared machines", () => { - const FlatStates = Machine.states({ Idle }) + const FlatStates = Machine.state({ initial: "Idle", states: { Idle } }) const executable = Machine.make({ - states: FlatStates.states, - events: Machine.events(Reset), - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: FlatStates, + events: Machine.eventsFromSchemas(Reset), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }).handle({ - Idle: { - on: { - Reset: (to) => to.none + states: { + Idle: { + on: { + Reset: (to) => to.none + } } } }) @@ -69,11 +82,11 @@ describe("Machine inspection", () => { it("preserves state paths for structural inspection", () => { expect(Machine.inputEventSchemas(machine)).type.toBe() const nodes = Machine.stateNodes(machine) - expect(nodes[0]!.path).type.toBe<"root" | "root.idle" | "root.recent">() + expect(nodes[0]!.path).type.toBe<"" | "root" | "root.idle" | "root.recent">() expect(nodes.find((node) => node.type === "history")!.path).type.toBe<"root.recent">() - expect(nodes.find((node) => node.type === "history")!.parent).type.toBe<"root" | "root.idle">() - expect(nodes.find((node) => node.type === "atomic")!.path).type.toBe<"root" | "root.idle">() - expect(Machine.configuration(machine, initial)[0]!.path).type.toBe<"root" | "root.idle">() + expect(nodes.find((node) => node.type === "history")!.parent).type.toBe<"" | "root" | "root.idle">() + expect(nodes.find((node) => node.type === "atomic")!.path).type.toBe<"" | "root" | "root.idle">() + expect(Machine.configuration(machine, initial)[0]!.path).type.toBe<"" | "root" | "root.idle">() }) it("narrows every compiled state-node property from its type", () => { @@ -136,78 +149,90 @@ describe("Machine inspection", () => { }) it("keeps choice initial paths while configuration remains active-only", () => { - const ChoiceStates = Machine.states({ - Flow: { - schema: Root, - initial: "Routing", - states: { - Routing: { type: "choice" }, - Ready: Idle + const ChoiceStates = Machine.state({ + initial: "Flow", + states: { + Flow: { + schema: Root, + initial: "Routing", + states: { + Routing: { type: "choice" }, + Ready: Idle + } } } }) const choiceMachine = Machine.make({ - states: ChoiceStates.states, - events: Machine.events(), - initial: (to) => to.Flow.initial.resolve(({ target }) => (target.decoded(new Root({}), (flow) => flow.Routing()))) + root: ChoiceStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.Flow.decoded(new Root({}), (flow) => flow.Routing())))) }) const flow = Machine.stateNodes(choiceMachine).find((node) => node.type === "compound")! const routing = Machine.stateNodes(choiceMachine).find((node) => node.type === "choice")! - expect(flow.path).type.toBe<"Flow" | "Flow.Ready">() - expect(flow.initial).type.toBe<"Flow" | "Flow.Ready" | "Flow.Routing">() + expect(flow.path).type.toBe<"" | "Flow" | "Flow.Ready">() + expect(flow.initial).type.toBe<"" | "Flow" | "Flow.Ready" | "Flow.Routing">() expect<"Flow.Routing">().type.toBeAssignableTo() expect(routing.path).type.toBe<"Flow.Routing">() - expect(routing.parent).type.toBe<"Flow" | "Flow.Ready">() + expect(routing.parent).type.toBe<"" | "Flow" | "Flow.Ready">() - const settled: Machine.Machine.Snapshot = { - path: "Flow", - value: new Root({}), - state: { path: "Flow.Ready", value: new Idle({}) } + const settled: Machine.Snapshot = { + path: "", + value: undefined, + state: { + path: "Flow", + value: new Root({}), + state: { path: "Flow.Ready", value: new Idle({}) } + } } const configuration = Machine.configuration(choiceMachine, settled) - expect<(typeof configuration)[number]["path"]>().type.toBe<"Flow" | "Flow.Ready">() + expect<(typeof configuration)[number]["path"]>().type.toBe<"" | "Flow" | "Flow.Ready">() expect<(typeof configuration)[number]["type"]>().type.toBe<"atomic" | "compound" | "parallel" | "final">() expect>().type.toBe() }) it("preserves source paths and event tags for transition inspection", () => { const initialDefinition = Machine.initialDefinition(machine) - expect(initialDefinition.target).type.toBe<"root">() - expect(initialDefinition.selection.path).type.toBe<"root">() + expect(initialDefinition.target).type.toBe<"">() + expect(initialDefinition.selection.path).type.toBe<"">() expect(initialDefinition.selection.kind).type.toBe<"state" | "initial">() expect(initialDefinition.selection.scope).type.toBe<"initial">() const definition = Machine.transitionDefinitions(machine)[0]! - expect(definition.source).type.toBe<"root" | "root.idle" | "root.recent">() + expect(definition.source).type.toBe<"" | "root" | "root.idle" | "root.recent">() expect(definition.acceptance).type.toBe() if (definition.trigger.type === "event") { expect(definition.trigger.event).type.toBe<"Reset">() } expect(definition.branches).type.toBe< - ReadonlyArray> + ReadonlyArray> >() - expect(definition.branches[0]!.selection.path).type.toBe<"root" | "root.idle" | "root.recent" | undefined>() + expect(definition.branches[0]!.selection.path).type.toBe<"" | "root" | "root.idle" | "root.recent" | undefined>() }) it("requires statically selected transitions", () => { - const FlatStates = Machine.states({ idle: Idle, running: Running }) + const FlatStates = Machine.state({ initial: "idle", states: { idle: Idle, running: Running } }) const flat = Machine.make({ - states: FlatStates.states, - events: Machine.events(Reset), - initial: (to) => to.idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: FlatStates, + events: Machine.eventsFromSchemas(Reset), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) }) flat.handle({ - idle: { - on: { - Reset: (to) => to.full.running().resolve(({ target }) => target.decoded(new Running({}))) + states: { + idle: { + on: { + Reset: (to) => to.branch.running().resolve(({ target }) => target.decoded(new Running({}))) + } } } }) - expect(flat.handle).type.not.toBeCallableWith({ idle: { on: { Reset: () => undefined } } }) + expect(flat.handle).type.not.toBeCallableWith({ states: { idle: { on: { Reset: () => undefined } } } }) expect(flat.handle).type.not.toBeCallableWith({ - idle: { on: { Reset: { target: () => undefined, resolve: () => undefined } } } + states: { + idle: { on: { Reset: { target: () => undefined, resolve: () => undefined } } } + } }) }) }) diff --git a/packages/effect-machine/typetest/machine/Machine.tst.ts b/packages/effect-machine/typetest/machine/Machine.tst.ts index 7df83be..7fb0ac5 100644 --- a/packages/effect-machine/typetest/machine/Machine.tst.ts +++ b/packages/effect-machine/typetest/machine/Machine.tst.ts @@ -70,68 +70,74 @@ describe("Machine", () => { readonly deferredMessage: string }>()("test/Machine/DeferredRequirement") {} - const UpStates = Machine.states({ - up: { - schema: Up, - type: "parallel", - states: { - auth: { - schema: Auth, - initial: "signedOut", - states: { - signedOut: SignedOut, - signedIn: SignedIn - } - }, - sync: { - schema: Sync, - initial: "idle", - states: { - idle: SyncIdle, - syncing: Syncing + const UpStates = Machine.state({ + initial: "up", + states: { + up: { + schema: Up, + type: "parallel", + states: { + auth: { + schema: Auth, + initial: "signedOut", + states: { + signedOut: SignedOut, + signedIn: SignedIn + } + }, + sync: { + schema: Sync, + initial: "idle", + states: { + idle: SyncIdle, + syncing: Syncing + } } } - } - }, - down: Down + }, + down: Down + } }) type DownInvokeSelector = Machine.Machine.InvokeSelector< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], readonly [], "down" > type DownInvoke = Machine.Machine.InvokeBuilderInput< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], readonly [], "down" > - const NestedParallelStates = Machine.states({ - root: { - schema: Up, - initial: "idle", - states: { - idle: Down, - work: { - schema: Payment, - type: "parallel", - states: { - auth: { - schema: Auth, - initial: "signedOut", - states: { - signedOut: SignedOut, - signedIn: SignedIn - } - }, - sync: { - schema: Sync, - initial: "idle", - states: { - idle: SyncIdle, - syncing: Syncing + const NestedParallelStates = Machine.state({ + initial: "root", + states: { + root: { + schema: Up, + initial: "idle", + states: { + idle: Down, + work: { + schema: Payment, + type: "parallel", + states: { + auth: { + schema: Auth, + initial: "signedOut", + states: { + signedOut: SignedOut, + signedIn: SignedIn + } + }, + sync: { + schema: Sync, + initial: "idle", + states: { + idle: SyncIdle, + syncing: Syncing + } } } } @@ -146,13 +152,13 @@ describe("Machine", () => { : never type IsCallable = A extends (...args: ReadonlyArray) => any ? true : false - type UpInitialSelection = Machine.Machine.InitialSelector["up"]["initial"] - type DownInitialSelection = ReturnType["down"]> + type UpInitialSelection = Machine.Machine.InitialSelector["up"]["initial"] + type DownInitialSelection = ReturnType["down"]> const UpInitial = null as unknown as Machine.Machine.SelectionBuilder const DownInitial = null as unknown as Machine.Machine.SelectionBuilder type SignInContext = Machine.Machine.HandlerContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], [], "down", @@ -162,7 +168,7 @@ describe("Machine", () => { > type SignedOutContext = Machine.Machine.HandlerContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], [], "up.auth.signedOut", @@ -172,7 +178,7 @@ describe("Machine", () => { > type AuthContext = Machine.Machine.HandlerContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], [], "up.auth", @@ -182,7 +188,7 @@ describe("Machine", () => { > type NestedIdleContext = Machine.Machine.HandlerContext< - typeof NestedParallelStates.states, + { readonly "": typeof NestedParallelStates.node }, readonly [typeof SignIn], [], "root.idle", @@ -192,7 +198,7 @@ describe("Machine", () => { > type NestedActiveContext = Machine.Machine.HandlerContext< - typeof NestedParallelStates.states, + { readonly "": typeof NestedParallelStates.node }, readonly [typeof SignIn], [], "root.work.auth.signedOut", @@ -202,7 +208,8 @@ describe("Machine", () => { > it("states preserves literal state paths", () => { - expect>().type.toBe< + expect>().type.toBe< + | "" | "up" | "up.auth" | "up.auth.signedOut" @@ -223,14 +230,14 @@ describe("Machine", () => { id: Schema.String }) {} const State = Schema.TaggedStruct("ConstructedEventState", {}) - const States = Machine.states({ State }) - const Events = Machine.events(Event) - const InternalEvents = Machine.internalEvents(Internal) + const States = Machine.state({ initial: "State", states: { State } }) + const Events = Machine.eventsFromSchemas(Event) + const InternalEvents = Machine.internalEventsFromSchemas(Internal) Machine.make({ - states: States.states, + root: States, events: Events, internalEvents: InternalEvents, - initial: (to) => to.State().resolve(({ target }) => (target.from())) + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.State.from()))) }) expect(Events.Tick({ amount: 1 })).type.toBe< @@ -264,7 +271,7 @@ describe("Machine", () => { } expect< Machine.Machine.StateActionContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], [], "up.auth.signedOut" @@ -272,7 +279,7 @@ describe("Machine", () => { >().type.toBe() expect< Machine.Machine.InvokeContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], [], "up.auth.signedOut" @@ -280,7 +287,7 @@ describe("Machine", () => { >().type.toBe() expect< Machine.Machine.InvokeContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], [], "up.auth.signedOut" @@ -288,7 +295,7 @@ describe("Machine", () => { >().type.toBe() expect< Machine.Machine.AlwaysContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], [], "up.auth.signedOut" @@ -296,7 +303,7 @@ describe("Machine", () => { >().type.toBe() expect< Machine.Machine.DoneContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], [], "up.auth.signedOut" @@ -304,14 +311,14 @@ describe("Machine", () => { >().type.toBe() expect< Machine.Machine.FinalOutputContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], "up.auth.signedOut" >["ancestors"] >().type.toBe() expect< Machine.Machine.ParallelOutputContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], "up.auth.signedOut" >["ancestors"] @@ -323,14 +330,14 @@ describe("Machine", () => { expect< Machine.Machine.ParentStateValue< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, "up.auth.signedOut" | "down" > >().type.toBe() }) it("states selects state values and snapshots with type-safe paths", () => { - const snapshot = UpInitial.decoded( + const childSnapshot = UpInitial.decoded( new Up({ id: "up-1" }), (up) => up @@ -344,6 +351,8 @@ describe("Machine", () => { ) ) + const snapshot = { path: "" as const, value: undefined, state: childSnapshot } + expect(UpStates.get(snapshot, "up")).type.toBe>() expect(UpStates.get(snapshot, "up.auth.signedOut")).type.toBe>() expect(UpStates.getWithParents(snapshot, "up.auth.signedOut")).type.toBe< @@ -378,7 +387,7 @@ describe("Machine", () => { > >() expect(UpStates.getSnapshot(snapshot, "up.auth")).type.toBe< - Option.Option> + Option.Option> >() expect(UpStates.matches(snapshot, "up.sync.idle")).type.toBe() expect(UpStates.get).type.not.toBeCallableWith(snapshot, "up.missing") @@ -388,7 +397,7 @@ describe("Machine", () => { const authSnapshot = Option.getOrThrow(UpStates.getSnapshot(upSnapshot, "up.auth")) expect(UpStates.get(upSnapshot, "up.auth.signedOut")).type.toBe>() expect(UpStates.getSnapshot(upSnapshot, "up.sync")).type.toBe< - Option.Option> + Option.Option> >() expect(UpStates.matches(authSnapshot, "up.auth.signedIn")).type.toBe() expect(UpStates.get(authSnapshot, "up.auth")).type.toBe>() @@ -403,58 +412,65 @@ describe("Machine", () => { }) it("states preserves declared compound initial keys", () => { - expect().type.toBe<"signedOut">() - expect().type.toBe<"idle">() + expect().type.toBe<"signedOut">() + expect().type.toBe<"idle">() }) it("make accepts defined states", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) - expect(machine.states).type.toBe() + expect(machine.root).type.toBe() }) it("make rejects raw decoded initial states", () => { expect(Machine.make).type.not.toBeCallableWith({ - states: UpStates.states, - events: Machine.events(SignIn), + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), initial: () => new Down({}) }) }) it("encodes and decodes snapshots with typed effects", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) - const encoded = Machine.encodeSnapshot(machine, DownInitial.decoded(new Down({}))) + const encoded = Machine.encodeSnapshot(machine, { + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }) expect>().type.toBe() expect>().type.toBe() expect>().type.toBe() const decoded = Machine.decodeSnapshot(machine, { + version: 2, _tag: "MachineSnapshot", - active: [{ path: "down", value: { _tag: "Down" } }] + active: [{ path: "" }, { path: "down", value: { _tag: "Down" } }] }) - expect>().type.toBe>() + expect>().type.toBe>() expect>().type.toBe() expect>().type.toBe() }) it("planInitial is synchronous at the transition boundary", () => { const definition = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) const machine = definition.handle({ - down: { - entry: () => {} + states: { + down: { + entry: () => {} + } } }) @@ -463,8 +479,8 @@ describe("Machine", () => { expect>().type.toBe() expect["commands"]>().type.toBe>() - const selectDown = (to: Machine.Machine.InitialSelector) => to.down() - expect(selectDown(null as unknown as Machine.Machine.InitialSelector).resolve).type.not + const selectDown = (to: Machine.Machine.InitialSelector) => to.down() + expect(selectDown(null as unknown as Machine.Machine.InitialSelector).resolve).type.not .toBeCallableWith(( { target }: { readonly input: void @@ -472,7 +488,9 @@ describe("Machine", () => { } ) => Effect.succeed(target.decoded(new Down({})))) expect(definition.handle).type.not.toBeCallableWith({ - down: { entry: () => Effect.void } + states: { + down: { entry: () => Effect.void } + } }) expect(machine).type.not.toHaveProperty("handle") }) @@ -480,25 +498,27 @@ describe("Machine", () => { it("types the closed enqueue protocol without exposing Effects", () => { const worker = Machine.childAddress("worker") const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - emittedEvents: Machine.emittedEvents(SignInCompleted), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + emittedEvents: Machine.emittedEventsFromSchemas(SignInCompleted), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }).handle({ - down: { - on: { - SignIn: (to) => - to.full.down().resolve(({ event, target }, enqueue) => { - expect(enqueue.raise).type.toBeCallableWith(event) - expect(enqueue.raise).type.not.toBeCallableWith(new Down({})) - expect(enqueue.emit).type.toBeCallableWith(new SignInCompleted({ userId: event.userId })) - expect(enqueue.emit).type.not.toBeCallableWith(event) - expect(enqueue.sendTo).type.toBeCallableWith(worker, event) - expect(enqueue.sendTo).type.not.toBeCallableWith(worker, new Down({})) - expect(enqueue.stop).type.toBeCallableWith(worker) - expect(enqueue.stop).type.not.toBeCallableWith("worker") - return target.decoded(new Down({})) - }) + states: { + down: { + on: { + SignIn: (to) => + to.branch.down().resolve(({ event, target }, enqueue) => { + expect(enqueue.raise).type.toBeCallableWith(event) + expect(enqueue.raise).type.not.toBeCallableWith(new Down({})) + expect(enqueue.emit).type.toBeCallableWith(new SignInCompleted({ userId: event.userId })) + expect(enqueue.emit).type.not.toBeCallableWith(event) + expect(enqueue.sendTo).type.toBeCallableWith(worker, event) + expect(enqueue.sendTo).type.not.toBeCallableWith(worker, new Down({})) + expect(enqueue.stop).type.toBeCallableWith(worker) + expect(enqueue.stop).type.not.toBeCallableWith("worker") + return target.decoded(new Down({})) + }) + } } } }) @@ -545,17 +565,19 @@ describe("Machine", () => { it("invoke infers one-shot outputs from factories in the owning state", () => { expect(Machine).type.not.toHaveProperty("invoke") const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) machine.handle({ - down: { - invoke: (from) => - from.effect("valid", () => Effect.succeed(1)).onDone((to) => - to.full.down().resolve(({ target }) => target.decoded(new Down({}))) - ) + states: { + down: { + invoke: (from) => + from.effect("valid", () => Effect.succeed(1)).onDone((to) => + to.branch.down().resolve(({ target }) => target.decoded(new Down({}))) + ) + } } }) const incomplete = (from: DownInvokeSelector) => from.effect("invalid", () => Effect.succeed(1)) @@ -566,18 +588,20 @@ describe("Machine", () => { it("contextually types dynamic Effect sources through the fluent invocation builder", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) machine.handle({ - down: { - invoke: (from) => - from.effect("dynamic", ({ state }) => { - expect(state).type.toBe() - return Effect.succeed(state._tag) - }).onDone((to) => to.full.down().resolve(({ target }) => target.decoded(new Down({})))) + states: { + down: { + invoke: (from) => + from.effect("dynamic", ({ state }) => { + expect(state).type.toBe() + return Effect.succeed(state._tag) + }).onDone((to) => to.branch.down().resolve(({ target }) => target.decoded(new Down({})))) + } } }) }) @@ -590,25 +614,27 @@ describe("Machine", () => { Effect.as(EntryRequirement, 1 as const) ).pipe(Stream.concat(Stream.fail(new StreamFailure()))) const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => target.decoded(new Down({}))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.down.decoded(new Down({})))) }) const handled = machine.handle({ - down: { - invoke: (from) => - from.stream("updates", ({ state }) => { - expect(state).type.toBe() - return updates - }).onElement((to) => - to.none.resolve(({ element }) => { - expect(element).type.toBe<1>() - }) - ).onDone((to) => to.none).onFailure((to) => - to.none.resolve(({ error }) => { - expect(error).type.toBe() - }) - ) + states: { + down: { + invoke: (from) => + from.stream("updates", ({ state }) => { + expect(state).type.toBe() + return updates + }).onElement((to) => + to.none.resolve(({ element }) => { + expect(element).type.toBe<1>() + }) + ).onDone((to) => to.none).onFailure((to) => + to.none.resolve(({ error }) => { + expect(error).type.toBe() + }) + ) + } } }) @@ -616,20 +642,22 @@ describe("Machine", () => { expect>().type.not.toBe() const staticHandled = machine.handle({ - down: { - invoke: (from) => - from.stream("static-updates", ({ state }) => { - expect(state).type.toBe() - return updates - }).onElement((to) => - to.none.resolve(({ element }) => { - expect(element).type.toBe<1>() - }) - ).onDone((to) => to.none).onFailure((to) => - to.none.resolve(({ error }) => { - expect(error).type.toBe() - }) - ) + states: { + down: { + invoke: (from) => + from.stream("static-updates", ({ state }) => { + expect(state).type.toBe() + return updates + }).onElement((to) => + to.none.resolve(({ element }) => { + expect(element).type.toBe<1>() + }) + ).onDone((to) => to.none).onFailure((to) => + to.none.resolve(({ error }) => { + expect(error).type.toBe() + }) + ) + } } }) @@ -639,7 +667,7 @@ describe("Machine", () => { it("requires only reachable Stream handlers", () => { type Context = Machine.Machine.InvokeContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], readonly [], "down" @@ -664,18 +692,20 @@ describe("Machine", () => { } const load = (userId: string) => Effect.fail(new LoadFailure()).pipe(Effect.as({ userId })) const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) machine.handle({ - down: { - invoke: (from) => - from.effect("dynamic", ({ state }) => { - expect(state).type.toBe() - return load(state._tag) - }).onDone((to) => to.none).onFailure((to) => to.none) + states: { + down: { + invoke: (from) => + from.effect("dynamic", ({ state }) => { + expect(state).type.toBe() + return load(state._tag) + }).onDone((to) => to.none).onFailure((to) => to.none) + } } }) }) @@ -685,33 +715,37 @@ describe("Machine", () => { readonly _tag = "LoadFailure" } const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) machine.handle({ - down: { - invoke: ( - from - ) => [ - from.effect("success", ({ state }) => Effect.succeed(state._tag)).onDone((to) => to.none), - from.effect( - "failure", - ({ state }) => Effect.fail(new LoadFailure()).pipe(Effect.annotateLogs("state", state._tag)) - ).onFailure((to) => to.none), - from.effect("never", ({ state }) => Effect.never.pipe(Effect.annotateLogs("state", state._tag))), - from.effect("requirements", ({ state }) => Effect.as(EntryRequirement, state._tag)).onDone((to) => to.none) - ] + states: { + down: { + invoke: ( + from + ) => [ + from.effect("success", ({ state }) => Effect.succeed(state._tag)).onDone((to) => to.none), + from.effect( + "failure", + ({ state }) => Effect.fail(new LoadFailure()).pipe(Effect.annotateLogs("state", state._tag)) + ).onFailure((to) => to.none), + from.effect("never", ({ state }) => Effect.never.pipe(Effect.annotateLogs("state", state._tag))), + from.effect("requirements", ({ state }) => Effect.as(EntryRequirement, state._tag)).onDone((to) => to.none) + ] + } } }) const requirementsHandled = machine.handle({ - down: { - invoke: (from) => - from.effect("requirements-only", ({ state }) => Effect.as(EntryRequirement, state._tag)).onDone((to) => - to.none - ) + states: { + down: { + invoke: (from) => + from.effect("requirements-only", ({ state }) => Effect.as(EntryRequirement, state._tag)).onDone((to) => + to.none + ) + } } }) @@ -722,7 +756,7 @@ describe("Machine", () => { it("rejects unreachable and missing handlers for dynamic Effect invocations", () => { type Context = Machine.Machine.InvokeContext< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn], readonly [], "down" @@ -748,23 +782,25 @@ describe("Machine", () => { it("separates public input events from the complete internal protocol", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - internalEvents: Machine.internalEvents(SignInCompleted), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + internalEvents: Machine.internalEventsFromSchemas(SignInCompleted), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }).handle({ - down: { - on: { - SignIn: (to) => - to.none.resolve(({ event }) => { - expect(event).type.toBe() - return undefined - }), - SignInCompleted: (to) => - to.none.resolve(({ event }) => { - expect(event).type.toBe() - return undefined - }) + states: { + down: { + on: { + SignIn: (to) => + to.none.resolve(({ event }) => { + expect(event).type.toBe() + return undefined + }), + SignInCompleted: (to) => + to.none.resolve(({ event }) => { + expect(event).type.toBe() + return undefined + }) + } } } }) @@ -779,34 +815,34 @@ describe("Machine", () => { expect[0]>().type.toBe>() expect(Machine.plan).type.toBeCallableWith( machine, - DownInitial.decoded(new Down({})), + { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, new SignIn({ userId: "user-1" }) ) expect(Machine.plan).type.not.toBeCallableWith( machine, - DownInitial.decoded(new Down({})), + { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, new SignInCompleted({ userId: "user-1" }) ) expect(Machine.can).type.toBeCallableWith( machine, - DownInitial.decoded(new Down({})), + { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, new SignInCompleted({ userId: "user-1" }) ) expect(Machine.can(machine)).type.toBeCallableWith( - DownInitial.decoded(new Down({})), + { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, new SignInCompleted({ userId: "user-1" }) ) expect(Machine.can(machine)).type.not.toBeCallableWith( - DownInitial.decoded(new Down({})), + { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, { _tag: "Undeclared" } ) - const publicEvents = Machine.events(SignIn) - const overlappingInternalEvents = Machine.internalEvents(SignIn) + const publicEvents = Machine.eventsFromSchemas(SignIn) + const overlappingInternalEvents = Machine.internalEventsFromSchemas(SignIn) expect(Machine.make).type.not.toBeCallableWith({ - states: UpStates.states, + root: UpStates, events: publicEvents, internalEvents: overlappingInternalEvents, - initial: (to: Machine.Machine.InitialSelector) => to.down() + initialConfiguration: (root: Machine.Machine.RootConfigurationBuilderInput) => root }) expect(Machine.events).type.not.toBeCallableWith(SignIn, SignIn) expect(Machine.internalEvents).type.not.toBeCallableWith(SignInCompleted, SignInCompleted) @@ -816,21 +852,21 @@ describe("Machine", () => { const failure = () => Effect.fail("unavailable" as const) const erasedFailure = () => Effect.fail("unavailable" as const) as Effect.Effect const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - internalEvents: Machine.internalEvents(SignInCompleted), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + internalEvents: Machine.internalEventsFromSchemas(SignInCompleted), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) type Selector = Machine.Machine.InvokeSelector< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn, typeof SignInCompleted], readonly [], "down", readonly [typeof SignIn] > type Invoke = Machine.Machine.InvokeBuilderInput< - typeof UpStates.states, + { readonly "": typeof UpStates.node }, readonly [typeof SignIn, typeof SignInCompleted], readonly [], "down", @@ -844,29 +880,34 @@ describe("Machine", () => { expect(erased).type.not.toBeAssignableTo() expect(from.effect("unreachable-failure", () => Effect.succeed("user-1"))).type.not.toHaveProperty("onFailure") machine.handle({ - down: { - invoke: (from) => from.effect("erased-failure", erasedFailure).onFailure((to) => to.none) + states: { + down: { + invoke: (from) => from.effect("erased-failure", erasedFailure).onFailure((to) => to.none) + } } }) }) it("constructs sibling targets from destructured source fields", () => { - const states = Machine.states({ source: Up, target: RetaggedUp }) + const states = Machine.state({ initial: "source", states: { source: Up, target: RetaggedUp } }) Machine.make({ - states: states.states, - events: Machine.events(SignIn), - initial: (to) => to.source().resolve(({ target }) => (target.decoded(new Up({ id: "up-1" })))) + root: states, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.source.decoded(new Up({ id: "up-1" }))))) }).handle({ - source: { - on: { - SignIn: (to) => - to.full.target().resolve(({ state, target }) => { - const { _tag: _, ...fields } = state - expect(target.from).type.toBeCallableWith({ ...fields, attempt: 1 }) - expect(target.from).type.not.toBeCallableWith(fields) - expect(target.from).type.not.toBeCallableWith({ ...fields, attempt: "invalid" }) - return target.from({ ...fields, attempt: 1 }) - }) + states: { + source: { + on: { + SignIn: (to) => + to.branch.target().resolve(({ state, target }) => { + const { _tag: _, ...fields } = state + expect(target.from).type.toBeCallableWith({ ...fields, attempt: 1 }) + expect(target.from).type.not.toBeCallableWith(fields) + expect(target.from).type.not.toBeCallableWith({ ...fields, attempt: "invalid" }) + return target.from({ ...fields, attempt: 1 }) + }) + } } } }) @@ -874,58 +915,67 @@ describe("Machine", () => { it("child invocation composes complete machines with type-safe protocols", () => { const ChildInput = Schema.Struct({ userId: Schema.String }) - const childStates = Machine.states({ - done: { - schema: Down, - type: "final", - output: SignIn + const childStates = Machine.state({ + initial: "done", + states: { + done: { + schema: Down, + type: "final", + output: SignIn + } } }) const child = Machine.make({ - states: childStates.states, - events: Machine.events(SignIn), - emittedEvents: Machine.emittedEvents(SignIn), + root: childStates, + events: Machine.eventsFromSchemas(SignIn), + emittedEvents: Machine.emittedEventsFromSchemas(SignIn), input: ChildInput, - initial: (to) => to.done().resolve(({ target }) => (target.decoded(new Down({})))) + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.done.decoded(new Down({}))))) }).handle({ - done: { - output: () => new SignIn({ userId: "child" }) + states: { + done: { + output: () => new SignIn({ userId: "child" }) + } } }) const Child = Machine.child("child", child) expect(Machine.sendTo).type.toBeCallableWith(Child, new SignIn({ userId: "child" })) expect(Machine.sendTo).type.not.toBeCallableWith(Child, new Down({})) const parent = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) type ParentInvokeSelector = Machine.Machine.InvokeSelector< - typeof UpStates.states, + Machine.Machine.States, readonly [typeof SignIn], readonly [], "down" > parent.handle({ - down: { - invoke: (from: ParentInvokeSelector) => - from.child(Child, { input: { userId: "child" } }).onSnapshot((to) => - to.none.resolve(({ snapshot }) => { - expect(snapshot.state).type.toBe>() - return undefined - }) - ).onDone((to) => - to.none.resolve(({ output, state }) => { - expect(output).type.toBe() - expect(state).type.toBe() - return undefined - }) - ) + states: { + down: { + invoke: (from: ParentInvokeSelector) => + from.child(Child, { input: { userId: "child" } }).onSnapshot((to) => + to.none.resolve(({ snapshot }) => { + expect(snapshot.state).type.toBe>() + return undefined + }) + ).onDone((to) => + to.none.resolve(({ output, state }) => { + expect(output).type.toBe() + expect(state).type.toBe() + return undefined + }) + ) + } } }) expect(parent.handle).type.not.toBeCallableWith({ - down: { invoke: { child: Child, onDone: () => undefined } } + states: { + down: { invoke: { child: Child, onDone: () => undefined } } + } }) const childBuilder = (null as unknown as ParentInvokeSelector).child(Child, { input: { userId: "child" } }) @@ -935,22 +985,24 @@ describe("Machine", () => { it("types nested invocation output handlers against their owning state", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) machine.handle({ - up: { - states: { - auth: { - states: { - signedOut: { - invoke: (from) => from.effect("nested", () => Effect.succeed(Option.some(1))).onDone((to) => to.none) + states: { + up: { + states: { + auth: { + states: { + signedOut: { + invoke: (from) => from.effect("nested", () => Effect.succeed(Option.some(1))).onDone((to) => to.none) + } } - } - }, - sync: {} + }, + sync: {} + } } } }) @@ -991,13 +1043,15 @@ describe("Machine", () => { it("start exposes machine infrastructure failure channels", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }).handle({ - down: { - on: { - SignIn: (to) => to.full.down().resolve(({ target }) => target.decoded(new Down({}))) + states: { + down: { + on: { + SignIn: (to) => to.branch.down().resolve(({ target }) => target.decoded(new Down({}))) + } } } }) @@ -1032,39 +1086,47 @@ describe("Machine", () => { it("plan and getters require snapshots", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) expect(Machine.plan).type.toBeCallableWith( machine, - DownInitial.decoded(new Down({})), + { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, new SignIn({ userId: "user-1" }) ) const planned = Machine.plan( machine, - DownInitial.decoded(new Down({})), + { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, new SignIn({ userId: "user-1" }) ) expect>().type.toBe< Machine.InfiniteTransitionError | Machine.MachineSchemaDecodeError >() - expect(Machine.enabled).type.toBeCallableWith(machine, DownInitial.decoded(new Down({}))) - expect(Machine.isFinal).type.toBeCallableWith(machine, DownInitial.decoded(new Down({}))) + expect(Machine.enabled).type.toBeCallableWith(machine, { + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }) + expect(Machine.isFinal).type.toBeCallableWith(machine, { + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }) const can = Machine.can( machine, - DownInitial.decoded(new Down({})), + { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, new SignIn({ userId: "user-1" }) ) const canMachine = Machine.can(machine) expect>().type.toBe() expect>().type.toBe() expect(canMachine).type.toBeCallableWith( - DownInitial.decoded(new Down({})), + { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, new SignIn({ userId: "user-1" }) ) @@ -1077,23 +1139,27 @@ describe("Machine", () => { it("handlers reject raw decoded state returns", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) expect>().type.toBe() expect(machine.handle).type.not.toBeCallableWith({ - down: { - on: { - SignIn: () => new Down({}) + states: { + down: { + on: { + SignIn: () => new Down({}) + } } } }) expect(machine.handle).type.not.toBeCallableWith({ - down: { - on: { - SignIn: () => Effect.succeed(new Down({})) + states: { + down: { + on: { + SignIn: () => Effect.succeed(new Down({})) + } } } }) @@ -1101,173 +1167,197 @@ describe("Machine", () => { it("branching transitions infer unbounded named targets", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => target.decoded(new Down({}))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.down.decoded(new Down({})))) }) machine.handle({ - down: { - on: { - SignIn: (to) => - to.branches({ - recognized: { - title: "recognized user", - target: to.full.down() - }, - measured: { - title: "measured user id", - target: to.none - }, - named: { - title: "named user", - target: to.full.down() - }, - active: { - title: "active user", - target: to.none - } - }).resolve(({ event, select, state }) => { - expect(event).type.toBe() - expect(state).type.toBe() - expect(select.recognized.decoded).type.toBeCallableWith(new Down({})) - expect(select.measured).type.toBeCallableWith() - switch (event.userId.length) { - case 0: - return select.measured() - case 1: - return select.named.decoded(new Down({})) - case 2: - return select.active() - default: - return select.recognized.decoded(new Down({})) - } - }, { reenter: true }) + states: { + down: { + on: { + SignIn: (to) => + to.branches({ + recognized: { + title: "recognized user", + target: to.branch.down() + }, + measured: { + title: "measured user id", + target: to.none + }, + named: { + title: "named user", + target: to.branch.down() + }, + active: { + title: "active user", + target: to.none + } + }).resolve(({ event, select, state }) => { + expect(event).type.toBe() + expect(state).type.toBe() + expect(select.recognized.decoded).type.toBeCallableWith(new Down({})) + expect(select.measured).type.toBeCallableWith() + switch (event.userId.length) { + case 0: + return select.measured() + case 1: + return select.named.decoded(new Down({})) + case 2: + return select.active() + default: + return select.recognized.decoded(new Down({})) + } + }, { reenter: true }) + } } } }) machine.handle({ - down: { - on: { - SignIn: (to) => to.none.reenter() - }, - // @ts-expect-error! - always: (to) => to.none.reenter() + states: { + down: { + on: { + SignIn: (to) => to.none.reenter() + }, + // @ts-expect-error! + always: (to) => to.none.reenter() + } } }) machine.handle({ - down: { - on: { - // @ts-expect-error! - SignIn: (to) => to.full.up() + states: { + down: { + on: { + // @ts-expect-error! + SignIn: (to) => to.branch.up() + } } } }) machine.handle({ - down: { - on: { - SignIn: (to) => - to.branches({ - accepted: { target: to.full.down() }, - consumed: { target: to.none } - }).resolve((context) => { - expect(context.decline()).type.toBe() - if (context.event.userId === "decline") return context.decline() - if (context.event.userId === "consume") return context.select.consumed() - return context.select.accepted.decoded(new Down({})) - }, { declinable: true }) + states: { + down: { + on: { + SignIn: (to) => + to.branches({ + accepted: { target: to.branch.down() }, + consumed: { target: to.none } + }).resolve((context) => { + expect(context.decline()).type.toBe() + if (context.event.userId === "decline") return context.decline() + if (context.event.userId === "consume") return context.select.consumed() + return context.select.accepted.decoded(new Down({})) + }, { declinable: true }) + } } } }) machine.handle({ - down: { - on: { - SignIn: (to) => { - expect(to.none).type.not.toBeAssignableTo<() => unknown>() - return to.none.resolve((context) => { - expect(context).type.not.toHaveProperty("decline") - }) + states: { + down: { + on: { + SignIn: (to) => { + expect(to.none).type.not.toBeAssignableTo<() => unknown>() + return to.none.resolve((context) => { + expect(context).type.not.toHaveProperty("decline") + }) + } } } } }) machine.handle({ - down: { - on: { - // @ts-expect-error! - SignIn: (to) => to.none.resolve(({ decline }) => decline()) + states: { + down: { + on: { + // @ts-expect-error! + SignIn: (to) => to.none.resolve(({ decline }) => decline()) + } } } }) const widenedDeclinable = true as boolean machine.handle({ - down: { - on: { - // @ts-expect-error! - SignIn: (to) => to.none.resolve(() => undefined, { declinable: widenedDeclinable }) + states: { + down: { + on: { + // @ts-expect-error! + SignIn: (to) => to.none.resolve(() => undefined, { declinable: widenedDeclinable }) + } } } }) machine.handle({ - down: { - on: { - // @ts-expect-error! - SignIn: (to) => to.branches({ ignored: { target: to.none } }).resolve(() => undefined) + states: { + down: { + on: { + // @ts-expect-error! + SignIn: (to) => to.branches({ ignored: { target: to.none } }).resolve(() => undefined) + } } } }) machine.handle({ - down: { - on: { - SignIn: (to) => { - // @ts-expect-error! - return to.branches({}).resolve(() => { - throw new Error("unreachable") - }) + states: { + down: { + on: { + SignIn: (to) => { + // @ts-expect-error! + return to.branches({}).resolve(() => { + throw new Error("unreachable") + }) + } } } } }) machine.handle({ - down: { - on: { - SignIn: (to) => { - // @ts-expect-error! - return to.branches({ "": { target: to.none } }).resolve(() => { - throw new Error("unreachable") - }) + states: { + down: { + on: { + SignIn: (to) => { + // @ts-expect-error! + return to.branches({ "": { target: to.none } }).resolve(() => { + throw new Error("unreachable") + }) + } } } } }) machine.handle({ - down: { - on: { - SignIn: (to) => { - // @ts-expect-error! - return to.branches({ 0: { target: to.none } }).resolve(() => { - throw new Error("unreachable") - }) + states: { + down: { + on: { + SignIn: (to) => { + // @ts-expect-error! + return to.branches({ 0: { target: to.none } }).resolve(() => { + throw new Error("unreachable") + }) + } } } } }) const symbolBranch = Symbol("branch") machine.handle({ - down: { - on: { - SignIn: (to) => { - // @ts-expect-error! - return to.branches({ - valid: { target: to.none }, - [symbolBranch]: { target: to.none } - }).resolve(({ select }) => select.valid()) + states: { + down: { + on: { + SignIn: (to) => { + // @ts-expect-error! + return to.branches({ + valid: { target: to.none }, + [symbolBranch]: { target: to.none } + }).resolve(({ select }) => select.valid()) + } } } } @@ -1276,25 +1366,27 @@ describe("Machine", () => { it("handle accepts nested states through reserved states objects", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) machine.handle({ - up: { - states: { - auth: { - states: { - signedOut: { - on: { - SignIn: (to) => - to.local.signedIn().resolve(({ event, state, target }) => { - expect(event).type.toBe() - expect(state).type.toBe() - return target.decoded(new SignedIn({ userId: event.userId })) - }) - } + states: { + up: { + states: { + auth: { + states: { + signedOut: { + on: { + SignIn: (to) => + to.local.signedIn().resolve(({ event, state, target }) => { + expect(event).type.toBe() + expect(state).type.toBe() + return target.decoded(new SignedIn({ userId: event.userId })) + }) + } + } } } } @@ -1305,47 +1397,49 @@ describe("Machine", () => { it("handle accepts parent config and child config in the same object", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) machine.handle({ - up: { - entry: ({ event, state }) => { - const id: string = state.id - expect(event).type.toBe() - if (Machine.isInitialEvent(event)) { - expect(event._tag).type.toBe() - } else { - expect(event.userId).type.toBe() - } - void id - }, - always: (to) => - to.none.resolve(({ event }) => { + states: { + up: { + entry: ({ event, state }) => { + const id: string = state.id expect(event).type.toBe() - return undefined - }), - states: { - auth: { - states: { - signedOut: { - on: { - SignIn: (to) => - to.local.signedIn().resolve(({ event, target }) => - target.decoded(new SignedIn({ userId: event.userId })) - ) - } - } + if (Machine.isInitialEvent(event)) { + expect(event._tag).type.toBe() + } else { + expect(event.userId).type.toBe() } + void id }, - sync: { - states: { - idle: { - entry: ({ state }) => { - const tag: "SyncIdle" = state._tag - void tag + always: (to) => + to.none.resolve(({ event }) => { + expect(event).type.toBe() + return undefined + }), + states: { + auth: { + states: { + signedOut: { + on: { + SignIn: (to) => + to.local.signedIn().resolve(({ event, target }) => + target.decoded(new SignedIn({ userId: event.userId })) + ) + } + } + } + }, + sync: { + states: { + idle: { + entry: ({ state }) => { + const tag: "SyncIdle" = state._tag + void tag + } } } } @@ -1357,12 +1451,11 @@ describe("Machine", () => { it("onDone handlers receive typed state context without Effect requirements", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => - to.up.initial.resolve(({ target }) => (target.decoded( - new Up({ id: "up-1" }), - (up) => + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => + to.up.decoded(new Up({ id: "up-1" }), (up) => up .auth.decoded( new Auth({ userId: "user-1" }), @@ -1371,21 +1464,23 @@ describe("Machine", () => { .sync.decoded( new Sync({ enabled: true }), (sync) => sync.idle.decoded(new SyncIdle({})) - ) + )) ))) }).handle({ - up: { - states: { - auth: { - onDone: (to) => - to.full.down().resolve(({ event, output, state, target }) => { - expect(event).type.toBe() - expect(output).type.toBe() - expect(state).type.toBe() - return target.decoded(new Down({})) - }), - states: { - signedIn: {} + states: { + up: { + states: { + auth: { + onDone: (to) => + to.branch.down().resolve(({ event, output, state, target }) => { + expect(event).type.toBe() + expect(output).type.toBe() + expect(state).type.toBe() + return target.decoded(new Down({})) + }), + states: { + signedIn: {} + } } } } @@ -1394,19 +1489,23 @@ describe("Machine", () => { const planned = Machine.plan( machine, - UpInitial.decoded( - new Up({ id: "up-1" }), - (up) => - up - .auth.decoded( - new Auth({ userId: "user-1" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) - .sync.decoded( - new Sync({ enabled: true }), - (sync) => sync.idle.decoded(new SyncIdle({})) - ) - ), + { + path: "" as const, + value: undefined, + state: UpInitial.decoded( + new Up({ id: "up-1" }), + (up) => + up + .auth.decoded( + new Auth({ userId: "user-1" }), + (auth) => auth.signedOut.decoded(new SignedOut({})) + ) + .sync.decoded( + new Sync({ enabled: true }), + (sync) => sync.idle.decoded(new SyncIdle({})) + ) + ) + }, new SignIn({ userId: "user-1" }) ) @@ -1415,9 +1514,9 @@ describe("Machine", () => { it("handle rejects old property and callback APIs", () => { const machine = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) expect(machine.handle).type.not.toHaveProperty("up") @@ -1429,13 +1528,13 @@ describe("Machine", () => { it("allows independent implementations but removes handle from each result", () => { const definition = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => target.decoded(new Down({}))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.down.decoded(new Down({})))) }) - const first = definition.handle({ down: {} }) - const second = definition.handle({ down: {} }) + const first = definition.handle({ states: { down: {} } }) + const second = definition.handle({ states: { down: {} } }) expect(definition).type.toHaveProperty("handle") expect(first).type.not.toHaveProperty("handle") @@ -1444,22 +1543,24 @@ describe("Machine", () => { it("final output callbacks receive lifecycle events", () => { const machine = Machine.make({ - states: { - down: { - schema: Down, - type: "final", - output: Schema.Void + root: Machine.state({ + initial: "down", + states: { + down: { + schema: Down, + type: "final", + output: Schema.Void + } } - }, - events: Machine.events(SignIn), - initial: (to) => - to.down().resolve(({ target }) => (target.decoded( - new Down({}) - ))) + }), + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }).handle({ - down: { - output: ({ event }) => { - expect(event).type.toBe() + states: { + down: { + output: ({ event }) => { + expect(event).type.toBe() + } } } }) @@ -1468,21 +1569,27 @@ describe("Machine", () => { }) it("final output callbacks conform to declared output schemas", () => { - const States = Machine.states({ - signedIn: { - schema: SignedIn, - type: "final", - output: Schema.String + const States = Machine.state({ + initial: "signedIn", + states: { + signedIn: { + schema: SignedIn, + type: "final", + output: Schema.String + } } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(SignIn), - initial: (to) => to.signedIn().resolve(({ target }) => (target.decoded(new SignedIn({ userId: "user-1" })))) + root: States, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.signedIn.decoded(new SignedIn({ userId: "user-1" }))))) }).handle({ - signedIn: { - output: ({ state }) => state.userId + states: { + signedIn: { + output: ({ state }) => state.userId + } } }) @@ -1500,20 +1607,24 @@ describe("Machine", () => { }) it("requires one definition-led final output contract before execution", () => { - const States = Machine.states({ - signedIn: { - schema: SignedIn, - type: "final", - output: Schema.String + const States = Machine.state({ + initial: "signedIn", + states: { + signedIn: { + schema: SignedIn, + type: "final", + output: Schema.String + } } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(SignIn), - initial: (to) => to.signedIn().resolve(({ target }) => (target.decoded(new SignedIn({ userId: "user-1" })))) + root: States, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.signedIn.decoded(new SignedIn({ userId: "user-1" }))))) }) type ForgedCompleteMachine = Machine.Machine< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof SignIn], typeof Schema.Void, "signedIn", @@ -1529,14 +1640,18 @@ describe("Machine", () => { > expect(machine.handle).type.not.toBeCallableWith({ - signedIn: { - output: () => 1 + states: { + signedIn: { + output: () => 1 + } } }) expect(machine.handle).type.not.toBeCallableWith({ - signedIn: { - type: "final", - output: () => "user-1" + states: { + signedIn: { + type: "final", + output: () => "user-1" + } } }) expect(Machine.planInitial).type.not.toBeCallableWith(machine) @@ -1546,8 +1661,10 @@ describe("Machine", () => { expect(machine).type.not.toBeAssignableTo() const complete = machine.handle({ - signedIn: { - output: ({ state }) => state.userId + states: { + signedIn: { + output: ({ state }) => state.userId + } } }) expect(Machine.planInitial).type.toBeCallableWith(complete) @@ -1555,75 +1672,89 @@ describe("Machine", () => { }) it("keeps only legitimate undefined values in terminal output", () => { - const States = Machine.states({ - active: Down, - succeeded: { - schema: SignedIn, - type: "final", - output: Schema.String - }, - cancelled: { - schema: SignedOut, - type: "final" + const States = Machine.state({ + initial: "active", + states: { + active: Down, + succeeded: { + schema: SignedIn, + type: "final", + output: Schema.String + }, + cancelled: { + schema: SignedOut, + type: "final" + } } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(SignIn), - initial: (to) => to.active().resolve(({ target }) => (target.decoded(new Down({})))) + root: States, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.active.decoded(new Down({}))))) }).handle({ - succeeded: { - output: ({ state }) => state.userId + states: { + succeeded: { + output: ({ state }) => state.userId + } } }) const started = Machine.start(machine) expect["join"]>>().type.toBe() const activeOnly = Machine.make({ - states: { active: Down }, - events: Machine.events(SignIn), - initial: (to) => to.active().resolve(({ target }) => (target.decoded(new Down({})))) + root: Machine.state({ initial: "active", states: { active: Down } }), + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.active.decoded(new Down({}))))) }) const activeRef = Machine.start(activeOnly) expect["join"]>>().type.toBe() }) it("compound onDone receives the declared child final output type", () => { - const States = Machine.states({ - auth: { - schema: Auth, - initial: "signedOut", - states: { - signedOut: SignedOut, - signedIn: { - schema: SignedIn, - type: "final", - output: Schema.String + const States = Machine.state({ + initial: "auth", + states: { + auth: { + schema: Auth, + initial: "signedOut", + states: { + signedOut: SignedOut, + signedIn: { + schema: SignedIn, + type: "final", + output: Schema.String + } } - } - }, - down: Down + }, + down: Down + } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(SignIn), - initial: (to) => - to.auth.initial.resolve(( + root: States, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(( { target } - ) => (target.decoded(new Auth({ userId: "user-1" }), (auth) => auth.signedOut.decoded(new SignedOut({}))))) + ) => (target.from((to) => + to.auth.decoded(new Auth({ userId: "user-1" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) + ))) }) machine.handle({ - auth: { - onDone: (to) => - to.full.down().resolve(({ output, target }) => { - expect(output).type.toBe() - return target.decoded(new Down({})) - }), - states: { - signedIn: { - output: ({ state }) => state.userId + states: { + auth: { + onDone: (to) => + to.branch.down().resolve(({ output, target }) => { + expect(output).type.toBe() + return target.decoded(new Down({})) + }), + states: { + signedIn: { + output: ({ state }) => state.userId + } } } } @@ -1631,95 +1762,109 @@ describe("Machine", () => { }) it("rejects compound onDone when declared child output is not implemented", () => { - const States = Machine.states({ - auth: { - schema: Auth, - initial: "signedOut", - states: { - signedOut: SignedOut, - signedIn: { - schema: SignedIn, - type: "final", - output: Schema.String + const States = Machine.state({ + initial: "auth", + states: { + auth: { + schema: Auth, + initial: "signedOut", + states: { + signedOut: SignedOut, + signedIn: { + schema: SignedIn, + type: "final", + output: Schema.String + } } } } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(SignIn), - initial: (to) => - to.auth.initial.resolve(( + root: States, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(( { target } - ) => (target.decoded(new Auth({ userId: "user-1" }), (auth) => auth.signedOut.decoded(new SignedOut({}))))) + ) => (target.from((to) => + to.auth.decoded(new Auth({ userId: "user-1" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) + ))) }) expect(machine.handle).type.not.toBeCallableWith({ - auth: { - onDone: () => undefined + states: { + auth: { + onDone: () => undefined + } } }) }) it("multiple final children produce a discriminated completion output union", () => { - const States = Machine.states({ - payment: { - schema: Payment, - initial: "pending", - states: { - pending: PendingPayment, - approved: { - schema: ApprovedPayment, - type: "final", - output: Schema.Struct({ - status: Schema.Literal("approved"), - authId: Schema.String - }) - }, - declined: { - schema: DeclinedPayment, - type: "final", - output: Schema.Struct({ - status: Schema.Literal("declined"), - reason: Schema.String - }) + const States = Machine.state({ + initial: "payment", + states: { + payment: { + schema: Payment, + initial: "pending", + states: { + pending: PendingPayment, + approved: { + schema: ApprovedPayment, + type: "final", + output: Schema.Struct({ + status: Schema.Literal("approved"), + authId: Schema.String + }) + }, + declined: { + schema: DeclinedPayment, + type: "final", + output: Schema.Struct({ + status: Schema.Literal("declined"), + reason: Schema.String + }) + } } } } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(SignIn), - initial: (to) => - to.payment.initial.resolve(( + root: States, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(( { target } - ) => (target.decoded(new Payment({}), (payment) => payment.pending.decoded(new PendingPayment({}))))) + ) => (target.from((to) => + to.payment.decoded(new Payment({}), (payment) => payment.pending.decoded(new PendingPayment({}))) + ))) }) machine.handle({ - payment: { - onDone: (to) => - to.none.resolve(({ output }) => { - expect(output.status).type.toBe<"approved" | "declined">() - if (output.status === "approved") { - expect(output.authId).type.toBe() - } else { - expect(output.reason).type.toBe() + states: { + payment: { + onDone: (to) => + to.none.resolve(({ output }) => { + expect(output.status).type.toBe<"approved" | "declined">() + if (output.status === "approved") { + expect(output.authId).type.toBe() + } else { + expect(output.reason).type.toBe() + } + return undefined + }), + states: { + approved: { + output: ({ state }) => ({ + status: "approved" as const, + authId: state.authId + }) + }, + declined: { + output: ({ state }) => ({ + status: "declined" as const, + reason: state.reason + }) } - return undefined - }), - states: { - approved: { - output: ({ state }) => ({ - status: "approved" as const, - authId: state.authId - }) - }, - declined: { - output: ({ state }) => ({ - status: "declined" as const, - reason: state.reason - }) } } } @@ -1727,36 +1872,39 @@ describe("Machine", () => { }) it("parallel output callbacks receive typed region outputs and conform to declared output schemas", () => { - const States = Machine.states({ - up: { - schema: Up, - type: "parallel", - output: Schema.Struct({ - userId: Schema.String, - requestId: Schema.String - }), - states: { - auth: { - schema: Auth, - initial: "signedOut", - states: { - signedOut: SignedOut, - signedIn: { - schema: SignedIn, - type: "final", - output: Schema.Struct({ userId: Schema.String }) + const States = Machine.state({ + initial: "up", + states: { + up: { + schema: Up, + type: "parallel", + output: Schema.Struct({ + userId: Schema.String, + requestId: Schema.String + }), + states: { + auth: { + schema: Auth, + initial: "signedOut", + states: { + signedOut: SignedOut, + signedIn: { + schema: SignedIn, + type: "final", + output: Schema.Struct({ userId: Schema.String }) + } } - } - }, - sync: { - schema: Sync, - initial: "idle", - states: { - idle: SyncIdle, - syncing: { - schema: Syncing, - type: "final", - output: Schema.Struct({ requestId: Schema.String }) + }, + sync: { + schema: Sync, + initial: "idle", + states: { + idle: SyncIdle, + syncing: { + schema: Syncing, + type: "final", + output: Schema.Struct({ requestId: Schema.String }) + } } } } @@ -1764,46 +1912,47 @@ describe("Machine", () => { } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(SignIn), - initial: (to) => - to.up.initial.resolve(({ target }) => (target.decoded( - new Up({ id: "up-1" }), - (up) => + root: States, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => + to.up.decoded(new Up({ id: "up-1" }), (up) => up .auth.decoded(new Auth({ userId: "user-1" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) - .sync.decoded(new Sync({ enabled: true }), (sync) => sync.idle.decoded(new SyncIdle({}))) + .sync.decoded(new Sync({ enabled: true }), (sync) => sync.idle.decoded(new SyncIdle({})))) ))) }) const complete = machine.handle({ - up: { - output: ({ outputs }) => { - expect(outputs.auth.userId).type.toBe() - expect(outputs.sync.requestId).type.toBe() - return { - userId: outputs.auth.userId, - requestId: outputs.sync.requestId - } - }, - onDone: (to) => - to.none.resolve(({ output }) => { - expect(output.userId).type.toBe() - expect(output.requestId).type.toBe() - return undefined - }), - states: { - auth: { - states: { - signedIn: { - output: ({ state }) => ({ userId: state.userId }) - } + states: { + up: { + output: ({ outputs }) => { + expect(outputs.auth.userId).type.toBe() + expect(outputs.sync.requestId).type.toBe() + return { + userId: outputs.auth.userId, + requestId: outputs.sync.requestId } }, - sync: { - states: { - syncing: { - output: ({ state }) => ({ requestId: state.requestId }) + onDone: (to) => + to.none.resolve(({ output }) => { + expect(output.userId).type.toBe() + expect(output.requestId).type.toBe() + return undefined + }), + states: { + auth: { + states: { + signedIn: { + output: ({ state }) => ({ userId: state.userId }) + } + } + }, + sync: { + states: { + syncing: { + output: ({ state }) => ({ requestId: state.requestId }) + } } } } @@ -1812,28 +1961,31 @@ describe("Machine", () => { }) const started = Machine.start(complete) expect["join"]>>().type.toBe< - Machine.Machine.OutputByIdentifier + Machine.Machine.OutputByIdentifier<{ readonly "": typeof States.node }, "up"> >() }) it("rejects parallel output callbacks that do not match declared output schemas", () => { - const States = Machine.states({ - up: { - schema: Up, - type: "parallel", - output: Schema.Struct({ - userId: Schema.String - }), - states: { - auth: { - schema: Auth, - initial: "signedOut", - states: { - signedOut: SignedOut, - signedIn: { - schema: SignedIn, - type: "final", - output: Schema.Struct({ userId: Schema.String }) + const States = Machine.state({ + initial: "up", + states: { + up: { + schema: Up, + type: "parallel", + output: Schema.Struct({ + userId: Schema.String + }), + states: { + auth: { + schema: Auth, + initial: "signedOut", + states: { + signedOut: SignedOut, + signedIn: { + schema: SignedIn, + type: "final", + output: Schema.Struct({ userId: Schema.String }) + } } } } @@ -1841,25 +1993,31 @@ describe("Machine", () => { } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(SignIn), - initial: (to) => - to.up.initial.resolve(({ target }) => (target.decoded( - new Up({ id: "up-1" }), - (up) => up.auth.decoded(new Auth({ userId: "user-1" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) + root: States, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => + root.resolve(( + { target } + ) => (target.from((to) => + to.up.decoded( + new Up({ id: "up-1" }), + (up) => up.auth.decoded(new Auth({ userId: "user-1" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) + ) ))) }) expect(machine.handle).type.not.toBeCallableWith({ - up: { - output: () => ({ - requestId: "request-1" - }), - states: { - auth: { - states: { - signedIn: { - output: ({ state }: { readonly state: SignedIn }) => ({ userId: state.userId }) + states: { + up: { + output: () => ({ + requestId: "request-1" + }), + states: { + auth: { + states: { + signedIn: { + output: ({ state }: { readonly state: SignedIn }) => ({ userId: state.userId }) + } } } } @@ -1884,7 +2042,7 @@ describe("Machine", () => { ) expect(snapshot).type.toBeAssignableTo< - Machine.Machine.SnapshotByIdentifier + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof UpStates.node }, "up"> >() expect(snapshot.path).type.toBe<"up">() expect(snapshot.value).type.toBe() @@ -1957,7 +2115,8 @@ describe("Machine", () => { it("target.full constructs typed full snapshots", () => { const context = null as unknown as SignInContext - const snapshot = context.target.full.up.decoded( + const full = null as unknown as Machine.Machine.FullTargetBuilder + const snapshot = full.up.decoded( new Up({ id: "up-1" }), (up) => up @@ -1972,7 +2131,7 @@ describe("Machine", () => { ) expect(snapshot).type.toBeAssignableTo< - Machine.Machine.SnapshotByIdentifier + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof UpStates.node }, "up"> >() expect(snapshot.path).type.toBe<"up">() expect(snapshot.states.auth.state.path).type.toBe<"up.auth.signedOut" | "up.auth.signedIn">() @@ -1995,12 +2154,12 @@ describe("Machine", () => { ) const context = null as unknown as SignedOutContext const local = context.target.local.signedIn.from({ userId: "user-1" }) - const full = context.target.full.down.from({}) + const full = context.target.branch[""].down.from({}) const localWith = context.target.local.with.from( { userId: "user-1" }, (auth) => auth.signedIn.from({ userId: "user-1" }) ) - const branch = context.target.branch.up.from( + const branch = context.target.branch[""].up.from( { id: "up-2" }, (up) => up.auth.from( @@ -2011,12 +2170,12 @@ describe("Machine", () => { expect(initial).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.SnapshotByIdentifier + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof UpStates.node }, "up"> > >() expect(local).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.Target + Machine.Machine.Target<{ readonly "": typeof UpStates.node }, "up.auth.signedIn"> > >() expect(local).type.not.toHaveProperty("path") @@ -2025,7 +2184,7 @@ describe("Machine", () => { expect(branch).type.not.toHaveProperty("path") expect(full).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.SnapshotByIdentifier + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof UpStates.node }, "down"> > >() expect(full).type.not.toHaveProperty("value") @@ -2066,53 +2225,59 @@ describe("Machine", () => { Schema.withConstructorDefault(Effect.succeed("default")) ) }) {} - const States = Machine.states({ - Flow: { - schema: State.cases.Flow, - initial: "Idle", - states: { - Idle: State.cases.Idle, - Running: State.cases.Running, - Nested: { - schema: State.cases.Nested, - initial: "NestedIdle", - states: { - NestedIdle: State.cases.NestedIdle + const States = Machine.state({ + initial: "Flow", + states: { + Flow: { + schema: State.cases.Flow, + initial: "Idle", + states: { + Idle: State.cases.Idle, + Running: State.cases.Running, + Nested: { + schema: State.cases.Nested, + initial: "NestedIdle", + states: { + NestedIdle: State.cases.NestedIdle + } + }, + Done: { + schema: State.cases.Done, + type: "final" } - }, - Done: { - schema: State.cases.Done, - type: "final" } - } - }, - Required: State.cases.Required, - DefaultOnly + }, + Required: State.cases.Required, + DefaultOnly + } }) - const ParallelStates = Machine.states({ - Parallel: { - schema: State.cases.Parallel, - type: "parallel", - states: { - left: { - schema: State.cases.Left, - initial: "LeftIdle", - states: { - LeftIdle: State.cases.LeftIdle - } - }, - right: { - schema: State.cases.Right, - initial: "RightIdle", - states: { - RightIdle: State.cases.RightIdle + const ParallelStates = Machine.state({ + initial: "Parallel", + states: { + Parallel: { + schema: State.cases.Parallel, + type: "parallel", + states: { + left: { + schema: State.cases.Left, + initial: "LeftIdle", + states: { + LeftIdle: State.cases.LeftIdle + } + }, + right: { + schema: State.cases.Right, + initial: "RightIdle", + states: { + RightIdle: State.cases.RightIdle + } } } } } }) type Context = Machine.Machine.HandlerContext< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof SignIn], [], "Flow.Idle", @@ -2121,7 +2286,7 @@ describe("Machine", () => { never > type ParallelContext = Machine.Machine.HandlerContext< - typeof ParallelStates.states, + { readonly "": typeof ParallelStates.node }, readonly [typeof SignIn], [], "Parallel.left.LeftIdle", @@ -2132,66 +2297,80 @@ describe("Machine", () => { const context = null as unknown as Context const parallelContext = null as unknown as ParallelContext const FlowInitial = null as unknown as Machine.Machine.SelectionBuilder< - Machine.Machine.InitialSelector["Flow"]["initial"] + Machine.Machine.InitialSelector["Flow"]["initial"] > const RequiredInitial = null as unknown as Machine.Machine.SelectionBuilder< - ReturnType["Required"]> + ReturnType["Required"]> > const DefaultOnlyInitial = null as unknown as Machine.Machine.SelectionBuilder< - ReturnType["DefaultOnly"]> + ReturnType["DefaultOnly"]> > const ParallelInitial = null as unknown as Machine.Machine.SelectionBuilder< - Machine.Machine.InitialSelector["Parallel"]["initial"] + Machine.Machine.InitialSelector["Parallel"]["initial"] > const initial = FlowInitial.from((flow) => flow.Idle.from()) const defaulted = DefaultOnlyInitial.from() const local = context.target.local.Running.from() const localWith = context.target.local.with.from((flow) => flow.Running.from()) - const branch = context.target.branch.Flow.Nested.from((nested) => nested.NestedIdle.from()) - const full = context.target.full.Flow.from((flow) => flow.Nested.from((nested) => nested.NestedIdle.from())) + const branch = context.target.branch[""].Flow.Nested.from((nested) => nested.NestedIdle.from()) + const fullBuilder = null as unknown as Machine.Machine.FullTargetBuilder + const full = fullBuilder.Flow.from((flow) => flow.Nested.from((nested) => nested.NestedIdle.from())) const final = context.target.local.Done.from() const parallel = ParallelInitial.from((root) => root .left.from((left) => left.LeftIdle.from()) .right.from((right) => right.RightIdle.from()) ) - const fullParallel = parallelContext.target.full.Parallel.from((root) => + const parallelBuilder = null as unknown as Machine.Machine.FullTargetBuilder + const fullParallel = parallelBuilder.Parallel.from((root) => root .left.from((left) => left.LeftIdle.from()) .right.from((right) => right.RightIdle.from()) ) expect(initial).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction< + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "Flow"> + > >() expect(defaulted).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction< + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "DefaultOnly"> + > >() expect(local).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction> >() expect(localWith).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction> >() expect(branch).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction< + Machine.Machine.Target<{ readonly "": typeof States.node }, "Flow.Nested.NestedIdle"> + > >() expect(full).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction< + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "Flow"> + > >() expect(final).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction> >() expect(parallel).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction< + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof ParallelStates.node }, "Parallel"> + > >() expect(fullParallel).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction< + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof ParallelStates.node }, "Parallel"> + > >() expect(RequiredInitial.from).type.not.toBeCallableWith() - expect(context.target.full.Required.from).type.not.toBeCallableWith() + expect(context.target.branch[""].Required.from).type.not.toBeCallableWith() expect(FlowInitial.from).type.not.toBeCallableWith() expect(ParallelInitial.from).type.not.toBeCallableWith() @@ -2202,8 +2381,9 @@ describe("Machine", () => { .auth.from({ userId: "guest" }, (auth) => auth.signedOut.from()) .sync.from({ enabled: true }, (sync) => sync.idle.from()) ) - expect(requiredContext.target.full.up.from).type.not.toBeCallableWith( - (up: ChildBuilder) => + const requiredFull = null as unknown as Machine.Machine.FullTargetBuilder + expect(requiredFull.up.from).type.not.toBeCallableWith( + (up: ChildBuilder) => up .auth.from({ userId: "guest" }, (auth) => auth.signedOut.from()) .sync.from({ enabled: true }, (sync) => sync.idle.from()) @@ -2211,8 +2391,8 @@ describe("Machine", () => { expect(requiredContext.target.local.with.from).type.not.toBeCallableWith( (auth: ChildBuilder) => auth.signedOut.from() ) - expect(requiredContext.target.branch.up.from).type.not.toBeCallableWith( - (up: ChildBuilder) => up.auth.signedOut.from() + expect(requiredContext.target.branch[""].up.from).type.not.toBeCallableWith( + (up: ChildBuilder) => up.auth.signedOut.from() ) }) @@ -2229,12 +2409,12 @@ describe("Machine", () => { .auth.from({ userId: "guest" }, (auth) => auth.signedOut.from({})) .sync.from({ enabled: true }, (sync) => sync.idle.from({})) ) - const branch = context.target.branch.root.work.from((work) => + const branch = context.target.branch[""].root.work.from((work) => work .auth.from({ userId: "guest" }, (auth) => auth.signedOut.from()) .sync.from({ enabled: true }, (sync) => sync.idle.from()) ) - const partial = activeContext.target.branch.root.work.sync.from( + const partial = activeContext.target.branch[""].root.work.sync.from( { enabled: true }, (sync) => sync.syncing.from({ requestId: "sync-1" }) ) @@ -2243,18 +2423,18 @@ describe("Machine", () => { expect(afterAuth).type.toHaveProperty("sync") expect(target).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.Target + Machine.Machine.Target<{ readonly "": typeof NestedParallelStates.node }, "root.work"> > >() expect(target).type.not.toHaveProperty("path") expect(branch).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.Target + Machine.Machine.Target<{ readonly "": typeof NestedParallelStates.node }, "root.work"> > >() expect(partial).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.Target + Machine.Machine.Target<{ readonly "": typeof NestedParallelStates.node }, "root.work.sync.syncing"> > >() expect(context.target.local.work.from).type.not.toBeCallableWith( @@ -2269,15 +2449,18 @@ describe("Machine", () => { Idle: {}, Active: { requestId: Schema.String } }) - const States = Machine.states({ - Idle: State.cases.Idle, - Active: State.cases.Active + const States = Machine.state({ + initial: "Idle", + states: { + Idle: State.cases.Idle, + Active: State.cases.Active + } }) const IdleInitial = null as unknown as Machine.Machine.SelectionBuilder< - ReturnType["Idle"]> + ReturnType["Idle"]> > const ActiveInitial = null as unknown as Machine.Machine.SelectionBuilder< - ReturnType["Active"]> + ReturnType["Active"]> > const initial = IdleInitial.from({}) @@ -2295,9 +2478,9 @@ describe("Machine", () => { it("target.full requires every parallel region", () => { const context = null as unknown as SignInContext - expect(context.target.full.up.decoded).type.not.toBeCallableWith( + expect(context.target.branch[""].up.decoded).type.not.toBeCallableWith( new Up({ id: "up-1" }), - (up: ChildBuilder) => + (up: ChildBuilder) => up.auth.decoded( new Auth({ userId: "guest" }), (auth) => auth.signedIn.decoded(new SignedIn({ userId: "user-1" })) @@ -2307,7 +2490,7 @@ describe("Machine", () => { it("target.full exposes every compound child", () => { const context = null as unknown as SignInContext - const up = null as unknown as ChildBuilder + const up = null as unknown as ChildBuilder const auth = null as unknown as ChildBuilder expect(auth.signedOut.decoded).type.toBeCallableWith(new SignedOut({})) @@ -2331,7 +2514,7 @@ describe("Machine", () => { ) expect(target).type.toBeAssignableTo< - Machine.Machine.Target + Machine.Machine.Target<{ readonly "": typeof NestedParallelStates.node }, "root.work"> >() expect(target.path).type.toBe<"root.work">() expect(context.target.local.work.decoded).type.not.toBeCallableWith( @@ -2346,7 +2529,7 @@ describe("Machine", () => { it("target.branch requires every region when entering an inactive nested parallel state", () => { const context = null as unknown as NestedIdleContext - const target = context.target.branch.root.work.decoded( + const target = context.target.branch[""].root.work.decoded( new Payment({}), (work) => work @@ -2361,10 +2544,10 @@ describe("Machine", () => { ) expect(target.path).type.toBe<"root.work">() - expect(context.target.branch.root.work).type.not.toHaveProperty("auth") - expect(context.target.branch.root.work.decoded).type.not.toBeCallableWith( + expect(context.target.branch[""].root.work).type.not.toHaveProperty("auth") + expect(context.target.branch[""].root.work.decoded).type.not.toBeCallableWith( new Payment({}), - (work: ChildBuilder) => + (work: ChildBuilder) => work.auth.decoded( new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({})) @@ -2390,13 +2573,13 @@ describe("Machine", () => { it("target.branch keeps partial navigation for an already-active parallel state", () => { const context = null as unknown as NestedActiveContext - const target = context.target.branch.root.work.sync.decoded( + const target = context.target.branch[""].root.work.sync.decoded( new Sync({ enabled: true }), (sync) => sync.syncing.decoded(new Syncing({ requestId: "sync-1" })) ) - expect(context.target.branch.root.work).type.toHaveProperty("auth") - expect(context.target.branch.root.work).type.toHaveProperty("sync") + expect(context.target.branch[""].root.work).type.toHaveProperty("auth") + expect(context.target.branch[""].root.work).type.toHaveProperty("sync") expect(target.path).type.toBe<"root.work.sync.syncing">() }) @@ -2405,7 +2588,7 @@ describe("Machine", () => { const target = context.target.local.signedIn.decoded(new SignedIn({ userId: "user-1" })) expect(target).type.toBeAssignableTo< - Machine.Machine.Target + Machine.Machine.Target<{ readonly "": typeof UpStates.node }, "up.auth.signedIn"> >() expect(target.path).type.toBe<"up.auth.signedIn">() expect(target.value).type.toBe() @@ -2442,33 +2625,33 @@ describe("Machine", () => { expect(context.target.local.signedIn.decoded).type.not.toBeCallableWith(new SignedOut({})) }) - it("target.local exposes no methods outside a compound scope", () => { + it("target.local exposes the root compound children", () => { const context = null as unknown as SignInContext - expect(context.target.local).type.not.toHaveProperty("up") - expect(context.target.local).type.not.toHaveProperty("down") + expect(context.target.local).type.toHaveProperty("up") + expect(context.target.local).type.toHaveProperty("down") expect(context.target.local).type.not.toHaveProperty("with") }) - it("target.branch exposes only the source root", () => { + it("target.branch starts at the shared machine root", () => { const context = null as unknown as SignedOutContext const downContext = null as unknown as SignInContext - expect(context.target.branch).type.toHaveProperty("up") - expect(context.target.branch).type.not.toHaveProperty("down") - expect(downContext.target.branch).type.toHaveProperty("down") - expect(downContext.target.branch).type.not.toHaveProperty("up") + expect(context.target.branch[""]).type.toHaveProperty("up") + expect(context.target.branch[""]).type.toHaveProperty("down") + expect(downContext.target.branch[""]).type.toHaveProperty("down") + expect(downContext.target.branch[""]).type.toHaveProperty("up") }) it("target.branch constructs typed partial branch targets", () => { const context = null as unknown as SignedOutContext - const target = context.target.branch.up.sync.decoded( + const target = context.target.branch[""].up.sync.decoded( new Sync({ enabled: true }), (sync) => sync.syncing.decoded(new Syncing({ requestId: "sync-1" })) ) expect(target).type.toBeAssignableTo< - Machine.Machine.Target + Machine.Machine.Target<{ readonly "": typeof UpStates.node }, "up.sync.syncing"> >() expect(target.path).type.toBe<"up.sync.syncing">() expect(target.value).type.toBe() @@ -2476,7 +2659,7 @@ describe("Machine", () => { it("target.branch can replace ancestors before selecting a leaf", () => { const context = null as unknown as SignedOutContext - const target = context.target.branch.up.decoded( + const target = context.target.branch[""].up.decoded( new Up({ id: "up-2" }), (up) => up.auth.decoded( @@ -2492,14 +2675,14 @@ describe("Machine", () => { it("target.branch rejects non-leaf targets and wrong values", () => { const context = null as unknown as SignedOutContext - expect(context.target.branch.up.decoded).type.not.toBeCallableWith(new Up({ id: "up-1" })) - expect(context.target.branch.up.sync.decoded).type.not.toBeCallableWith(new Sync({ enabled: true })) - expect(context.target.branch.up.sync.decoded).type.not.toBeCallableWith( + expect(context.target.branch[""].up.decoded).type.not.toBeCallableWith(new Up({ id: "up-1" })) + expect(context.target.branch[""].up.sync.decoded).type.not.toBeCallableWith(new Sync({ enabled: true })) + expect(context.target.branch[""].up.sync.decoded).type.not.toBeCallableWith( new Auth({ userId: "user-1" }), - (sync: ChildBuilder) => + (sync: ChildBuilder) => sync.syncing.decoded(new Syncing({ requestId: "sync-1" })) ) - expect(context.target.branch.up.auth.signedIn.decoded).type.not.toBeCallableWith(new SignedOut({})) + expect(context.target.branch[""].up.auth.signedIn.decoded).type.not.toBeCallableWith(new SignedOut({})) }) it("target is not callable", () => { @@ -2512,33 +2695,39 @@ describe("Machine", () => { it("requires explicit targetless transitions", () => { expect(Machine).type.not.toHaveProperty("targetless") const definition = Machine.make({ - states: UpStates.states, - events: Machine.events(SignIn), - initial: (to) => to.down().resolve(({ target }) => (target.decoded(new Down({})))) + root: UpStates, + events: Machine.eventsFromSchemas(SignIn), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) }) expect(definition.handle).type.not.toBeCallableWith({ - down: { on: { SignIn: () => undefined } } + states: { + down: { on: { SignIn: () => undefined } } + } }) expect( definition.handle({ - down: { - on: { - SignIn: (to) => - to.none.resolve(({ event }, enqueue) => { - expect(event).type.toBe() - expect(enqueue.raise).type.toBeCallableWith(event) - }) + states: { + down: { + on: { + SignIn: (to) => + to.none.resolve(({ event }, enqueue) => { + expect(event).type.toBe() + expect(enqueue.raise).type.toBeCallableWith(event) + }) + } } } }) ).type.not.toRaiseError() definition.handle({ - down: { - on: { - SignIn: (to) => { - // @ts-expect-error! - return to.none.resolve(() => 1) + states: { + down: { + on: { + SignIn: (to) => { + // @ts-expect-error! + return to.none.resolve(() => 1) + } } } } @@ -2546,41 +2735,50 @@ describe("Machine", () => { }) it("rejects invalid compound initial keys", () => { - expect(Machine.states).type.not.toBeCallableWith({ - up: { - schema: Up, - initial: "missing", - states: { - signedOut: SignedOut + expect(Machine.state).type.not.toBeCallableWith({ + initial: "up", + states: { + up: { + schema: Up, + initial: "missing", + states: { + signedOut: SignedOut + } } } }) }) it("rejects initial keys on parallel states", () => { - expect(Machine.states).type.not.toBeCallableWith({ - up: { - schema: Up, - type: "parallel", - initial: "auth", - states: { - auth: Auth + expect(Machine.state).type.not.toBeCallableWith({ + initial: "up", + states: { + up: { + schema: Up, + type: "parallel", + initial: "auth", + states: { + auth: Auth + } } } }) }) it("rejects invalid nested state definitions", () => { - expect(Machine.states).type.not.toBeCallableWith({ - up: { - schema: Up, - type: "parallel", - states: { - auth: { - schema: Auth, - initial: "missing", - states: { - signedOut: SignedOut + expect(Machine.state).type.not.toBeCallableWith({ + initial: "up", + states: { + up: { + schema: Up, + type: "parallel", + states: { + auth: { + schema: Auth, + initial: "missing", + states: { + signedOut: SignedOut + } } } } @@ -2599,7 +2797,7 @@ describe("Machine", () => { } } }, - events: Machine.events(), + events: Machine.eventsFromSchemas(), initial: (): never => { throw new Error("unreachable") } @@ -2607,12 +2805,15 @@ describe("Machine", () => { }) it("rejects child states on final states", () => { - expect(Machine.states).type.not.toBeCallableWith({ - down: { - schema: Down, - type: "final", - states: { - child: SignedOut + expect(Machine.state).type.not.toBeCallableWith({ + initial: "down", + states: { + down: { + schema: Down, + type: "final", + states: { + child: SignedOut + } } } }) diff --git a/packages/effect-machine/typetest/machine/MachineReferences.tst.ts b/packages/effect-machine/typetest/machine/MachineReferences.tst.ts index 51b757e..fb8af1d 100644 --- a/packages/effect-machine/typetest/machine/MachineReferences.tst.ts +++ b/packages/effect-machine/typetest/machine/MachineReferences.tst.ts @@ -23,22 +23,24 @@ describe("machine reference event channels", () => { { value: Schema.Number } ) {} - const ParentEvents = Machine.events(ParentNotice) - const Events = Machine.events(Ping) - const InternalEvents = Machine.internalEvents(Local) - const Emissions = Machine.emittedEvents(Published, ValuedPublished) - const states = Machine.states({ Idle }) + const ParentEvents = Machine.eventsFromSchemas(ParentNotice) + const Events = Machine.eventsFromSchemas(Ping) + const InternalEvents = Machine.internalEventsFromSchemas(Local) + const Emissions = Machine.emittedEventsFromSchemas(Published, ValuedPublished) + const states = Machine.state({ initial: "Idle", states: { Idle } }) const childMachine = Machine.make({ - states: states.states, + root: states, events: Events, internalEvents: InternalEvents, parent: Machine.optionalParent(ParentEvents), emittedEvents: Emissions, - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }).handle({ - Idle: { - on: { - Ping: (to) => to.none + states: { + Idle: { + on: { + Ping: (to) => to.none + } } } }) @@ -57,50 +59,54 @@ describe("machine reference event channels", () => { expect(Machine.optionalParent).type.not.toBeCallableWith(InternalEvents) const independentMachine = Machine.make({ - states: states.states, + root: states, events: Events, - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) }).handle({ - Idle: { - on: { - Ping: (to) => - to.none.resolve((context) => { - expect<"parent">().type.not.toBeAssignableTo() - return undefined - }) + states: { + Idle: { + on: { + Ping: (to) => + to.none.resolve((context) => { + expect<"parent">().type.not.toBeAssignableTo() + return undefined + }) + } } } }) expect(independentMachine.parent).type.toBe() Machine.make({ - states: states.states, + root: states, events: Events, internalEvents: InternalEvents, parent: Machine.optionalParent(ParentEvents), emittedEvents: Emissions, - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }).handle({ - Idle: { - on: { - Ping: (to) => - to.none.resolve(({ parent, self }, enqueue) => { - expect(self.send).type.toBeCallableWith(Events.Ping()) - expect(self.send).type.not.toBeCallableWith(InternalEvents.Local()) - expect(enqueue.sendTo).type.toBeCallableWith(self, Events.Ping()) - expect(enqueue.sendTo).type.not.toBeCallableWith(self, ParentEvents.ParentNotice({ value: 1 })) + states: { + Idle: { + on: { + Ping: (to) => + to.none.resolve(({ parent, self }, enqueue) => { + expect(self.send).type.toBeCallableWith(Events.Ping()) + expect(self.send).type.not.toBeCallableWith(InternalEvents.Local()) + expect(enqueue.sendTo).type.toBeCallableWith(self, Events.Ping()) + expect(enqueue.sendTo).type.not.toBeCallableWith(self, ParentEvents.ParentNotice({ value: 1 })) - if (parent !== undefined) { - expect(enqueue.sendTo).type.toBeCallableWith(parent, ParentEvents.ParentNotice({ value: 1 })) - expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) - } + if (parent !== undefined) { + expect(enqueue.sendTo).type.toBeCallableWith(parent, ParentEvents.ParentNotice({ value: 1 })) + expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) + } - expect(enqueue.raise).type.toBeCallableWith(InternalEvents.Local()) - expect(enqueue.emit).type.toBeCallableWith(Emissions.Published()) - expect(enqueue.emit).type.toBeCallableWith(Emissions.ValuedPublished({ value: 1 })) - expect(enqueue.emit).type.not.toBeCallableWith(Events.Ping()) - return undefined - }) + expect(enqueue.raise).type.toBeCallableWith(InternalEvents.Local()) + expect(enqueue.emit).type.toBeCallableWith(Emissions.Published()) + expect(enqueue.emit).type.toBeCallableWith(Emissions.ValuedPublished({ value: 1 })) + expect(enqueue.emit).type.not.toBeCallableWith(Events.Ping()) + return undefined + }) + } } } }) @@ -108,29 +114,33 @@ describe("machine reference event channels", () => { it("composes builder protocols and checks required parent inputs", () => { const compatible = Machine.make({ - states: states.states, - events: Machine.events(Ping, ParentEvents), - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: states, + events: Machine.eventsFromSchemas(Ping, ParentEvents), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }) compatible.handle({ - Idle: { invoke: (from) => from.child(Child) } + states: { + Idle: { invoke: (from) => from.child(Child) } + } }) const incompatible = Machine.make({ - states: states.states, - events: Machine.events(Ping, OtherParentEvent), - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: states, + events: Machine.eventsFromSchemas(Ping, OtherParentEvent), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }) expect(incompatible.handle).type.not.toBeCallableWith({ - Idle: { - invoke: ( - from: Machine.Machine.InvokeSelector< - typeof states.states, - readonly [typeof Ping, typeof OtherParentEvent], - readonly [], - "Idle" - > - ) => from.child(Child) + states: { + Idle: { + invoke: ( + from: Machine.Machine.InvokeSelector< + { readonly "": typeof states.node }, + readonly [typeof Ping, typeof OtherParentEvent], + readonly [], + "Idle" + > + ) => from.child(Child) + } } }) }) @@ -142,7 +152,7 @@ describe("machine reference event channels", () => { expect().type.toBe< Stream.Stream< Machine.RuntimeSnapshot< - Machine.Machine.Snapshot, + Machine.Snapshot, Machine.InfiniteTransitionError | Machine.MachineSchemaDecodeError | Machine.StoppedError >, | Machine.InfiniteTransitionError @@ -164,49 +174,53 @@ describe("machine reference event channels", () => { it("contextually binds invocation self and parent references to the owning machine protocols", () => { Machine.make({ - states: states.states, + root: states, events: Events, internalEvents: InternalEvents, parent: Machine.parent(ParentEvents), emittedEvents: Emissions, - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }).handle({ - Idle: { - invoke: (from) => - from.effect("notify-parent", ({ parent, self }) => { - expect(self.send).type.toBeCallableWith(Events.Ping()) - expect(self.send).type.not.toBeCallableWith(InternalEvents.Local()) - expect(self.send).type.not.toBeCallableWith(ParentEvents.ParentNotice({ value: 1 })) - expect(parent.send).type.toBeCallableWith(ParentEvents.ParentNotice({ value: 1 })) - expect(parent.send).type.not.toBeCallableWith(Events.Ping()) - return Effect.void - }).onDone((to) => - to.none.resolve(({ parent, self }, enqueue) => { - enqueue.sendTo(self, Events.Ping()) - enqueue.sendTo(parent, ParentEvents.ParentNotice({ value: 1 })) - expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) - return undefined - }) - ) + states: { + Idle: { + invoke: (from) => + from.effect("notify-parent", ({ parent, self }) => { + expect(self.send).type.toBeCallableWith(Events.Ping()) + expect(self.send).type.not.toBeCallableWith(InternalEvents.Local()) + expect(self.send).type.not.toBeCallableWith(ParentEvents.ParentNotice({ value: 1 })) + expect(parent.send).type.toBeCallableWith(ParentEvents.ParentNotice({ value: 1 })) + expect(parent.send).type.not.toBeCallableWith(Events.Ping()) + return Effect.void + }).onDone((to) => + to.none.resolve(({ parent, self }, enqueue) => { + enqueue.sendTo(self, Events.Ping()) + enqueue.sendTo(parent, ParentEvents.ParentNotice({ value: 1 })) + expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) + return undefined + }) + ) + } } }) const requiredParentMachine = Machine.make({ - states: states.states, + root: states, events: Events, parent: Machine.parent(ParentEvents), - initial: (to) => to.Idle().resolve(({ target }) => target.from()) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.from())) }).handle({ - Idle: { - invoke: (from) => - from.effect("notify-parent-failure", () => Effect.fail("failed" as const)).onFailure((to) => - to.none.resolve(({ parent, self }, enqueue) => { - enqueue.sendTo(self, Events.Ping()) - enqueue.sendTo(parent, ParentEvents.ParentNotice({ value: 1 })) - expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) - return undefined - }) - ) + states: { + Idle: { + invoke: (from) => + from.effect("notify-parent-failure", () => Effect.fail("failed" as const)).onFailure((to) => + to.none.resolve(({ parent, self }, enqueue) => { + enqueue.sendTo(self, Events.Ping()) + enqueue.sendTo(parent, ParentEvents.ParentNotice({ value: 1 })) + expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) + return undefined + }) + ) + } } }) @@ -215,24 +229,24 @@ describe("machine reference event channels", () => { expect(Machine.planInitial).type.not.toBeCallableWith(requiredParentMachine) expect(Machine.plan).type.not.toBeCallableWith( requiredParentMachine, - null as unknown as Machine.Machine.Snapshot, + null as unknown as Machine.Snapshot, Events.Ping() ) expect(Machine.can).type.not.toBeCallableWith(requiredParentMachine) expect(Machine.can).type.not.toBeCallableWith( requiredParentMachine, - null as unknown as Machine.Machine.Snapshot, + null as unknown as Machine.Snapshot, Events.Ping() ) expect(Machine.resume).type.not.toBeCallableWith( requiredParentMachine, - null as unknown as Machine.Machine.Snapshot + null as unknown as Machine.Snapshot ) expect(AtomMachine.make).type.not.toBeCallableWith(requiredParentMachine) expect(AtomMachine.factory).type.not.toBeCallableWith(requiredParentMachine) expect(AtomMachine.resume).type.not.toBeCallableWith( requiredParentMachine, - null as unknown as Machine.Machine.Snapshot + null as unknown as Machine.Snapshot ) expect(ClusterMachine.make).type.not.toBeCallableWith("RequiredParent", requiredParentMachine, { version: "1" }) expect(MachineTest.run).type.not.toBeCallableWith(requiredParentMachine, { events: [] }) diff --git a/packages/effect-machine/typetest/machine/Readiness.tst.ts b/packages/effect-machine/typetest/machine/Readiness.tst.ts index 2588c15..36d33df 100644 --- a/packages/effect-machine/typetest/machine/Readiness.tst.ts +++ b/packages/effect-machine/typetest/machine/Readiness.tst.ts @@ -11,61 +11,70 @@ class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} class Done extends Schema.TaggedClass("Done")("Done", { value: Schema.String }) {} class Tick extends Schema.TaggedClass("Tick")("Tick", {}) {} -const choiceStates = Machine.states({ - Ready, - Flow: { - schema: Flow, - initial: "Route", - states: { - Route: { type: "choice" }, - Idle +const choiceStates = Machine.state({ + initial: "Ready", + states: { + Ready, + Flow: { + schema: Flow, + initial: "Route", + states: { + Route: { type: "choice" }, + Idle + } } } }) const choiceIncomplete = Machine.make({ - states: choiceStates.states, - events: Machine.events(Tick), - initial: (to) => to.Ready().resolve(({ target }) => (target.decoded(new Ready({})))) + root: choiceStates, + events: Machine.eventsFromSchemas(Tick), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Ready.decoded(new Ready({}))))) }) -const choiceSnapshot = { path: "Ready" as const, value: new Ready({}) } +const choiceSnapshot = { path: "" as const, value: undefined, state: { path: "Ready" as const, value: new Ready({}) } } -const historyStates = Machine.states({ - Ready, - Flow: { - schema: Flow, - initial: "Idle", - states: { - Idle, - recent: { type: "history", history: "deep" } +const historyStates = Machine.state({ + initial: "Ready", + states: { + Ready, + Flow: { + schema: Flow, + initial: "Idle", + states: { + Idle, + recent: { type: "history", history: "deep" } + } } } }) const historyIncomplete = Machine.make({ - states: historyStates.states, - events: Machine.events(Tick), - initial: (to) => to.Ready().resolve(({ target }) => (target.decoded(new Ready({})))) + root: historyStates, + events: Machine.eventsFromSchemas(Tick), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Ready.decoded(new Ready({}))))) }) -const historySnapshot = { path: "Ready" as const, value: new Ready({}) } +const historySnapshot = { path: "" as const, value: undefined, state: { path: "Ready" as const, value: new Ready({}) } } -const outputStates = Machine.states({ - Ready, - Done: { - schema: Done, - type: "final", - output: Schema.String +const outputStates = Machine.state({ + initial: "Ready", + states: { + Ready, + Done: { + schema: Done, + type: "final", + output: Schema.String + } } }) const outputIncomplete = Machine.make({ - states: outputStates.states, - events: Machine.events(Tick), - initial: (to) => to.Ready().resolve(({ target }) => (target.decoded(new Ready({})))) + root: outputStates, + events: Machine.eventsFromSchemas(Tick), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Ready.decoded(new Ready({}))))) }) const outputSnapshot = { path: "Ready" as const, value: new Ready({}) } type InvokeSelector = Machine.Machine.InvokeSelector< - typeof outputStates.states, + { readonly "": typeof outputStates.node }, readonly [typeof Tick], readonly [], "Ready" @@ -130,49 +139,61 @@ describe("executable machine readiness", () => { }) it("accepts a complete machine and preserves its exact channels through every adapter", () => { - const completeStates = Machine.states({ - Ready, - Flow: { - schema: Flow, - initial: "Route", - states: { - Route: { type: "choice" }, - Idle, - recent: { type: "history", history: "deep" }, - Done: { - schema: Done, - type: "final", - output: Schema.String + const completeStates = Machine.state({ + initial: "Ready", + states: { + Ready, + Flow: { + schema: Flow, + initial: "Route", + states: { + Route: { type: "choice" }, + Idle, + recent: { type: "history", history: "deep" }, + Done: { + schema: Done, + type: "final", + output: Schema.String + } } } } }) const complete = Machine.make({ - states: completeStates.states, - events: Machine.events(Tick), - initial: (to) => to.Ready().resolve(({ target }) => (target.decoded(new Ready({})))) + root: completeStates, + events: Machine.eventsFromSchemas(Tick), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.Ready.decoded(new Ready({}))))) }).handle({ - Flow: { - history: { - recent: { - default: ({ target }) => - target.Flow.decoded( - new Flow({}), - (flow) => flow.Idle.decoded(new Idle({})) - ) - } - }, - states: { - Route: { - choice: (to) => to.local.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + states: { + Flow: { + history: { + recent: { + default: ({ target }) => + target.from((tree) => + tree.Flow.decoded( + new Flow({}), + (flow) => flow.Idle.decoded(new Idle({})) + ) + ) + } }, - Done: { - output: ({ state }) => state.value + states: { + Route: { + choice: (to) => to.local.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + }, + Done: { + output: ({ state }) => state.value + } } } } }) - const completeSnapshot = { path: "Ready" as const, value: new Ready({}) } + const completeSnapshot = { + path: "" as const, + value: undefined, + state: { path: "Ready" as const, value: new Ready({}) } + } const plannedInitial = Machine.planInitial(complete) const planned = Machine.plan(complete, completeSnapshot, new Tick({})) @@ -197,14 +218,14 @@ describe("executable machine readiness", () => { expect>().type.toBe() expect>().type.toBe() expect["state"]>().type.toBe< - Machine.Machine.Snapshot + Machine.Snapshot >() expect>().type.toBe() expect(canComplete).type.toBeCallableWith(completeSnapshot, new Tick({})) expect(makeAtom).type.toBeCallableWith() expect(makeBoundAtom).type.toBeCallableWith() expect["next"]>().type.toBe< - Machine.Machine.Snapshot + Machine.Snapshot >() expect["send"]>().type.toBe< (event: Machine.Machine.EventInput) => Effect.Effect @@ -215,28 +236,28 @@ describe("executable machine readiness", () => { expect>().type.toBe>() expect>().type.toBe< readonly [ - Machine.Machine.Snapshot, + Machine.Snapshot, Machine.Machine.EventInput, string ] >() expect>().type.toBe< readonly [ - Machine.Machine.Snapshot, + Machine.Snapshot, Machine.Machine.EventInput, string ] >() expect>().type.toBe< readonly [ - Machine.Machine.Snapshot, + Machine.Snapshot, Machine.Machine.EventInput, string ] >() expect>().type.toBe< readonly [ - Machine.Machine.Snapshot, + Machine.Snapshot, Machine.Machine.EventInput, string ] diff --git a/packages/effect-machine/typetest/machine/Resume.tst.ts b/packages/effect-machine/typetest/machine/Resume.tst.ts index a10c7cb..730d4f8 100644 --- a/packages/effect-machine/typetest/machine/Resume.tst.ts +++ b/packages/effect-machine/typetest/machine/Resume.tst.ts @@ -7,23 +7,31 @@ import { AtomMachine } from "../../src/unstable/reactivity/index.js" class Idle extends Schema.TaggedClass("Idle")("Idle", { value: Schema.Number }) {} class Tick extends Schema.TaggedClass("Tick")("Tick", {}) {} -const States = Machine.states({ Idle }) -type Snapshot = Machine.Machine.Snapshot +const States = Machine.state({ initial: "Idle", states: { Idle } }) +type Snapshot = Machine.Snapshot const machine = Machine.make({ - states: States.states, - events: Machine.events(Tick), + root: States, + events: Machine.eventsFromSchemas(Tick), input: Schema.Struct({ seed: Schema.Number }), - initial: (to) => to.Idle().resolve(({ input: input, target }) => (target.decoded(new Idle({ value: input.seed })))) + initialConfiguration: (root) => + root.resolve(({ input: input, target }) => (target.from((to) => to.Idle.decoded(new Idle({ value: input.seed }))))) }).handle({ - Idle: { - on: { - Tick: (to) => to.full.Idle().resolve(({ state, target }) => target.decoded(new Idle({ value: state.value + 1 }))) + states: { + Idle: { + on: { + Tick: (to) => + to.branch.Idle().resolve(({ state, target }) => target.decoded(new Idle({ value: state.value + 1 }))) + } } } }) -const snapshot: Snapshot = { path: "Idle", value: new Idle({ value: 3 }) } +const snapshot: Snapshot = { + path: "" as const, + value: undefined, + state: { path: "Idle", value: new Idle({ value: 3 }) } +} describe("Machine logical resumption", () => { it("excludes input while preserving synchronous runtime inference", () => { @@ -40,8 +48,9 @@ describe("Machine logical resumption", () => { expect>().type.toBe() expect(Machine.resume).type.not.toBeCallableWith(machine, snapshot, { seed: 1 }) expect(Machine.resume).type.not.toBeCallableWith(machine, { + version: 2, _tag: "MachineSnapshot", - active: [{ path: "Idle", value: { _tag: "Idle", value: 3 } }] + active: [{ path: "" }, { path: "Idle", value: { _tag: "Idle", value: 3 } }] }) }) @@ -53,6 +62,6 @@ describe("Machine logical resumption", () => { expect(AtomMachine.resume).type.toBeCallableWith(machine, snapshot) expect().type.toBe() expect>().type.toBe() - expect>().type.toBe() + expect>().type.toBe() }) }) diff --git a/packages/effect-machine/typetest/machine/Root.tst.ts b/packages/effect-machine/typetest/machine/Root.tst.ts new file mode 100644 index 0000000..cadd7ca --- /dev/null +++ b/packages/effect-machine/typetest/machine/Root.tst.ts @@ -0,0 +1,72 @@ +import { Schema } from "effect" +import { describe, expect, test } from "tstyche" +import { Machine } from "../../src/index.js" + +const root = Machine.state({ + fields: { count: Schema.Number }, + initial: "Editing", + states: { + Editing: { fields: { draft: Schema.String } }, + Saving: {} + } +}) +const events = Machine.events({ Increment: { by: Schema.Number }, Save: {} }) + +describe("root contracts", () => { + test("preserves fields and child paths", () => { + expect(root.path("")).type.toBe<"">() + expect(root.path("Editing")).type.toBe<"Editing">() + expect>().type.toBe<{ readonly _tag: ""; readonly count: number }>() + expect>().type.toBe<{ readonly _tag: "Editing"; readonly draft: string }>() + expect(root.path).type.not.toBeCallableWith("Missing") + }) + + test("requires root values and default child values before execution", () => { + // A valued root needs an initializer. + // @ts-expect-error not assignable + Machine.make({ root, events }) + const definition = Machine.make({ root, events, initial: (root) => root.from(() => ({ count: 0 })) }) + const incomplete = definition.handle({}) + // The default Editing state has no draft value yet. + // @ts-expect-error MissingInitialImplementation + Machine.start(incomplete) + const complete = definition.handle({ initialize: ({ builder }) => builder.from({ draft: "" }) }) + expect(Machine.start).type.toBeCallableWith(complete) + }) + + test("reusing a root restores default initialization requirements", () => { + const explicit = Machine.make({ + root, + events, + initialConfiguration: (root) => root.resolve(({ target }) => target.from({ count: 0 }, (to) => to.Saving.from())) + }).handle({}) + expect(Machine.start).type.toBeCallableWith(explicit) + const reused = Machine.make({ root: explicit.root, events, initial: (root) => root.from(() => ({ count: 0 })) }) + .handle({}) + // An override belongs to a machine, not its reusable root descriptor. + // @ts-expect-error MissingInitialImplementation + Machine.start(reused) + }) + + test("does not accept raw constructor shorthand or incompatible fields", () => { + // fields and schema express different construction contracts. + // @ts-expect-error not assignable + Machine.state({ fields: { count: Schema.Number }, schema: Schema.TaggedStruct("Count", { count: Schema.Number }) }) + // Tags are generated by the event protocol. + // @ts-expect-error EventProtocolError + Machine.events({ Save: { _tag: Schema.String } }) + Machine.make({ + root, + events, + initial: (root) => { + // Direct from takes a callback, not an object. + // @ts-expect-error does not exist + root.from({ count: 0 }) + // Root count is numeric. + // @ts-expect-error not assignable + root.from(() => ({ count: "zero" })) + return root.from(() => ({ count: 0 })) + } + }) + }) +}) diff --git a/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts b/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts index 498b630..919c95f 100644 --- a/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts +++ b/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts @@ -10,23 +10,26 @@ class Right extends Schema.TaggedClass("Right")("Right", {}) {} class RightIdle extends Schema.TaggedClass("RightIdle")("RightIdle", {}) {} class Advance extends Schema.TaggedClass("Advance")("Advance", {}) {} -const States = Machine.states({ - Root: { - schema: Root, - type: "parallel", - states: { - Left: { - schema: Left, - initial: "LeftIdle", - states: { - LeftIdle, - LeftDone: { schema: LeftDone, type: "final" } +const States = Machine.state({ + initial: "Root", + states: { + Root: { + schema: Root, + type: "parallel", + states: { + Left: { + schema: Left, + initial: "LeftIdle", + states: { + LeftIdle, + LeftDone: { schema: LeftDone, type: "final" } + } + }, + Right: { + schema: Right, + initial: "RightIdle", + states: { RightIdle } } - }, - Right: { - schema: Right, - initial: "RightIdle", - states: { RightIdle } } } } @@ -35,42 +38,43 @@ const States = Machine.states({ describe("Machine transition snapshot context", () => { it("infers the complete machine snapshot for event, always, and onDone handlers", () => { Machine.make({ - states: States.states, - events: Machine.events(Advance), - initial: (to) => - to.Root.initial.resolve(({ target }) => (target.decoded( - new Root({}), - (root) => + root: States, + events: Machine.eventsFromSchemas(Advance), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => + to.Root.decoded(new Root({}), (root) => root .Left.decoded(new Left({}), (left) => left.LeftIdle.decoded(new LeftIdle({}))) - .Right.decoded(new Right({}), (right) => right.RightIdle.decoded(new RightIdle({}))) + .Right.decoded(new Right({}), (right) => right.RightIdle.decoded(new RightIdle({})))) ))) }).handle({ - Root: { - states: { - Left: { - onDone: (to) => - to.local.LeftIdle().resolve(({ snapshot, target }) => { - expect(snapshot).type.toBe>() - expect(States.matches).type.toBeCallableWith(snapshot, "Root.Right.RightIdle") - expect(States.get).type.toBeCallableWith(snapshot, "Root.Right.RightIdle") - expect(States.getSnapshot).type.toBeCallableWith(snapshot, "Root.Right.RightIdle") - return target.decoded(new LeftIdle({})) - }), - states: { - LeftIdle: { - always: (to) => - to.none.resolve(({ snapshot }) => { - expect(snapshot).type.toBe>() - return undefined - }), - on: { - Advance: (to) => - to.local.LeftDone().resolve(({ snapshot, target }) => { - expect(snapshot).type.toBe>() - expect(States.matches(snapshot, "Root.Right.RightIdle")).type.toBe() - return target.decoded(new LeftDone({})) - }) + states: { + Root: { + states: { + Left: { + onDone: (to) => + to.local.LeftIdle().resolve(({ snapshot, target }) => { + expect(snapshot).type.toBe>() + expect(States.matches).type.toBeCallableWith(snapshot, "Root.Right.RightIdle") + expect(States.get).type.toBeCallableWith(snapshot, "Root.Right.RightIdle") + expect(States.getSnapshot).type.toBeCallableWith(snapshot, "Root.Right.RightIdle") + return target.decoded(new LeftIdle({})) + }), + states: { + LeftIdle: { + always: (to) => + to.none.resolve(({ snapshot }) => { + expect(snapshot).type.toBe>() + return undefined + }), + on: { + Advance: (to) => + to.local.LeftDone().resolve(({ snapshot, target }) => { + expect(snapshot).type.toBe>() + expect(States.matches(snapshot, "Root.Right.RightIdle")).type.toBe() + return target.decoded(new LeftDone({})) + }) + } } } } @@ -83,32 +87,38 @@ describe("Machine transition snapshot context", () => { it("does not expose a fabricated snapshot to choices or state actions", () => { class Flow extends Schema.TaggedClass("Flow")("Flow", {}) {} class Active extends Schema.TaggedClass("Active")("Active", {}) {} - const choiceStates = Machine.states({ - Flow: { - schema: Flow, - initial: "Routing", - states: { - Routing: { type: "choice" }, - Active + const choiceStates = Machine.state({ + initial: "Flow", + states: { + Flow: { + schema: Flow, + initial: "Routing", + states: { + Routing: { type: "choice" }, + Active + } } } }) Machine.make({ - states: choiceStates.states, - events: Machine.events(), - initial: (to) => to.Flow.initial.resolve(({ target }) => (target.decoded(new Flow({}), (flow) => flow.Routing()))) + root: choiceStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.Flow.decoded(new Flow({}), (flow) => flow.Routing())))) }).handle({ - Flow: { - entry: (context) => { - expect(context).type.not.toHaveProperty("snapshot") - }, - states: { - Routing: { - choice: (to) => - to.local.Active().resolve((context) => { - expect(context).type.not.toHaveProperty("snapshot") - return context.target.decoded(new Active({})) - }) + states: { + Flow: { + entry: (context) => { + expect(context).type.not.toHaveProperty("snapshot") + }, + states: { + Routing: { + choice: (to) => + to.local.Active().resolve((context) => { + expect(context).type.not.toHaveProperty("snapshot") + return context.target.decoded(new Active({})) + }) + } } } } diff --git a/packages/effect-machine/typetest/machine/StateDefinition.tst.ts b/packages/effect-machine/typetest/machine/StateDefinition.tst.ts index 903f63e..b93a640 100644 --- a/packages/effect-machine/typetest/machine/StateDefinition.tst.ts +++ b/packages/effect-machine/typetest/machine/StateDefinition.tst.ts @@ -14,28 +14,32 @@ describe("exact state definitions", () => { title: "Idle", arbitrarySchemaAnnotation: { owner: "machine-team" } }) - const states = Machine.states({ - Atomic: { schema: Idle, type: "active" }, - SchemaAtomic: AnnotatedIdle, - Final: { schema: Done, type: "final", output: Schema.String }, - Compound: { - schema: Root, - initial: "Idle", - states: { - Idle, - Choice: { type: "choice", annotations: { title: "Route" } }, - History: { type: "history", history: "deep", annotations: { description: "Resume" } } + const states = Machine.state({ + initial: "Atomic", + states: { + Atomic: { schema: Idle, type: "active" }, + SchemaAtomic: AnnotatedIdle, + Final: { schema: Done, type: "final", output: Schema.String }, + Compound: { + schema: Root, + initial: "Idle", + states: { + Idle, + Choice: { type: "choice", annotations: { title: "Route" } }, + History: { type: "history", history: "deep", annotations: { description: "Resume" } } + } + }, + Parallel: { + schema: Root, + type: "parallel", + output: Schema.String, + states: { Region: Idle } } - }, - Parallel: { - schema: Root, - type: "parallel", - output: Schema.String, - states: { Region: Idle } } }) - expect>().type.toBe< + expect>().type.toBe< + | "" | "Atomic" | "SchemaAtomic" | "Final" @@ -49,26 +53,41 @@ describe("exact state definitions", () => { }) it("rejects unknown properties for every state kind, including nested nodes", () => { - expect(Machine.states).type.not.toBeCallableWith({ - Idle: { schema: Idle, unknown: true } - }) - expect(Machine.states).type.not.toBeCallableWith({ - Done: { schema: Done, type: "final", unknown: true } - }) - expect(Machine.states).type.not.toBeCallableWith({ - Root: { schema: Root, initial: "Idle", states: { Idle }, unknown: true } - }) - expect(Machine.states).type.not.toBeCallableWith({ - Root: { schema: Root, type: "parallel", states: { Idle }, unknown: true } - }) - expect(Machine.states).type.not.toBeCallableWith({ - Root: { - schema: Root, - initial: "Idle", - states: { - Idle, - History: { type: "history", unknown: true }, - Choice: { type: "choice", unknown: true } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Idle", + states: { + Idle: { schema: Idle, unknown: true } + } + }) + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Done", + states: { + Done: { schema: Done, type: "final", unknown: true } + } + }) + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Root", + states: { + Root: { schema: Root, initial: "Idle", states: { Idle }, unknown: true } + } + }) + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Root", + states: { + Root: { schema: Root, type: "parallel", states: { Idle }, unknown: true } + } + }) + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Root", + states: { + Root: { + schema: Root, + initial: "Idle", + states: { + Idle, + History: { type: "history", unknown: true }, + Choice: { type: "choice", unknown: true } + } } } }) @@ -80,7 +99,7 @@ describe("exact state definitions", () => { states: { Idle: { schema: Idle, nestedUnknown: true } } } }, - events: Machine.events(), + events: Machine.eventsFromSchemas(), initial: (): never => { throw new Error("unreachable") } @@ -90,24 +109,27 @@ describe("exact state definitions", () => { it("rejects empty, dotted, numeric-form, symbol, and __proto__ keys recursively", () => { const symbolKey = Symbol("state") - expect(Machine.states).type.not.toBeCallableWith({ "": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ "bad.path": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ 0: Idle }) - expect(Machine.states).type.not.toBeCallableWith({ "01": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ "-1": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ "1e3": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ "0x10": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ " 1": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ "1 ": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ " ": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ "\t": Idle }) - expect(Machine.states).type.not.toBeCallableWith({ [symbolKey]: Idle }) - expect(Machine.states).type.not.toBeCallableWith({ __proto__: Idle }) - expect(Machine.states).type.not.toBeCallableWith({ - Root: { - schema: Root, - initial: "bad.path", - states: { "bad.path": Idle } + expect(Machine.state).type.not.toBeCallableWith({ initial: "", states: { "": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: "bad.path", states: { "bad.path": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: "0", states: { 0: Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: "01", states: { "01": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: "-1", states: { "-1": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: "1e3", states: { "1e3": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: "0x10", states: { "0x10": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: " 1", states: { " 1": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: "1 ", states: { "1 ": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: " ", states: { " ": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: "\t", states: { "\t": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: symbolKey, states: { [symbolKey]: Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ initial: "__proto__", states: { __proto__: Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Root", + states: { + Root: { + schema: Root, + initial: "bad.path", + states: { "bad.path": Idle } + } } }) @@ -135,17 +157,23 @@ describe("exact state definitions", () => { }) it("rejects child keys reserved by definition-time target selectors", () => { - expect(Machine.states).type.not.toBeCallableWith({ - Root: { - initial: "initial", - states: { initial: Idle } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Root", + states: { + Root: { + initial: "initial", + states: { initial: Idle } + } } }) - expect(Machine.states).type.not.toBeCallableWith({ - Root: { - schema: Root, - initial: "with", - states: { with: Idle } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "Root", + states: { + Root: { + schema: Root, + initial: "with", + states: { with: Idle } + } } }) }) @@ -159,27 +187,30 @@ describe("exact state definitions", () => { Applying: Done } }) - const States = Machine.states({ - root: { - type: "parallel", - states: { - trading: { - type: "parallel", - states: { - slot1: TradingSlot, - slot2: TradingSlot, - slot3: TradingSlot, - slot4: TradingSlot, - slot5: TradingSlot, - slot6: TradingSlot - } - }, - teamStatus: { - initial: "TeamLoaded", - states: { - TeamLoaded: { - initial: "SlotSelected", - states: { SlotSelected: Idle } + const States = Machine.state({ + initial: "root", + states: { + root: { + type: "parallel", + states: { + trading: { + type: "parallel", + states: { + slot1: TradingSlot, + slot2: TradingSlot, + slot3: TradingSlot, + slot4: TradingSlot, + slot5: TradingSlot, + slot6: TradingSlot + } + }, + teamStatus: { + initial: "TeamLoaded", + states: { + TeamLoaded: { + initial: "SlotSelected", + states: { SlotSelected: Idle } + } } } } @@ -197,7 +228,7 @@ describe("exact state definitions", () => { expect(inSessionPath(3)).type.toBe<"root.trading.slot3.InSession">() expect>().type.toBe() expect>().type.toBe< - Machine.Machine.Snapshot + Machine.Snapshot >() const invalidFamily = null as unknown as `root.trading.slot${TeamSlot}.Missing` diff --git a/packages/effect-machine/typetest/machine/StateUpdate.tst.ts b/packages/effect-machine/typetest/machine/StateUpdate.tst.ts index c9b3133..0f2ee0c 100644 --- a/packages/effect-machine/typetest/machine/StateUpdate.tst.ts +++ b/packages/effect-machine/typetest/machine/StateUpdate.tst.ts @@ -11,84 +11,91 @@ class SignedIn extends Schema.TaggedClass("SignedIn")("SignedIn", {}) class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} class Tick extends Schema.TaggedClass("Tick")("Tick", {}) {} -const States = Machine.states({ - root: { - schema: Root, - initial: "work", - states: { - work: { - schema: Work, - type: "parallel", - states: { - auth: { - schema: Auth, - initial: "signedOut", - states: { signedOut: SignedOut, signedIn: { schema: SignedIn, type: "final" } } - }, - sync: { - schema: Sync, - initial: "idle", - states: { idle: Idle } +const States = Machine.state({ + initial: "structural", + states: { + root: { + schema: Root, + initial: "work", + states: { + work: { + schema: Work, + type: "parallel", + states: { + auth: { + schema: Auth, + initial: "signedOut", + states: { signedOut: SignedOut, signedIn: { schema: SignedIn, type: "final" } } + }, + sync: { + schema: Sync, + initial: "idle", + states: { idle: Idle } + } } - } - }, - routing: { type: "choice" } + }, + routing: { type: "choice" } + } + }, + structural: { + initial: "idle", + states: { idle: Idle } } - }, - structural: { - initial: "idle", - states: { idle: Idle } } }) describe("Machine state-value updates", () => { it("exposes updates only for the valued active ancestor chain", () => { const machine = Machine.make({ - states: States.states, - events: Machine.events(Tick), - initial: (to) => - to.root.initial.resolve(({ target }) => - target.decoded(new Root({ revision: 0 }), (root) => - root.work.decoded( - new Work({ revision: 0 }), - (work) => - work.auth.decoded( - new Auth({ user: "" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) - .sync.decoded(new Sync({ cursor: 0 }), (sync) => sync.idle.decoded(new Idle({}))) - )) + root: States, + events: Machine.eventsFromSchemas(Tick), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.root.decoded(new Root({ revision: 0 }), (root) => + root.work.decoded( + new Work({ revision: 0 }), + (work) => + work.auth.decoded( + new Auth({ user: "" }), + (auth) => auth.signedOut.decoded(new SignedOut({})) + ) + .sync.decoded(new Sync({ cursor: 0 }), (sync) => sync.idle.decoded(new Idle({}))) + )) + ) ) }) machine.handle({ - root: { - states: { - work: { - states: { - auth: { - states: { - signedOut: { - on: { - Tick: (to) => { - expect(to.local).type.toHaveProperty("update") - expect(to.local.update).type.not.toHaveProperty("resolve") - expect(to.branch.root).type.toHaveProperty("update") - expect(to.branch.root.work).type.toHaveProperty("update") - expect(to.branch.root.work.auth).type.toHaveProperty("update") - expect(to.branch.root.work.auth.signedOut).type.not.toHaveProperty("update") - expect(to.branch.root.work.sync).type.not.toHaveProperty("update") - expect(to.full).type.not.toHaveProperty("update") - expect(to.history).type.not.toHaveProperty("update") - expect(to.none).type.not.toHaveProperty("update") - - return to.local.update(({ current, owner, state }) => { - expect(state).type.toBe() - expect(current).type.toBe() - expect(owner.decoded).type.toBeCallableWith(new Auth({ user: "next" })) - expect(owner.from).type.toBeCallableWith({ user: "next" }) - return owner.decoded(new Auth({ user: "next" })) - }, { reenter: true }) + states: { + root: { + states: { + work: { + states: { + auth: { + states: { + signedOut: { + on: { + Tick: (to) => { + expect(to.local).type.toHaveProperty("update") + expect(to.local.update).type.toHaveProperty("resolve") + expect(to.branch.root).type.toHaveProperty("update") + expect(to.branch.root.work).type.toHaveProperty("update") + expect(to.branch.root.work.auth).type.toHaveProperty("update") + expect(to.branch.root.work.auth.signedOut).type.toHaveProperty("update") + expect(to.branch.root.work.sync).type.not.toHaveProperty("update") + expect(to.full).type.not.toHaveProperty("update") + expect(to.history).type.not.toHaveProperty("update") + expect(to.none).type.not.toHaveProperty("update") + + return to.local.update.resolve(({ current, owner, state }) => { + expect(state).type.toBe() + expect(current).type.toBe() + expect(owner.decoded).type.toBeCallableWith(new Auth({ user: "next" })) + expect(owner.from).type.toBeCallableWith({ user: "next" }) + return owner.decoded(new Auth({ user: "next" })) + }, { reenter: true }) + } } } } @@ -103,12 +110,12 @@ describe("Machine state-value updates", () => { it("requires a declared retained owner to be replaced by the combined resolver", () => { const transition = null as unknown as Machine.Machine.TransitionSelector< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof Tick], readonly [], "root.work.auth.signedOut", Machine.Machine.HandlerContext< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof Tick], readonly [], "root.work.auth.signedOut", @@ -138,7 +145,7 @@ describe("Machine state-value updates", () => { transition.local.signedIn() .updating(transition.branch.root.work.auth) .resolve( - // @ts-expect-error! Declaring `.updating(...)` requires the resolver to finish with `.update(...)`. + // @ts-expect-error! Declaring `.updating(...)` requires the resolver to finish with `.update.resolve(...)`. ({ target }) => target.decoded(new SignedIn({})) ) @@ -153,17 +160,17 @@ describe("Machine state-value updates", () => { } }) - expect(transition.full.structural()).type.not.toHaveProperty("updating") + expect(transition.branch.structural()).type.not.toHaveProperty("updating") }) it("supports named update branches and rejects missing update evidence", () => { const update = null as unknown as Machine.Machine.TransitionSelector< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof Tick], readonly [], "root.work.auth.signedOut", Machine.Machine.HandlerContext< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof Tick], readonly [], "root.work.auth.signedOut", @@ -180,14 +187,14 @@ describe("Machine state-value updates", () => { unchanged: { target: update.none } }).resolve(({ select }) => select.changed.from({ revision: 1 })) - update.local.update(({ owner }) => owner.from({ user: "next" }), { declinable: true }) - update.local.update(({ decline }) => decline(), { declinable: true }) + update.local.update.resolve(({ owner }) => owner.from({ user: "next" }), { declinable: true }) + update.local.update.resolve(({ decline }) => decline(), { declinable: true }) - update.local.update( + update.local.update.resolve( // @ts-expect-error! () => new Auth({ user: "next" }) ) - update.local.update( + update.local.update.resolve( // @ts-expect-error! () => undefined ) @@ -195,12 +202,12 @@ describe("Machine state-value updates", () => { it("omits updates from structural scopes and choice resolvers", () => { type StructuralSelector = Machine.Machine.TransitionSelector< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof Tick], readonly [], "structural.idle", Machine.Machine.HandlerContext< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof Tick], readonly [], "structural.idle", @@ -216,11 +223,16 @@ describe("Machine state-value updates", () => { expect(structural.branch.structural).type.not.toHaveProperty("update") type ChoiceSelector = Machine.Machine.TransitionSelector< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof Tick], readonly [], "root.routing", - Machine.Machine.ChoiceContext, + Machine.Machine.ChoiceContext< + { readonly "": typeof States.node }, + readonly [typeof Tick], + readonly [], + "root.routing" + >, false, "required" > @@ -229,12 +241,12 @@ describe("Machine state-value updates", () => { expect(choice.branch.root).type.not.toHaveProperty("update") type FinalSelector = Machine.Machine.TransitionSelector< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof Tick], readonly [], "root.work.auth.signedIn", Machine.Machine.HandlerContext< - typeof States.states, + { readonly "": typeof States.node }, readonly [typeof Tick], readonly [], "root.work.auth.signedIn", diff --git a/packages/effect-machine/typetest/machine/StructuralStates.tst.ts b/packages/effect-machine/typetest/machine/StructuralStates.tst.ts index f29098d..acf1845 100644 --- a/packages/effect-machine/typetest/machine/StructuralStates.tst.ts +++ b/packages/effect-machine/typetest/machine/StructuralStates.tst.ts @@ -25,28 +25,31 @@ class Loaded extends Schema.TaggedClass("StructuralTypeLoaded")("Loaded" }) {} class Play extends Schema.TaggedClass("StructuralTypePlay")("Play", {}) {} -const States = Machine.states({ - player: { - type: "parallel", - states: { - transport: { - initial: "Empty", - states: { - Empty: {}, - Loading, - Ready: { - schema: Ready, - initial: "Paused", - states: { - Paused: {}, - Playing +const States = Machine.state({ + initial: "player", + states: { + player: { + type: "parallel", + states: { + transport: { + initial: "Empty", + states: { + Empty: {}, + Loading, + Ready: { + schema: Ready, + initial: "Paused", + states: { + Paused: {}, + Playing + } } } + }, + settings: { + initial: "Audible", + states: { Audible, Muted } } - }, - settings: { - initial: "Audible", - states: { Audible, Muted } } } } @@ -54,14 +57,15 @@ const States = Machine.states({ describe("structural active state types", () => { it("separates active, valued, and structural identifiers", () => { - expect>().type.toBe< + expect>().type.toBe< | "player.transport.Loading" | "player.transport.Ready" | "player.transport.Ready.Playing" | "player.settings.Audible" | "player.settings.Muted" >() - expect>().type.toBe< + expect>().type.toBe< + | "" | "player" | "player.transport" | "player.transport.Empty" @@ -72,14 +76,18 @@ describe("structural active state types", () => { it("requires values only for schema-backed snapshot builders", () => { Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.player.initial.resolve(({ target }) => { + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => { expect(target.from).type.not.toBeCallableWith({}, () => undefined) expect) => any ? true : false>().type.toBe() - type PlayerBuilder = Parameters[0] extends (builder: infer Builder) => unknown ? Builder + type RootBuilder = Parameters[0] extends (builder: infer Builder) => unknown ? Builder + : never + const tree = null as unknown as RootBuilder + type PlayerBuilder = Parameters[0] extends (builder: infer Builder) => unknown + ? Builder : never const player = null as unknown as PlayerBuilder expect(player.transport.from).type.not.toBeCallableWith((transport: unknown) => transport) @@ -97,17 +105,19 @@ describe("structural active state types", () => { expect(settings.Audible.from).type.toBeCallableWith({ volume: 1 }) expect(settings.Audible.from).type.not.toBeCallableWith() - return target.from((player) => - player - .transport.from((transport) => transport.Empty.from()) - .settings.from((settings) => settings.Audible.from({ volume: 1 })) + return target.from((to) => + to.player.from((player) => + player + .transport.from((transport) => transport.Empty.from()) + .settings.from((settings) => settings.Audible.from({ volume: 1 })) + ) ) }) }) }) it("restricts value access while retaining structural snapshot queries", () => { - type Snapshot = Machine.Machine.Snapshot + type Snapshot = Machine.Snapshot const snapshot = null as unknown as Snapshot expect(States.get(snapshot, "player.transport.Loading")).type.toBe>() @@ -119,69 +129,73 @@ describe("structural active state types", () => { expect(States.matches).type.toBeCallableWith(snapshot, "player.transport.Empty") expect(States.getSnapshot).type.toBeCallableWith(snapshot, "player.transport") expect(States.getSnapshot(snapshot, "player.transport")).type.toBe< - Option.Option> + Option.Option> >() }) it("types structural handler contexts and targets without fake values", () => { Machine.make({ - states: States.states, - events: Machine.events(Select, Loaded, Play), - initial: (to) => - to.player.initial.resolve(({ target }) => (target.from((player) => - player - .transport.from((transport) => transport.Empty.from()) - .settings.from((settings) => settings.Audible.from({ volume: 1 })) + root: States, + events: Machine.eventsFromSchemas(Select, Loaded, Play), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => + to.player.from((player) => + player + .transport.from((transport) => transport.Empty.from()) + .settings.from((settings) => settings.Audible.from({ volume: 1 })) + ) ))) }).handle({ - player: { - states: { - transport: { - states: { - Empty: { - entry: ({ state }) => { - expect(state).type.toBe() - }, - on: { - Select: (to) => { - expect(to.local).type.not.toHaveProperty("with") - return to.local.Loading().resolve(({ containingState, ancestors, state, target }) => { - expect(state).type.toBe() - expect(containingState).type.toBe() - expect(ancestors).type.toBe<{}>() - expect(target.from).type.toBeCallableWith({ url: "/song.mp3" }) - expect(target.from).type.not.toBeCallableWith() - return target.from({ url: "/song.mp3" }) - }) + states: { + player: { + states: { + transport: { + states: { + Empty: { + entry: ({ state }) => { + expect(state).type.toBe() + }, + on: { + Select: (to) => { + expect(to.local).type.not.toHaveProperty("with") + return to.local.Loading().resolve(({ containingState, ancestors, state, target }) => { + expect(state).type.toBe() + expect(containingState).type.toBe() + expect(ancestors).type.toBe<{}>() + expect(target.from).type.toBeCallableWith({ url: "/song.mp3" }) + expect(target.from).type.not.toBeCallableWith() + return target.from({ url: "/song.mp3" }) + }) + } } - } - }, - Loading: { - on: { - Loaded: (to) => - to.local.Ready().resolve(({ event, target }) => - target.from( - { duration: event.duration }, - (ready) => ready.Paused.from() + }, + Loading: { + on: { + Loaded: (to) => + to.local.Ready().resolve(({ event, target }) => + target.from( + { duration: event.duration }, + (ready) => ready.Paused.from() + ) ) - ) - } - }, - Ready: { - states: { - Paused: { - on: { - Play: (to) => - to.local.with.resolve(({ containingState, ancestors, state, target }) => { - expect(to.local.with).type.not.toBeAssignableTo<() => unknown>() - expect(state).type.toBe() - expect(containingState).type.toBe() - expect(ancestors).type.toBe<{ readonly "player.transport.Ready": Ready }>() - return target.from( - { duration: containingState.duration }, - (ready) => ready.Playing.from({ position: 0 }) - ) - }) + } + }, + Ready: { + states: { + Paused: { + on: { + Play: (to) => + to.local.with.resolve(({ containingState, ancestors, state, target }) => { + expect(to.local.with).type.not.toBeAssignableTo<() => unknown>() + expect(state).type.toBe() + expect(containingState).type.toBe() + expect(ancestors).type.toBe<{ readonly "player.transport.Ready": Ready }>() + return target.from( + { duration: containingState.duration }, + (ready) => ready.Playing.from({ position: 0 }) + ) + }) + } } } } @@ -194,14 +208,23 @@ describe("structural active state types", () => { }) it("rejects malformed structural declarations", () => { - expect(Machine.states).type.not.toBeCallableWith({ - invalid: { states: { child: {} } } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "invalid", + states: { + invalid: { states: { child: {} } } + } }) - expect(Machine.states).type.not.toBeCallableWith({ - invalid: { type: "parallel" } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "invalid", + states: { + invalid: { type: "parallel" } + } }) - expect(Machine.states).type.not.toBeCallableWith({ - invalid: { schema: undefined } + expect(Machine.state).type.not.toBeCallableWith({ + initial: "invalid", + states: { + invalid: { schema: undefined } + } }) }) }) diff --git a/packages/effect-machine/typetest/testing/Coverage.tst.ts b/packages/effect-machine/typetest/testing/Coverage.tst.ts index 333c3f1..11cad41 100644 --- a/packages/effect-machine/typetest/testing/Coverage.tst.ts +++ b/packages/effect-machine/typetest/testing/Coverage.tst.ts @@ -10,18 +10,20 @@ describe("MachineTest coverage and observed graph", () => { class Done extends Schema.TaggedClass("Done")("Done", {}) {} class Start extends Schema.TaggedClass("Start")("Start", {}) {} - const States = Machine.states({ idle: Idle, done: Done }) + const States = Machine.state({ initial: "idle", states: { idle: Idle, done: Done } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(Start), - initial: (to) => to.idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: States, + events: Machine.eventsFromSchemas(Start), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) }).handle({ - idle: { - on: { - Start: (to) => to.full.done().resolve(({ target }) => target.decoded(new Done({}))) - } - }, - done: {} + states: { + idle: { + on: { + Start: (to) => to.branch.done().resolve(({ target }) => target.decoded(new Done({}))) + } + }, + done: {} + } }) const trace = {} as MachineTest.Trace @@ -30,15 +32,15 @@ describe("MachineTest coverage and observed graph", () => { expect(result).type.toBe>() if (result.events.available) expect(result.events.hits[0]!.tag).type.toBe<"Start">() - expect(result.states.activation.hits[0]!.path).type.toBe<"idle" | "done">() - expect(result.transitions.definitions.hits[0]!.source).type.toBe<"idle" | "done">() + expect(result.states.activation.hits[0]!.path).type.toBe<"" | "idle" | "done">() + expect(result.transitions.definitions.hits[0]!.source).type.toBe<"" | "idle" | "done">() expect(result.transitions.definitions.hits[0]!.trigger).type.toBe>() expect(result.transitions.definitions.hits[0]!.acceptance).type.toBe() - expect(result.transitions.branches.hits[0]!.source).type.toBe<"idle" | "done">() + expect(result.transitions.branches.hits[0]!.source).type.toBe<"" | "idle" | "done">() expect(result.transitions.branches.hits[0]!.trigger).type.toBe>() expect(result.transitions.branches.hits[0]!.acceptance).type.toBe() expect(result.transitions.branches.hits[0]!.branch).type.toBe< - Machine.Machine.TransitionBranch<"idle" | "done"> + Machine.Machine.TransitionBranch<"" | "idle" | "done"> >() }) @@ -57,7 +59,7 @@ describe("MachineTest coverage and observed graph", () => { type Node = MachineTest.ObservedGraphNode type Edge = MachineTest.ObservedGraphEdge - expect().type.toBe<"idle" | "done">() + expect().type.toBe<"" | "idle" | "done">() expect().type.toBe() expect["event"]>().type.toBe() }) diff --git a/packages/effect-machine/typetest/testing/Exploration.tst.ts b/packages/effect-machine/typetest/testing/Exploration.tst.ts index 489f096..441ddd0 100644 --- a/packages/effect-machine/typetest/testing/Exploration.tst.ts +++ b/packages/effect-machine/typetest/testing/Exploration.tst.ts @@ -9,18 +9,21 @@ describe("MachineTest exploration", () => { class Increment extends Schema.TaggedClass("Increment")("Increment", {}) {} class Internal extends Schema.TaggedClass("Internal")("Internal", {}) {} - const States = Machine.states({ counter: Counter }) + const States = Machine.state({ initial: "counter", states: { counter: Counter } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(Increment), - internalEvents: Machine.internalEvents(Internal), + root: States, + events: Machine.eventsFromSchemas(Increment), + internalEvents: Machine.internalEventsFromSchemas(Internal), input: Input, - initial: (to) => to.counter().resolve(({ input, target }) => target.decoded(new Counter({ count: input.seed }))) + initialConfiguration: (root) => + root.resolve(({ input, target }) => target.from((to) => to.counter.decoded(new Counter({ count: input.seed })))) }).handle({ - counter: { - on: { - Increment: (to) => to.none, - Internal: (to) => to.none + states: { + counter: { + on: { + Increment: (to) => to.none, + Internal: (to) => to.none + } } } }) @@ -29,13 +32,13 @@ describe("MachineTest exploration", () => { const explored = MachineTest.explore(machine, { input: new Input({ seed: 0 }), events: (context) => { - expect(context.snapshot.value).type.toBe() - expect(context.configuration[0]).type.toBe<"counter" | undefined>() + expect(context.snapshot.state.value).type.toBe() + expect(context.configuration[0]).type.toBe<"" | "counter" | undefined>() return [new Increment({})] }, stateKey: (context) => { expect(context.trace).type.toBe>() - return context.snapshot.value.count + return context.snapshot.state.value.count } }) @@ -47,12 +50,12 @@ describe("MachineTest exploration", () => { expect>().type.toBe() type Exploration = Effect.Success - expect().type.toBe<"counter">() + expect().type.toBe<"" | "counter">() expect().type.toBe< Machine.Machine.TransitionTrigger<"Increment" | "Internal"> >() expect().type.toBe< - Machine.Machine.TransitionBranch<"counter"> + Machine.Machine.TransitionBranch<"" | "counter"> >() }) @@ -60,7 +63,7 @@ describe("MachineTest exploration", () => { const exploration = {} as MachineTest.Exploration const reachable = MachineTest.assertReachable(exploration, "one", ({ key, snapshot }) => { expect(key).type.toBe() - expect(snapshot.value).type.toBe() + expect(snapshot.state.value).type.toBe() return key === 1 }) const unreachable = MachineTest.assertUnreachable(exploration, "two", ({ key }) => key === 2) @@ -73,10 +76,11 @@ describe("MachineTest exploration", () => { it("forbids input for machines without an input schema", () => { const noInput = Machine.make({ - states: States.states, - events: Machine.events(Increment), - initial: (to) => to.counter().resolve(({ target }) => (target.decoded(new Counter({ count: 0 })))) - }).handle({ counter: {} }) + root: States, + events: Machine.eventsFromSchemas(Increment), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.counter.decoded(new Counter({ count: 0 }))))) + }).handle({ states: { counter: {} } }) type Options = MachineTest.ExploreOptions expect().type.toBe() }) diff --git a/packages/effect-machine/typetest/testing/Invariant.tst.ts b/packages/effect-machine/typetest/testing/Invariant.tst.ts index ea70b0c..892f823 100644 --- a/packages/effect-machine/typetest/testing/Invariant.tst.ts +++ b/packages/effect-machine/typetest/testing/Invariant.tst.ts @@ -8,17 +8,20 @@ describe("MachineTest invariants", () => { class Tick extends Schema.TaggedClass("Tick")("Tick", { amount: Schema.Int }) {} class Internal extends Schema.TaggedClass("Internal")("Internal", {}) {} - const States = Machine.states({ idle: Idle }) + const States = Machine.state({ initial: "idle", states: { idle: Idle } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(Tick), - internalEvents: Machine.internalEvents(Internal), - initial: (to) => to.idle().resolve(({ target }) => (target.decoded(new Idle({ count: 0 })))) + root: States, + events: Machine.eventsFromSchemas(Tick), + internalEvents: Machine.internalEventsFromSchemas(Internal), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({ count: 0 }))))) }).handle({ - idle: { - on: { - Tick: (to) => to.none, - Internal: (to) => to.none + states: { + idle: { + on: { + Tick: (to) => to.none, + Internal: (to) => to.none + } } } }) @@ -28,15 +31,15 @@ describe("MachineTest invariants", () => { it("infers exact state, event, and trace evidence from a machine-bound builder", () => { const state = define.state("state", (context) => { expect(context.machine).type.toBe() - expect(context.snapshot.value).type.toBe() - expect(context.configuration[0]).type.toBe<"idle" | undefined>() + expect(context.snapshot.state.value).type.toBe() + expect(context.configuration[0]).type.toBe<"" | "idle" | undefined>() expect(context.event).type.toBe() return true }) const step = define.step("step", (context) => { expect(context.event).type.toBe() - expect(context.before.value).type.toBe() - expect(context.after.value).type.toBe() + expect(context.before.state.value).type.toBe() + expect(context.after.state.value).type.toBe() return true }) const trace = define.trace("trace", (context) => { @@ -62,7 +65,7 @@ describe("MachineTest invariants", () => { expect>().type.toBe() expect["trace"]>().type.toBe>() expect["violations"][number]["configuration"]>().type.toBe< - ReadonlyArray<"idle"> | undefined + ReadonlyArray<"" | "idle"> | undefined >() const asserted = MachineTest.assertInvariants(machine, trace, [define.state("state", () => true)]) diff --git a/packages/effect-machine/typetest/testing/MachineTest.tst.ts b/packages/effect-machine/typetest/testing/MachineTest.tst.ts index 8772f21..6fa9bc9 100644 --- a/packages/effect-machine/typetest/testing/MachineTest.tst.ts +++ b/packages/effect-machine/typetest/testing/MachineTest.tst.ts @@ -12,18 +12,20 @@ describe("MachineTest", () => { }) {} class InternalEvent extends Schema.TaggedClass("InternalEvent")("InternalEvent", {}) {} - const States = Machine.states({ idle: Idle }) + const States = Machine.state({ initial: "idle", states: { idle: Idle } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(PublicEvent), - internalEvents: Machine.internalEvents(InternalEvent), + root: States, + events: Machine.eventsFromSchemas(PublicEvent), + internalEvents: Machine.internalEventsFromSchemas(InternalEvent), input: Input, - initial: (to) => to.idle().resolve(({ target }) => (target.decoded(new Idle({})))) + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) }).handle({ - idle: { - on: { - PublicEvent: (to) => to.none, - InternalEvent: (to) => to.none + states: { + idle: { + on: { + PublicEvent: (to) => to.none, + InternalEvent: (to) => to.none + } } } }) @@ -34,7 +36,7 @@ describe("MachineTest", () => { type Scenario = MachineTest.Scenario expect().type.toBe() - expect().type.toBe() + expect().type.toBe>() expect().type.not.toBeAssignableTo() }) @@ -49,10 +51,10 @@ describe("MachineTest", () => { it("omits input for machines without an input schema", () => { const noInput = Machine.make({ - states: States.states, - events: Machine.events(PublicEvent), - initial: (to) => to.idle().resolve(({ target }) => (target.decoded(new Idle({})))) - }).handle({ idle: {} }) + root: States, + events: Machine.eventsFromSchemas(PublicEvent), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) + }).handle({ states: { idle: {} } }) type Scenario = MachineTest.Scenario type Options = MachineTest.ScenarioOptions @@ -72,21 +74,21 @@ describe("MachineTest", () => { MachineTest.RunFailure, typeof machine> >() - expect["initial"]["startingConfiguration"][number]>().type.toBe<"idle">() - expect["initial"]["initialEntryPaths"][number]>().type.toBe<"idle">() + expect["initial"]["startingConfiguration"][number]>().type.toBe<"" | "idle">() + expect["initial"]["initialEntryPaths"][number]>().type.toBe<"" | "idle">() type InitialMicrostep = MachineTest.Trace["initial"]["plan"]["microsteps"][number] type EventMicrostep = MachineTest.Trace["steps"][number]["plan"]["microsteps"][number] - expect().type.toBe<"idle">() + expect().type.toBe<"" | "idle">() expect().type.toBe< Machine.Machine.TransitionTrigger<"PublicEvent" | "InternalEvent"> >() - expect().type.toBe<"idle" | undefined>() - expect().type.toBe<"idle" | undefined>() + expect().type.toBe<"" | "idle" | undefined>() + expect().type.toBe<"" | "idle" | undefined>() const startup = Machine.planInitial(machine, new Input({ id: "test" })) type StartupMicrostep = Effect.Success["microsteps"][number] - expect().type.toBe<"idle">() + expect().type.toBe<"" | "idle">() expect().type.toBe< Machine.Machine.TransitionTrigger<"PublicEvent" | "InternalEvent"> >() @@ -95,16 +97,18 @@ describe("MachineTest", () => { it("does not require invoke services while planning scenarios", () => { class InvokeRequirement extends Context.Service()("InvokeRequirement") {} const invokedMachine = Machine.make({ - states: States.states, - events: Machine.events(PublicEvent), - initial: (to) => to.idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: States, + events: Machine.eventsFromSchemas(PublicEvent), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) }).handle({ - idle: { - invoke: (from) => - from.effect("service-backed-invoke", () => - Effect.gen(function*() { - yield* InvokeRequirement - })).onDone((to) => to.none) + states: { + idle: { + invoke: (from) => + from.effect("service-backed-invoke", () => + Effect.gen(function*() { + yield* InvokeRequirement + })).onDone((to) => to.none) + } } }) diff --git a/packages/effect-machine/typetest/testing/Probe.tst.ts b/packages/effect-machine/typetest/testing/Probe.tst.ts index 3e7569e..30be37f 100644 --- a/packages/effect-machine/typetest/testing/Probe.tst.ts +++ b/packages/effect-machine/typetest/testing/Probe.tst.ts @@ -10,17 +10,20 @@ describe("MachineTest probe", () => { class PublicEvent extends Schema.TaggedClass("ProbeTypePublicEvent")("PublicEvent", {}) {} class InternalEvent extends Schema.TaggedClass("ProbeTypeInternalEvent")("InternalEvent", {}) {} - const states = Machine.states({ State }) + const states = Machine.state({ initial: "State", states: { State } }) const machine = Machine.make({ - states: states.states, - events: Machine.events(PublicEvent), - internalEvents: Machine.internalEvents(InternalEvent), - initial: (to) => to.State().resolve(({ target }) => (target.decoded(new State({ count: 0 })))) + root: states, + events: Machine.eventsFromSchemas(PublicEvent), + internalEvents: Machine.internalEventsFromSchemas(InternalEvent), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.State.decoded(new State({ count: 0 }))))) }).handle({ - State: { - on: { - PublicEvent: (to) => to.none, - InternalEvent: (to) => to.none + states: { + State: { + on: { + PublicEvent: (to) => to.none, + InternalEvent: (to) => to.none + } } } }) @@ -32,7 +35,7 @@ describe("MachineTest probe", () => { expect["machine"]>().type.toBe() expect>().type.toBe>() - expect["before"]["value"]>().type.toBe() + expect["before"]["state"]["value"]>().type.toBe() expect["event"]>().type.toBe() expect["plan"]["microsteps"][number]["event"]>().type.toBe< PublicEvent | InternalEvent | Machine.InitialEvent @@ -57,8 +60,8 @@ describe("MachineTest probe", () => { model: model + 1, expected: model + 1, await: probe.await.until((snapshot) => { - expect(snapshot.state.value).type.toBe() - return snapshot.state.value.count >= 0 + expect(snapshot.state.state.value).type.toBe() + return snapshot.state.state.value.count >= 0 }) }), assert: ({ actual }) => { @@ -87,9 +90,9 @@ describe("MachineTest probe", () => { const invariant = MachineTest.runtimeInvariants(machine) const laws = [ invariant.snapshot("state is typed", ({ snapshot, command }) => { - expect(snapshot.state.value).type.toBe() + expect(snapshot.state.state.value).type.toBe() expect(command).type.toBe | undefined>() - return snapshot.state.value.count >= 0 + return snapshot.state.state.value.count >= 0 }), invariant.command("public commands are typed", ({ command, result }) => { expect(command).type.toBe>() diff --git a/packages/effect-machine/typetest/testing/Verification.tst.ts b/packages/effect-machine/typetest/testing/Verification.tst.ts index 2c179ab..e611c55 100644 --- a/packages/effect-machine/typetest/testing/Verification.tst.ts +++ b/packages/effect-machine/typetest/testing/Verification.tst.ts @@ -7,12 +7,12 @@ describe("MachineTest.verify", () => { class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} class Tick extends Schema.TaggedClass("Tick")("Tick", {}) {} - const States = Machine.states({ idle: Idle }) + const States = Machine.state({ initial: "idle", states: { idle: Idle } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(Tick), - initial: (to) => to.idle().resolve(({ target }) => (target.decoded(new Idle({})))) - }).handle({ idle: {} }) + root: States, + events: Machine.eventsFromSchemas(Tick), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) + }).handle({ states: { idle: {} } }) const trace = {} as MachineTest.Trace const verified = MachineTest.verify(machine, trace) diff --git a/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts b/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts index 503c204..e6af686 100644 --- a/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts +++ b/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts @@ -64,21 +64,24 @@ describe("ClusterMachine", () => { "test/ClusterMachine/SnapshotEncoding" ) {} - const states = Machine.states({ Count }) + const states = Machine.state({ initial: "Count", states: { Count } }) const machine = Machine.make({ id: "Counter", - states: states.states, - events: Machine.events(Increment, Reset), - initial: (to) => to.Count().resolve(({ target }) => (target.decoded(new Count({ value: 0 })))) + root: states, + events: Machine.eventsFromSchemas(Increment, Reset), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.Count.decoded(new Count({ value: 0 }))))) }).handle({ - Count: { - on: { - Increment: (to) => - to.full.Count().resolve(({ event, state, target }) => - target.decoded(new Count({ value: state.value + event.by })) - ), - Reset: (to) => to.full.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + states: { + Count: { + on: { + Increment: (to) => + to.branch.Count().resolve(({ event, state, target }) => + target.decoded(new Count({ value: state.value + event.by })) + ), + Reset: (to) => to.branch.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + } } } }) @@ -91,10 +94,11 @@ describe("ClusterMachine", () => { expect>().type.toBe() const internalMachine = Machine.make({ - states: states.states, - events: Machine.events(Increment), - internalEvents: Machine.internalEvents(Reset), - initial: (to) => to.Count().resolve(({ target }) => (target.decoded(new Count({ value: 0 })))) + root: states, + events: Machine.eventsFromSchemas(Increment), + internalEvents: Machine.internalEventsFromSchemas(Reset), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.Count.decoded(new Count({ value: 0 }))))) }) const internalBridge = ClusterMachine.make("InternalCounterEntity", internalMachine, { version: "1" @@ -113,11 +117,13 @@ describe("ClusterMachine", () => { it("captures non-void machine input", () => { const inputMachine = Machine.make({ id: "InputCounter", - states: states.states, - events: Machine.events(Reset), + root: states, + events: Machine.eventsFromSchemas(Reset), input: Input, - initial: (to) => - to.Count().resolve(({ input: input, target }) => (target.decoded(new Count({ value: input.value })))) + initialConfiguration: (root) => + root.resolve(( + { input: input, target } + ) => (target.from((to) => to.Count.decoded(new Count({ value: input.value }))))) }) expect(ClusterMachine.make).type.not.toBeCallableWith( @@ -143,12 +149,14 @@ describe("ClusterMachine", () => { }) it("requires JSON-encoded state, output, and public input schemas", () => { - const resourceStates = Machine.states({ ResourceState }) + const resourceStates = Machine.state({ initial: "ResourceState", states: { ResourceState } }) const resourceMachine = Machine.make({ - states: resourceStates.states, - events: Machine.events(ResourceEvent), - initial: (to) => - to.ResourceState().resolve(({ target }) => target.decoded(new ResourceState({ resource: { close() {} } }))) + root: resourceStates, + events: Machine.eventsFromSchemas(ResourceEvent), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => to.ResourceState.decoded(new ResourceState({ resource: { close() {} } }))) + ) }) expect(ClusterMachine.make).type.not.toBeCallableWith( @@ -158,9 +166,10 @@ describe("ClusterMachine", () => { ) const unknownEventMachine = Machine.make({ - states: states.states, - events: Machine.events(UnknownEvent), - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: states, + events: Machine.eventsFromSchemas(UnknownEvent), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) }) expect(ClusterMachine.make).type.not.toBeCallableWith( @@ -169,15 +178,21 @@ describe("ClusterMachine", () => { { version: "1" } ) - const anyOutputStates = Machine.states({ - Done: { schema: Done, type: "final", output: Schema.Any } + const anyOutputStates = Machine.state({ + initial: "Done", + states: { + Done: { schema: Done, type: "final", output: Schema.Any } + } }) const anyOutputMachine = Machine.make({ - states: anyOutputStates.states, - events: Machine.events(Reset), - initial: (to) => to.Done().resolve(({ target }) => target.decoded(new Done({ value: "done" }))) + root: anyOutputStates, + events: Machine.eventsFromSchemas(Reset), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Done.decoded(new Done({ value: "done" })))) }).handle({ - Done: { output: () => null } + states: { + Done: { output: () => null } + } }) expect(ClusterMachine.make).type.not.toBeCallableWith( @@ -186,17 +201,23 @@ describe("ClusterMachine", () => { { version: "1" } ) - const neverOutputStates = Machine.states({ - Done: { schema: Done, type: "final", output: Schema.Never } + const neverOutputStates = Machine.state({ + initial: "Done", + states: { + Done: { schema: Done, type: "final", output: Schema.Never } + } }) const neverOutputMachine = Machine.make({ - states: neverOutputStates.states, - events: Machine.events(Reset), - initial: (to) => to.Done().resolve(({ target }) => target.decoded(new Done({ value: "done" }))) + root: neverOutputStates, + events: Machine.eventsFromSchemas(Reset), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Done.decoded(new Done({ value: "done" })))) }).handle({ - Done: { - output: () => { - throw new Error("unreachable") + states: { + Done: { + output: () => { + throw new Error("unreachable") + } } } }) @@ -207,12 +228,17 @@ describe("ClusterMachine", () => { { version: "1" } ) - const scheduledStates = Machine.states({ Scheduled: Schema.toCodecJson(Scheduled) }) + const scheduledStates = Machine.state({ + initial: "Scheduled", + states: { Scheduled: Schema.toCodecJson(Scheduled) } + }) const scheduledMachine = Machine.make({ - states: scheduledStates.states, - events: Machine.events(Reset), - initial: (to) => - to.Scheduled().resolve(({ target }) => target.decoded(new Scheduled({ at: new Date("2026-08-19") }))) + root: scheduledStates, + events: Machine.eventsFromSchemas(Reset), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => to.Scheduled.decoded(new Scheduled({ at: new Date("2026-08-19") }))) + ) }) expect(ClusterMachine.make).type.toBeCallableWith( @@ -222,10 +248,11 @@ describe("ClusterMachine", () => { ) const internalResourceMachine = Machine.make({ - states: states.states, - events: Machine.events(Reset), - internalEvents: Machine.internalEvents(ResourceEvent), - initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + root: states, + events: Machine.eventsFromSchemas(Reset), + internalEvents: Machine.internalEventsFromSchemas(ResourceEvent), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) }) expect(ClusterMachine.make).type.toBeCallableWith( @@ -236,17 +263,21 @@ describe("ClusterMachine", () => { }) it("requires declared output implementations", () => { - const outputStates = Machine.states({ - Done: { - schema: Done, - type: "final", - output: Schema.String + const outputStates = Machine.state({ + initial: "Done", + states: { + Done: { + schema: Done, + type: "final", + output: Schema.String + } } }) const incomplete = Machine.make({ - states: outputStates.states, - events: Machine.events(Reset), - initial: (to) => to.Done().resolve(({ target }) => (target.decoded(new Done({ value: "done" })))) + root: outputStates, + events: Machine.eventsFromSchemas(Reset), + initialConfiguration: (root) => + root.resolve(({ target }) => (target.from((to) => to.Done.decoded(new Done({ value: "done" }))))) }) expect(ClusterMachine.make).type.not.toBeCallableWith( @@ -256,8 +287,10 @@ describe("ClusterMachine", () => { ) const complete = incomplete.handle({ - Done: { - output: ({ state }) => state.value + states: { + Done: { + output: ({ state }) => state.value + } } }) expect(ClusterMachine.make).type.toBeCallableWith( @@ -281,11 +314,14 @@ describe("ClusterMachine", () => { class ContextualCount extends Schema.TaggedClass("ContextualCount")("ContextualCount", { value: ContextualNumber }) {} - const contextualStates = Machine.states({ ContextualCount }) + const contextualStates = Machine.state({ initial: "ContextualCount", states: { ContextualCount } }) const contextualMachine = Machine.make({ - states: contextualStates.states, - events: Machine.events(Reset), - initial: (to) => to.ContextualCount().resolve(({ target }) => (target.decoded(new ContextualCount({ value: 0 })))) + root: contextualStates, + events: Machine.eventsFromSchemas(Reset), + initialConfiguration: (root) => + root.resolve(( + { target } + ) => (target.from((to) => to.ContextualCount.decoded(new ContextualCount({ value: 0 }))))) }) const layer = ClusterMachine.make("ContextualCounter", contextualMachine, { version: "1" }).toLayer() diff --git a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts index efd3c3f..ba33c7c 100644 --- a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts +++ b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts @@ -64,68 +64,78 @@ interface RuntimeFailure { readonly _tag: "RuntimeFailure" } -const States = Machine.states({ Idle }) -const Emissions = Machine.emittedEvents(Published) - -const NestedStates = Machine.states({ - Dormant, - Ready: { - schema: Ready, - type: "parallel", - states: { - editor: { - schema: Editor, - initial: "Editing", - states: { - Editing, - Saving - } - }, - network: { - schema: Network, - initial: "Online", - states: { - Online, - Offline +const States = Machine.state({ initial: "Idle", states: { Idle } }) +const Emissions = Machine.emittedEventsFromSchemas(Published) + +const NestedStates = Machine.state({ + initial: "Dormant", + states: { + Dormant, + Ready: { + schema: Ready, + type: "parallel", + states: { + editor: { + schema: Editor, + initial: "Editing", + states: { + Editing, + Saving + } + }, + network: { + schema: Network, + initial: "Online", + states: { + Online, + Offline + } } } } } }) -const StructuralNestedStates = Machine.states({ - Ready: { - type: "parallel", - states: { - editor: { - initial: "Idle", - states: { - Idle: {}, - Saving - } - }, - network: {} +const StructuralNestedStates = Machine.state({ + initial: "Ready", + states: { + Ready: { + type: "parallel", + states: { + editor: { + initial: "Idle", + states: { + Idle: {}, + Saving + } + }, + network: {} + } } } }) const makeMachine = () => Machine.make({ - states: States.states, - events: Machine.events(Tick), - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: States, + events: Machine.eventsFromSchemas(Tick), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }).handle({ - Idle: {} + states: { + Idle: {} + } }) const makeEmittingMachine = () => Machine.make({ - states: States.states, - events: Machine.events(Tick), + root: States, + events: Machine.eventsFromSchemas(Tick), emittedEvents: Emissions, - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) }).handle({ - Idle: {} + states: { + Idle: {} + } }) describe("AtomMachine", () => { @@ -133,12 +143,14 @@ describe("AtomMachine", () => { const childMachine = makeMachine() const Child = Machine.child("child", childMachine) const parentMachine = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }).handle({ - Idle: { - invoke: (from) => from.child(Child) + states: { + Idle: { + invoke: (from) => from.child(Child) + } } }) const child = AtomMachine.make(parentMachine).child(Child) @@ -162,12 +174,12 @@ describe("AtomMachine", () => { expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option> >() expect>().type.toBe() expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option> >() expect>().type.toBe() expect(AtomMachine.select).type.not.toBeCallableWith(direct, "Missing") @@ -176,13 +188,15 @@ describe("AtomMachine", () => { const Child = Machine.child("emitting-child", machine) const parentMachine = Machine.make({ - states: States.states, - events: Machine.events(), + root: States, + events: Machine.eventsFromSchemas(), emittedEvents: Emissions, - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) }).handle({ - Idle: { - invoke: (from) => from.child(Child) + states: { + Idle: { + invoke: (from) => from.child(Child) + } } }) const parent = AtomMachine.make(parentMachine) @@ -196,7 +210,7 @@ describe("AtomMachine", () => { expect().type.toBe() expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option> >() expect>().type.toBe() expect>().type.toBe() @@ -208,7 +222,7 @@ describe("AtomMachine", () => { it("derives fail-aware results, selectors, and child bridge types", () => { const childMachine = makeMachine() const Child = Machine.child("child", childMachine) - type Snapshot = Machine.Machine.Snapshot + type Snapshot = Machine.Snapshot type Parent = AtomMachine.MachineAtom const parent = null as unknown as Parent const child = null as unknown as AtomMachine.ChildOf @@ -226,14 +240,14 @@ describe("AtomMachine", () => { Atom.Atom, StartFailure | RuntimeFailure>> >() expect>().type.toBe< - Option.Option> + Option.Option> >() expect().type.toBe< Atom.Atom> >() expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option> >() expect>().type.toBe() expect>().type.toBe>() @@ -256,7 +270,7 @@ describe("AtomMachine", () => { }) it("infers exact compound and parallel paths from bridge snapshots", () => { - type Snapshot = Machine.Machine.Snapshot + type Snapshot = Machine.Snapshot type Parent = AtomMachine.MachineAtom const parent = null as unknown as Parent const root = AtomMachine.select(parent, "Ready") @@ -274,11 +288,13 @@ describe("AtomMachine", () => { expect>().type.toBe>() expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option> >() expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof NestedStates.node }, "Ready.editor.Editing"> + > >() expect>().type.toBe() expect>().type.toBe>() @@ -288,7 +304,7 @@ describe("AtomMachine", () => { expect(AtomMachine.matches).type.not.toBeCallableWith(parent, "Ready.network.Missing") expect(AtomMachine.select).type.not.toBeCallableWith(parent, widenedPath) - type NestedMachine = Machine.Machine + type NestedMachine = Machine.Machine<{ readonly "": typeof NestedStates.node }, readonly [typeof Tick]> const NestedChild = Machine.child("nested", null as unknown as NestedMachine) const child = null as unknown as AtomMachine.ChildOf const childSelected = AtomMachine.selectChild(child, "Ready.editor.Saving") @@ -297,7 +313,9 @@ describe("AtomMachine", () => { expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof NestedStates.node }, "Ready.editor.Saving"> + > >() expect>().type.toBe() expect>().type.toBe>() @@ -307,7 +325,7 @@ describe("AtomMachine", () => { }) it("selects values only from schema-backed paths while structural paths remain queryable", () => { - type Snapshot = Machine.Machine.Snapshot + type Snapshot = Machine.Snapshot type Parent = AtomMachine.MachineAtom const parent = null as unknown as Parent @@ -317,10 +335,12 @@ describe("AtomMachine", () => { const saving = AtomMachine.select(parent, "Ready.editor.Saving") expect>().type.toBe< - Option.Option> + Option.Option> >() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof StructuralNestedStates.node }, "Ready.editor"> + > >() expect>().type.toBe() expect>().type.toBe>() @@ -331,7 +351,7 @@ describe("AtomMachine", () => { }) it("supports data-last root and child selectors", () => { - type Snapshot = Machine.Machine.Snapshot + type Snapshot = Machine.Snapshot type Parent = AtomMachine.MachineAtom const parent = null as unknown as Parent const selected = AtomMachine.select("Ready.editor.Editing")(parent) @@ -341,7 +361,7 @@ describe("AtomMachine", () => { const Child = Machine.child( "nested", - null as unknown as Machine.Machine + null as unknown as Machine.Machine<{ readonly "": typeof NestedStates.node }, readonly [typeof Tick]> ) const child = null as unknown as AtomMachine.ChildOf const childSelected = AtomMachine.selectChild("Ready.editor.Saving")(child) @@ -351,20 +371,20 @@ describe("AtomMachine", () => { expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option> >() expect>().type.toBe() expect(invalid).type.not.toBeCallableWith(parent) expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option> >() expect>().type.toBe() expect(invalidChild).type.not.toBeCallableWith(child) }) it("infers static and reactive event acceptance projections", () => { - type Snapshot = Machine.Machine.Snapshot + type Snapshot = Machine.Snapshot type Parent = AtomMachine.MachineAtom< Snapshot, Machine.Machine.EventInput, @@ -373,7 +393,7 @@ describe("AtomMachine", () => { StartFailure > const parent = null as unknown as Parent - const Events = Machine.events(Tick) + const Events = Machine.eventsFromSchemas(Tick) const staticProjection = AtomMachine.can(Events.Tick()) const reactiveProjection = AtomMachine.can(Atom.make(Events.Tick())) const staticCan = staticProjection(parent) @@ -389,14 +409,16 @@ describe("AtomMachine", () => { }) it("infers keyed root family inputs and exact projected atoms", () => { - const Events = Machine.events(Tick) + const Events = Machine.eventsFromSchemas(Tick) const machine = Machine.make({ - states: States.states, + root: States, events: Events, input: Schema.String, - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) }).handle({ - Idle: {} + states: { + Idle: {} + } }) const atoms = AtomMachine.family(machine, { atoms: { @@ -404,7 +426,7 @@ describe("AtomMachine", () => { selected: AtomMachine.select("Idle"), snapshot: AtomMachine.selectSnapshot("Idle"), matched: AtomMachine.matches("Idle"), - state: (machine) => machine.state, + state: (machine) => machine.result, send: (machine) => machine.send } }) @@ -419,30 +441,33 @@ describe("AtomMachine", () => { expect>().type.toBe>() expect>().type.toBe() expect>().type.toBe< - Option.Option> + Option.Option> >() expect>().type.toBe() - expect>().type.toBe>() + expect>().type.toBe>() expect ? Event : never>().type.toBe< Machine.Machine.EventInput >() expect(atoms.selected).type.not.toBeCallableWith(1) expect(AtomMachine.family).type.not.toBeCallableWith(makeMachine(), { atoms: { - state: (machine: AtomMachine.MachineAtom) => machine.state + state: (machine: AtomMachine.MachineAtom) => machine.result } }) }) it("preserves bound runtime errors and child protocols through families", () => { const machine = Machine.make({ - states: States.states, - events: Machine.events(Tick), + root: States, + events: Machine.eventsFromSchemas(Tick), input: Schema.String, - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) }).handle({ - Idle: { - invoke: (from) => from.effect("read-multiplier", () => Effect.as(Multiplier, undefined)).onDone((to) => to.none) + states: { + Idle: { + invoke: (from) => + from.effect("read-multiplier", () => Effect.as(Multiplier, undefined)).onDone((to) => to.none) + } } }) const runtime = Atom.runtime( @@ -466,10 +491,10 @@ describe("AtomMachine", () => { const Child = Machine.childFamily(childMachine) const parent = AtomMachine.make( Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) - }).handle({ Idle: {} }) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) + }).handle({ states: { Idle: {} } }) ) const children = AtomMachine.familyChild(parent, { child: (id: string) => Child(id), @@ -485,7 +510,7 @@ describe("AtomMachine", () => { expect(AtomMachine.factory).type.not.toBeCallableWith(machine) expect().type.toBe() expect(AtomMachine.family).type.not.toBeCallableWith(machine, { - atoms: { state: (machine: AtomMachine.MachineAtom) => machine.state } + atoms: { state: (machine: AtomMachine.MachineAtom) => machine.result } }) expect>>().type.toBe>() expect>>().type.toBe() @@ -507,14 +532,14 @@ describe("AtomMachine", () => { expect(makeBridge).type.toBeCallableWith() expect(makeBridge).type.not.toBeCallableWith("input") expect().type.toBe() - expect>().type.toBe>() + expect>().type.toBe>() const inputMachine = Machine.make({ - states: States.states, - events: Machine.events(Tick), + root: States, + events: Machine.eventsFromSchemas(Tick), input: Schema.String, - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) - }).handle({ Idle: {} }) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) + }).handle({ states: { Idle: {} } }) const makeInputBridge = AtomMachine.factory(inputMachine) expect(makeInputBridge).type.toBeCallableWith("input") expect(makeInputBridge).type.not.toBeCallableWith() @@ -545,15 +570,17 @@ describe("AtomMachine", () => { it("only exposes public input events through atom send boundaries", () => { const machine = Machine.make({ - states: States.states, - events: Machine.events(Tick), - internalEvents: Machine.internalEvents(InternalTick), - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: States, + events: Machine.eventsFromSchemas(Tick), + internalEvents: Machine.internalEventsFromSchemas(InternalTick), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }).handle({ - Idle: { - on: { - Tick: (to) => to.full.Idle().resolve(({ target }) => target.decoded(new Idle({}))), - InternalTick: (to) => to.full.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + states: { + Idle: { + on: { + Tick: (to) => to.branch.Idle().resolve(({ target }) => target.decoded(new Idle({}))), + InternalTick: (to) => to.branch.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + } } } }) @@ -565,18 +592,21 @@ describe("AtomMachine", () => { }) it("requires output implementations and preserves exact terminal output", () => { - const OutputStates = Machine.states({ - Idle, - Done: { - schema: Done, - type: "final", - output: Schema.String + const OutputStates = Machine.state({ + initial: "Idle", + states: { + Idle, + Done: { + schema: Done, + type: "final", + output: Schema.String + } } }) const incomplete = Machine.make({ - states: OutputStates.states, - events: Machine.events(Tick), - initial: (to) => to.Idle().resolve(({ target }) => (target.decoded(new Idle({})))) + root: OutputStates, + events: Machine.eventsFromSchemas(Tick), + initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) }) const runtime = Atom.runtime(Layer.empty) const bound = AtomMachine.bind(runtime) @@ -588,8 +618,10 @@ describe("AtomMachine", () => { expect(bound.factory).type.not.toBeCallableWith(incomplete) const complete = incomplete.handle({ - Done: { - output: ({ state }) => state.value + states: { + Done: { + output: ({ state }) => state.value + } } }) const bridge = AtomMachine.make(complete) From 5262f605228c2719f5456088a3c488134bdea53f Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 6 Sep 2026 14:24:55 +0200 Subject: [PATCH 2/4] feat: add typed React state rendering and owned contexts --- packages/effect-machine-react/README.md | 54 +++++-- .../effect-machine-react/src/MachineAtom.ts | 84 ++++++++-- .../src/internal/react.ts | 50 ++++++ .../test/MachineAtom.test.tsx | 31 ++-- .../test/RootContext.test.tsx | 144 ++++++++++++++++++ .../typetest/MachineAtom.tst.ts | 64 +++++++- 6 files changed, 380 insertions(+), 47 deletions(-) create mode 100644 packages/effect-machine-react/src/internal/react.ts create mode 100644 packages/effect-machine-react/test/RootContext.test.tsx diff --git a/packages/effect-machine-react/README.md b/packages/effect-machine-react/README.md index 7832dfc..52ab39c 100644 --- a/packages/effect-machine-react/README.md +++ b/packages/effect-machine-react/README.md @@ -1,26 +1,54 @@ # @typeonce/effect-machine-react -React ownership hooks for machine atoms created by +React ownership and typed state rendering for [`@typeonce/effect-machine`](../effect-machine/README.md). ```tsx -import { useMachineAtom } from "@typeonce/effect-machine-react" +import { createMachineContext, MachineState } from "@typeonce/effect-machine-react" +import { AtomMachine } from "@typeonce/effect-machine/reactivity" +import { Suspense } from "react" +import { AuthMachine } from "./auth-machine" -function AuthProvider({ input, children }: Props) { - const machine = useMachineAtom(() => MachineAtoms.make(AuthMachine, input)) +const Auth = createMachineContext(AtomMachine.factory(AuthMachine)) +function AuthRoute({ input }: Props) { return ( - - {children} - + + }> + + + + ) +} + +function AuthForm() { + const machine = Auth.useMachine() + return ( + + {({ value }) => } + ) } ``` -`useMachineAtom` strongly owns one machine atom, mounts it after commit, and -does not subscribe the owner to machine state. Descendants subscribe to the -specific state paths they render with `AtomMachine.select`, -`AtomMachine.selectSnapshot`, or `AtomMachine.matches`. +Each Provider creates an independent machine in the current Atom registry, +including when two Providers receive equal inputs. Supply an Atom +`RegistryProvider` at the application boundary. Bind Effect services through +`AtomMachine.runtime(...)` before creating a factory when the machine needs them. + +The Provider owns the reference without subscribing to state changes. +`MachineState` subscribes to its typed path and infers the callback snapshot. +Inactive paths render `inactive` (null by default); startup suspends and failures +propagate to the nearest error boundary. Keep the owner above Suspense. + +Input is startup-only. Send an event to update a running workflow, or change the +Provider's React `key` to start a new one. Machines without input have a Provider +without an `input` prop. + +`useMachineAtom(() => makeMachine(input))` remains available for custom owners. +Use `AtomMachine.select`, `selectSnapshot`, and `matches` for custom granular +subscriptions. Read `.result` for the whole logical snapshot or `.snapshot` for +runtime status; the bridge has no separate `.state` projection. -Machine input is startup-only. Send an event to update a running workflow, or -change the provider's React `key` to replace it with a new machine. +See the [React guide](../effect-machine/docs/effect-atom-react.md) for events, +services, child machines, and registry-owned families. diff --git a/packages/effect-machine-react/src/MachineAtom.ts b/packages/effect-machine-react/src/MachineAtom.ts index ac3be62..ff31979 100644 --- a/packages/effect-machine-react/src/MachineAtom.ts +++ b/packages/effect-machine-react/src/MachineAtom.ts @@ -1,29 +1,87 @@ /** - * React ownership for machine atoms. + * React ownership and state rendering for machine atoms. * * @since 0.29.0 */ "use client" -import { useAtomMount } from "@effect/atom-react" +import type { Machine } from "@typeonce/effect-machine" import type { AtomMachine } from "@typeonce/effect-machine/reactivity" -import * as React from "react" +import type * as React from "react" +import * as internal from "./internal/react.js" type AnyMachineAtom = AtomMachine.MachineAtom /** - * Creates one machine atom for a committed React owner and mounts its machine - * reference without subscribing the owner to machine state. - * - * The factory is startup-only. Later changes to values captured by the factory - * do not replace the machine. Send an event to change a running workflow, or - * change the owner's React `key` to create a new machine. + * Creates one bridge per React owner and mounts its reference without subscribing + * the owner to state changes. The factory is startup-only. Change the owner's + * React key to start a fresh machine. Keep this owner above any Suspense boundary + * that reads the machine. * * @category hooks * @since 0.29.0 */ -export const useMachineAtom = (create: () => A): A => { - const [machine] = React.useState(create) - useAtomMount(machine.ref) - return machine +export const useMachineAtom: (create: () => A) => A = internal.useMachineAtom + +/** Props for a typed render of one active state path. + * @category models + * @since 0.32.0 + */ +export interface MachineStateProps< + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + Emitted, + Path extends Machine.Snapshot.Path +> { + readonly machine: AtomMachine.MachineAtom + readonly path: Path + readonly inactive?: React.ReactNode + readonly children: (state: Machine.Snapshot.At>) => React.ReactNode +} + +/** + * Subscribes only this renderer to a state path. Startup suspends and failures + * propagate to the nearest error boundary. Inactive child states render + * `inactive`, which defaults to null. + * + * @category components + * @since 0.32.0 + */ +export const MachineState: < + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + Emitted, + const Path extends Machine.Snapshot.Path +>( + props: MachineStateProps +) => React.ReactNode = internal.MachineState + +/** An isolated React owner and accessor for a bridge factory. + * @category models + * @since 0.32.0 + */ +export interface MachineContext { + readonly Provider: React.ComponentType< + & { readonly children?: React.ReactNode } + & (Args extends [] ? { readonly input?: never } : { readonly input: Args[0] }) + > + readonly useMachine: () => A } + +/** + * Creates a context from an AtomMachine factory. Each Provider owns a fresh + * bridge in its current registry. Input is read once at startup; use a new React + * key for a new owner. The Provider does not subscribe to machine state. + * + * @category constructors + * @since 0.32.0 + */ +export const createMachineContext: ( + create: (...args: Args) => A +) => MachineContext = internal.createMachineContext diff --git a/packages/effect-machine-react/src/internal/react.ts b/packages/effect-machine-react/src/internal/react.ts new file mode 100644 index 0000000..47d6e8e --- /dev/null +++ b/packages/effect-machine-react/src/internal/react.ts @@ -0,0 +1,50 @@ +import { useAtomMount, useAtomSuspense } from "@effect/atom-react" +import type { Machine } from "@typeonce/effect-machine" +import { AtomMachine } from "@typeonce/effect-machine/reactivity" +import { Option } from "effect" +import * as React from "react" +import type { MachineContext, MachineStateProps } from "../MachineAtom.js" + +type AnyMachineAtom = AtomMachine.MachineAtom + +export const useMachineAtom = (create: () => A): A => { + const [machine] = React.useState(create) + useAtomMount(machine.ref) + return machine +} + +export const MachineState = < + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + Emitted, + const Path extends Machine.Snapshot.Path +>( + props: MachineStateProps +): React.ReactNode => { + const selected = React.useMemo(() => AtomMachine.selectSnapshot(props.machine, props.path), [ + props.machine, + props.path + ]) + const snapshot = useAtomSuspense(selected).value + return Option.isSome(snapshot) ? props.children(snapshot.value) : props.inactive ?? null +} + +export const createMachineContext = ( + create: (...args: Args) => A +): MachineContext => { + const Context = React.createContext(undefined) + const Provider = (props: { readonly input?: unknown; readonly children?: React.ReactNode }): React.ReactNode => { + // Input belongs to this committed owner. A new React key creates a new owner. + const machine = useMachineAtom(() => create(...("input" in props ? [props.input] : []) as Args)) + return React.createElement(Context.Provider, { value: machine }, props.children) + } + const useMachine = (): A => { + const machine = React.useContext(Context) + if (machine === undefined) throw new Error("Machine context must be read inside its Provider") + return machine + } + return Object.freeze({ Provider, useMachine }) as MachineContext +} diff --git a/packages/effect-machine-react/test/MachineAtom.test.tsx b/packages/effect-machine-react/test/MachineAtom.test.tsx index 28a5933..7d0c2d5 100644 --- a/packages/effect-machine-react/test/MachineAtom.test.tsx +++ b/packages/effect-machine-react/test/MachineAtom.test.tsx @@ -16,23 +16,25 @@ class Active extends Schema.TaggedClass("Active")("Active", { }) {} class Increment extends Schema.TaggedClass("Increment")("Increment", {}) {} -const States = Machine.states({ Active }) +const States = Machine.state({ initial: "Active", states: { Active } }) const trackedMachine = (onStart: () => void) => Machine.make({ - states: States.states, - events: Machine.events(Increment), + root: States, + events: Machine.eventsFromSchemas(Increment), input: Schema.Number, - initial: (to) => - to.Active().resolve(({ input, target }) => { + initialConfiguration: (root) => + root.resolve(({ input, target }) => { onStart() - return target.decoded(new Active({ value: input })) + return target.from((to) => to.Active.decoded(new Active({ value: input }))) }) }).handle({ - Active: { - on: { - Increment: (to) => - to.full.Active().resolve(({ state, target }) => target.decoded(new Active({ value: state.value + 1 }))) + states: { + Active: { + on: { + Increment: (to) => + to.branch.Active().resolve(({ state, target }) => target.decoded(new Active({ value: state.value + 1 }))) + } } } }) @@ -114,7 +116,7 @@ describe("useMachineAtom", () => { await waitFor(() => assert.strictEqual(starts, 1)) const first = current! - assert.deepStrictEqual(await Effect.runPromise(AtomRegistry.getResult(registry, first.result)), { + assert.deepStrictEqual((await Effect.runPromise(AtomRegistry.getResult(registry, first.result))).state, { path: "Active", value: new Active({ value: 1 }) }) @@ -129,7 +131,7 @@ describe("useMachineAtom", () => { assert.strictEqual(current, first) assert.strictEqual(starts, 1) - assert.strictEqual((await Effect.runPromise(AtomRegistry.getResult(registry, first.result))).value.value, 1) + assert.strictEqual((await Effect.runPromise(AtomRegistry.getResult(registry, first.result))).state.value.value, 1) view.rerender( @@ -141,7 +143,10 @@ describe("useMachineAtom", () => { await waitFor(() => assert.strictEqual(starts, 2)) assert.notStrictEqual(current, first) - assert.strictEqual((await Effect.runPromise(AtomRegistry.getResult(registry, current!.result))).value.value, 2) + assert.strictEqual( + (await Effect.runPromise(AtomRegistry.getResult(registry, current!.result))).state.value.value, + 2 + ) view.unmount() registry.dispose() diff --git a/packages/effect-machine-react/test/RootContext.test.tsx b/packages/effect-machine-react/test/RootContext.test.tsx new file mode 100644 index 0000000..49ed104 --- /dev/null +++ b/packages/effect-machine-react/test/RootContext.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom + +import { RegistryContext } from "@effect/atom-react" +import { act, cleanup, render, screen, waitFor } from "@testing-library/react" +import { Schema } from "effect" +import { AtomRegistry } from "effect/unstable/reactivity" +import * as React from "react" +import { afterEach, expect, it } from "vitest" +import { Machine } from "../../effect-machine/src/index.js" +import { AtomMachine } from "../../effect-machine/src/unstable/reactivity/index.js" +import { createMachineContext, MachineState } from "../src/index.js" + +const Events = Machine.events({ Increment: {}, Close: {} }) +const counter = Machine.make({ + root: Machine.state({ fields: { count: Schema.Number }, initial: "Open", states: { Open: {}, Closed: {} } }), + events: Events, + input: Schema.Number, + initial: (root) => root.from(({ input }) => ({ count: input })) +}).handle({ + on: { Increment: (to) => to.self.update.from(({ current }) => ({ count: current.count + 1 })) }, + states: { Open: { on: { Close: (to) => to.local.Closed() } } } +}) +const Counter = createMachineContext(AtomMachine.factory(counter)) +afterEach(cleanup) + +it("keeps a provider's input and bridge stable until its React key changes", async () => { + const registry = AtomRegistry.make({ defaultIdleTTL: 1 }) + let bridge: ReturnType | undefined + let readers = 0 + function Reader() { + readers++ + bridge = Counter.useMachine() + return ( + + + {(root) => {root.value.count}} + + closed}> + {() => open} + + + ) + } + const tree = (input: number, id: string) => ( + + + + + + + + ) + const view = render(tree(1, "first")) + await waitFor(() => expect(screen.getByTestId("count").textContent).toBe("1")) + const first = bridge! + const renders = readers + await act(async () => { + registry.set(first.send, Events.Increment()) + }) + await waitFor(() => expect(screen.getByTestId("count").textContent).toBe("2")) + expect(readers).toBe(renders) + await act(async () => { + registry.set(first.send, Events.Close()) + }) + await screen.findByText("closed") + view.rerender(tree(99, "first")) + expect(bridge).toBe(first) + expect(screen.getByTestId("count").textContent).toBe("2") + view.rerender(tree(9, "second")) + await waitFor(() => expect(screen.getByTestId("count").textContent).toBe("9")) + expect(bridge).not.toBe(first) + await screen.findByText("open") + view.unmount() + registry.dispose() +}) + +it("keeps independent providers isolated in the same registry", async () => { + const registry = AtomRegistry.make() + const bridges: Array> = [] + function Reader({ index }: { readonly index: number }) { + const machine = Counter.useMachine() + bridges[index] = machine + return ( + + + {(root) => {root.value.count}} + + + ) + } + const view = render( + + + + + + + + + ) + await screen.findByTestId("count-1") + expect(bridges[0]).not.toBe(bridges[1]) + await act(async () => { + registry.set(bridges[0]!.send, Events.Increment()) + }) + await waitFor(() => expect(screen.getByTestId("count-0").textContent).toBe("2")) + expect(screen.getByTestId("count-1").textContent).toBe("1") + view.unmount() + registry.dispose() +}) + +it("sends startup failures from a state renderer to an error boundary", async () => { + const registry = AtomRegistry.make() + let caught: unknown + class Boundary extends React.Component { + state = { failed: false } + static getDerivedStateFromError(error: unknown) { + caught = error + return { failed: true } + } + render() { + return this.state.failed ? startup failed : this.props.children + } + } + function Reader() { + return {() => ready} + } + const view = render( + + + + + + + + + + ) + await screen.findByText("startup failed") + expect(caught).toBeDefined() + expect(screen.queryByText("ready")).toBeNull() + view.unmount() + registry.dispose() +}) diff --git a/packages/effect-machine-react/typetest/MachineAtom.tst.ts b/packages/effect-machine-react/typetest/MachineAtom.tst.ts index 929604e..465d0cf 100644 --- a/packages/effect-machine-react/typetest/MachineAtom.tst.ts +++ b/packages/effect-machine-react/typetest/MachineAtom.tst.ts @@ -1,21 +1,24 @@ import { Schema } from "effect" +import type * as React from "react" import { expect } from "tstyche" import { Machine } from "../../effect-machine/src/index.js" import { AtomMachine } from "../../effect-machine/src/unstable/reactivity/index.js" -import { useMachineAtom } from "../src/index.js" +import { createMachineContext, MachineState, useMachineAtom } from "../src/index.js" class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} class Continue extends Schema.TaggedClass("Continue")("Continue", {}) {} -const States = Machine.states({ Idle }) +const States = Machine.state({ initial: "Idle", states: { Idle } }) const machine = Machine.make({ - states: States.states, - events: Machine.events(Continue), - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + root: States, + events: Machine.eventsFromSchemas(Continue), + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) }).handle({ - Idle: { - on: { - Continue: (to) => to.none + states: { + Idle: { + on: { + Continue: (to) => to.none + } } } }) @@ -24,3 +27,48 @@ const expected = AtomMachine.make(machine) const owned = useMachineAtom(() => AtomMachine.make(machine)) expect(owned).type.toBe() + +const Root = Machine.state({ + fields: { count: Schema.Number }, + initial: "Editing", + states: { Editing: { fields: { draft: Schema.String } }, Closed: {} } +}) +const withInput = Machine.make({ + root: Root, + input: Schema.Number, + events: Machine.events({ Close: {} }), + initial: (root) => root.from(({ input }) => ({ count: input })) +}).handle({ initialize: ({ builder }) => builder.from({ draft: "" }) }) +const InputContext = createMachineContext(AtomMachine.factory(withInput)) +const NoInputContext = createMachineContext(AtomMachine.factory(machine)) + +type InputProps = React.ComponentProps +type NoInputProps = React.ComponentProps +expect().type.toBe() +expect<{}>().type.not.toBeAssignableTo() +expect<{ input: string }>().type.not.toBeAssignableTo() +expect<{ input: number }>().type.not.toBeAssignableTo() +expect<{}>().type.toBeAssignableTo() +const contextBridge = InputContext.useMachine() +const inputFactory = AtomMachine.factory(withInput) +expect(contextBridge).type.toBe>() + +MachineState({ + machine: contextBridge, + path: "Editing", + children: (snapshot) => { + expect(snapshot.path).type.toBe<"Editing">() + expect(snapshot.value.draft).type.toBe() + expect(snapshot.value).type.not.toHaveProperty("count") + return null + } +}) +MachineState({ + machine: contextBridge, + path: "", + children: (snapshot) => { + expect(snapshot.value.count).type.toBe() + return null + } +}) +expect(MachineState).type.not.toBeCallableWith({ machine: contextBridge, path: "Missing", children: () => null }) From 5f3a9cf3aa4319375829212220e0405c12dba852 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 6 Sep 2026 14:25:04 +0200 Subject: [PATCH 3/4] refactor: migrate integrations and release checks to root machines --- .changeset/bright-roots-render.md | 12 + README.md | 34 +- api-reference.config.json | 8 +- .../src/internal/browser/chart-layout.ts | 25 +- .../src/internal/browser/chart-model.ts | 3 +- .../src/internal/browser/example-machine.ts | 179 ++++----- .../browser/hierarchy-routing-example.ts | 125 ++++--- .../browser/invoke-outcomes-example.ts | 296 ++++++++------- .../browser/layout-resilience-example.ts | 219 ++++++----- .../browser/parallel-completion-example.ts | 224 +++++------ .../browser/parallel-owner-routing-example.ts | 115 +++--- .../src/internal/browser/planner-example.ts | 82 ++-- .../browser/protocol-events-example.ts | 225 ++++++----- .../shared-terminal-routing-example.ts | 245 ++++++------ .../src/internal/browser/text-tree.ts | 4 +- .../browser/transition-semantics-example.ts | 274 +++++++------- .../src/internal/browser/visualizer-model.ts | 14 +- .../devtools/test/MachineWalkthrough.test.ts | 3 + .../InteractiveTextVisualization.test.ts | 17 +- .../test/internal/browser/StaticChart.test.ts | 4 +- packages/effect-machine/README.md | 285 +++++++------- packages/effect-machine/docs/agent-guide.md | 90 ++--- .../effect-machine/docs/effect-atom-react.md | 98 ++--- .../effect-machine/docs/machine-review.md | 14 +- packages/effect-machine/docs/root-api.md | 214 +++++++++++ .../effect-machine/perf/runtime/counter.mjs | 17 +- .../runtime/effect-machine-compatibility.mjs | 29 +- .../perf/types/adapter-readiness-control.ts | 72 ++-- .../perf/types/composition-control.ts | 103 ++--- .../effect-machine/perf/types/composition.ts | 96 ++--- .../perf/types/define-states.ts | 2 +- .../perf/types/definition-variants-control.ts | 49 +-- .../perf/types/definition-variants.ts | 67 ++-- .../perf/types/dynamic-invoke-control.ts | 9 +- .../perf/types/dynamic-invoke.ts | 32 +- .../perf/types/exact-channels-control.ts | 26 +- .../perf/types/exact-channels.ts | 28 +- .../perf/types/handle-depth-24-control.ts | 215 +++++------ .../perf/types/handle-depth-24.ts | 105 +++--- .../types/handle-depth-wide-16-control.ts | 275 +++++++------- .../perf/types/handle-depth-wide-16.ts | 198 +++++----- packages/effect-machine/perf/types/handle.ts | 33 +- .../effect-machine/perf/types/make-control.ts | 2 +- packages/effect-machine/perf/types/make.ts | 7 +- .../perf/types/named-branches-control.ts | 9 +- .../perf/types/named-branches.ts | 94 ++--- packages/oxlint-plugin/README.md | 28 +- .../oxlint-plugin/src/internal/planning.ts | 9 +- .../test/noAsyncPlanningCallback.test.ts | 49 ++- .../test/noBrowserApiInPlanning.test.ts | 12 +- .../noConflictingInvocationIdentity.test.ts | 40 +- .../test/noNondeterministicPlanning.test.ts | 12 +- .../test/noRedundantResolve.test.ts | 28 +- .../test/preferInlineHandle.test.ts | 16 +- plans/root-machine-api.md | 79 ++++ scripts/devtools-pack-check.mjs | 7 +- scripts/fixtures/consumer/consumer.ts | 57 +-- scripts/fixtures/consumer/deep-bound.ts | 354 +++++++++--------- scripts/invoke-autocomplete.test.mjs | 117 +++--- scripts/react-pack-check.mjs | 4 +- ...runtime-performance-compatibility.test.mjs | 40 ++ scripts/type-performance.mjs | 2 +- 62 files changed, 2843 insertions(+), 2288 deletions(-) create mode 100644 .changeset/bright-roots-render.md create mode 100644 packages/effect-machine/docs/root-api.md create mode 100644 plans/root-machine-api.md diff --git a/.changeset/bright-roots-render.md b/.changeset/bright-roots-render.md new file mode 100644 index 0000000..f8a746a --- /dev/null +++ b/.changeset/bright-roots-render.md @@ -0,0 +1,12 @@ +--- +"@typeonce/effect-machine": minor +"@typeonce/effect-machine-react": minor +"@typeonce/effect-machine-devtools": minor +"@typeonce/oxlint-plugin-effect-machine": minor +--- + +Model each machine with one `Machine.state` root, passed to `Machine.make({ root })`. Root `fields` support data-only machines and shared data that survives child transitions. Replace the former top-level state map with a root descriptor, put child handlers under `handle({ states })`, and use `initial` for root values or `initialConfiguration` for a complete startup override. Add direct target construction, non-reentering `self.update`, and guards with ancestor fallback. Construct local event protocols from field records or import existing schemas with the explicit `FromSchemas` constructors. + +Render typed state paths with React `MachineState` and own isolated instances with `createMachineContext(AtomMachine.factory(machine))`. Providers capture startup input without subscribing to state. Replace Atom bridge `.state` reads with `.result` or path selectors; `.snapshot` retains runtime status. Core `MachineRef.state` remains available. Testing runs and probes accept public deferred event inputs and record decoded receipts. + +Encoded snapshots use version 2 and include the root at path `""`; explicitly migrate older persisted snapshots before decoding. Replace removed `Machine.Emit`, `Machine.Emits`, `Machine.EmitOf`, and `Machine.PseudoStateAnnotations` aliases with `EmittedEvent`, `EmittedEvents`, `EmittedEventOf`, and `SchemaLessStateAnnotations`. diff --git a/README.md b/README.md index f8bdec5..3278c8f 100644 --- a/README.md +++ b/README.md @@ -10,31 +10,18 @@ Effect-native, schema-first, completely type-safe state machines and statecharts ```ts import { Machine } from "@typeonce/effect-machine" -import { Effect, Schema } from "effect" +import { Effect } from "effect" -const States = Machine.states({ - Locked: {}, - Unlocked: {} +const Root = Machine.state({ + initial: "Locked", + states: { Locked: {}, Unlocked: {} } }) +const Events = Machine.events({ Coin: {}, Push: {} }) -const Events = Machine.events( - Schema.TaggedUnion({ - Coin: {}, - Push: {} - }) -) - -const Turnstile = Machine.make({ - id: "Turnstile", - states: States.states, - events: Events, - initial: (to) => to.Locked() -}).handle({ - Locked: { - on: { Coin: (to) => to.full.Unlocked() } - }, - Unlocked: { - on: { Push: (to) => to.full.Locked() } +const Turnstile = Machine.make({ root: Root, events: Events }).handle({ + states: { + Locked: { on: { Coin: (to) => to.local.Unlocked() } }, + Unlocked: { on: { Push: (to) => to.local.Locked() } } } }) @@ -44,8 +31,7 @@ const program = Effect.gen(function*() { }) ``` -State and event schemas define the protocol. The handler tree defines the -statechart, and the result runs as an Effect-managed machine. +State and event schemas define the protocol. The root defines the topology and the handler tree adds behavior, and the result runs as an Effect-managed machine. ## Packages diff --git a/api-reference.config.json b/api-reference.config.json index e3b8274..671b6fa 100644 --- a/api-reference.config.json +++ b/api-reference.config.json @@ -31,7 +31,6 @@ "title": "Define state topology", "description": "Declare the complete state tree and its schema-backed values before constructing the machine.", "entries": [ - { "declaration": "states" }, { "declaration": "state" } ] }, @@ -84,9 +83,9 @@ ], "usageSections": [ { - "owner": "states", + "owner": "state", "title": "State node configuration", - "description": "Properties accepted while declaring the state tree passed to `Machine.states`.", + "description": "Properties accepted by root and nested `Machine.state` descriptors.", "roots": [ { "reflection": "Machine.AtomicStateNodeConfig", "label": "Atomic and final states" }, { "reflection": "Machine.CompoundStateNodeConfig", "label": "Compound states" }, @@ -176,8 +175,7 @@ "planInitial", "resume", "state", - "start", - "states" + "start" ] }, { diff --git a/packages/devtools/src/internal/browser/chart-layout.ts b/packages/devtools/src/internal/browser/chart-layout.ts index bdac2f7..4fdaeaf 100644 --- a/packages/devtools/src/internal/browser/chart-layout.ts +++ b/packages/devtools/src/internal/browser/chart-layout.ts @@ -1036,18 +1036,28 @@ const routeLabelCandidates = ( const required = (horizontal ? width : height) + 24 return length < required ? [] : [{ start, end, horizontal, length }] }) + const adjacent: Array = [] const ordered = [ ...candidates.filter(({ horizontal }) => !horizontal).sort((left, right) => right.length - left.length), ...candidates.filter(({ horizontal }) => horizontal).sort((left, right) => right.length - left.length) ].flatMap(({ start, end, horizontal, length }) => { const clearance = (horizontal ? width : height) / 2 + 12 - return [0.5, 2 / 3, 1 / 3].flatMap((ratio) => { + return [0.5, 2 / 3, 1 / 3, 0.2, 0.8, 0.1, 0.9].flatMap((ratio) => { const distance = length * ratio if (distance < clearance || length - distance < clearance) return [] const onRoute = { x: start.x + (end.x - start.x) * ratio, y: start.y + (end.y - start.y) * ratio } + // A root container can place two routes in the same corridor. Keep the + // label attached beside its own segment when no position on it is clear. + for (const direction of [-1, 1]) { + adjacent.push( + horizontal + ? { x: onRoute.x, y: onRoute.y + direction * (height / 2 + chartEdgeLabelSpacing) } + : { x: onRoute.x + direction * (width / 2 + chartEdgeLabelSpacing), y: onRoute.y } + ) + } if (horizontal || localDescendantTarget === undefined) return [onRoute] const targetCenter = localDescendantTarget.x + localDescendantTarget.width / 2 const direction = targetCenter < onRoute.x ? -1 : 1 @@ -1057,7 +1067,7 @@ const routeLabelCandidates = ( }, onRoute] }) }) - return [...ordered, fallback].filter((candidate, index, all) => + return [...ordered, ...adjacent, fallback].filter((candidate, index, all) => all.findIndex((other) => other.x === candidate.x && other.y === candidate.y) === index ) } @@ -1120,7 +1130,8 @@ const placeTransitionLabels = ( const collectLayout = ( model: ChartModel, graph: ElkNode, - unconnected: ReadonlyArray + unconnected: ReadonlyArray, + shortenRoutes = true ): LaidOutChart => { const chartNodes = new Map(model.nodes.map((node) => [node.path, node])) const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target])) @@ -1202,7 +1213,7 @@ const collectLayout = ( chartEdge, avoidCompoundHeaders( chartEdge, - shortenTransitionRoute( + (shortenRoutes ? shortenTransitionRoute : (_edge: ChartEdge, route: ReadonlyArray) => route)( chartEdge, normalizeHierarchyRoute(chartEdge, elkPoints, nodesByPath, nodes, hierarchyLanes), nodesByPath, @@ -1708,6 +1719,12 @@ export const layoutChartWith = ( const validation = validate(model, candidate) if (validation.valid) return Effect.succeed(candidate) invalid.push({ profile: profile.id, layout: candidate, validation }) + // Route shortening can crowd labels beside unrelated transitions. + // Keep the original ELK corridor as a checked alternative. + const originalRoutes = collectLayout(model, graph, regions, false) + const originalValidation = validate(model, originalRoutes) + if (originalValidation.valid) return Effect.succeed(originalRoutes) + invalid.push({ profile: profile.id, layout: originalRoutes, validation: originalValidation }) return attempt(index + 1) } } diff --git a/packages/devtools/src/internal/browser/chart-model.ts b/packages/devtools/src/internal/browser/chart-model.ts index f6be732..49c905a 100644 --- a/packages/devtools/src/internal/browser/chart-model.ts +++ b/packages/devtools/src/internal/browser/chart-model.ts @@ -71,7 +71,8 @@ export interface ChartModel { readonly initials: ReadonlyArray } -export const isChartStateDescendant = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`) +export const isChartStateDescendant = (path: string, ancestor: string): boolean => + ancestor === "" ? path !== "" : path.startsWith(`${ancestor}.`) const activityLabel = (activity: VisualizationActivity): string => { switch (activity.type) { diff --git a/packages/devtools/src/internal/browser/example-machine.ts b/packages/devtools/src/internal/browser/example-machine.ts index 61d4e43..c73ae27 100644 --- a/packages/devtools/src/internal/browser/example-machine.ts +++ b/packages/devtools/src/internal/browser/example-machine.ts @@ -24,116 +24,125 @@ class Finish extends Schema.TaggedClass("Finish")("Finish", {}) {} class Disconnect extends Schema.TaggedClass("Disconnect")("Disconnect", {}) {} class Refresh extends Schema.TaggedClass("Refresh")("Refresh", {}) {} -const States = Machine.states({ - application: { - schema: Application, - type: "parallel", - states: { - workflow: { - schema: Workflow, - initial: "idle", - states: { - idle: Idle, - running: { - schema: Running, - initial: "editing", - states: { - editing: Editing, - complete: { - schema: Complete, - type: "final" +const States = Machine.state({ + initial: "application", + states: { + application: { + schema: Application, + type: "parallel", + states: { + workflow: { + schema: Workflow, + initial: "idle", + states: { + idle: Idle, + running: { + schema: Running, + initial: "editing", + states: { + editing: Editing, + complete: { + schema: Complete, + type: "final" + } } + }, + recent: { + type: "history" } - }, - recent: { - type: "history" } - } - }, - connection: { - schema: Connection, - initial: "online", - states: { - online: Online, - offline: Offline + }, + connection: { + schema: Connection, + initial: "online", + states: { + online: Online, + offline: Offline + } } } - } - }, - disabled: Disabled + }, + disabled: Disabled + } }) export const snapshot = { - path: "application" as const, - value: new Application({ workspace: "effect-machine", revision: 7 }), - states: { - workflow: { - path: "application.workflow" as const, - value: new Workflow({ document: "Machine.ts", unsavedChanges: 2 }), - state: { path: "application.workflow.idle" as const, value: new Idle({}) } - }, - connection: { - path: "application.connection" as const, - value: new Connection({}), - state: { path: "application.connection.online" as const, value: new Online({}) } + path: "" as const, + value: undefined, + state: { + path: "application" as const, + value: new Application({ workspace: "effect-machine", revision: 7 }), + states: { + workflow: { + path: "application.workflow" as const, + value: new Workflow({ document: "Machine.ts", unsavedChanges: 2 }), + state: { path: "application.workflow.idle" as const, value: new Idle({}) } + }, + connection: { + path: "application.connection" as const, + value: new Connection({}), + state: { path: "application.connection.online" as const, value: new Online({}) } + } } } } const initialWorkflow = (): Machine.Machine.CompleteSnapshotContaining< - typeof States.states, + { readonly "": typeof States.node }, "application.workflow" > => snapshot export const machine = Machine.make({ id: "inspection-example", - states: States.states, - events: Machine.events(Start, Finish, Disconnect, Refresh), - initial: (to) => to.application.initial.resolve(() => snapshot) + root: States, + events: Machine.eventsFromSchemas(Start, Finish, Disconnect, Refresh), + initialConfiguration: (to) => to.resolve(() => snapshot) }).handle({ - application: { - states: { - workflow: { - history: { - recent: { - default: initialWorkflow - } - }, - states: { - idle: { - on: { - Start: (to) => - to.local.running() - .updating(to.branch.application.workflow) - .resolve(({ owner, target }) => - target.decoded( - new Running({}), - (running) => running.editing.decoded(new Editing({})) - ).update(owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 3 }))) - ), - Refresh: (to) => - to.local.update(({ owner }) => - owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 0 })) - ) + states: { + application: { + states: { + workflow: { + history: { + recent: { + default: initialWorkflow } }, - running: { - initialize: ({ builder }) => builder.decoded(new Editing({})), - states: { - editing: { - on: { - Finish: (to) => to.local.complete().resolve(({ target }) => target.decoded(new Complete({}))) + states: { + idle: { + on: { + Start: (to) => + to.local.running() + .updating(to.branch.application.workflow) + .resolve(({ owner, target }) => + target.decoded( + new Running({}), + (running) => running.editing.decoded(new Editing({})) + ).update(owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 3 }))) + ), + Refresh: (to) => + to.local.update.resolve(({ owner }) => + owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 0 })) + ) + } + }, + running: { + initialize: ({ builder }) => builder.decoded(new Editing({})), + states: { + editing: { + on: { + Finish: (to) => to.local.complete().resolve(({ target }) => target.decoded(new Complete({}))) + } } } } } - } - }, - connection: { - states: { - online: { - on: { - Disconnect: (to) => to.local.offline().resolve(({ target }) => target.decoded(new Offline({}))) + }, + connection: { + states: { + online: { + on: { + Disconnect: (to) => to.local.offline().resolve(({ target }) => target.decoded(new Offline({}))) + } } } } diff --git a/packages/devtools/src/internal/browser/hierarchy-routing-example.ts b/packages/devtools/src/internal/browser/hierarchy-routing-example.ts index e99ede8..936ed32 100644 --- a/packages/devtools/src/internal/browser/hierarchy-routing-example.ts +++ b/packages/devtools/src/internal/browser/hierarchy-routing-example.ts @@ -7,26 +7,29 @@ const ReviewState = Schema.TaggedUnion({ Complete: { slug: Schema.String } }) -const ReviewStates = Machine.states({ - Workflow: { - initial: "Review", - states: { - Review: { - schema: ReviewState.cases.Review, - initial: "Form", - states: { - Form: {}, - Failed: ReviewState.cases.ReviewFailed - } - }, - Saving: {}, - Publishing: {}, - Complete: ReviewState.cases.Complete +const ReviewStates = Machine.state({ + initial: "Workflow", + states: { + Workflow: { + initial: "Review", + states: { + Review: { + schema: ReviewState.cases.Review, + initial: "Form", + states: { + Form: {}, + Failed: ReviewState.cases.ReviewFailed + } + }, + Saving: {}, + Publishing: {}, + Complete: ReviewState.cases.Complete + } } } }) -const ReviewEvents = Machine.events( +const ReviewEvents = Machine.eventsFromSchemas( Schema.TaggedUnion({ Submit: { route: Schema.Literals(["save", "invalid"]) } }) @@ -37,55 +40,61 @@ const publishReview = Effect.succeed("deterministic-chart") export const hierarchyRoutingMachine = Machine.make({ id: "hierarchy-routing", - states: ReviewStates.states, + root: ReviewStates, events: ReviewEvents, - initial: (to) => - to.Workflow.initial.resolve(({ target }) => - target.from((workflow) => - workflow.Review.from({ title: "A deterministic chart" }, (review) => review.Form.from()) + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.Workflow.from((workflow) => + workflow.Review.from({ title: "A deterministic chart" }, (review) => review.Form.from()) + ) ) ) }).handle({ - Workflow: { - states: { - Review: { - on: { - Submit: (to) => - to.branches({ - save: { - title: "Save the review", - target: to.branch.Workflow.Saving() - }, - invalid: { - title: "Show validation failure", - target: to.local.Failed() - } - }).resolve(({ event, select }) => - event.route === "save" - ? select.save.from() - : select.invalid.from({ message: "Add a title before continuing." }) + states: { + Workflow: { + states: { + Review: { + on: { + Submit: (to) => + to.branches({ + save: { + title: "Save the review", + target: to.branch.Workflow.Saving() + }, + invalid: { + title: "Show validation failure", + target: to.local.Failed() + } + }).resolve(({ event, select }) => + event.route === "save" + ? select.save.from() + : select.invalid.from({ message: "Add a title before continuing." }) + ) + }, + states: { + Form: {}, + Failed: {} + } + }, + Saving: { + invoke: (from) => + from.effect("save-review", () => saveReview).onDone((to) => to.branch.Workflow.Publishing()).onFailure(( + to + ) => + to.branch.Workflow.Review.Failed().resolve(({ target }) => + target.from({ message: "The review could not be saved." }) + ) ) }, - states: { - Form: {}, - Failed: {} - } - }, - Saving: { - invoke: (from) => - from.effect("save-review", () => saveReview).onDone((to) => to.branch.Workflow.Publishing()).onFailure((to) => - to.branch.Workflow.Review.Failed().resolve(({ target }) => - target.from({ message: "The review could not be saved." }) + Publishing: { + invoke: (from) => + from.effect("publish-review", () => publishReview).onDone((to) => + to.branch.Workflow.Complete().resolve(({ output, target }) => target.from({ slug: output })) ) - ) - }, - Publishing: { - invoke: (from) => - from.effect("publish-review", () => publishReview).onDone((to) => - to.branch.Workflow.Complete().resolve(({ output, target }) => target.from({ slug: output })) - ) - }, - Complete: {} + }, + Complete: {} + } } } }) diff --git a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts index be6b527..9a150fb 100644 --- a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts +++ b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts @@ -9,27 +9,33 @@ class ChildDone extends Schema.TaggedClass("InvokeGalleryChildDone")( }) {} class FinishChild extends Schema.TaggedClass("InvokeGalleryFinishChild")("FinishChild", {}) {} -const ChildStates = Machine.states({ - Working: ChildWorking, - Done: { schema: ChildDone, type: "final", output: Schema.String } +const ChildStates = Machine.state({ + initial: "Working", + states: { + Working: ChildWorking, + Done: { schema: ChildDone, type: "final", output: Schema.String } + } }) export const invokeGalleryChildMachine = Machine.make({ id: "invoke-gallery-child", - states: ChildStates.states, - events: Machine.events(FinishChild), - initial: (to) => to.Working().resolve(({ target }) => target.decoded(new ChildWorking({ task: "render-preview" }))) + root: ChildStates, + events: Machine.eventsFromSchemas(FinishChild), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Working.decoded(new ChildWorking({ task: "render-preview" })))) }).handle({ - Working: { - on: { - FinishChild: (to) => - to.full.Done().resolve(({ state, target }) => - target.decoded(new ChildDone({ result: `${state.task}:complete` })) - ) + states: { + Working: { + on: { + FinishChild: (to) => + to.branch.Done().resolve(({ state, target }) => + target.decoded(new ChildDone({ result: `${state.task}:complete` })) + ) + } + }, + Done: { + output: ({ state }) => state.result } - }, - Done: { - output: ({ state }) => state.result } }) @@ -77,155 +83,159 @@ class StreamValue extends Schema.TaggedClass("InvokeGalleryStreamVa value: Schema.Number }) {} -const GalleryEvents = Machine.events(RunEffect, RunStream, RunTimer, RunProcess, RunChild, Reset) -const GalleryInternalEvents = Machine.internalEvents(StreamValue) +const GalleryEvents = Machine.eventsFromSchemas(RunEffect, RunStream, RunTimer, RunProcess, RunChild, Reset) +const GalleryInternalEvents = Machine.internalEventsFromSchemas(StreamValue) const processLogic = Machine.logic({ initial: "starting" as "starting" | "ready", run: () => Effect.fail("process stopped") }) -const GalleryStates = Machine.states({ - Gallery: { - schema: Gallery, - initial: "Choose", - states: { - Choose, - LoadingDocument, - StreamingUpdates, - WaitingForTimeout, - WatchingProcess, - RunningChild - } - }, - Completed, - Failed +const GalleryStates = Machine.state({ + initial: "Gallery", + states: { + Gallery: { + schema: Gallery, + initial: "Choose", + states: { + Choose, + LoadingDocument, + StreamingUpdates, + WaitingForTimeout, + WatchingProcess, + RunningChild + } + }, + Completed, + Failed + } }) export const invokeOutcomesMachine = Machine.make({ id: "invoke-outcomes", - states: GalleryStates.states, + root: GalleryStates, events: GalleryEvents, internalEvents: GalleryInternalEvents, - initial: (to) => - to.Gallery.initial.resolve(({ target }) => - target.decoded( - new Gallery({ selectedDemo: null }), - (gallery) => gallery.Choose.decoded(new Choose({})) + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.Gallery.decoded(new Gallery({ selectedDemo: null }), (gallery) => gallery.Choose.decoded(new Choose({}))) ) ) }).handle({ - Gallery: { - initialize: ({ builder }) => builder.decoded(new Choose({})), - on: { - Reset: (to) => to.local.Choose().resolve(({ target }) => target.decoded(new Choose({}))) - }, - states: { - Choose: { - on: { - RunEffect: (to) => - to.local.LoadingDocument().resolve(({ event, target }) => - target.decoded(new LoadingDocument({ request: event.request })) - ), - RunStream: (to) => - to.local.StreamingUpdates().resolve(({ target }) => target.decoded(new StreamingUpdates({ values: [] }))), - RunTimer: (to) => - to.local.WaitingForTimeout().resolve(({ target }) => - target.decoded(new WaitingForTimeout({ delay: "2 seconds" })) - ), - RunProcess: (to) => - to.local.WatchingProcess().resolve(({ target }) => target.decoded(new WatchingProcess({ revision: 1 }))), - RunChild: (to) => to.local.RunningChild().resolve(({ target }) => target.decoded(new RunningChild({}))) - } + states: { + Gallery: { + initialize: ({ builder }) => builder.decoded(new Choose({})), + on: { + Reset: (to) => to.local.Choose().resolve(({ target }) => target.decoded(new Choose({}))) }, - LoadingDocument: { - invoke: (from) => - from.effect( - "load-document", - ({ state }) => - state.request === "fail" ? Effect.fail("document unavailable") : Effect.succeed("document loaded") - ).onDone((to) => - to.full.Completed().resolve(({ output, target }) => - target.decoded(new Completed({ source: "effect", result: output })) + states: { + Choose: { + on: { + RunEffect: (to) => + to.local.LoadingDocument().resolve(({ event, target }) => + target.decoded(new LoadingDocument({ request: event.request })) + ), + RunStream: (to) => + to.local.StreamingUpdates().resolve(({ target }) => target.decoded(new StreamingUpdates({ values: [] }))), + RunTimer: (to) => + to.local.WaitingForTimeout().resolve(({ target }) => + target.decoded(new WaitingForTimeout({ delay: "2 seconds" })) + ), + RunProcess: (to) => + to.local.WatchingProcess().resolve(({ target }) => target.decoded(new WatchingProcess({ revision: 1 }))), + RunChild: (to) => to.local.RunningChild().resolve(({ target }) => target.decoded(new RunningChild({}))) + } + }, + LoadingDocument: { + invoke: (from) => + from.effect( + "load-document", + ({ state }) => + state.request === "fail" ? Effect.fail("document unavailable") : Effect.succeed("document loaded") + ).onDone((to) => + to.branch.Completed().resolve(({ output, target }) => + target.decoded(new Completed({ source: "effect", result: output })) + ) + ).onFailure((to) => + to.branch.Failed().resolve(({ error, target }) => + target.decoded(new Failed({ source: "effect", message: error })) + ) ) - ).onFailure((to) => - to.full.Failed().resolve(({ error, target }) => - target.decoded(new Failed({ source: "effect", message: error })) - ) - ) - }, - StreamingUpdates: { - invoke: (from) => - from.stream( - "document-updates", - () => Stream.fromIterable([1, 2, 3]).pipe(Stream.concat(Stream.fail("stream disconnected"))) - ).onElement((to) => - to.none.resolve(({ element }, enqueue) => { - enqueue.raise(GalleryInternalEvents.StreamValue({ value: element })) - }) - ).onDone((to) => - to.full.Completed().resolve(({ state, target }) => - target.decoded(new Completed({ source: "stream", result: state.values.join(", ") })) + }, + StreamingUpdates: { + invoke: (from) => + from.stream( + "document-updates", + () => Stream.fromIterable([1, 2, 3]).pipe(Stream.concat(Stream.fail("stream disconnected"))) + ).onElement((to) => + to.none.resolve(({ element }, enqueue) => { + enqueue.raise(GalleryInternalEvents.StreamValue({ value: element })) + }) + ).onDone((to) => + to.branch.Completed().resolve(({ state, target }) => + target.decoded(new Completed({ source: "stream", result: state.values.join(", ") })) + ) + ).onFailure((to) => + to.branch.Failed().resolve(({ error, target }) => + target.decoded(new Failed({ source: "stream", message: error })) + ) + ), + on: { + StreamValue: (to) => + to.local.StreamingUpdates().resolve(({ event, state, target }) => + target.decoded(new StreamingUpdates({ values: [...state.values, event.value] })) + ) + } + }, + WaitingForTimeout: { + invoke: (from) => + from.timer("request-timeout", "2 seconds").onDone((to) => + to.branch.Completed().resolve(({ target }) => + target.decoded(new Completed({ source: "timer", result: "timeout elapsed" })) + ) ) - ).onFailure((to) => - to.full.Failed().resolve(({ error, target }) => - target.decoded(new Failed({ source: "stream", message: error })) + }, + WatchingProcess: { + invoke: (from) => + from.logic("status-worker", { + address: Machine.childAddress("status-worker"), + logic: processLogic + }).onFailure((to) => + to.branch.Failed().resolve(({ error, target }) => + target.decoded(new Failed({ source: "process", message: String(error) })) + ) + ).onSnapshot((to) => + to.branches({ + ready: { title: "Worker reports ready", target: to.branch.Completed() }, + waiting: { title: "Worker is still starting", target: to.none } + }).resolve(({ select, snapshot }) => + snapshot.state === "ready" + ? select.ready.decoded(new Completed({ source: "process", result: "ready" })) + : select.waiting() + ) ) - ), - on: { - StreamValue: (to) => - to.local.StreamingUpdates().resolve(({ event, state, target }) => - target.decoded(new StreamingUpdates({ values: [...state.values, event.value] })) + }, + RunningChild: { + invoke: (from) => + from.child(InvokedChild).onDone((to) => + to.branch.Completed().resolve(({ output, target }) => + target.decoded(new Completed({ source: "child machine", result: output })) + ) ) } - }, - WaitingForTimeout: { - invoke: (from) => - from.timer("request-timeout", "2 seconds").onDone((to) => - to.full.Completed().resolve(({ target }) => - target.decoded(new Completed({ source: "timer", result: "timeout elapsed" })) - ) - ) - }, - WatchingProcess: { - invoke: (from) => - from.logic("status-worker", { - address: Machine.childAddress("status-worker"), - logic: processLogic - }).onFailure((to) => - to.full.Failed().resolve(({ error, target }) => - target.decoded(new Failed({ source: "process", message: String(error) })) - ) - ).onSnapshot((to) => - to.branches({ - ready: { title: "Worker reports ready", target: to.full.Completed() }, - waiting: { title: "Worker is still starting", target: to.none } - }).resolve(({ select, snapshot }) => - snapshot.state === "ready" - ? select.ready.decoded(new Completed({ source: "process", result: "ready" })) - : select.waiting() - ) - ) - }, - RunningChild: { - invoke: (from) => - from.child(InvokedChild).onDone((to) => - to.full.Completed().resolve(({ output, target }) => - target.decoded(new Completed({ source: "child machine", result: output })) - ) - ) } - } - }, - Completed: { - on: { - Reset: (to) => - to.full.Gallery.initial.resolve(({ target }) => target.decoded(new Gallery({ selectedDemo: null }))) - } - }, - Failed: { - on: { - Reset: (to) => - to.full.Gallery.initial.resolve(({ target }) => target.decoded(new Gallery({ selectedDemo: null }))) + }, + Completed: { + on: { + Reset: (to) => + to.branch.Gallery.initial.resolve(({ target }) => target.decoded(new Gallery({ selectedDemo: null }))) + } + }, + Failed: { + on: { + Reset: (to) => + to.branch.Gallery.initial.resolve(({ target }) => target.decoded(new Gallery({ selectedDemo: null }))) + } } } }) diff --git a/packages/devtools/src/internal/browser/layout-resilience-example.ts b/packages/devtools/src/internal/browser/layout-resilience-example.ts index 55380ba..9e6849f 100644 --- a/packages/devtools/src/internal/browser/layout-resilience-example.ts +++ b/packages/devtools/src/internal/browser/layout-resilience-example.ts @@ -20,33 +20,36 @@ const AuthState = Schema.TaggedUnion({ Navigating: { href: Schema.String } }) -const AuthStates = Machine.states({ - Editing: { - schema: AuthState.cases.Editing, - initial: "Form", - states: { - Form: { - initial: "Ready", - states: { - Ready: {}, - Failed: AuthState.cases.EditingFailed - } - }, - SubmittingLogin: {}, - RequestingVerification: {} - } - }, - Verification: { - schema: AuthState.cases.Verification, - initial: "CodeEntry", - states: { - CodeEntry: {} - } - }, - Navigating: AuthState.cases.Navigating +const AuthStates = Machine.state({ + initial: "Editing", + states: { + Editing: { + schema: AuthState.cases.Editing, + initial: "Form", + states: { + Form: { + initial: "Ready", + states: { + Ready: {}, + Failed: AuthState.cases.EditingFailed + } + }, + SubmittingLogin: {}, + RequestingVerification: {} + } + }, + Verification: { + schema: AuthState.cases.Verification, + initial: "CodeEntry", + states: { + CodeEntry: {} + } + }, + Navigating: AuthState.cases.Navigating + } }) -const AuthEvents = Machine.events( +const AuthEvents = Machine.eventsFromSchemas( Schema.TaggedUnion({ EmailChanged: { value: Schema.String }, LoginMethodChanged: { value: LoginMethod }, @@ -57,89 +60,97 @@ const AuthEvents = Machine.events( export const layoutResilienceMachine = Machine.make({ id: "layout-resilience", - states: AuthStates.states, + root: AuthStates, events: AuthEvents, - initial: (to) => - to.Editing.initial.resolve(({ target }) => - target.from({ - mode: "login", - email: "", - loginMethod: "code", - password: "" - }, (editing) => editing.Form.from((form) => form.Ready.from())) + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.Editing.from({ + mode: "login", + email: "", + loginMethod: "code", + password: "" + }, (editing) => editing.Form.from((form) => form.Ready.from())) + ) ) }).handle({ - Editing: { - states: { - Form: { - on: { - EmailChanged: (to) => - to.branch.Editing.update(({ current, event, owner }) => owner.from({ ...current, email: event.value })), - LoginMethodChanged: (to) => - to.branch.Editing.update(({ current, event, owner }) => - owner.from({ ...current, loginMethod: event.value }) - ), - PasswordChanged: (to) => - to.branch.Editing.update(({ current, event, owner }) => owner.from({ ...current, password: event.value })), - Submit: (to) => - to.branches({ - requestVerification: { - title: "Request a verification code", - target: to.branch.Editing.RequestingVerification() - }, - login: { - title: "Submit password login", - target: to.branch.Editing.SubmittingLogin() - }, - invalid: { - title: "Show validation failure", - target: to.local.Failed() - } - }).resolve(({ event, select }) => { - if (event.route === "login") return select.login.from() - if (event.route === "verification") return select.requestVerification.from() - return select.invalid.from({ message: "Enter valid authentication details." }) - }) + states: { + Editing: { + states: { + Form: { + on: { + EmailChanged: (to) => + to.branch.Editing.update.resolve(({ current, event, owner }) => + owner.from({ ...current, email: event.value }) + ), + LoginMethodChanged: (to) => + to.branch.Editing.update.resolve(({ current, event, owner }) => + owner.from({ ...current, loginMethod: event.value }) + ), + PasswordChanged: (to) => + to.branch.Editing.update.resolve(({ current, event, owner }) => + owner.from({ ...current, password: event.value }) + ), + Submit: (to) => + to.branches({ + requestVerification: { + title: "Request a verification code", + target: to.branch.Editing.RequestingVerification() + }, + login: { + title: "Submit password login", + target: to.branch.Editing.SubmittingLogin() + }, + invalid: { + title: "Show validation failure", + target: to.local.Failed() + } + }).resolve(({ event, select }) => { + if (event.route === "login") return select.login.from() + if (event.route === "verification") return select.requestVerification.from() + return select.invalid.from({ message: "Enter valid authentication details." }) + }) + }, + states: { + Ready: {}, + Failed: {} + } }, - states: { - Ready: {}, - Failed: {} + SubmittingLogin: { + invoke: (from) => + from.effect("submit-login", ({ containingState }) => + containingState.email === "fail" + ? Effect.fail("invalid credentials") + : Effect.succeed("/creator")).onDone((to) => + to.branch.Navigating().resolve(({ output, target }) => target.from({ href: output }))).onFailure((to) => + to.branch.Editing.Form.Failed().resolve(({ target }) => + target.from({ message: "Email or password is incorrect." }) + )) + }, + RequestingVerification: { + invoke: (from) => + from.effect("request-verification", ({ containingState }) => + containingState.email === "fail" + ? Effect.fail("verification unavailable") + : Effect.succeed(undefined)).onDone((to) => + to.branch.Verification.initial.resolve(({ containingState, target }) => + target.from({ + mode: containingState.mode, + email: containingState.email, + code: "" + }) + )).onFailure((to) => + to.branch.Editing.Form.Failed().resolve(({ target }) => + target.from({ message: "The verification code could not be sent." }) + )) } - }, - SubmittingLogin: { - invoke: (from) => - from.effect("submit-login", ({ containingState }) => - containingState.email === "fail" - ? Effect.fail("invalid credentials") - : Effect.succeed("/creator")).onDone((to) => - to.full.Navigating().resolve(({ output, target }) => target.from({ href: output }))).onFailure((to) => - to.branch.Editing.Form.Failed().resolve(({ target }) => - target.from({ message: "Email or password is incorrect." }) - )) - }, - RequestingVerification: { - invoke: (from) => - from.effect("request-verification", ({ containingState }) => - containingState.email === "fail" - ? Effect.fail("verification unavailable") - : Effect.succeed(undefined)).onDone((to) => - to.full.Verification.initial.resolve(({ containingState, target }) => - target.from({ - mode: containingState.mode, - email: containingState.email, - code: "" - }) - )).onFailure((to) => - to.branch.Editing.Form.Failed().resolve(({ target }) => - target.from({ message: "The verification code could not be sent." }) - )) } - } - }, - Verification: { - states: { - CodeEntry: {} - } - }, - Navigating: {} + }, + Verification: { + states: { + CodeEntry: {} + } + }, + Navigating: {} + } }) diff --git a/packages/devtools/src/internal/browser/parallel-completion-example.ts b/packages/devtools/src/internal/browser/parallel-completion-example.ts index c70b93b..3b36d89 100644 --- a/packages/devtools/src/internal/browser/parallel-completion-example.ts +++ b/packages/devtools/src/internal/browser/parallel-completion-example.ts @@ -62,40 +62,43 @@ class CancelOrder extends Schema.TaggedClass("ParallelCancelOrder") class RetryOrder extends Schema.TaggedClass("ParallelRetryOrder")("RetryOrder", {}) {} class AutoShip extends Schema.TaggedClass("ParallelAutoShip")("AutoShip", {}) {} -const ParallelInternalEvents = Machine.internalEvents(AutoShip) -const ParallelStates = Machine.states({ - Cart, - Order: { - schema: Order, - type: "parallel", - states: { - payment: { - schema: Payment, - initial: "AwaitingAuthorization", - states: { - AwaitingAuthorization, - Authorized: { schema: Authorized, type: "final" } - } - }, - fulfillment: { - schema: Fulfillment, - initial: "WaitingForPayment", - states: { - WaitingForPayment, - Packing, - Shipped: { schema: Shipped, type: "final" } +const ParallelInternalEvents = Machine.internalEventsFromSchemas(AutoShip) +const ParallelStates = Machine.state({ + initial: "Cart", + states: { + Cart, + Order: { + schema: Order, + type: "parallel", + states: { + payment: { + schema: Payment, + initial: "AwaitingAuthorization", + states: { + AwaitingAuthorization, + Authorized: { schema: Authorized, type: "final" } + } + }, + fulfillment: { + schema: Fulfillment, + initial: "WaitingForPayment", + states: { + WaitingForPayment, + Packing, + Shipped: { schema: Shipped, type: "final" } + } } } - } - }, - Complete: { schema: OrderComplete, type: "final", output: Schema.String }, - Cancelled: OrderCancelled + }, + Complete: { schema: OrderComplete, type: "final", output: Schema.String }, + Cancelled: OrderCancelled + } }) export const parallelCompletionMachine = Machine.make({ id: "parallel-completion", - states: ParallelStates.states, - events: Machine.events( + root: ParallelStates, + events: Machine.eventsFromSchemas( Checkout, Authorize, DeclinePayment, @@ -106,94 +109,101 @@ export const parallelCompletionMachine = Machine.make({ RetryOrder ), internalEvents: ParallelInternalEvents, - initial: (to) => to.Cart().resolve(({ target }) => target.decoded(new Cart({ items: 2 }))) + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Cart.decoded(new Cart({ items: 2 })))) }).handle({ - Cart: { - on: { - Checkout: (to) => - to.full.Order.initial.resolve(({ event, target }) => - target.decoded(new Order({ orderId: event.orderId, total: event.total })) - ) - } - }, - Order: { - initialize: ({ builder }) => builder.payment.from({ attempts: 0 }).fulfillment.from({ warehouse: "north" }), - on: { - CancelOrder: (to) => - to.full.Cancelled().resolve(({ event, target }) => target.decoded(new OrderCancelled({ reason: event.reason }))) + states: { + Cart: { + on: { + Checkout: (to) => + to.branch.Order.initial.resolve(({ event, target }) => + target.decoded(new Order({ orderId: event.orderId, total: event.total })) + ) + } }, - onDone: (to) => - to.full.Complete().resolve(({ target }) => target.decoded(new OrderComplete({ orderId: "completed-order" }))), - states: { - payment: { - initialize: ({ builder }) => builder.from(), - states: { - AwaitingAuthorization: { - on: { - Authorize: (to) => - to.local.Authorized().resolve(({ event, target }) => - target.decoded(new Authorized({ authorizationId: event.authorizationId })) - ), - CompleteAll: (to) => - to.local.Authorized().resolve(({ event, target }) => - target.decoded(new Authorized({ authorizationId: event.authorizationId })) - ), - DeclinePayment: (to) => - to.full.Cancelled().resolve(({ event, target }) => - target.decoded(new OrderCancelled({ reason: event.reason })) - ) - } - } - } + Order: { + initialize: ({ builder }) => builder.payment.from({ attempts: 0 }).fulfillment.from({ warehouse: "north" }), + on: { + CancelOrder: (to) => + to.branch.Cancelled().resolve(({ event, target }) => + target.decoded(new OrderCancelled({ reason: event.reason })) + ) }, - fulfillment: { - initialize: ({ builder }) => builder.from(), - states: { - WaitingForPayment: { - on: { - Authorize: (to) => - to.local.Packing().resolve(({ target }) => target.decoded(new Packing({ packageCount: 1 }))), - Pack: (to) => - to.local.Packing().resolve(({ event, target }) => - target.decoded(new Packing({ packageCount: event.packages })) - ), - CompleteAll: (to) => - to.local.Shipped().resolve(({ event, target }) => - target.decoded(new Shipped({ trackingCode: event.trackingCode })) - ) + onDone: (to) => + to.branch.Complete().resolve(({ target }) => target.decoded(new OrderComplete({ orderId: "completed-order" }))), + states: { + payment: { + initialize: ({ builder }) => builder.from(), + states: { + AwaitingAuthorization: { + on: { + Authorize: (to) => + to.local.Authorized().resolve(({ event, target }) => + target.decoded(new Authorized({ authorizationId: event.authorizationId })) + ), + CompleteAll: (to) => + to.local.Authorized().resolve(({ event, target }) => + target.decoded(new Authorized({ authorizationId: event.authorizationId })) + ), + DeclinePayment: (to) => + to.branch.Cancelled().resolve(({ event, target }) => + target.decoded(new OrderCancelled({ reason: event.reason })) + ) + } } - }, - Packing: { - invoke: (from) => - from.timer("packing-sla", "5 seconds").onDone((to) => - to.none.resolve((_, enqueue) => { - enqueue.raise(ParallelInternalEvents.AutoShip()) - }) - ), - on: { - AutoShip: (to) => - to.local.Shipped().resolve(({ target }) => target.decoded(new Shipped({ trackingCode: "automatic" }))), - Ship: (to) => - to.local.Shipped().resolve(({ event, target }) => - target.decoded(new Shipped({ trackingCode: event.trackingCode })) + } + }, + fulfillment: { + initialize: ({ builder }) => builder.from(), + states: { + WaitingForPayment: { + on: { + Authorize: (to) => + to.local.Packing().resolve(({ target }) => target.decoded(new Packing({ packageCount: 1 }))), + Pack: (to) => + to.local.Packing().resolve(({ event, target }) => + target.decoded(new Packing({ packageCount: event.packages })) + ), + CompleteAll: (to) => + to.local.Shipped().resolve(({ event, target }) => + target.decoded(new Shipped({ trackingCode: event.trackingCode })) + ) + } + }, + Packing: { + invoke: (from) => + from.timer("packing-sla", "5 seconds").onDone((to) => + to.none.resolve((_, enqueue) => { + enqueue.raise(ParallelInternalEvents.AutoShip()) + }) ), - CompleteAll: (to) => - to.local.Shipped().resolve(({ event, target }) => - target.decoded(new Shipped({ trackingCode: event.trackingCode })) - ) + on: { + AutoShip: (to) => + to.local.Shipped().resolve(({ target }) => + target.decoded(new Shipped({ trackingCode: "automatic" })) + ), + Ship: (to) => + to.local.Shipped().resolve(({ event, target }) => + target.decoded(new Shipped({ trackingCode: event.trackingCode })) + ), + CompleteAll: (to) => + to.local.Shipped().resolve(({ event, target }) => + target.decoded(new Shipped({ trackingCode: event.trackingCode })) + ) + } } } } } - } - }, - Complete: { - output: ({ state }) => state.orderId - }, - Cancelled: { - on: { - RetryOrder: (to) => - to.full.Order.initial.resolve(({ target }) => target.decoded(new Order({ orderId: "retry", total: 0 }))) + }, + Complete: { + output: ({ state }) => state.orderId + }, + Cancelled: { + on: { + RetryOrder: (to) => + to.branch.Order.initial.resolve(({ target }) => target.decoded(new Order({ orderId: "retry", total: 0 }))) + } } } }) diff --git a/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts b/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts index 86631e6..463722e 100644 --- a/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts +++ b/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts @@ -14,45 +14,50 @@ class PrintRequested extends Schema.TaggedClass("ParallelOwnerPr {} ) {} -const ChildParentEvents = Machine.events(ChildSignal) -const ParallelOwnerChildStates = Machine.states({ Idle: ChildIdle }) +const ChildParentEvents = Machine.eventsFromSchemas(ChildSignal) +const ParallelOwnerChildStates = Machine.state({ initial: "Idle", states: { Idle: ChildIdle } }) export const parallelOwnerRoutingChildMachine = Machine.make({ id: "parallel-owner-routing-child", - states: ParallelOwnerChildStates.states, - events: Machine.events(), + root: ParallelOwnerChildStates, + events: Machine.eventsFromSchemas(), parent: Machine.parent(ChildParentEvents), - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new ChildIdle({}))) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new ChildIdle({})))) }).handle({ - Idle: { - invoke: (from) => - from.effect( - "signal-parent", - ({ parent }) => parent.send(ChildParentEvents.ChildSignal()) - ).onDone((to) => to.none).onFailure((to) => to.none) + states: { + Idle: { + invoke: (from) => + from.effect( + "signal-parent", + ({ parent }) => parent.send(ChildParentEvents.ChildSignal()) + ).onDone((to) => to.none).onFailure((to) => to.none) + } } }) const ParallelOwnerChild = Machine.child("parallel-owner-child", parallelOwnerRoutingChildMachine) -const ParallelOwnerStates = Machine.states({ - Print: { - schema: Print, - type: "parallel", - states: { - Options: { - schema: Options, - initial: "Ready", - states: { - Ready: OptionsReady - } - }, - Operation: { - schema: Operation, - initial: "Active", - states: { - Active, - Printing +const ParallelOwnerStates = Machine.state({ + initial: "Print", + states: { + Print: { + schema: Print, + type: "parallel", + states: { + Options: { + schema: Options, + initial: "Ready", + states: { + Ready: OptionsReady + } + }, + Operation: { + schema: Operation, + initial: "Active", + states: { + Active, + Printing + } } } } @@ -61,34 +66,38 @@ const ParallelOwnerStates = Machine.states({ export const parallelOwnerRoutingMachine = Machine.make({ id: "parallel-owner-routing", - states: ParallelOwnerStates.states, - events: Machine.events(PrintRequested, ChildParentEvents), - initial: (to) => - to.Print.initial.resolve(({ target }) => - target.decoded( - new Print({}), - (print) => - print.Options.decoded(new Options({}), (options) => options.Ready.decoded(new OptionsReady({}))).Operation - .decoded(new Operation({}), (operation) => operation.Active.decoded(new Active({}))) + root: ParallelOwnerStates, + events: Machine.eventsFromSchemas(PrintRequested, ChildParentEvents), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.Print.decoded( + new Print({}), + (print) => + print.Options.decoded(new Options({}), (options) => options.Ready.decoded(new OptionsReady({}))).Operation + .decoded(new Operation({}), (operation) => operation.Active.decoded(new Active({}))) + ) ) ) }).handle({ - Print: { - initialize: ({ builder }) => builder.Options.from({}).Operation.from({}), - invoke: (from) => - from.child(ParallelOwnerChild).onFailure((to) => - to.branch.Print.Operation.Active().resolve(({ target }) => target.decoded(new Active({}))) - ), - on: { - PrintRequested: (to) => - to.branch.Print.Operation.Printing().resolve(({ target }) => target.decoded(new Printing({}))) - }, - states: { - Options: { - initialize: ({ builder }) => builder.from({}) + states: { + Print: { + initialize: ({ builder }) => builder.Options.from({}).Operation.from({}), + invoke: (from) => + from.child(ParallelOwnerChild).onFailure((to) => + to.branch.Print.Operation.Active().resolve(({ target }) => target.decoded(new Active({}))) + ), + on: { + PrintRequested: (to) => + to.branch.Print.Operation.Printing().resolve(({ target }) => target.decoded(new Printing({}))) }, - Operation: { - initialize: ({ builder }) => builder.from({}) + states: { + Options: { + initialize: ({ builder }) => builder.from({}) + }, + Operation: { + initialize: ({ builder }) => builder.from({}) + } } } } diff --git a/packages/devtools/src/internal/browser/planner-example.ts b/packages/devtools/src/internal/browser/planner-example.ts index b04a5e2..3dad60e 100644 --- a/packages/devtools/src/internal/browser/planner-example.ts +++ b/packages/devtools/src/internal/browser/planner-example.ts @@ -64,18 +64,21 @@ class Cancel extends Schema.TaggedClass("PlannerCancel")("Cancel", { rea class AutoFinish extends Schema.TaggedClass("PlannerAutoFinish")("AutoFinish", {}) {} class Planned extends Schema.TaggedClass("PlannerPlanned")("Planned", { job: Schema.String }) {} -const Events = Machine.events(Begin, Cancel) -const InternalEvents = Machine.internalEvents(AutoFinish) -const Emissions = Machine.emittedEvents(Planned) -const States = Machine.states({ - Idle, - Working, - Finished: { schema: Finished, type: "final", output: Schema.String } +const Events = Machine.eventsFromSchemas(Begin, Cancel) +const InternalEvents = Machine.internalEventsFromSchemas(AutoFinish) +const Emissions = Machine.emittedEventsFromSchemas(Planned) +const States = Machine.state({ + initial: "Idle", + states: { + Idle, + Working, + Finished: { schema: Finished, type: "final", output: Schema.String } + } }) export const plannerMachine = Machine.make({ id: "planner-example", - states: States.states, + root: States, events: Events, internalEvents: InternalEvents, emittedEvents: Emissions, @@ -109,37 +112,40 @@ export const plannerMachine = Machine.make({ }) ) }), - initial: (to) => to.Idle().resolve(({ input, target }) => target.decoded(new Idle({ owner: input.owner }))) + initialConfiguration: (root) => + root.resolve(({ input, target }) => target.from((to) => to.Idle.decoded(new Idle({ owner: input.owner })))) }).handle({ - Idle: { - on: { - Begin: (to) => - to.branches({ - urgent: { title: "Finish immediately", target: to.full.Working() }, - normal: { title: "Wait in working", target: to.full.Working() } - }).resolve(({ event, self, select, state }, enqueue) => { - enqueue.emit(new Planned({ job: event.job })) - enqueue.sendTo(self, Events.Cancel({ reason: "planner command example" })) - if (event.priority === "urgent") enqueue.raise(InternalEvents.AutoFinish()) - const working = new Working({ owner: state.owner, job: event.job }) - return event.priority === "urgent" - ? select.urgent.decoded(working) - : select.normal.decoded(working) - }) - } - }, - Working: { - invoke: (from) => from.effect("monitor-job", () => Effect.never), - on: { - AutoFinish: (to) => - to.full.Finished().resolve(({ state, target }) => target.decoded(new Finished({ job: state.job }))), - Cancel: (to) => - to.full.Idle().resolve(({ event, state, target }) => - target.decoded(new Idle({ owner: `${state.owner} · ${event.reason}` })) - ) + states: { + Idle: { + on: { + Begin: (to) => + to.branches({ + urgent: { title: "Finish immediately", target: to.branch.Working() }, + normal: { title: "Wait in working", target: to.branch.Working() } + }).resolve(({ event, self, select, state }, enqueue) => { + enqueue.emit(new Planned({ job: event.job })) + enqueue.sendTo(self, Events.Cancel({ reason: "planner command example" })) + if (event.priority === "urgent") enqueue.raise(InternalEvents.AutoFinish()) + const working = new Working({ owner: state.owner, job: event.job }) + return event.priority === "urgent" + ? select.urgent.decoded(working) + : select.normal.decoded(working) + }) + } + }, + Working: { + invoke: (from) => from.effect("monitor-job", () => Effect.never), + on: { + AutoFinish: (to) => + to.branch.Finished().resolve(({ state, target }) => target.decoded(new Finished({ job: state.job }))), + Cancel: (to) => + to.branch.Idle().resolve(({ event, state, target }) => + target.decoded(new Idle({ owner: `${state.owner} · ${event.reason}` })) + ) + } + }, + Finished: { + output: ({ state }) => state.job } - }, - Finished: { - output: ({ state }) => state.job } }) diff --git a/packages/devtools/src/internal/browser/protocol-events-example.ts b/packages/devtools/src/internal/browser/protocol-events-example.ts index b00cedf..8f6ccad 100644 --- a/packages/devtools/src/internal/browser/protocol-events-example.ts +++ b/packages/devtools/src/internal/browser/protocol-events-example.ts @@ -11,7 +11,7 @@ class ChildProblem extends Schema.TaggedClass("ProtocolChildProble message: Schema.String }) {} -const ParentEvents = Machine.events(ChildProgress, ChildFinished, ChildProblem) +const ParentEvents = Machine.eventsFromSchemas(ChildProgress, ChildFinished, ChildProblem) class ChildIdle extends Schema.TaggedClass("ProtocolChildIdle")("Idle", {}) {} class ChildWorking extends Schema.TaggedClass("ProtocolChildWorking")("Working", { @@ -43,64 +43,71 @@ class ChildTrace extends Schema.TaggedClass("ProtocolChildTrace")("C message: Schema.String }) {} -const ChildEvents = Machine.events(BeginChildWork, CancelChildWork) -const ChildInternalEvents = Machine.internalEvents(Heartbeat, CommitChildWork) -const ChildEmissions = Machine.emittedEvents(ChildTrace) -const ChildStates = Machine.states({ - Idle: ChildIdle, - Working: ChildWorking, - Done: { schema: ChildDone, type: "final", output: Schema.String }, - Cancelled: { schema: ChildCancelled, type: "final", output: Schema.String } +const ChildEvents = Machine.eventsFromSchemas(BeginChildWork, CancelChildWork) +const ChildInternalEvents = Machine.internalEventsFromSchemas(Heartbeat, CommitChildWork) +const ChildEmissions = Machine.emittedEventsFromSchemas(ChildTrace) +const ChildStates = Machine.state({ + initial: "Idle", + states: { + Idle: ChildIdle, + Working: ChildWorking, + Done: { schema: ChildDone, type: "final", output: Schema.String }, + Cancelled: { schema: ChildCancelled, type: "final", output: Schema.String } + } }) export const requiredParentChildMachine = Machine.make({ id: "required-parent-child", - states: ChildStates.states, + root: ChildStates, events: ChildEvents, internalEvents: ChildInternalEvents, emittedEvents: ChildEmissions, parent: Machine.parent(ParentEvents), - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new ChildIdle({}))) + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new ChildIdle({})))) }).handle({ - Idle: { - on: { - BeginChildWork: (to) => - to.full.Working().resolve(({ event, target }, enqueue) => { - enqueue.raise(ChildInternalEvents.Heartbeat({ percent: 25 })) - enqueue.emit(ChildEmissions.ChildTrace({ message: `started ${event.job}` })) - return target.decoded(new ChildWorking({ job: event.job, progress: 0 })) - }) - } - }, - Working: { - invoke: (from) => - from.effect( - "report-progress-to-parent", - ({ parent, state }) => parent.send(ParentEvents.ChildProgress({ percent: state.progress })) - ).onDone((to) => to.none).onFailure((to) => - to.full.Cancelled().resolve(({ error, target }) => - target.decoded(new ChildCancelled({ reason: String(error) })) - ) - ), - on: { - Heartbeat: (to) => - to.full.Working().resolve(({ event, state, target }, enqueue) => { - enqueue.raise(ChildInternalEvents.CommitChildWork()) - return target.decoded(new ChildWorking({ job: state.job, progress: event.percent })) - }), - CommitChildWork: (to) => - to.full.Done().resolve(({ state, target }) => - target.decoded(new ChildDone({ result: `${state.job}:complete` })) + states: { + Idle: { + on: { + BeginChildWork: (to) => + to.branch.Working().resolve(({ event, target }, enqueue) => { + enqueue.raise(ChildInternalEvents.Heartbeat({ percent: 25 })) + enqueue.emit(ChildEmissions.ChildTrace({ message: `started ${event.job}` })) + return target.decoded(new ChildWorking({ job: event.job, progress: 0 })) + }) + } + }, + Working: { + invoke: (from) => + from.effect( + "report-progress-to-parent", + ({ parent, state }) => parent.send(ParentEvents.ChildProgress({ percent: state.progress })) + ).onDone((to) => to.none).onFailure((to) => + to.branch.Cancelled().resolve(({ error, target }) => + target.decoded(new ChildCancelled({ reason: String(error) })) + ) ), - CancelChildWork: (to) => - to.full.Cancelled().resolve(({ event, target }) => target.decoded(new ChildCancelled({ reason: event.reason }))) + on: { + Heartbeat: (to) => + to.branch.Working().resolve(({ event, state, target }, enqueue) => { + enqueue.raise(ChildInternalEvents.CommitChildWork()) + return target.decoded(new ChildWorking({ job: state.job, progress: event.percent })) + }), + CommitChildWork: (to) => + to.branch.Done().resolve(({ state, target }) => + target.decoded(new ChildDone({ result: `${state.job}:complete` })) + ), + CancelChildWork: (to) => + to.branch.Cancelled().resolve(({ event, target }) => + target.decoded(new ChildCancelled({ reason: event.reason })) + ) + } + }, + Done: { + output: ({ state }) => state.result + }, + Cancelled: { + output: ({ state }) => state.reason } - }, - Done: { - output: ({ state }) => state.result - }, - Cancelled: { - output: ({ state }) => state.reason } }) @@ -119,50 +126,61 @@ class ParentFailed extends Schema.TaggedClass("ProtocolParentFaile class LaunchChild extends Schema.TaggedClass("ProtocolLaunchChild")("LaunchChild", {}) {} class ResetParent extends Schema.TaggedClass("ProtocolResetParent")("ResetParent", {}) {} -const ParentStates = Machine.states({ - Idle: ParentIdle, - Supervising, - Complete: { schema: ParentComplete, type: "final", output: Schema.String }, - Failed: ParentFailed +const ParentStates = Machine.state({ + initial: "Idle", + states: { + Idle: ParentIdle, + Supervising, + Complete: { schema: ParentComplete, type: "final", output: Schema.String }, + Failed: ParentFailed + } }) export const parentProtocolMachine = Machine.make({ id: "parent-child-protocol", - states: ParentStates.states, - events: Machine.events(LaunchChild, ResetParent, ParentEvents), - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new ParentIdle({}))) + root: ParentStates, + events: Machine.eventsFromSchemas(LaunchChild, ResetParent, ParentEvents), + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new ParentIdle({})))) }).handle({ - Idle: { - on: { - LaunchChild: (to) => - to.full.Supervising().resolve(({ target }) => target.decoded(new Supervising({ latestProgress: 0 }))) - } - }, - Supervising: { - invoke: (from) => - from.child(ProtocolChild).onDone((to) => - to.full.Complete().resolve(({ output, target }) => target.decoded(new ParentComplete({ result: output }))) - ).onFailure((to) => - to.full.Failed().resolve(({ error, target }) => target.decoded(new ParentFailed({ message: String(error) }))) - ), - on: { - ChildProgress: (to) => - to.full.Supervising().resolve(({ event, target }) => - target.decoded(new Supervising({ latestProgress: event.percent })) + states: { + Idle: { + on: { + LaunchChild: (to) => + to.branch.Supervising().resolve(({ target }) => target.decoded(new Supervising({ latestProgress: 0 }))) + } + }, + Supervising: { + invoke: (from) => + from.child(ProtocolChild).onDone((to) => + to.branch.Complete().resolve(({ output, target }) => target.decoded(new ParentComplete({ result: output }))) + ).onFailure((to) => + to.branch.Failed().resolve(({ error, target }) => + target.decoded(new ParentFailed({ message: String(error) })) + ) ), - ChildFinished: (to) => - to.full.Complete().resolve(({ event, target }) => target.decoded(new ParentComplete({ result: event.result }))), - ChildProblem: (to) => - to.full.Failed().resolve(({ event, target }) => target.decoded(new ParentFailed({ message: event.message }))), - ResetParent: (to) => to.full.Idle().resolve(({ target }) => target.decoded(new ParentIdle({}))) - } - }, - Complete: { - output: ({ state }) => state.result - }, - Failed: { - on: { - ResetParent: (to) => to.full.Idle().resolve(({ target }) => target.decoded(new ParentIdle({}))) + on: { + ChildProgress: (to) => + to.branch.Supervising().resolve(({ event, target }) => + target.decoded(new Supervising({ latestProgress: event.percent })) + ), + ChildFinished: (to) => + to.branch.Complete().resolve(({ event, target }) => + target.decoded(new ParentComplete({ result: event.result })) + ), + ChildProblem: (to) => + to.branch.Failed().resolve(({ event, target }) => + target.decoded(new ParentFailed({ message: event.message })) + ), + ResetParent: (to) => to.branch.Idle().resolve(({ target }) => target.decoded(new ParentIdle({}))) + } + }, + Complete: { + output: ({ state }) => state.result + }, + Failed: { + on: { + ResetParent: (to) => to.branch.Idle().resolve(({ target }) => target.decoded(new ParentIdle({}))) + } } } }) @@ -176,25 +194,28 @@ class PublishOutside extends Schema.TaggedClass("OptionalParentP { result: Schema.String } ) {} -const OptionalParentStates = Machine.states({ Detached, Published }) +const OptionalParentStates = Machine.state({ initial: "Detached", states: { Detached, Published } }) export const optionalParentMachine = Machine.make({ id: "optional-parent-protocol", - states: OptionalParentStates.states, - events: Machine.events(PublishOutside), + root: OptionalParentStates, + events: Machine.eventsFromSchemas(PublishOutside), parent: Machine.optionalParent(ParentEvents), - initial: (to) => to.Detached().resolve(({ target }) => target.decoded(new Detached({}))) + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Detached.decoded(new Detached({})))) }).handle({ - Detached: { - on: { - PublishOutside: (to) => - to.full.Published().resolve(({ event, parent, target }, enqueue) => { - if (parent !== undefined) { - enqueue.sendTo(parent, ParentEvents.ChildFinished({ result: event.result })) - } - return target.decoded(new Published({ deliveredToParent: parent !== undefined })) - }) - } - }, - Published: {} + states: { + Detached: { + on: { + PublishOutside: (to) => + to.branch.Published().resolve(({ event, parent, target }, enqueue) => { + if (parent !== undefined) { + enqueue.sendTo(parent, ParentEvents.ChildFinished({ result: event.result })) + } + return target.decoded(new Published({ deliveredToParent: parent !== undefined })) + }) + } + }, + Published: {} + } }) diff --git a/packages/devtools/src/internal/browser/shared-terminal-routing-example.ts b/packages/devtools/src/internal/browser/shared-terminal-routing-example.ts index 12d4bf2..8eff5fd 100644 --- a/packages/devtools/src/internal/browser/shared-terminal-routing-example.ts +++ b/packages/devtools/src/internal/browser/shared-terminal-routing-example.ts @@ -1,23 +1,26 @@ import { Machine } from "@typeonce/effect-machine" import { Effect, Schema } from "effect" -const ReplicationStates = Machine.states({ - Connecting: {}, - IdentifyingSource: {}, - ReadingServerInfo: {}, - ReadingSlot: {}, - CreatingSlot: {}, - CopyingSnapshot: {}, - CatchingUp: {}, - ApplyingChanges: {}, - Ready: {}, - SessionUnavailable: {}, - Stopping: {}, - Stopped: {}, - Failed: {} +const ReplicationStates = Machine.state({ + initial: "Connecting", + states: { + Connecting: {}, + IdentifyingSource: {}, + ReadingServerInfo: {}, + ReadingSlot: {}, + CreatingSlot: {}, + CopyingSnapshot: {}, + CatchingUp: {}, + ApplyingChanges: {}, + Ready: {}, + SessionUnavailable: {}, + Stopping: {}, + Stopped: {}, + Failed: {} + } }) -const ReplicationEvents = Machine.events( +const ReplicationEvents = Machine.eventsFromSchemas( Schema.TaggedUnion({ Retry: {}, SessionUnavailable: {}, @@ -29,113 +32,115 @@ const operation = (): Effect.Effect => Effect.succeed(undefined) export const sharedTerminalRoutingMachine = Machine.make({ id: "shared-terminal-routing", - states: ReplicationStates.states, + root: ReplicationStates, events: ReplicationEvents, - initial: (to) => to.Connecting() + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Connecting.from())) }).handle({ - Connecting: { - invoke: (from) => - from.effect("connect", operation) - .onDone((to) => to.full.IdentifyingSource()) - .onFailure((to) => to.full.Failed()), - on: { - SessionUnavailable: (to) => to.full.SessionUnavailable(), - StopRequested: (to) => to.full.Stopping() - } - }, - IdentifyingSource: { - invoke: (from) => - from.effect("identify-source", operation) - .onDone((to) => to.full.ReadingServerInfo()) - .onFailure((to) => to.full.Failed()), - on: { - SessionUnavailable: (to) => to.full.SessionUnavailable(), - StopRequested: (to) => to.full.Stopping() - } - }, - ReadingServerInfo: { - invoke: (from) => - from.effect("read-server-info", operation) - .onDone((to) => to.full.ReadingSlot()) - .onFailure((to) => to.full.Failed()), - on: { - SessionUnavailable: (to) => to.full.SessionUnavailable(), - StopRequested: (to) => to.full.Stopping() - } - }, - ReadingSlot: { - invoke: (from) => - from.effect("read-slot", operation) - .onDone((to) => to.full.CreatingSlot()) - .onFailure((to) => to.full.Failed()), - on: { - SessionUnavailable: (to) => to.full.SessionUnavailable(), - StopRequested: (to) => to.full.Stopping() - } - }, - CreatingSlot: { - invoke: (from) => - from.effect("create-slot", operation) - .onDone((to) => to.full.CopyingSnapshot()) - .onFailure((to) => to.full.Failed()), - on: { - SessionUnavailable: (to) => to.full.SessionUnavailable(), - StopRequested: (to) => to.full.Stopping() - } - }, - CopyingSnapshot: { - invoke: (from) => - from.effect("copy-snapshot", operation) - .onDone((to) => to.full.CatchingUp()) - .onFailure((to) => to.full.Failed()), - on: { - SessionUnavailable: (to) => to.full.SessionUnavailable(), - StopRequested: (to) => to.full.Stopping() - } - }, - CatchingUp: { - invoke: (from) => - from.effect("catch-up", operation) - .onDone((to) => to.full.ApplyingChanges()) - .onFailure((to) => to.full.Failed()), - on: { - SessionUnavailable: (to) => to.full.SessionUnavailable(), - StopRequested: (to) => to.full.Stopping() - } - }, - ApplyingChanges: { - invoke: (from) => - from.effect("apply-changes", operation) - .onDone((to) => to.full.Ready()) - .onFailure((to) => to.full.Failed()), - on: { - SessionUnavailable: (to) => to.full.SessionUnavailable(), - StopRequested: (to) => to.full.Stopping() - } - }, - Ready: { - on: { - SessionUnavailable: (to) => to.full.SessionUnavailable(), - StopRequested: (to) => to.full.Stopping() - } - }, - SessionUnavailable: { - on: { - Retry: (to) => to.full.Connecting(), - StopRequested: (to) => to.full.Stopping() - } - }, - Stopping: { - invoke: (from) => - from.effect("stop-session", operation) - .onDone((to) => to.full.Stopped()) - .onFailure((to) => to.full.Failed()) - }, - Stopped: {}, - Failed: { - on: { - Retry: (to) => to.full.Connecting(), - StopRequested: (to) => to.full.Stopping() + states: { + Connecting: { + invoke: (from) => + from.effect("connect", operation) + .onDone((to) => to.branch.IdentifyingSource()) + .onFailure((to) => to.branch.Failed()), + on: { + SessionUnavailable: (to) => to.branch.SessionUnavailable(), + StopRequested: (to) => to.branch.Stopping() + } + }, + IdentifyingSource: { + invoke: (from) => + from.effect("identify-source", operation) + .onDone((to) => to.branch.ReadingServerInfo()) + .onFailure((to) => to.branch.Failed()), + on: { + SessionUnavailable: (to) => to.branch.SessionUnavailable(), + StopRequested: (to) => to.branch.Stopping() + } + }, + ReadingServerInfo: { + invoke: (from) => + from.effect("read-server-info", operation) + .onDone((to) => to.branch.ReadingSlot()) + .onFailure((to) => to.branch.Failed()), + on: { + SessionUnavailable: (to) => to.branch.SessionUnavailable(), + StopRequested: (to) => to.branch.Stopping() + } + }, + ReadingSlot: { + invoke: (from) => + from.effect("read-slot", operation) + .onDone((to) => to.branch.CreatingSlot()) + .onFailure((to) => to.branch.Failed()), + on: { + SessionUnavailable: (to) => to.branch.SessionUnavailable(), + StopRequested: (to) => to.branch.Stopping() + } + }, + CreatingSlot: { + invoke: (from) => + from.effect("create-slot", operation) + .onDone((to) => to.branch.CopyingSnapshot()) + .onFailure((to) => to.branch.Failed()), + on: { + SessionUnavailable: (to) => to.branch.SessionUnavailable(), + StopRequested: (to) => to.branch.Stopping() + } + }, + CopyingSnapshot: { + invoke: (from) => + from.effect("copy-snapshot", operation) + .onDone((to) => to.branch.CatchingUp()) + .onFailure((to) => to.branch.Failed()), + on: { + SessionUnavailable: (to) => to.branch.SessionUnavailable(), + StopRequested: (to) => to.branch.Stopping() + } + }, + CatchingUp: { + invoke: (from) => + from.effect("catch-up", operation) + .onDone((to) => to.branch.ApplyingChanges()) + .onFailure((to) => to.branch.Failed()), + on: { + SessionUnavailable: (to) => to.branch.SessionUnavailable(), + StopRequested: (to) => to.branch.Stopping() + } + }, + ApplyingChanges: { + invoke: (from) => + from.effect("apply-changes", operation) + .onDone((to) => to.branch.Ready()) + .onFailure((to) => to.branch.Failed()), + on: { + SessionUnavailable: (to) => to.branch.SessionUnavailable(), + StopRequested: (to) => to.branch.Stopping() + } + }, + Ready: { + on: { + SessionUnavailable: (to) => to.branch.SessionUnavailable(), + StopRequested: (to) => to.branch.Stopping() + } + }, + SessionUnavailable: { + on: { + Retry: (to) => to.branch.Connecting(), + StopRequested: (to) => to.branch.Stopping() + } + }, + Stopping: { + invoke: (from) => + from.effect("stop-session", operation) + .onDone((to) => to.branch.Stopped()) + .onFailure((to) => to.branch.Failed()) + }, + Stopped: {}, + Failed: { + on: { + Retry: (to) => to.branch.Connecting(), + StopRequested: (to) => to.branch.Stopping() + } } } }) diff --git a/packages/devtools/src/internal/browser/text-tree.ts b/packages/devtools/src/internal/browser/text-tree.ts index 6e96844..ff53814 100644 --- a/packages/devtools/src/internal/browser/text-tree.ts +++ b/packages/devtools/src/internal/browser/text-tree.ts @@ -30,10 +30,10 @@ export interface TextTree { const nodeLabel = (node: VisualizationState, active: ReadonlySet): string => { const status = active.has(node.path) ? "●" : "○" - const label = node.title === null ? node.key : `${node.title} (${node.key})` + const label = node.title === null ? (node.path === "" ? "(root)" : node.key) : `${node.title} (${node.key})` const details: Array = node.type === "atomic" ? [] : [node.type] if (node.initial !== null) { - details.push(`initial: ${node.initial.slice(node.path.length + 1)}`) + details.push(`initial: ${node.initial.slice(node.path === "" ? 0 : node.path.length + 1)}`) } if (node.history !== null) { details.push(node.history) diff --git a/packages/devtools/src/internal/browser/transition-semantics-example.ts b/packages/devtools/src/internal/browser/transition-semantics-example.ts index 18cb4a3..68d91d9 100644 --- a/packages/devtools/src/internal/browser/transition-semantics-example.ts +++ b/packages/devtools/src/internal/browser/transition-semantics-example.ts @@ -74,46 +74,53 @@ class Archive extends Schema.TaggedClass("TransitionArchive")("Archive" reason: Schema.String }) {} -const TransitionStates = Machine.states({ - Workspace: { - schema: Workspace, - initial: "Routing", - states: { - Routing: { type: "choice" }, - Draft, - AutoSaving, - Review: { - schema: Review, - initial: "Checking", - states: { - Checking, - ChangesRequested, - Approved: { schema: Approved, type: "final" } - } - }, - Finished: { schema: WorkspaceFinished, type: "final" }, - recent: { type: "history" }, - exact: { type: "history", history: "deep" } - } - }, - Paused, - Disabled, - Published: { schema: Published, type: "final", output: Schema.String } +const TransitionStates = Machine.state({ + initial: "Paused", + states: { + Workspace: { + schema: Workspace, + initial: "Routing", + states: { + Routing: { type: "choice" }, + Draft, + AutoSaving, + Review: { + schema: Review, + initial: "Checking", + states: { + Checking, + ChangesRequested, + Approved: { schema: Approved, type: "final" } + } + }, + Finished: { schema: WorkspaceFinished, type: "final" }, + recent: { type: "history" }, + exact: { type: "history", history: "deep" } + } + }, + Paused, + Disabled, + Published: { schema: Published, type: "final", output: Schema.String } + } }) const defaultWorkspaceSnapshot = () => ({ - path: "Workspace" as const, - value: new Workspace({ revision: 0, preferredRoute: "draft" as const }), + path: "" as const, + value: undefined, state: { - path: "Workspace.Draft" as const, - value: new Draft({ text: "Recovered draft", autosaves: 0 }) + path: "Workspace" as const, + value: new Workspace({ revision: 0, preferredRoute: "draft" as const }), + state: { + path: "Workspace.Draft" as const, + value: new Draft({ text: "Recovered draft", autosaves: 0 }) + } } }) export const transitionSemanticsMachine = Machine.make({ id: "transition-semantics", - states: TransitionStates.states, - events: Machine.events( + root: TransitionStates, + events: Machine.eventsFromSchemas( Create, Edit, Save, @@ -131,115 +138,120 @@ export const transitionSemanticsMachine = Machine.make({ BumpWorkspace, Archive ), - initial: (to) => to.Paused().resolve(({ target }) => target.decoded(new Paused({ reason: "not started" }))) + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Paused.decoded(new Paused({ reason: "not started" })))) }).handle({ - Workspace: { - history: { - recent: { default: defaultWorkspaceSnapshot }, - exact: { default: defaultWorkspaceSnapshot } - }, - on: { - Pause: (to) => - to.full.Paused().resolve(({ event, target }) => target.decoded(new Paused({ reason: event.reason }))), - BumpWorkspace: (to) => - to.branch.Workspace.update(({ current, owner }) => - owner.decoded( - new Workspace({ - revision: current.revision + 1, - preferredRoute: current.preferredRoute - }) - ) - ) - }, - onDone: (to) => - to.full.Published().resolve(({ target }) => target.decoded(new Published({ result: "workspace published" }))), - states: { - Routing: { - choice: (to) => - to.branches({ - draft: { title: "Preferred route is draft", target: to.local.Draft() }, - review: { title: "Preferred route is review", target: to.local.Review.initial } - }).resolve(({ containingState, select }) => - containingState.preferredRoute === "review" - ? select.review.decoded(new Review({ requestedBy: "initial route" })) - : select.draft.decoded(new Draft({ text: "", autosaves: 0 })) + states: { + Workspace: { + history: { + recent: { default: defaultWorkspaceSnapshot }, + exact: { default: defaultWorkspaceSnapshot } + }, + on: { + Pause: (to) => + to.branch.Paused().resolve(({ event, target }) => target.decoded(new Paused({ reason: event.reason }))), + BumpWorkspace: (to) => + to.branch.Workspace.update.resolve(({ current, owner }) => + owner.decoded( + new Workspace({ + revision: current.revision + 1, + preferredRoute: current.preferredRoute + }) + ) ) }, - Draft: { - on: { - Edit: (to) => - to.local.Draft().resolve(({ event, state, target }) => - target.decoded(new Draft({ text: event.text, autosaves: state.autosaves })) - ), - Save: (to) => to.local.AutoSaving().resolve(({ target }) => target.decoded(new AutoSaving({}))), - Submit: (to) => + onDone: (to) => + to.branch.Published().resolve(({ target }) => target.decoded(new Published({ result: "workspace published" }))), + states: { + Routing: { + choice: (to) => to.branches({ - review: { title: "Enter the review flow", target: to.local.Review.initial }, - publish: { title: "Publish without review", target: to.local.Finished() } - }).resolve(({ event, select }) => - event.mode === "publish" - ? select.publish.decoded(new WorkspaceFinished({ result: "published directly" })) - : select.review.decoded(new Review({ requestedBy: event.requestedBy })) + draft: { title: "Preferred route is draft", target: to.local.Draft() }, + review: { title: "Preferred route is review", target: to.local.Review.initial } + }).resolve(({ containingState, select }) => + containingState.preferredRoute === "review" + ? select.review.decoded(new Review({ requestedBy: "initial route" })) + : select.draft.decoded(new Draft({ text: "", autosaves: 0 })) + ) + }, + Draft: { + on: { + Edit: (to) => + to.local.Draft().resolve(({ event, state, target }) => + target.decoded(new Draft({ text: event.text, autosaves: state.autosaves })) + ), + Save: (to) => to.local.AutoSaving().resolve(({ target }) => target.decoded(new AutoSaving({}))), + Submit: (to) => + to.branches({ + review: { title: "Enter the review flow", target: to.local.Review.initial }, + publish: { title: "Publish without review", target: to.local.Finished() } + }).resolve(({ event, select }) => + event.mode === "publish" + ? select.publish.decoded(new WorkspaceFinished({ result: "published directly" })) + : select.review.decoded(new Review({ requestedBy: event.requestedBy })) + ), + Refresh: (to) => to.none.resolve(() => undefined, { reenter: true }), + Ignore: (to) => to.none, + MaybeHandle: (to) => + to.none.resolve(({ decline, event }) => event.accept ? undefined : decline(), { + declinable: true + }) + } + }, + AutoSaving: { + always: (to) => + to.local.Draft().resolve(({ target }) => + target.decoded(new Draft({ text: "Autosaved draft", autosaves: 1 })) + ) + }, + Review: { + initialize: ({ builder }) => builder.decoded(new Checking({ checks: ["types", "tests"] })), + onDone: (to) => + to.branch.Workspace.Finished().resolve(({ target }) => + target.decoded(new WorkspaceFinished({ result: "approved review" })) ), - Refresh: (to) => to.none.resolve(() => undefined, { reenter: true }), - Ignore: (to) => to.none, - MaybeHandle: (to) => - to.none.resolve(({ decline, event }) => event.accept ? undefined : decline(), { - declinable: true - }) - } - }, - AutoSaving: { - always: (to) => - to.local.Draft().resolve(({ target }) => target.decoded(new Draft({ text: "Autosaved draft", autosaves: 1 }))) - }, - Review: { - initialize: ({ builder }) => builder.decoded(new Checking({ checks: ["types", "tests"] })), - onDone: (to) => - to.branch.Workspace.Finished().resolve(({ target }) => - target.decoded(new WorkspaceFinished({ result: "approved review" })) - ), - states: { - Checking: { - on: { - Approve: (to) => - to.local.Approved().resolve(({ event, target }) => - target.decoded(new Approved({ reviewer: event.reviewer })) - ), - Reject: (to) => - to.local.ChangesRequested().resolve(({ event, target }) => - target.decoded(new ChangesRequested({ reason: event.reason })) - ) - } - }, - ChangesRequested: { - on: { - Revise: (to) => - to.branch.Workspace.Draft().resolve(({ target }) => - target.decoded(new Draft({ text: "Revised draft", autosaves: 0 })) - ) + states: { + Checking: { + on: { + Approve: (to) => + to.local.Approved().resolve(({ event, target }) => + target.decoded(new Approved({ reviewer: event.reviewer })) + ), + Reject: (to) => + to.local.ChangesRequested().resolve(({ event, target }) => + target.decoded(new ChangesRequested({ reason: event.reason })) + ) + } + }, + ChangesRequested: { + on: { + Revise: (to) => + to.branch.Workspace.Draft().resolve(({ target }) => + target.decoded(new Draft({ text: "Revised draft", autosaves: 0 })) + ) + } } } } } + }, + Paused: { + on: { + Create: (to) => + to.branch.Workspace.initial.resolve(({ event, target }) => + target.decoded(new Workspace({ revision: 0, preferredRoute: event.route })) + ), + ResumeShallow: (to) => to.history.Workspace.recent.resolve(({ target }) => target()), + ResumeDeep: (to) => to.history.Workspace.exact.resolve(({ target }) => target()), + Restart: (to) => + to.branch.Workspace.initial.resolve(({ target }) => + target.decoded(new Workspace({ revision: 0, preferredRoute: "draft" })) + ) + } + }, + Disabled: {}, + Published: { + output: ({ state }) => state.result } - }, - Paused: { - on: { - Create: (to) => - to.full.Workspace.initial.resolve(({ event, target }) => - target.decoded(new Workspace({ revision: 0, preferredRoute: event.route })) - ), - ResumeShallow: (to) => to.history.Workspace.recent.resolve(({ target }) => target()), - ResumeDeep: (to) => to.history.Workspace.exact.resolve(({ target }) => target()), - Restart: (to) => - to.full.Workspace.initial.resolve(({ target }) => - target.decoded(new Workspace({ revision: 0, preferredRoute: "draft" })) - ) - } - }, - Disabled: {}, - Published: { - output: ({ state }) => state.result } }) diff --git a/packages/devtools/src/internal/browser/visualizer-model.ts b/packages/devtools/src/internal/browser/visualizer-model.ts index d3f2e49..749170a 100644 --- a/packages/devtools/src/internal/browser/visualizer-model.ts +++ b/packages/devtools/src/internal/browser/visualizer-model.ts @@ -55,7 +55,7 @@ export interface VisualizerModel { } export const stateLabel = (state: VisualizationState): string => - state.title === null ? state.key : `${state.title} (${state.key})` + state.title === null ? (state.path === "" ? "(root)" : state.key) : `${state.title} (${state.key})` export const triggerLabel = (transition: VisualizationTransition): string => { switch (transition.trigger.type) { @@ -74,7 +74,7 @@ export const triggerLabel = (transition: VisualizationTransition): string => { const propertyAccess = (key: string): string => /^[$A-Z_a-z][$\w]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]` -const pathAccess = (path: string): string => path.split(".").map(propertyAccess).join("") +const pathAccess = (path: string): string => path === "" ? "" : path.split(".").map(propertyAccess).join("") const nearestCompoundScope = ( document: VisualizationDocument, @@ -97,7 +97,7 @@ const localPathAccess = ( const scope = nearestCompoundScope(document, source) if (scope === undefined) return undefined if (path === scope) return "" - const prefix = `${scope}.` + const prefix = scope === "" ? "" : `${scope}.` return path.startsWith(prefix) ? pathAccess(path.slice(prefix.length)) : undefined } @@ -138,13 +138,13 @@ export const branchTargetApi = ( switch (selection.kind) { case "state": case "choice": - api = `to.branch${pathAccess(path)}()` + api = path === "" ? "to.root()" : `to.branch${pathAccess(path)}()` break case "initial": - api = `to.branch${pathAccess(path)}.initial` + api = path === "" ? "to.root.initial" : `to.branch${pathAccess(path)}.initial` break case "update": - api = `to.branch${pathAccess(path)}.update` + api = path === "" ? "to.root.update" : `to.branch${pathAccess(path)}.update` break case "history": break @@ -165,7 +165,7 @@ export const branchTargetApi = ( break } } else if (selection.scope === "initial") { - if (selection.kind === "state") api = `to${pathAccess(path)}()` + if (selection.kind === "state") api = path === "" ? "to" : `to${pathAccess(path)}()` if (selection.kind === "initial") api = `to${pathAccess(path)}.initial` } diff --git a/packages/devtools/test/MachineWalkthrough.test.ts b/packages/devtools/test/MachineWalkthrough.test.ts index 761086d..013f10a 100644 --- a/packages/devtools/test/MachineWalkthrough.test.ts +++ b/packages/devtools/test/MachineWalkthrough.test.ts @@ -169,6 +169,7 @@ describe("MachineWalkthrough", () => { it("starts from compound and parallel initial topology", () => { const session = MachineWalkthrough.start(MachineDocument.make(machine)) assert.deepStrictEqual(MachineWalkthrough.current(session).after.activePaths, [ + "", "application", "application.workflow", "application.workflow.idle", @@ -187,6 +188,7 @@ describe("MachineWalkthrough", () => { const started = MachineWalkthrough.start(MachineDocument.make(machine)) const advanced = takeEvent(started, "Start") assert.deepStrictEqual(MachineWalkthrough.current(advanced).after.activePaths, [ + "", "application", "application.workflow", "application.workflow.running", @@ -221,6 +223,7 @@ describe("MachineWalkthrough", () => { assert.isTrue(Result.isSuccess(crossed)) if (Result.isFailure(crossed)) throw crossed.failure assert.deepStrictEqual(MachineWalkthrough.current(crossed.success).after.activePaths, [ + "", "application", "application.workflow", "application.workflow.idle", diff --git a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts index 35b2e84..8edfe21 100644 --- a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts +++ b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts @@ -18,10 +18,10 @@ describe("Interactive text visualization", () => { const idle = document.states.find((state) => state.path === "application.workflow.idle") assert.deepStrictEqual(document.initial, { - target: "application", - selection: { path: "application", kind: "initial", scope: "initial" } + target: "", + selection: { path: "", kind: "state", scope: "initial" } }) - assert.deepStrictEqual(document.roots, ["application", "disabled"]) + assert.deepStrictEqual(document.roots, [""]) assert.deepStrictEqual(idle?.transitionIds, [ "application.workflow.idle:transition:0", "application.workflow.idle:transition:1" @@ -50,6 +50,7 @@ describe("Interactive text visualization", () => { ) assert.deepStrictEqual(document.snapshot, { activePaths: [ + "", "application", "application.workflow", "application.workflow.idle", @@ -69,14 +70,14 @@ describe("Interactive text visualization", () => { it("projects a state-only topology and structured state inspection", () => { const model = makeVisualizerModel(buildDocument()) - const application = model.roots[0] + const application = model.roots[0]?.children[0] const workflow = application?.children[0] const idle = workflow?.children[0] const inspection = idle === undefined ? undefined : model.inspectState(idle.path) assert.deepStrictEqual( model.roots.map((root) => root.path), - ["application", "disabled"] + [""] ) assert.strictEqual(application?.active, true) assert.strictEqual(application?.initial, true) @@ -94,7 +95,7 @@ describe("Interactive text visualization", () => { assert.strictEqual(model.inspectEvent("Start").candidate, true) assert.deepStrictEqual( inspection?.breadcrumbs.map((item) => item.path), - ["application", "application.workflow", "application.workflow.idle"] + ["", "application", "application.workflow", "application.workflow.idle"] ) }) @@ -125,7 +126,7 @@ describe("Interactive text visualization", () => { assert.deepStrictEqual( begin?.branches.map((branch) => branchTargetApi(plannerDocument, begin.source, branch)), - ["to.full.Working()", "to.full.Working()"] + ["to.branch.Working()", "to.branch.Working()"] ) assert.strictEqual( @@ -176,7 +177,7 @@ describe("Interactive text visualization", () => { }), "to.none" ) - assert.strictEqual(api("application", document.initial.selection), "to.application.initial") + assert.strictEqual(api("application", document.initial.selection), "to") }) it("accepts an empty partial topology", () => { diff --git a/packages/devtools/test/internal/browser/StaticChart.test.ts b/packages/devtools/test/internal/browser/StaticChart.test.ts index ed58e03..847f378 100644 --- a/packages/devtools/test/internal/browser/StaticChart.test.ts +++ b/packages/devtools/test/internal/browser/StaticChart.test.ts @@ -463,7 +463,7 @@ describe("Static chart", () => { model.edges.find((edge) => edge.transitionId === "Idle:transition:0")?.branchIds, ["Idle:transition:0:branch:0", "Idle:transition:0:branch:1"] ) - assert.deepStrictEqual(model.initials.map(({ target }) => target), ["Idle"]) + assert.deepStrictEqual(model.initials.map(({ target }) => target), ["", "Idle"]) }) it("links invoke outcome edges to their declared activity type", () => { @@ -1031,7 +1031,7 @@ describe("Static chart", () => { assert.deepStrictEqual(model.runtimeTargets, [{ id: `runtime:${runtime.id}`, edgeId: runtime.id, - parent: null, + parent: "", label: "runtime target" }]) diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 947a0e1..3855b66 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -42,57 +42,42 @@ in lockstep with this package. ## Quick start -Define schemas first, derive the state topology, then add behavior: +Start with events and root-owned data. A data-only machine needs no child states: ```ts import { Machine } from "@typeonce/effect-machine" -import { Effect, Schema, Stream } from "effect" - -const State = Schema.TaggedUnion({ - Running: { count: Schema.Number } -}) - -const States = Machine.states({ - Idle: {}, - Running: State.cases.Running -}) - -const CounterEvent = Machine.events( - Schema.TaggedUnion({ - Start: {}, - Increment: {}, - Stop: {} - }) -) +import { Effect, Schema } from "effect" +const Root = Machine.state({ fields: { count: Schema.Number } }) +const Events = Machine.events({ Increment: { by: Schema.Number } }) const CounterDefinition = Machine.make({ - id: "Counter", - states: States.states, - events: CounterEvent, - initial: (to) => to.Idle() + root: Root, + events: Events, + initial: (root) => root.from(() => ({ count: 0 })) }) - const Counter = CounterDefinition.handle({ - Idle: { - on: { - Start: (to) => to.full.Running().resolve(({ target }) => target.from({ count: 0 })) - } - }, - Running: { - on: { - Increment: (to) => to.full.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })), - Stop: (to) => to.full.Idle() - } + on: { + Increment: (to) => + to.self.update.from(({ current, event }) => ({ + count: current.count + event.by + })) } }) -const program = Effect.gen(function*() { +const program = Effect.scoped(Effect.gen(function*() { const ref = yield* Machine.start(Counter) - yield* ref.send(CounterEvent.Start()) - yield* ref.send(CounterEvent.Increment()) -}) + yield* ref.send(Events.Increment({ by: 1 })) +})) ``` +`self.update` replaces the owner's complete value without exiting and reentering +it. Its children and scoped work remain active. Add `initial` and `states` to +the same root descriptor when the workflow needs distinct modes; the root data +survives transitions between those children. + +See [the root API guide](./docs/root-api.md) for topology-only machines, +initialization, reusable schemas, guards, migration, and observation contracts. + `handle` creates a complete implementation boundary. Its result does not expose `handle`, so all behavior for one machine belongs in the same handler tree. Reuse the definition when multiple independent implementations are @@ -109,26 +94,25 @@ const TestingCounter = CounterDefinition.handle(testingHandlers) ## Modeling workflow -Use this order to preserve inference and keep boundaries explicit: - -1. Define domain schemas used by state and by shared event fields. -2. Declare topology with `Machine.states`, naming a tagged state union - when its `.cases` are reused. -3. Create event descriptors with `Machine.events`, `Machine.internalEvents`, - and `Machine.emittedEvents`, passing tagged unions or tagged classes directly. -4. Create the machine and implement every active state with - `Machine.make({...}).handle({...})`. -5. Add child descriptors, then runtime, Atom, testing, or cluster adapters at - the application boundary. - -Keep one-off topology inline in `Machine.states`. Use `Machine.state` only when -the same active state definition is mounted more than once; tagged schemas are -already reusable without it. For repeated finite regions, derive names with -`States.path(...)` so every literal in the path family is checked against the -complete tree. Type full-snapshot helpers as `Machine.Snapshot` -or `Machine.Snapshot`, schema-backed state payloads as -`Machine.Value`, and path-rooted snapshots as -`Machine.SnapshotAt`. +1. Define reusable domain schemas where they express a domain boundary. +2. Declare one root with `Machine.state`. Use inline children or mount reusable + descriptors under `states`. `fields` builds a tagged schema; `schema` keeps + an existing schema and its construction behavior. +3. Declare field records with `Machine.events`, `Machine.internalEvents`, and + `Machine.emittedEvents`. Import existing tagged unions or tagged classes with + the corresponding `eventsFromSchemas`, `internalEventsFromSchemas`, or + `emittedEventsFromSchemas` constructor. +4. Pass the root descriptor to `Machine.make({ root, events })`. Supply root + values with `initial`; declared child defaults determine startup topology. +5. Implement root behavior directly in `handle`, with child behavior in its + `states` property. Add runtime, Atom, testing, or Cluster adapters at the + application boundary. + +The root has path `""`; children retain paths such as `Editing` and +`Editing.Form`. Use `Root.path(...)` to validate path literals. Type full +snapshots as `Machine.Snapshot` or `Machine.Snapshot`, +state values as `Machine.Value`, and selected snapshots as +`Machine.SnapshotAt`. ### Make invalid states unrepresentable @@ -187,20 +171,24 @@ Omit `schema` when a state represents control flow but owns no data. Use `{}` instead of defining an empty tagged schema: ```ts -const States = Machine.states({ - Form: { - initial: "Editing", - states: { - Editing: {}, - Saving +const States = Machine.state({ + initial: "Form", + states: { + Form: { + initial: "Editing", + states: { + Editing: {}, + Saving + } } } }) const definition = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => to.Form.initial.resolve(({ target }) => target.from((form) => form.Editing.from())) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Form.from((form) => form.Editing.from()))) }) ``` @@ -212,7 +200,7 @@ schema-backed paths. Add a schema later if the state starts owning data. Keep data-bearing state schemas together in a named `Schema.TaggedUnion` and reference its cases from the topology. For a standalone state schema whose class identity is useful, declare a named `Schema.TaggedClass`. Do not bury -one-off tagged schema declarations inside `Machine.states`. +inline `fields` in a `Machine.state` descriptor. Put data on the narrowest state where it is valid. If sibling phases share data, put it on their compound parent. @@ -224,24 +212,24 @@ belong in `internalEvents`. Ephemeral outward notifications have their own `emittedEvents` protocol: ```ts -export const CommandEvent = Machine.events( +export const CommandEvent = Machine.eventsFromSchemas( Schema.TaggedUnion({ Save: {} }) ) export type PublicCommandEvent = Machine.EventOf -const InternalEvent = Machine.internalEvents( +const InternalEvent = Machine.internalEventsFromSchemas( Schema.TaggedUnion({ Saved: { id: Schema.String }, SaveFailed: { message: Schema.String } }) ) -const Emissions = Machine.emittedEvents( +const Emissions = Machine.emittedEventsFromSchemas( Schema.TaggedUnion({ SaveObserved: { id: Schema.String } }) ) const definition = Machine.make({ - states: States.states, + root: States, events: CommandEvent, internalEvents: InternalEvent, emittedEvents: Emissions, @@ -352,28 +340,30 @@ machine mailbox and is processed later. A machine that requires an owner declares the subset of parent inputs it may send with `Machine.parent`: ```ts -const ParentEvents = Machine.events(ChildFinished) +const ParentEvents = Machine.eventsFromSchemas(ChildFinished) const child = Machine.make({ - states: ChildStates.states, + root: ChildStates, events: ChildEvents, parent: Machine.parent(ParentEvents), initial: (to) => to.Working() }).handle({ - Working: { - on: { - Finish: (to) => - to.full.Done().resolve(({ parent, target }, enqueue) => { - enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) - return target.from() - }) - } - }, - Done: {} + states: { + Working: { + on: { + Finish: (to) => + to.branch.Done().resolve(({ parent, target }, enqueue) => { + enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) + return target.from() + }) + } + }, + Done: {} + } }) const Child = Machine.child("worker", child) -const ParentInputs = Machine.events(Start, ParentEvents) +const ParentInputs = Machine.eventsFromSchemas(Start, ParentEvents) ``` `parent` is statically present in every child callback, and root APIs such as @@ -409,7 +399,7 @@ paths. `parent` always means the owning machine target. | `target.history` | Restoring a declared history node | The remembered configuration or its typed default | Every required transition handler selects a target from its inline `to` -builder. Return a bare selection such as `to.full.Idle()` when the selected +builder. Return a bare selection such as `to.local.Idle()` when the selected builder supports zero-argument construction; the machine applies the same default construction as `target.from()`. This includes empty schemas and schemas whose constructor fields are all optional or defaulted. TypeScript @@ -427,7 +417,7 @@ restarts the source while retaining its configuration. Topology-only definition instructions are values: `to.none`, declared `.initial` and history selections, and `to.local.with`. Concrete state and -choice destinations remain calls such as `to.full.Running()`. Runtime named +choice destinations remain calls such as `to.local.Running()`. Runtime named branch builders remain callable, including `select.unchanged()`, because their result carries the selected branch evidence. @@ -574,7 +564,7 @@ lifecycle do not run. ## Statechart capabilities -`Machine.states` supports: +`Machine.state` supports: - atomic states; - compound states with one active child; @@ -599,16 +589,18 @@ State-scoped work starts on entry and is interrupted on exit: ```ts machine.handle({ - Loading: { - invoke: (from) => - from.effect("save-document", () => saveDocument) - .onDone((to) => to.full.Saved().resolve(({ output, target }) => target.from({ id: output.id }))) - .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) }))) - }, - Waiting: { - invoke: (from) => - from.timer("save-timeout", "3 seconds") - .onDone((to) => to.full.Failed().resolve(({ target }) => target.from({ message: "Timed out" }))) + states: { + Loading: { + invoke: (from) => + from.effect("save-document", () => saveDocument) + .onDone((to) => to.branch.Saved().resolve(({ output, target }) => target.from({ id: output.id }))) + .onFailure((to) => to.branch.Failed().resolve(({ error, target }) => target.from({ message: String(error) }))) + }, + Waiting: { + invoke: (from) => + from.timer("save-timeout", "3 seconds") + .onDone((to) => to.branch.Failed().resolve(({ target }) => target.from({ message: "Timed out" }))) + } } }) ``` @@ -621,11 +613,13 @@ the completed chain is the value returned by the callback: ```ts machine.handle({ - Loading: { - invoke: (from) => - from.effect("load-document", ({ state }) => loadDocument(state.documentId)) - .onDone((to) => to.full.Ready().resolve(({ output, target }) => target.from({ document: output }))) - .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: error.message }))) + states: { + Loading: { + invoke: (from) => + from.effect("load-document", ({ state }) => loadDocument(state.documentId)) + .onDone((to) => to.branch.Ready().resolve(({ output, target }) => target.from({ document: output }))) + .onFailure((to) => to.branch.Failed().resolve(({ error, target }) => target.from({ message: error.message }))) + } } }) ``` @@ -636,16 +630,18 @@ macrostep commits: ```ts machine.handle({ - Listening: { - invoke: (from) => - from.stream("channel", () => channelMessages) - .onElement((to) => - to.none.resolve(({ element }, enqueue) => { - enqueue.raise(Events.MessageReceived({ message: element })) - }) - ) - .onDone((to) => to.none) - .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ error }))) + states: { + Listening: { + invoke: (from) => + from.stream("channel", () => channelMessages) + .onElement((to) => + to.none.resolve(({ element }, enqueue) => { + enqueue.raise(Events.MessageReceived({ message: element })) + }) + ) + .onDone((to) => to.none) + .onFailure((to) => to.branch.Failed().resolve(({ error, target }) => target.from({ error }))) + } } }) ``` @@ -667,16 +663,18 @@ const machine = Machine.make({ parent: Machine.parent(ParentEvents) // ... }).handle({ - Saving: { - invoke: (from) => - from.effect("notify-parent", () => saveDocument) - .onDone((to) => - to.none.resolve(({ parent, self }, enqueue) => { - enqueue.sendTo(self, Commands.Save()) - enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) - }) - ) - .onFailure((to) => to.none) + states: { + Saving: { + invoke: (from) => + from.effect("notify-parent", () => saveDocument) + .onDone((to) => + to.none.resolve(({ parent, self }, enqueue) => { + enqueue.sendTo(self, Commands.Save()) + enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) + }) + ) + .onFailure((to) => to.none) + } } }) ``` @@ -690,11 +688,13 @@ transitions retain the exact owning state and machine protocols. const refreshCache = Cache.refresh machine.handle({ - Active: { - invoke: (from) => [ - from.effect("refresh-cache", () => refreshCache).onDone((to) => to.none).onFailure((to) => to.none), - from.timer("expire-session", "5 minutes").onDone((to) => to.full.Expired()) - ] + states: { + Active: { + invoke: (from) => [ + from.effect("refresh-cache", () => refreshCache).onDone((to) => to.none).onFailure((to) => to.none), + from.timer("expire-session", "5 minutes").onDone((to) => to.branch.Expired()) + ] + } } }) ``` @@ -720,19 +720,21 @@ open set of children that must survive state changes: const Plant = Machine.childFamily(plantMachine) const central = Machine.make({ - events: Machine.events(ResourcesOffered, PlantBroken) + events: Machine.eventsFromSchemas(ResourcesOffered, PlantBroken) // ... }).handle({ - Commissioning: { - invoke: (from) => - from.effect("commission-wave", ({ children, state }) => - Effect.forEach( - state.plants, - (input) => children.spawn(Plant(input.id), { input }), - { discard: true } - )) - .onDone((to) => to.full.Operating()) - .onFailure((to) => to.full.CommissioningFailed()) + states: { + Commissioning: { + invoke: (from) => + from.effect("commission-wave", ({ children, state }) => + Effect.forEach( + state.plants, + (input) => children.spawn(Plant(input.id), { input }), + { discard: true } + )) + .onDone((to) => to.branch.Operating()) + .onFailure((to) => to.branch.CommissioningFailed()) + } } }) ``` @@ -857,7 +859,10 @@ or child instance. ## Persistence -Logical snapshots can be validated for storage or transport: +Logical snapshots use codec version 2, including the root at path `""`. Earlier +encoded snapshots are rejected; migrate persisted data explicitly before decoding. + +Snapshots can be validated for storage or transport: ```ts const encoded = yield * Machine.encodeSnapshot(machine, snapshot) diff --git a/packages/effect-machine/docs/agent-guide.md b/packages/effect-machine/docs/agent-guide.md index 6eeefae..38ed3ce 100644 --- a/packages/effect-machine/docs/agent-guide.md +++ b/packages/effect-machine/docs/agent-guide.md @@ -22,12 +22,12 @@ const CounterState = Schema.TaggedUnion({ Running: { count: Schema.Number } }) -export const CounterStates = Machine.states({ +export const CounterStates = Machine.state({ initial: "Idle", states: { Idle: {}, Running: CounterState.cases.Running -}) +} }) -export const CounterEvents = Machine.events( +export const CounterEvents = Machine.eventsFromSchemas( Schema.TaggedUnion({ Start: {}, Increment: {}, @@ -37,27 +37,27 @@ export const CounterEvents = Machine.events( export const CounterMachine = Machine.make({ id: "Counter", - states: CounterStates.states, + root: CounterStates, events: CounterEvents, - initial: (to) => to.Idle() -}).handle({ + initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) +}).handle({ states: { Idle: { on: { - Start: (to) => to.full.Running().resolve(({ target }) => target.from({ count: 0 })) + Start: (to) => to.branch.Running().resolve(({ target }) => target.from({ count: 0 })) } }, Running: { on: { - Increment: (to) => to.full.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })), - Stop: (to) => to.full.Idle() + Increment: (to) => to.branch.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })), + Stop: (to) => to.branch.Idle() } } -}) +} }) ``` Each step has one job: -- `Machine.states` declares the state tree and the data owned by each state. +- `Machine.state` declares the root, its child topology, and state-owned data. - `Machine.events` declares the public messages the machine accepts and returns typed event constructors. - `Machine.make` joins the state tree, event protocol, input, and initial state. @@ -106,12 +106,12 @@ const RequestState = Schema.TaggedUnion({ Failed: { message: Schema.String } }) -const RequestStates = Machine.states({ +const RequestStates = Machine.state({ initial: "Idle", states: { Idle: {}, Loading: {}, Ready: RequestState.cases.Ready, Failed: RequestState.cases.Failed -}) +} }) ``` The machine can now be `Loading`, `Ready`, or `Failed`. It cannot construct a @@ -139,7 +139,7 @@ const DocumentState = Schema.TaggedUnion({ } }) -const DocumentStates = Machine.states({ +const DocumentStates = Machine.state({ initial: "Closed", states: { Closed: {}, Open: { // Editing, Saving, and SaveFailed all need the document and draft. @@ -152,7 +152,7 @@ const DocumentStates = Machine.states({ SaveFailed: DocumentState.cases.SaveFailed } } -}) +} }) ``` Do not copy `documentId` and `draft` into every child. Copies can disagree after @@ -165,22 +165,22 @@ Hierarchy owns behavior as well as data. Define a transition on the lowest compound state whose children share it. ```ts -const DocumentEvents = Machine.events( +const DocumentEvents = Machine.eventsFromSchemas( Schema.TaggedUnion({ Close: {} }) ) const DocumentMachine = Machine.make({ - states: DocumentStates.states, + root: DocumentStates, events: DocumentEvents, initial: (to) => to.Closed() -}).handle({ +}).handle({ states: { Closed: {}, Open: { on: { // All Open children close the document in the same way. - Close: (to) => to.full.Closed() + Close: (to) => to.branch.Closed() }, states: { Editing: {}, @@ -188,7 +188,7 @@ const DocumentMachine = Machine.make({ SaveFailed: {} } } -}) +} }) ``` The machine checks the deepest active state first, then its ancestors. Put a @@ -202,7 +202,7 @@ after domain actions and outcomes. Do not expose state setters such as `SetLoading` or `SetError`. ```ts -export const CheckoutEvents = Machine.events( +export const CheckoutEvents = Machine.eventsFromSchemas( Schema.TaggedUnion({ Submit: {}, Cancel: {} @@ -210,23 +210,23 @@ export const CheckoutEvents = Machine.events( ) const CheckoutMachine = Machine.make({ - states: CheckoutStates.states, + root: CheckoutStates, events: CheckoutEvents, initial: (to) => to.Editing() -}).handle({ +}).handle({ states: { Editing: { on: { - Submit: (to) => to.full.Submitting() + Submit: (to) => to.branch.Submitting() } }, Submitting: { on: { // Cancel has meaning while work is in progress. - Cancel: (to) => to.full.Editing() + Cancel: (to) => to.branch.Editing() } }, Complete: {} -}) +} }) ``` The sender requests `Submit`. The machine decides whether `Submit` has a @@ -243,7 +243,7 @@ child in every region. A parallel model therefore accepts the full product of those regions. ```ts -const ScreenStates = Machine.states({ +const ScreenStates = Machine.state({ initial: "Screen", states: { Screen: { type: "parallel", states: { @@ -263,7 +263,7 @@ const ScreenStates = Machine.states({ } } } -}) +} }) ``` This model permits all four combinations: online with a closed panel, online @@ -286,29 +286,29 @@ const LoadState = Schema.TaggedUnion({ Failed: { message: Schema.String } }) -const LoadStates = Machine.states({ +const LoadStates = Machine.state({ initial: "Idle", states: { Idle: {}, Loading: LoadState.cases.Loading, Ready: LoadState.cases.Ready, Failed: LoadState.cases.Failed -}) +} }) const LoadMachine = Machine.make({ - states: LoadStates.states, - events: Machine.events(), - initial: (to) => to.Idle() -}).handle({ + root: LoadStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) +}).handle({ states: { Idle: {}, Loading: { invoke: (from) => from .effect("load-document", ({ state }) => loadDocument(state.documentId)) - .onDone((to) => to.full.Ready().resolve(({ output, target }) => target.from({ content: output }))) - .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) }))) + .onDone((to) => to.branch.Ready().resolve(({ output, target }) => target.from({ content: output }))) + .onFailure((to) => to.branch.Failed().resolve(({ error, target }) => target.from({ message: String(error) }))) }, Ready: {}, Failed: {} -}) +} }) ``` `loadDocument` may require Effect services. Those requirements remain on the @@ -356,8 +356,8 @@ Commissioning: { { discard: true } ) ) - .onDone((to) => to.full.Running()) - .onFailure((to) => to.full.Failed()) + .onDone((to) => to.branch.Running()) + .onFailure((to) => to.branch.Failed()) } ``` @@ -374,24 +374,24 @@ A transition should choose the next state from the current snapshot and event. Use ordinary TypeScript conditions when one event has several valid outcomes. ```ts -const ReviewEvents = Machine.events( +const ReviewEvents = Machine.eventsFromSchemas( Schema.TaggedUnion({ Evaluate: { score: Schema.Number } }) ) const ReviewMachine = Machine.make({ - states: ReviewStates.states, + root: ReviewStates, events: ReviewEvents, initial: (to) => to.Pending() -}).handle({ +}).handle({ states: { Pending: { on: { Evaluate: (to) => to .branches({ - accepted: { target: to.full.Accepted() }, - rejected: { target: to.full.Rejected() } + accepted: { target: to.branch.Accepted() }, + rejected: { target: to.branch.Rejected() } }) .resolve(({ event, select }) => event.score >= 80 @@ -402,7 +402,7 @@ const ReviewMachine = Machine.make({ }, Accepted: {}, Rejected: {} -}) +} }) ``` Given the same snapshot and event, the handler should choose the same result. diff --git a/packages/effect-machine/docs/effect-atom-react.md b/packages/effect-machine/docs/effect-atom-react.md index d9fa4cc..7e445e0 100644 --- a/packages/effect-machine/docs/effect-atom-react.md +++ b/packages/effect-machine/docs/effect-atom-react.md @@ -39,85 +39,61 @@ Service-free machines can use `AtomMachine.make` directly. ## Own a machine in one React subtree -Use `useMachineAtom` when a provider, route, dialog, or other React subtree -owns one machine instance: +Create a context from a factory. Each Provider owns a fresh bridge in its current +Atom registry; it does not subscribe to state changes. ```tsx -import { useMachineAtom } from "@typeonce/effect-machine-react" -import { createContext, type ReactNode, useContext } from "react" -import { AuthMachine, type AuthMachineInput } from "../machines/auth-machine" +import { createMachineContext } from "@typeonce/effect-machine-react" +import { AuthMachine } from "../machines/auth-machine" import { MachineAtoms } from "../lib/atom-runtime" -const makeAuthMachine = MachineAtoms.factory(AuthMachine) -type AuthMachineAtom = ReturnType - -const AuthMachineContext = createContext(null) - -export function AuthMachineProvider({ - children, - input -}: { - readonly children: ReactNode - readonly input: AuthMachineInput -}) { - const machine = useMachineAtom(() => makeAuthMachine(input)) +export const Auth = createMachineContext(MachineAtoms.factory(AuthMachine)) +export const useAuthMachine = Auth.useMachine +function AuthRoute({ input, attemptId }: Props) { return ( - - {children} - + + }> + + + ) } - -export function useAuthMachine(): AuthMachineAtom { - const machine = useContext(AuthMachineContext) - if (machine === null) { - throw new Error("useAuthMachine must be used inside AuthMachineProvider") - } - return machine -} ``` -The provider strongly owns the complete `MachineAtom`. The hook mounts -`machine.ref` after React commits the owner, but it does not read `state`, -`snapshot`, or `result`. Machine updates therefore do not rerender the -provider. +Input is startup-only. Send events to update the existing workflow; change the +Provider's React key to create a new one. Keep the Provider above Suspense so +suspension does not discard the owner. Supply an Atom RegistryProvider at the +application boundary. The same factory used by two Providers creates isolated +machines, even when their inputs are equal. -The factory captures startup input once. A later `input` prop change does not -replace the running workflow. Send an event when the change belongs to that -workflow. Change the provider's React key when React should own a new machine: - -```tsx - - - -``` - -Put the owner above a Suspense boundary. React can then retain the same machine -while a state-reading descendant suspends. +`useMachineAtom(() => makeMachine(input))` remains available for custom owners. +It mounts the reference after commit without reading `result` or `snapshot`. ## Render state-owned data -Subscribe in the smallest component that renders a state path: +Render a typed path in the smallest component that needs it: ```tsx -import { useAtomSuspense } from "@effect/atom-react" -import { AtomMachine } from "@typeonce/effect-machine/reactivity" -import { Option } from "effect" +import { MachineState } from "@typeonce/effect-machine-react" function EditingFields() { const machine = useAuthMachine() - const editing = useAtomSuspense( - AtomMachine.selectSnapshot(machine, "Editing") - ).value - - return Option.match(editing, { - onNone: () => null, - onSome: ({ value }) => - }) + return ( + + {({ value }) => } + + ) } ``` +`MachineState` subscribes only its renderer. The path determines the callback's +snapshot type. Startup suspends, failures reach the nearest error boundary, +and an inactive path renders `inactive` (null by default). + +For custom hooks, `useAtomSuspense(AtomMachine.selectSnapshot(machine, path)).value` +returns an Option of the selected snapshot. + `AtomMachine.select` returns the selected state value. `AtomMachine.selectSnapshot` also retains the selected state's child topology. Both return `Option.none()` while the path is inactive. Do not replace that @@ -240,11 +216,7 @@ function AuthScreen() { const machine = useAuthMachine() const state = useAtomSuspense(machine.result).value - return AuthStates.match(state, { - Editing: (editing) => , - Verification: (verification) => , - Failed: (failed) => - }) + return } ``` @@ -320,7 +292,7 @@ export const plantAtoms = AtomMachine.familyChild(CentralMachineAtom, { child: (plantId: string) => Plant(plantId), atoms: { broken: AtomMachine.matchesChild("Broken"), - state: (plant) => plant.state, + state: (plant) => plant.result, send: (plant) => plant.send } }) diff --git a/packages/effect-machine/docs/machine-review.md b/packages/effect-machine/docs/machine-review.md index 60021d9..931d48b 100644 --- a/packages/effect-machine/docs/machine-review.md +++ b/packages/effect-machine/docs/machine-review.md @@ -39,12 +39,12 @@ job is returning `target.from()`. // Redundant const handlers = { Start: (to) => - to.full.Running().resolve(({ target }) => target.from()) + to.branch.Running().resolve(({ target }) => target.from()) } // Preferred const handlers = { - Start: (to) => to.full.Running() + Start: (to) => to.branch.Running() } ``` @@ -176,11 +176,11 @@ Model `Submit` as the component-facing event. Let a machine state own the work and its lifetime: ```ts -machine.handle({ +machine.handle({ states: { Editing: { on: { Submit: (to) => - to.full.Submitting().resolve(({ event, target }) => + to.branch.Submitting().resolve(({ event, target }) => target.from({ order: event.order }) ) } @@ -190,17 +190,17 @@ machine.handle({ from .effect("submit-order", ({ state }) => submitOrder(state.order)) .onDone((to) => - to.full.Complete().resolve(({ output, target }) => + to.branch.Complete().resolve(({ output, target }) => target.from({ order: output }) ) ) .onFailure((to) => - to.full.Failed().resolve(({ error, target }) => + to.branch.Failed().resolve(({ error, target }) => target.from({ message: String(error) }) ) ) } -}) +} }) ``` `submitOrder` can use Effect services for the API request and analytics. The diff --git a/packages/effect-machine/docs/root-api.md b/packages/effect-machine/docs/root-api.md new file mode 100644 index 0000000..ceda809 --- /dev/null +++ b/packages/effect-machine/docs/root-api.md @@ -0,0 +1,214 @@ +# Root machine API + +A machine is one explicit root state. The root can own data, child topology, +both, or neither. There is no separate global context store. + +## Events and transitions only + +```ts +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +const Events = Machine.events({ Open: {}, Close: {} }) +const Root = Machine.state({ + initial: "Closed", + states: { Closed: {}, Open: {} } +}) +const Door = Machine.make({ root: Root, events: Events }).handle({ + states: { + Closed: { on: { Open: (to) => to.local.Open() } }, + Open: { on: { Close: (to) => to.local.Closed() } } + } +}) +``` + +The root declares the default child once. No startup target, empty schema, or +resolver is needed. Structural nodes have `value: undefined` in snapshots. + +## Root data and child data + +```ts +const Root = Machine.state({ + fields: { title: Schema.String }, + initial: "Editing", + states: { + Editing: { fields: { draft: Schema.String } }, + Saving: {} + } +}) +const Events = Machine.events({ Rename: { title: Schema.String }, Save: {} }) +const Editor = Machine.make({ + root: Root, + events: Events, + initial: (root) => root.from(() => ({ title: "Untitled" })) +}).handle({ + initialize: ({ builder, state }) => builder.from({ draft: state.title }), + on: { + Rename: (to) => to.self.update.from(({ event }) => ({ title: event.title })) + }, + states: { + Editing: { on: { Save: (to) => to.local.Saving() } } + } +}) +``` + +`initialize` constructs the declared child configuration. Required values must +be supplied before the machine becomes executable. Values whose schema make +input is optional can use their schema defaults. A parallel owner's initializer +constructs every required region through the typed builder. + +The complete snapshot always includes the root: + +```ts +// While Editing is active: +{ + path: "", + value: { _tag: "", title: "Untitled" }, + state: { + path: "Editing", + value: { _tag: "Editing", draft: "Untitled" } + } +} +``` + +Root handlers remain active across child transitions. Child handlers receive +their own state, typed ancestors, and the complete snapshot. `to.root.update` +addresses root data from a child. `to.self.update` addresses the handler owner. +Both replace the entire value; omitted fields are not silently retained. +Updates preserve the active topology and do not restart scoped work. + +Simultaneous transitions retain conflict checking. Two parallel handlers cannot +silently overwrite the same owner. Use `.updating(owner)` to combine a selected +destination and a retained owner's value in one atomic transition. + +## Schemas and reusable descriptors + +Use `fields` for local data or `schema` for an existing tagged schema. They are +mutually exclusive. A schema preserves class identity, refinements, defaults, +and transformations; its tag is independent of its mount path. + +```ts +class Ready extends Schema.TaggedClass("Ready")("Ready", { + name: Schema.String +}) {} + +const Form = Machine.state({ + initial: "Idle", + states: { Idle: {}, Ready: { schema: Ready } } +}) +const Root = Machine.state({ + type: "parallel", + states: { First: Form, Second: Form } +}) +``` + +Each mount has its own path and lifecycle. Descriptors capture their definition +without retaining caller-owned mutable topology containers. `Machine.make` +accepts the complete descriptor as `root`; callers do not extract its internals. + +`Machine.events({ Save: { title: Schema.String } })` creates a deferred +`Events.Save({ title })` constructor. The machine validates it when processing +the event. To reuse tagged classes or a tagged union, use +`Machine.eventsFromSchemas(...)`. Internal and emitted protocols have distinct +constructors with the same fields/import distinction. Public sends cannot send +internal events, and emissions do not become machine input events implicitly. + +## Default initialization and explicit configuration + +`initial` supplies root values while following the topology's declared child +defaults. It does not override those defaults. A structural root usually needs +no initializer. + +Use the separately named `initialConfiguration` to select a complete startup +configuration, for example to start the door open: + +```ts +const DoorOpen = Machine.make({ + root: Root, + events: Events, + initialConfiguration: (root) => root.resolve(({ target }) => + target.from((states) => states.Open.from()) + ) +}).handle({}) +``` + +This example uses the door root and events from the first section. Every active +value and region in an explicit configuration must be provided. `initial` and +`initialConfiguration` cannot be combined. Use `Machine.resume` when restoring +a validated snapshot, including its completion and history metadata. + +## Construction, guards, and branches + +A selected target supports a direct callback: + +```ts +Save: (to) => to.local.Saving().from(({ event }) => ({ requestId: event.requestId })) +``` + +Use `.decoded(({ state }) => state)` for already decoded schema values. Use +`.resolve((context, enqueue) => ...)` when constructing child configurations or +queuing commands; its `context.target.from(...)` and `.decoded(...)` take values +and explicit child builders. + +A guard runs before construction and commands: + +```ts +Save: (to) => to.local.Saving() + .guard(({ state }) => state.draft.length > 0) + .from(({ state }) => ({ requestId: state.draft })) +``` + +A false guard declines this handler, allowing ancestor fallback. Guards are not +available for mandatory initial resolution or a total choice. `.none` accepts +an event without changing topology; declining and accepting have different +semantics. Named branches keep `{ target, title? }`: `target` identifies the +checked destination and `title` supplies optional presentation metadata. + +## Completion and history + +A compound root returns its completed direct workflow's output when that child +has no `onDone` handler. A child with `onDone` first handles its completion. +Nested compound states retain statechart completion rules: an arbitrary +completed descendant does not make every ancestor final. Parallel roots +complete when all required regions complete. + +Restoring history beneath the root preserves current root data. Restoring the +root's own history can restore root-owned data. History fallback builders must +construct a complete configuration containing the history owner. + +## Observation and testing + +- `MachineRef.state` reads the complete logical root snapshot. +- `MachineRef.snapshot` includes runtime status and completion information. +- `MachineAtom.result` retains asynchronous startup, failure, and the successful + logical snapshot. `MachineAtom.snapshot` exposes the runtime snapshot. +- `AtomMachine.select` selects an optional value; `selectSnapshot` selects an + optional subtree. Child inactivity remains `Option.none()`. +- `MachineState` renders a typed path in React. `createMachineContext(factory)` + owns a separate machine per Provider without subscribing the Provider to it. + +The Atom bridge and child bridge no longer expose `.state`; use `.result` or a +path selector. The core `MachineRef.state` contract remains available. + +`MachineTest.run` and `MachineTest.probe` accept the same public event inputs as +production sends, including deferred constructors. Traces and acknowledgements +record the decoded event that was processed, including ignored events. + +## Migration + +Replace `Machine.states(tree)` and `Machine.make({ states: States.states })` +with one `Machine.state({ initial, states: tree })` descriptor and +`Machine.make({ root })`. Move the old root-level handler map under +`handle({ states: ... })`; place machine-wide behavior directly in `handle`. +Use root `fields` to replace a wrapper state introduced only to carry shared data. +Use `Machine.Snapshot` for the complete logical snapshot. `Machine.StateAccessors` replaces the former `Machine.DefinedStates` +helper interface; it exposes paths and projections without a public state map. + +Replace schema arguments to `Machine.events`, `internalEvents`, and +`emittedEvents` with field records, or use their explicit `FromSchemas` +constructors. Replace old Atom `.state` reads with `.result`. Use +`EmittedEvent`, `EmittedEvents`, `EmittedEventOf`, and `SchemaLessStateAnnotations` in place +of the removed deprecated type aliases. + +Encoded snapshots now use version 2 and include the root at path `""`. +Version 1 payloads must be explicitly migrated; the decoder rejects them. diff --git a/packages/effect-machine/perf/runtime/counter.mjs b/packages/effect-machine/perf/runtime/counter.mjs index 4c6c26b..4b153be 100644 --- a/packages/effect-machine/perf/runtime/counter.mjs +++ b/packages/effect-machine/perf/runtime/counter.mjs @@ -49,7 +49,7 @@ const CounterStates = benchmarkApi.states({ } }) -export const counterMachine = Machine.make({ +export const counterMachine = benchmarkApi.make({ id: "RuntimeBenchmarkCounter", states: CounterStates.states, events: benchmarkApi.events(CounterEvent.cases.Increment, CounterEvent.cases.Finish), @@ -78,7 +78,7 @@ export const counterMachine = Machine.make({ const ParentState = Schema.TaggedUnion({ Active: {} }) const ParentStates = benchmarkApi.states({ Active: ParentState.cases.Active }) const CounterChild = Machine.child("counter", counterMachine) -const counterParentMachine = Machine.make({ +const counterParentMachine = benchmarkApi.make({ id: "RuntimeBenchmarkCounterParent", states: ParentStates.states, events: benchmarkApi.events(), @@ -92,7 +92,7 @@ const counterParentMachine = Machine.make({ } }) -const counterSnapshotParentMachine = Machine.make({ +const counterSnapshotParentMachine = benchmarkApi.make({ id: "RuntimeBenchmarkSnapshotCounterParent", states: ParentStates.states, events: benchmarkApi.events(), @@ -138,7 +138,7 @@ const HierarchicalStates = benchmarkApi.states({ } }) -const hierarchicalCounterMachine = Machine.make({ +const hierarchicalCounterMachine = benchmarkApi.make({ id: "RuntimeBenchmarkHierarchicalCounter", states: HierarchicalStates.states, events: benchmarkApi.events(HierarchicalEvent.cases.Increment, HierarchicalEvent.cases.Finish), @@ -194,7 +194,7 @@ const ParallelStates = benchmarkApi.states({ } }) -const parallelCounterMachine = Machine.make({ +const parallelCounterMachine = benchmarkApi.make({ id: "RuntimeBenchmarkParallelCounter", states: ParallelStates.states, events: benchmarkApi.events(HierarchicalEvent.cases.IncrementLeft, HierarchicalEvent.cases.IncrementRight, HierarchicalEvent.cases.Finish), @@ -267,7 +267,7 @@ const initialParallelSnapshot = Effect.runSync( Machine.planInitial(parallelCounterMachine).pipe(Effect.map((planned) => planned.state)) ) -export const counterValue = (snapshot) => snapshot.value.value +export const counterValue = (snapshot) => benchmarkApi.snapshot(snapshot).value.value export const planCounterBatch = (size) => { return Effect.runSync( @@ -288,7 +288,7 @@ const planHierarchicalCounterBatch = (size) => for (let index = 0; index < size; index += 1) { snapshot = (yield* Machine.plan(hierarchicalCounterMachine, snapshot, hierarchicalIncrementEvent)).next } - return snapshot.state.value.value + return benchmarkApi.snapshot(snapshot).state.value.value }) ) @@ -303,7 +303,8 @@ const planParallelCounterBatch = (size) => index % 2 === 0 ? parallelIncrementLeftEvent : parallelIncrementRightEvent )).next } - return snapshot.states.Left.value.value + snapshot.states.Right.value.value + const active = benchmarkApi.snapshot(snapshot) + return active.states.Left.value.value + active.states.Right.value.value }) ) diff --git a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs index 0fe335e..84baa17 100644 --- a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs +++ b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs @@ -8,6 +8,8 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { // The legacy process constructor takes `(initial, transition)`. The static // definition constructor is deliberately unary and returns its config. + const hasRoot = typeof Machine.eventsFromSchemas === "function" + const snapshot = (value) => hasRoot ? value.state : value const hasStaticTransitions = typeof Machine.transition === "function" && Machine.transition.length === 1 const hasFluentTransitions = !hasStaticTransitions && typeof Machine.invokeMachine !== "function" const hasFluentInvocations = typeof Machine.invoke !== "function" && typeof Machine.invokeMachine !== "function" @@ -16,9 +18,9 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { const selectInstruction = (selection) => typeof selection === "function" ? selection() : selection const fluentTransition = (definition) => (to) => { - const selection = selectInstruction(definition.target(to)) + const selection = selectInstruction(definition.target(hasRoot ? { ...to, full: to.branch } : to)) if (definition.resolve !== undefined) { - return selection.resolve(definition.resolve, { + return selection.resolve(hasRoot ? (context, enqueue) => definition.resolve({ ...context, snapshot: context.snapshot?.path === "" ? context.snapshot.state : context.snapshot }, enqueue) : definition.resolve, { ...(definition.reenter === true ? { reenter: true } : {}), ...(definition.declinable === true ? { declinable: true } : {}) }) @@ -42,12 +44,29 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { }) return { + snapshot, + make: (config) => { + if (!hasRoot) return Machine.make(config) + const { states: root, initial, ...rest } = config + const definition = initial.benchmarkInitial + const machine = Machine.make({ ...rest, root, initialConfiguration: (root) => root.resolve((context) => + context.target.from((tree) => { + const selectors = Object.fromEntries(Object.entries(tree).map(([key, builder]) => { + const selected = () => builder + selected.initial = builder + return [key, selected] + })) + const target = selectInstruction(definition.target(selectors)) + return definition.resolve === undefined ? target.from() : definition.resolve({ ...context, target }) + })) }) + return { handle: (states) => machine.handle({ states }) } + }, states: (definitions) => - typeof Machine.states === "function" ? Machine.states(definitions) : Machine.defineStates(definitions), - events: typeof Machine.event === "function" + hasRoot ? { states: Machine.state({ initial: Object.keys(definitions)[0], states: definitions }) } : typeof Machine.states === "function" ? Machine.states(definitions) : Machine.defineStates(definitions), + events: hasRoot ? (...schemas) => Machine.eventsFromSchemas(...schemas) : typeof Machine.event === "function" ? (...schemas) => schemas : (...schemas) => Machine.events(...schemas), - initial: (definition, legacy) => hasValueSelectors + initial: (definition, legacy) => hasRoot ? { benchmarkInitial: definition } : hasValueSelectors ? fluentInitial(definition) : hasStaticTransitions || hasFluentTransitions ? objectInitial(definition) diff --git a/packages/effect-machine/perf/types/adapter-readiness-control.ts b/packages/effect-machine/perf/types/adapter-readiness-control.ts index d882dcd..5aca004 100644 --- a/packages/effect-machine/perf/types/adapter-readiness-control.ts +++ b/packages/effect-machine/perf/types/adapter-readiness-control.ts @@ -5,44 +5,54 @@ export const Flow = Schema.TaggedStruct("Flow", {}) export const Idle = Schema.TaggedStruct("Idle", {}) export const Ready = Schema.TaggedStruct("Ready", {}) -export const States = Machine.states({ - Flow: { - schema: Flow, - initial: "Idle", - states: { - Idle, - Route: { - type: "choice" - }, - recent: { - type: "history", - history: "deep" +export const States = Machine.state({ + initial: "Ready", + states: { + Flow: { + schema: Flow, + initial: "Idle", + states: { + Idle, + Route: { + type: "choice" + }, + recent: { + type: "history", + history: "deep" + } } - } - }, - Ready + }, + Ready + } }) -export const snapshot = { path: "Ready" as const, value: Ready.make({}) } +export const snapshot = { + path: "" as const, + value: undefined, + state: { path: "Ready" as const, value: Ready.make({}) } +} export const machine = Machine.make({ id: "perf-readiness", - states: States.states, - events: Machine.events(), - initial: (to) => to.Ready().resolve(({ target }) => target.from(Ready.make({}))) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Ready.from(Ready.make({})))) }).handle({ - Flow: { - history: { - recent: { - default: ({ target }) => target.Flow.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({}))) + states: { + Flow: { + history: { + recent: { + default: ({ target }) => + target.from((tree) => tree.Flow.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({})))) + } + }, + states: { + Idle: {}, + Route: { + choice: (to) => to.branch.Ready().resolve(({ target }) => target.from(Ready.make({}))) + } } }, - states: { - Idle: {}, - Route: { - choice: (to) => to.full.Ready().resolve(({ target }) => target.from(Ready.make({}))) - } - } - }, - Ready: {} + Ready: {} + } }) diff --git a/packages/effect-machine/perf/types/composition-control.ts b/packages/effect-machine/perf/types/composition-control.ts index f56a84b..d5b5461 100644 --- a/packages/effect-machine/perf/types/composition-control.ts +++ b/packages/effect-machine/perf/types/composition-control.ts @@ -15,65 +15,70 @@ export const WorkspaceOutput = Schema.Struct({ Sync: Schema.Number }) -export const States = Machine.states({ - App: { - schema: App, - initial: "Workspace", - states: { - Workspace: { - schema: Workspace, - type: "parallel", - output: WorkspaceOutput, - states: { - Editor: { - schema: Editor, - initial: "Editing", - states: { - Editing, - Done: { - schema: EditorDone, - type: "final", - output: Schema.String +export const States = Machine.state({ + initial: "App", + states: { + App: { + schema: App, + initial: "Workspace", + states: { + Workspace: { + schema: Workspace, + type: "parallel", + output: WorkspaceOutput, + states: { + Editor: { + schema: Editor, + initial: "Editing", + states: { + Editing, + Done: { + schema: EditorDone, + type: "final", + output: Schema.String + } } - } - }, - Sync: { - schema: Sync, - initial: "Idle", - states: { - Idle: SyncIdle, - Done: { - schema: SyncDone, - type: "final", - output: Schema.Number + }, + Sync: { + schema: Sync, + initial: "Idle", + states: { + Idle: SyncIdle, + Done: { + schema: SyncDone, + type: "final", + output: Schema.Number + } } + }, + recent: { + type: "history", + history: "deep" } - }, - recent: { - type: "history", - history: "deep" } + }, + Route: { + type: "choice" } - }, - Route: { - type: "choice" } } } }) export const machine = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.App.initial.resolve(({ target }) => - target.from(App.make({}), (app) => - app.Workspace.from( - Workspace.make({}), - (workspace) => - workspace - .Editor.from(Editor.make({}), (editor) => editor.Editing.from(Editing.make({}))) - .Sync.from(Sync.make({}), (sync) => sync.Idle.from(SyncIdle.make({}))) - )) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => + to.App.from(App.make({}), (app) => + app.Workspace.from( + Workspace.make({}), + (workspace) => + workspace + .Editor.from(Editor.make({}), (editor) => editor.Editing.from(Editing.make({}))) + .Sync.from(Sync.make({}), (sync) => sync.Idle.from(SyncIdle.make({}))) + )) + ) ) }) diff --git a/packages/effect-machine/perf/types/composition.ts b/packages/effect-machine/perf/types/composition.ts index a2a6928..f01a106 100644 --- a/packages/effect-machine/perf/types/composition.ts +++ b/packages/effect-machine/perf/types/composition.ts @@ -25,13 +25,54 @@ class CompositionService extends Context.Service()( class CompositionFailure extends Data.TaggedError("CompositionFailure")<{}> {} const handled = machine.handle({ - App: { - states: { - Workspace: { - history: { - recent: { - default: ({ target }) => - target.App.from(App.make({}), (app) => + states: { + App: { + states: { + Workspace: { + history: { + recent: { + default: ({ target }) => + target.from((tree) => + tree.App.from(App.make({}), (app) => + app.Workspace.from( + Workspace.make({}), + (workspace) => + workspace + .Editor.from(Editor.make({}), (editor) => editor.Editing.from(Editing.make({}))) + .Sync.from(Sync.make({}), (sync) => sync.Idle.from(SyncIdle.make({}))) + )) + ) + } + }, + output: ({ outputs }) => ({ + Editor: outputs.Editor, + Sync: outputs.Sync + }), + states: { + Editor: { + states: { + Editing: { + entry: () => {} + }, + Done: { + output: ({ state }) => state.value + } + } + }, + Sync: { + states: { + Idle: {}, + Done: { + output: ({ state }) => state.value + } + } + } + } + }, + Route: { + choice: (to) => + to.branch.App().resolve(({ target }) => + target.from(App.make({}), (app) => app.Workspace.from( Workspace.make({}), (workspace) => @@ -39,45 +80,8 @@ const handled = machine.handle({ .Editor.from(Editor.make({}), (editor) => editor.Editing.from(Editing.make({}))) .Sync.from(Sync.make({}), (sync) => sync.Idle.from(SyncIdle.make({}))) )) - } - }, - output: ({ outputs }) => ({ - Editor: outputs.Editor, - Sync: outputs.Sync - }), - states: { - Editor: { - states: { - Editing: { - entry: () => {} - }, - Done: { - output: ({ state }) => state.value - } - } - }, - Sync: { - states: { - Idle: {}, - Done: { - output: ({ state }) => state.value - } - } - } + ) } - }, - Route: { - choice: (to) => - to.full.App().resolve(({ target }) => - target.from(App.make({}), (app) => - app.Workspace.from( - Workspace.make({}), - (workspace) => - workspace - .Editor.from(Editor.make({}), (editor) => editor.Editing.from(Editing.make({}))) - .Sync.from(Sync.make({}), (sync) => sync.Idle.from(SyncIdle.make({}))) - )) - ) } } } @@ -87,7 +91,7 @@ type ErrorIsExact = Expect, never>> type ServicesAreExact = Expect, never>> type OutputIsExact = Expect< Equal< - Machine.Machine.OutputByIdentifier, + Machine.Machine.OutputByIdentifier<{ readonly "": typeof States.node }, "App.Workspace">, { readonly Editor: string; readonly Sync: number } > > diff --git a/packages/effect-machine/perf/types/define-states.ts b/packages/effect-machine/perf/types/define-states.ts index 6059783..f08b3c3 100644 --- a/packages/effect-machine/perf/types/define-states.ts +++ b/packages/effect-machine/perf/types/define-states.ts @@ -7,6 +7,6 @@ const State = Schema.TaggedUnion({ Done: { value: Schema.String } }) -const States = Machine.states(State.cases) +const States = Machine.state({ initial: "Idle", states: State.cases }) void States diff --git a/packages/effect-machine/perf/types/definition-variants-control.ts b/packages/effect-machine/perf/types/definition-variants-control.ts index f2ed2ce..ec015a3 100644 --- a/packages/effect-machine/perf/types/definition-variants-control.ts +++ b/packages/effect-machine/perf/types/definition-variants-control.ts @@ -8,32 +8,37 @@ export const Done = Schema.TaggedStruct("Done", { value: Schema.String }) export const Start = Schema.TaggedStruct("Start", {}) export const Finish = Schema.TaggedStruct("Finish", { value: Schema.String }) -export const States = Machine.states({ - Flow: { - schema: Flow, - initial: "Idle", - states: { - Idle, - Running, - Done: { - schema: Done, - type: "final", - output: Schema.String - }, - Route: { - type: "choice" - }, - recent: { - type: "history", - history: "deep" +export const States = Machine.state({ + initial: "Flow", + states: { + Flow: { + schema: Flow, + initial: "Idle", + states: { + Idle, + Running, + Done: { + schema: Done, + type: "final", + output: Schema.String + }, + Route: { + type: "choice" + }, + recent: { + type: "history", + history: "deep" + } } } } }) export const machine = Machine.make({ - states: States.states, - events: Machine.events(Start, Finish), - initial: (to) => - to.Flow.initial.resolve(({ target }) => target.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({})))) + root: States, + events: Machine.eventsFromSchemas(Start, Finish), + initialConfiguration: (root) => + root.resolve(({ target }) => + target.from((to) => to.Flow.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({})))) + ) }) diff --git a/packages/effect-machine/perf/types/definition-variants.ts b/packages/effect-machine/perf/types/definition-variants.ts index 8f8f53c..996810a 100644 --- a/packages/effect-machine/perf/types/definition-variants.ts +++ b/packages/effect-machine/perf/types/definition-variants.ts @@ -8,39 +8,45 @@ type Expect = Value type IsAny = 0 extends 1 & Value ? true : false const complete = machine.handle({ - Flow: { - history: { - recent: { - default: ({ target }) => target.Flow.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({}))) - } - }, - states: { - Route: { - choice: (to) => to.local.Idle().resolve(({ target }) => target.from(Idle.make({}))) - }, - Idle: { - on: { - Start: (to) => to.local.Running().resolve(({ target }) => target.from(Running.make({}))) + states: { + Flow: { + history: { + recent: { + default: ({ target }) => + target.from((tree) => tree.Flow.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({})))) } }, - Running: { - on: { - Finish: (to) => to.local.Done().resolve(({ event, target }) => target.from(Done.make({ value: event.value }))) + states: { + Route: { + choice: (to) => to.local.Idle().resolve(({ target }) => target.from(Idle.make({}))) + }, + Idle: { + on: { + Start: (to) => to.local.Running().resolve(({ target }) => target.from(Running.make({}))) + } + }, + Running: { + on: { + Finish: (to) => + to.local.Done().resolve(({ event, target }) => target.from(Done.make({ value: event.value }))) + } + }, + Done: { + output: ({ state }) => state.value } - }, - Done: { - output: ({ state }) => state.value } } } }) const idleOnly = machine.handle({ - Flow: { - states: { - Idle: { - on: { - Start: (to) => to.local.Running().resolve(({ target }) => target.from(Running.make({}))) + states: { + Flow: { + states: { + Idle: { + on: { + Start: (to) => to.local.Running().resolve(({ target }) => target.from(Running.make({}))) + } } } } @@ -48,11 +54,14 @@ const idleOnly = machine.handle({ }) const runningOnly = machine.handle({ - Flow: { - states: { - Running: { - on: { - Finish: (to) => to.local.Done().resolve(({ event, target }) => target.from(Done.make({ value: event.value }))) + states: { + Flow: { + states: { + Running: { + on: { + Finish: (to) => + to.local.Done().resolve(({ event, target }) => target.from(Done.make({ value: event.value }))) + } } } } diff --git a/packages/effect-machine/perf/types/dynamic-invoke-control.ts b/packages/effect-machine/perf/types/dynamic-invoke-control.ts index 3553b34..66716d3 100644 --- a/packages/effect-machine/perf/types/dynamic-invoke-control.ts +++ b/packages/effect-machine/perf/types/dynamic-invoke-control.ts @@ -7,12 +7,13 @@ export class LoadError { export const Loading = Schema.TaggedStruct("Loading", { userId: Schema.String }) -export const States = Machine.states({ Loading }) +export const States = Machine.state({ initial: "Loading", states: { Loading } }) export const loadUser = (userId: string) => Effect.fail(new LoadError()).pipe(Effect.as({ id: userId, name: "Ada" })) export const machine = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => to.Loading().resolve(({ target }) => target.from(Loading.make({ userId: "user-1" }))) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Loading.from(Loading.make({ userId: "user-1" })))) }) diff --git a/packages/effect-machine/perf/types/dynamic-invoke.ts b/packages/effect-machine/perf/types/dynamic-invoke.ts index fd1d74d..077176e 100644 --- a/packages/effect-machine/perf/types/dynamic-invoke.ts +++ b/packages/effect-machine/perf/types/dynamic-invoke.ts @@ -7,21 +7,23 @@ interface User { } const invoked = machine.handle({ - Loading: { - invoke: (from) => - from.effect("load-user", ({ state }) => loadUser(state.userId)).onDone((to) => - to.none.resolve(({ output }) => { - const user: User = output - void user - return undefined - }) - ).onFailure((to) => - to.none.resolve(({ error }) => { - const loadError: LoadError = error - void loadError - return undefined - }) - ) + states: { + Loading: { + invoke: (from) => + from.effect("load-user", ({ state }) => loadUser(state.userId)).onDone((to) => + to.none.resolve(({ output }) => { + const user: User = output + void user + return undefined + }) + ).onFailure((to) => + to.none.resolve(({ error }) => { + const loadError: LoadError = error + void loadError + return undefined + }) + ) + } } }) diff --git a/packages/effect-machine/perf/types/exact-channels-control.ts b/packages/effect-machine/perf/types/exact-channels-control.ts index d6af7d7..5be3370 100644 --- a/packages/effect-machine/perf/types/exact-channels-control.ts +++ b/packages/effect-machine/perf/types/exact-channels-control.ts @@ -8,20 +8,24 @@ export const Loaded = Schema.TaggedStruct("Loaded", { value: Schema.String }) export const Notice = Schema.TaggedStruct("Notice", { value: Schema.String }) export const Input = Schema.Struct({ seed: Schema.Number }) -export const States = Machine.states({ - Idle, - Done: { - schema: Done, - type: "final", - output: Schema.String +export const States = Machine.state({ + initial: "Idle", + states: { + Idle, + Done: { + schema: Done, + type: "final", + output: Schema.String + } } }) export const machine = Machine.make({ - states: States.states, - events: Machine.events(Start), - internalEvents: Machine.internalEvents(Loaded), - emittedEvents: Machine.emittedEvents(Notice), + root: States, + events: Machine.eventsFromSchemas(Start), + internalEvents: Machine.internalEventsFromSchemas(Loaded), + emittedEvents: Machine.emittedEventsFromSchemas(Notice), input: Input, - initial: (to) => to.Idle().resolve(({ input, target }) => target.from(Idle.make({ value: input.seed }))) + initialConfiguration: (root) => + root.resolve(({ input, target }) => target.from((to) => to.Idle.from(Idle.make({ value: input.seed })))) }) diff --git a/packages/effect-machine/perf/types/exact-channels.ts b/packages/effect-machine/perf/types/exact-channels.ts index 28d2209..8ff905e 100644 --- a/packages/effect-machine/perf/types/exact-channels.ts +++ b/packages/effect-machine/perf/types/exact-channels.ts @@ -9,19 +9,21 @@ type Expect = Value type IsAny = 0 extends 1 & Value ? true : false const complete = machine.handle({ - Idle: { - entry: () => {}, - on: { - Start: (to) => - to.full.Done().resolve(({ event, target }, enqueue) => { - enqueue.emit(Notice.make({ value: event.value })) - return target.from(Done.make({ value: event.value })) - }), - Loaded: (to) => to.full.Done().resolve(({ event, target }) => target.from(Done.make({ value: event.value }))) + states: { + Idle: { + entry: () => {}, + on: { + Start: (to) => + to.branch.Done().resolve(({ event, target }, enqueue) => { + enqueue.emit(Notice.make({ value: event.value })) + return target.from(Done.make({ value: event.value })) + }), + Loaded: (to) => to.branch.Done().resolve(({ event, target }) => target.from(Done.make({ value: event.value }))) + } + }, + Done: { + output: ({ state }) => state.value } - }, - Done: { - output: ({ state }) => state.value } }) @@ -31,7 +33,7 @@ type InputSchemaIsExact = Expect< type InputIsExact = Expect, { readonly seed: number }>> type InputEventIsExact = Expect, typeof Start.Type>> type EventIsExact = Expect, typeof Start.Type | typeof Loaded.Type>> -type EmitIsExact = Expect, typeof Notice.Type>> +type EmitIsExact = Expect, typeof Notice.Type>> type InitialErrorIsExact = Expect, never>> type InitialServicesAreExact = Expect, never>> type ErrorIsExact = Expect, never>> diff --git a/packages/effect-machine/perf/types/handle-depth-24-control.ts b/packages/effect-machine/perf/types/handle-depth-24-control.ts index 3f5bb11..27816ca 100644 --- a/packages/effect-machine/perf/types/handle-depth-24-control.ts +++ b/packages/effect-machine/perf/types/handle-depth-24-control.ts @@ -3,108 +3,111 @@ import { Machine } from "../../dist/index.js" export const NodeState = Schema.TaggedStruct("Node", {}) -export const States = Machine.states({ - n0: { - schema: NodeState, - initial: "n1", - states: { - n1: { - schema: NodeState, - initial: "n2", - states: { - n2: { - schema: NodeState, - initial: "n3", - states: { - n3: { - schema: NodeState, - initial: "n4", - states: { - n4: { - schema: NodeState, - initial: "n5", - states: { - n5: { - schema: NodeState, - initial: "n6", - states: { - n6: { - schema: NodeState, - initial: "n7", - states: { - n7: { - schema: NodeState, - initial: "n8", - states: { - n8: { - schema: NodeState, - initial: "n9", - states: { - n9: { - schema: NodeState, - initial: "n10", - states: { - n10: { - schema: NodeState, - initial: "n11", - states: { - n11: { - schema: NodeState, - initial: "n12", - states: { - n12: { - schema: NodeState, - initial: "n13", - states: { - n13: { - schema: NodeState, - initial: "n14", - states: { - n14: { - schema: NodeState, - initial: "n15", - states: { - n15: { - schema: NodeState, - initial: "n16", - states: { - n16: { - schema: NodeState, - initial: "n17", - states: { - n17: { - schema: NodeState, - initial: "n18", - states: { - n18: { - schema: NodeState, - initial: "n19", - states: { - n19: { - schema: NodeState, - initial: "n20", - states: { - n20: { - schema: NodeState, - initial: "n21", - states: { - n21: { - schema: NodeState, - initial: "n22", - states: { - n22: { - schema: NodeState, - initial: "n23", - states: { - n23: { - schema: NodeState, - initial: "n24", - states: { - n24: { - schema: NodeState, - type: "final", - output: - Schema.String +export const States = Machine.state({ + initial: "n0", + states: { + n0: { + schema: NodeState, + initial: "n1", + states: { + n1: { + schema: NodeState, + initial: "n2", + states: { + n2: { + schema: NodeState, + initial: "n3", + states: { + n3: { + schema: NodeState, + initial: "n4", + states: { + n4: { + schema: NodeState, + initial: "n5", + states: { + n5: { + schema: NodeState, + initial: "n6", + states: { + n6: { + schema: NodeState, + initial: "n7", + states: { + n7: { + schema: NodeState, + initial: "n8", + states: { + n8: { + schema: NodeState, + initial: "n9", + states: { + n9: { + schema: NodeState, + initial: "n10", + states: { + n10: { + schema: NodeState, + initial: "n11", + states: { + n11: { + schema: NodeState, + initial: "n12", + states: { + n12: { + schema: NodeState, + initial: "n13", + states: { + n13: { + schema: NodeState, + initial: "n14", + states: { + n14: { + schema: NodeState, + initial: "n15", + states: { + n15: { + schema: NodeState, + initial: "n16", + states: { + n16: { + schema: NodeState, + initial: "n17", + states: { + n17: { + schema: NodeState, + initial: "n18", + states: { + n18: { + schema: NodeState, + initial: "n19", + states: { + n19: { + schema: NodeState, + initial: "n20", + states: { + n20: { + schema: NodeState, + initial: "n21", + states: { + n21: { + schema: NodeState, + initial: "n22", + states: { + n22: { + schema: NodeState, + initial: "n23", + states: { + n23: { + schema: NodeState, + initial: "n24", + states: { + n24: { + schema: NodeState, + type: "final", + output: + Schema.String + } } } } @@ -157,10 +160,10 @@ export const States = Machine.states({ }) export const machine = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.n0.initial.resolve((): never => { + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (to) => + to.resolve((): never => { throw new Error("type-performance fixture") }) }) diff --git a/packages/effect-machine/perf/types/handle-depth-24.ts b/packages/effect-machine/perf/types/handle-depth-24.ts index 570dec3..a1e1cfc 100644 --- a/packages/effect-machine/perf/types/handle-depth-24.ts +++ b/packages/effect-machine/perf/types/handle-depth-24.ts @@ -11,57 +11,60 @@ class DeepService extends Context.Service()("perf/depth-24/ class DeepFailure extends Data.TaggedError("DeepFailure")<{}> {} const handled = machine.handle({ - n0: { - states: { - n1: { - states: { - n2: { - states: { - n3: { - states: { - n4: { - states: { - n5: { - states: { - n6: { - states: { - n7: { - states: { - n8: { - states: { - n9: { - states: { - n10: { - states: { - n11: { - states: { - n12: { - states: { - n13: { - states: { - n14: { - states: { - n15: { - states: { - n16: { - states: { - n17: { - states: { - n18: { - states: { - n19: { - states: { - n20: { - states: { - n21: { - states: { - n22: { - states: { - n23: { - states: { - n24: { - entry: () => {}, - output: () => "done" + states: { + n0: { + states: { + n1: { + states: { + n2: { + states: { + n3: { + states: { + n4: { + states: { + n5: { + states: { + n6: { + states: { + n7: { + states: { + n8: { + states: { + n9: { + states: { + n10: { + states: { + n11: { + states: { + n12: { + states: { + n13: { + states: { + n14: { + states: { + n15: { + states: { + n16: { + states: { + n17: { + states: { + n18: { + states: { + n19: { + states: { + n20: { + states: { + n21: { + states: { + n22: { + states: { + n23: { + states: { + n24: { + entry: () => {}, + output: () => + "done" + } } } } diff --git a/packages/effect-machine/perf/types/handle-depth-wide-16-control.ts b/packages/effect-machine/perf/types/handle-depth-wide-16-control.ts index d9e9d7d..f55e35b 100644 --- a/packages/effect-machine/perf/types/handle-depth-wide-16-control.ts +++ b/packages/effect-machine/perf/types/handle-depth-wide-16-control.ts @@ -3,147 +3,150 @@ import { Machine } from "../../dist/index.js" export const NodeState = Schema.TaggedStruct("Node", {}) -export const States = Machine.states({ - n0: { - schema: NodeState, - initial: "n1", - states: { - n1: { - schema: NodeState, - initial: "n2", - states: { - n2: { - schema: NodeState, - initial: "n3", - states: { - n3: { - schema: NodeState, - initial: "n4", - states: { - n4: { - schema: NodeState, - initial: "n5", - states: { - n5: { - schema: NodeState, - initial: "n6", - states: { - n6: { - schema: NodeState, - initial: "n7", - states: { - n7: { - schema: NodeState, - initial: "n8", - states: { - n8: { - schema: NodeState, - initial: "n9", - states: { - n9: { - schema: NodeState, - initial: "n10", - states: { - n10: { - schema: NodeState, - initial: "n11", - states: { - n11: { - schema: NodeState, - initial: "n12", - states: { - n12: { - schema: NodeState, - initial: "n13", - states: { - n13: { - schema: NodeState, - initial: "n14", - states: { - n14: { - schema: NodeState, - initial: "n15", - states: { - n15: { - schema: NodeState, - initial: "n16", - states: { - n16: { - schema: NodeState, - type: "final", - output: Schema.String - }, - side15_0: NodeState, - side15_1: NodeState - } - }, - side14_0: NodeState, - side14_1: NodeState - } - }, - side13_0: NodeState, - side13_1: NodeState - } - }, - side12_0: NodeState, - side12_1: NodeState - } - }, - side11_0: NodeState, - side11_1: NodeState - } - }, - side10_0: NodeState, - side10_1: NodeState - } - }, - side9_0: NodeState, - side9_1: NodeState - } - }, - side8_0: NodeState, - side8_1: NodeState - } - }, - side7_0: NodeState, - side7_1: NodeState - } - }, - side6_0: NodeState, - side6_1: NodeState - } - }, - side5_0: NodeState, - side5_1: NodeState - } - }, - side4_0: NodeState, - side4_1: NodeState - } - }, - side3_0: NodeState, - side3_1: NodeState - } - }, - side2_0: NodeState, - side2_1: NodeState - } - }, - side1_0: NodeState, - side1_1: NodeState - } - }, - side0_0: NodeState, - side0_1: NodeState +export const States = Machine.state({ + initial: "n0", + states: { + n0: { + schema: NodeState, + initial: "n1", + states: { + n1: { + schema: NodeState, + initial: "n2", + states: { + n2: { + schema: NodeState, + initial: "n3", + states: { + n3: { + schema: NodeState, + initial: "n4", + states: { + n4: { + schema: NodeState, + initial: "n5", + states: { + n5: { + schema: NodeState, + initial: "n6", + states: { + n6: { + schema: NodeState, + initial: "n7", + states: { + n7: { + schema: NodeState, + initial: "n8", + states: { + n8: { + schema: NodeState, + initial: "n9", + states: { + n9: { + schema: NodeState, + initial: "n10", + states: { + n10: { + schema: NodeState, + initial: "n11", + states: { + n11: { + schema: NodeState, + initial: "n12", + states: { + n12: { + schema: NodeState, + initial: "n13", + states: { + n13: { + schema: NodeState, + initial: "n14", + states: { + n14: { + schema: NodeState, + initial: "n15", + states: { + n15: { + schema: NodeState, + initial: "n16", + states: { + n16: { + schema: NodeState, + type: "final", + output: Schema.String + }, + side15_0: NodeState, + side15_1: NodeState + } + }, + side14_0: NodeState, + side14_1: NodeState + } + }, + side13_0: NodeState, + side13_1: NodeState + } + }, + side12_0: NodeState, + side12_1: NodeState + } + }, + side11_0: NodeState, + side11_1: NodeState + } + }, + side10_0: NodeState, + side10_1: NodeState + } + }, + side9_0: NodeState, + side9_1: NodeState + } + }, + side8_0: NodeState, + side8_1: NodeState + } + }, + side7_0: NodeState, + side7_1: NodeState + } + }, + side6_0: NodeState, + side6_1: NodeState + } + }, + side5_0: NodeState, + side5_1: NodeState + } + }, + side4_0: NodeState, + side4_1: NodeState + } + }, + side3_0: NodeState, + side3_1: NodeState + } + }, + side2_0: NodeState, + side2_1: NodeState + } + }, + side1_0: NodeState, + side1_1: NodeState + } + }, + side0_0: NodeState, + side0_1: NodeState + } } } }) export const machine = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to.n0.initial.resolve((): never => { + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (to) => + to.resolve((): never => { throw new Error("type-performance fixture") }) }) diff --git a/packages/effect-machine/perf/types/handle-depth-wide-16.ts b/packages/effect-machine/perf/types/handle-depth-wide-16.ts index 7f68264..6630fbe 100644 --- a/packages/effect-machine/perf/types/handle-depth-wide-16.ts +++ b/packages/effect-machine/perf/types/handle-depth-wide-16.ts @@ -11,104 +11,106 @@ class DeepService extends Context.Service()("perf/depth-wid class DeepFailure extends Data.TaggedError("DeepFailure")<{}> {} const handled = machine.handle({ - n0: { - states: { - n1: { - states: { - n2: { - states: { - n3: { - states: { - n4: { - states: { - n5: { - states: { - n6: { - states: { - n7: { - states: { - n8: { - states: { - n9: { - states: { - n10: { - states: { - n11: { - states: { - n12: { - states: { - n13: { - states: { - n14: { - states: { - n15: { - states: { - n16: { - entry: () => {}, - output: () => "done" - }, - side15_0: {}, - side15_1: {} - } - }, - side14_0: {}, - side14_1: {} - } - }, - side13_0: {}, - side13_1: {} - } - }, - side12_0: {}, - side12_1: {} - } - }, - side11_0: {}, - side11_1: {} - } - }, - side10_0: {}, - side10_1: {} - } - }, - side9_0: {}, - side9_1: {} - } - }, - side8_0: {}, - side8_1: {} - } - }, - side7_0: {}, - side7_1: {} - } - }, - side6_0: {}, - side6_1: {} - } - }, - side5_0: {}, - side5_1: {} - } - }, - side4_0: {}, - side4_1: {} - } - }, - side3_0: {}, - side3_1: {} - } - }, - side2_0: {}, - side2_1: {} - } - }, - side1_0: {}, - side1_1: {} - } - }, - side0_0: {}, - side0_1: {} + states: { + n0: { + states: { + n1: { + states: { + n2: { + states: { + n3: { + states: { + n4: { + states: { + n5: { + states: { + n6: { + states: { + n7: { + states: { + n8: { + states: { + n9: { + states: { + n10: { + states: { + n11: { + states: { + n12: { + states: { + n13: { + states: { + n14: { + states: { + n15: { + states: { + n16: { + entry: () => {}, + output: () => "done" + }, + side15_0: {}, + side15_1: {} + } + }, + side14_0: {}, + side14_1: {} + } + }, + side13_0: {}, + side13_1: {} + } + }, + side12_0: {}, + side12_1: {} + } + }, + side11_0: {}, + side11_1: {} + } + }, + side10_0: {}, + side10_1: {} + } + }, + side9_0: {}, + side9_1: {} + } + }, + side8_0: {}, + side8_1: {} + } + }, + side7_0: {}, + side7_1: {} + } + }, + side6_0: {}, + side6_1: {} + } + }, + side5_0: {}, + side5_1: {} + } + }, + side4_0: {}, + side4_1: {} + } + }, + side3_0: {}, + side3_1: {} + } + }, + side2_0: {}, + side2_1: {} + } + }, + side1_0: {}, + side1_1: {} + } + }, + side0_0: {}, + side0_1: {} + } } } }) diff --git a/packages/effect-machine/perf/types/handle.ts b/packages/effect-machine/perf/types/handle.ts index 09d4ffb..f01e08b 100644 --- a/packages/effect-machine/perf/types/handle.ts +++ b/packages/effect-machine/perf/types/handle.ts @@ -12,25 +12,26 @@ const Event = Schema.TaggedUnion({ Finish: { value: Schema.String } }) -const States = Machine.states(State.cases) +const States = Machine.state({ initial: "Idle", states: State.cases }) const machine = Machine.make({ - states: States.states, - events: Machine.events(Event.cases.Start, Event.cases.Finish), - initial: (to) => to.Idle().resolve(({ target }) => target.from(State.cases.Idle.make({}))) + root: States, + events: Machine.eventsFromSchemas(Event) }).handle({ - Idle: { - on: { - Start: (to) => to.full.Running().resolve(({ target }) => target.from(State.cases.Running.make({}))) - } - }, - Running: { - on: { - Finish: (to) => - to.full.Done().resolve(({ event, target }) => target.from(State.cases.Done.make({ value: event.value }))) - } - }, - Done: {} + states: { + Idle: { + on: { + Start: (to) => to.branch.Running().resolve(({ target }) => target.from(State.cases.Running.make({}))) + } + }, + Running: { + on: { + Finish: (to) => + to.branch.Done().resolve(({ event, target }) => target.from(State.cases.Done.make({ value: event.value }))) + } + }, + Done: {} + } }) void machine diff --git a/packages/effect-machine/perf/types/make-control.ts b/packages/effect-machine/perf/types/make-control.ts index b575b11..bbf282f 100644 --- a/packages/effect-machine/perf/types/make-control.ts +++ b/packages/effect-machine/perf/types/make-control.ts @@ -12,7 +12,7 @@ const Event = Schema.TaggedUnion({ Finish: { value: Schema.String } }) -const States = Machine.states(State.cases) +const States = Machine.state({ initial: "Idle", states: State.cases }) void Event void States diff --git a/packages/effect-machine/perf/types/make.ts b/packages/effect-machine/perf/types/make.ts index 8014de5..bcea564 100644 --- a/packages/effect-machine/perf/types/make.ts +++ b/packages/effect-machine/perf/types/make.ts @@ -12,12 +12,11 @@ const Event = Schema.TaggedUnion({ Finish: { value: Schema.String } }) -const States = Machine.states(State.cases) +const States = Machine.state({ initial: "Idle", states: State.cases }) const machine = Machine.make({ - states: States.states, - events: Machine.events(Event.cases.Start, Event.cases.Finish), - initial: (to) => to.Idle().resolve(({ target }) => target.from(State.cases.Idle.make({}))) + root: States, + events: Machine.eventsFromSchemas(Event) }) void machine diff --git a/packages/effect-machine/perf/types/named-branches-control.ts b/packages/effect-machine/perf/types/named-branches-control.ts index 7ce4ea5..194470c 100644 --- a/packages/effect-machine/perf/types/named-branches-control.ts +++ b/packages/effect-machine/perf/types/named-branches-control.ts @@ -8,10 +8,11 @@ export const State = Schema.TaggedUnion({ }) export const Route = Schema.TaggedStruct("Route", { value: Schema.String }) -export const States = Machine.states(State.cases) +export const States = Machine.state({ initial: "Idle", states: State.cases }) export const machine = Machine.make({ - states: States.states, - events: Machine.events(Route), - initial: (to) => to.Idle().resolve(({ target }) => target.from(State.cases.Idle.make({}))) + root: States, + events: Machine.eventsFromSchemas(Route), + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Idle.from(State.cases.Idle.make({})))) }) diff --git a/packages/effect-machine/perf/types/named-branches.ts b/packages/effect-machine/perf/types/named-branches.ts index 565459f..a1a3297 100644 --- a/packages/effect-machine/perf/types/named-branches.ts +++ b/packages/effect-machine/perf/types/named-branches.ts @@ -2,52 +2,54 @@ import { Machine } from "../../dist/index.js" import { machine, State } from "./named-branches-control.js" const handled = machine.handle({ - Idle: { - on: { - Route: (to) => - to.branches({ - length1: { target: to.full.Text() }, - length2: { target: to.full.Count() }, - length3: { target: to.full.Text() }, - length4: { target: to.full.Count() }, - length5: { target: to.full.Text() }, - length6: { target: to.none }, - length7: { target: to.full.Count() }, - length8: { target: to.full.Text() }, - length9: { target: to.full.Count() }, - length10: { target: to.full.Idle() }, - unchanged: { target: to.none } - }).resolve(({ event, select }) => { - const value = event.value - switch (value.length) { - case 1: - return select.length1.from(State.cases.Text.make({ value })) - case 2: - return select.length2.from(State.cases.Count.make({ value: value.length })) - case 3: - return select.length3.from(State.cases.Text.make({ value })) - case 4: - return select.length4.from(State.cases.Count.make({ value: value.length })) - case 5: - return select.length5.from(State.cases.Text.make({ value })) - case 6: - return select.length6() - case 7: - return select.length7.from(State.cases.Count.make({ value: value.length })) - case 8: - return select.length8.from(State.cases.Text.make({ value: value.toUpperCase() })) - case 9: - return select.length9.from(State.cases.Count.make({ value: value.length })) - case 10: - return select.length10.from(State.cases.Idle.make({})) - default: - return select.unchanged() - } - }) - } - }, - Text: {}, - Count: {} + states: { + Idle: { + on: { + Route: (to) => + to.branches({ + length1: { target: to.branch.Text() }, + length2: { target: to.branch.Count() }, + length3: { target: to.branch.Text() }, + length4: { target: to.branch.Count() }, + length5: { target: to.branch.Text() }, + length6: { target: to.none }, + length7: { target: to.branch.Count() }, + length8: { target: to.branch.Text() }, + length9: { target: to.branch.Count() }, + length10: { target: to.branch.Idle() }, + unchanged: { target: to.none } + }).resolve(({ event, select }) => { + const value = event.value + switch (value.length) { + case 1: + return select.length1.from(State.cases.Text.make({ value })) + case 2: + return select.length2.from(State.cases.Count.make({ value: value.length })) + case 3: + return select.length3.from(State.cases.Text.make({ value })) + case 4: + return select.length4.from(State.cases.Count.make({ value: value.length })) + case 5: + return select.length5.from(State.cases.Text.make({ value })) + case 6: + return select.length6() + case 7: + return select.length7.from(State.cases.Count.make({ value: value.length })) + case 8: + return select.length8.from(State.cases.Text.make({ value: value.toUpperCase() })) + case 9: + return select.length9.from(State.cases.Count.make({ value: value.length })) + case 10: + return select.length10.from(State.cases.Idle.make({})) + default: + return select.unchanged() + } + }) + } + }, + Text: {}, + Count: {} + } }) void Machine.planInitial(handled) diff --git a/packages/oxlint-plugin/README.md b/packages/oxlint-plugin/README.md index 7222771..9ae5657 100644 --- a/packages/oxlint-plugin/README.md +++ b/packages/oxlint-plugin/README.md @@ -68,12 +68,12 @@ Removes a resolver whose only result is empty default construction: ```ts // Before const handlers = { - Start: (to) => to.full.Running().resolve(({ target }) => target.from()) + Start: (to) => to.branch.Running().resolve(({ target }) => target.from()) } // After `oxlint --fix` const handlers = { - Start: (to) => to.full.Running() + Start: (to) => to.branch.Running() } ``` @@ -103,7 +103,7 @@ no lifetime in which to own work started by a transition. const incorrect = { Submit: (to) => { fetch("/orders", { method: "POST" }) - return to.full.Complete() + return to.branch.Complete() } } @@ -112,8 +112,8 @@ const correct = { Submitting: { invoke: (from) => from.effect("submit-order", () => submitOrder()) - .onDone((to) => to.full.Complete()) - .onFailure((to) => to.full.Failed()) + .onDone((to) => to.branch.Complete()) + .onFailure((to) => to.branch.Failed()) } } ``` @@ -166,8 +166,8 @@ planning. const incorrect = { Restore: (to) => localStorage.getItem("draft") === null - ? to.full.Empty() - : to.full.Editing() + ? to.branch.Empty() + : to.branch.Editing() } // Correct: state-owned work reads storage and reports an outcome. @@ -175,8 +175,8 @@ const correct = { Restoring: { invoke: (from) => from.effect("restore-draft", () => restoreDraft()) - .onDone((to) => to.full.Editing()) - .onFailure((to) => to.full.Empty()) + .onDone((to) => to.branch.Editing()) + .onFailure((to) => to.branch.Empty()) } } ``` @@ -197,7 +197,7 @@ performance clocks, `Temporal.Now`, and process clocks. const incorrect = { Check: (to) => Date.now() >= deadline - ? to.full.Expired() + ? to.branch.Expired() : to.none } @@ -205,8 +205,8 @@ const incorrect = { const correct = { Check: (to) => to.branches({ - expired: { target: to.full.Expired() }, - current: { target: to.full.Current() } + expired: { target: to.branch.Expired() }, + current: { target: to.branch.Current() } }).resolve(({ event, select }) => event.now >= event.deadline ? select.expired.from() @@ -228,10 +228,10 @@ one `.handle(...)` call: ```ts // Before const definition = Machine.make({/* ... */}) -export const machine = definition.handle({/* ... */}) +export const machine = definition.handle({ states: {/* ... */} }) // After -export const machine = Machine.make({/* ... */}).handle({/* ... */}) +export const machine = Machine.make({/* ... */}).handle({ states: {/* ... */} }) ``` Definitions that are exported or reused remain valid. diff --git a/packages/oxlint-plugin/src/internal/planning.ts b/packages/oxlint-plugin/src/internal/planning.ts index e93300d..7ca2024 100644 --- a/packages/oxlint-plugin/src/internal/planning.ts +++ b/packages/oxlint-plugin/src/internal/planning.ts @@ -10,7 +10,10 @@ const directPlanningMethods = new Set([ "onElement", "onFailure", "onSnapshot", - "resolve" + "resolve", + "from", + "decoded", + "guard" ]) const statePlanningProperties = new Set([ @@ -53,12 +56,12 @@ const isStateConfig = ( node: ESTree.ObjectExpression, bindings: MachineBindings ): boolean => { + if (isMachineHandleConfig(node, bindings)) return true if (node.parent.type !== "Property" || node.parent.parent.type !== "ObjectExpression") { return false } const stateCollection = node.parent.parent - if (isMachineHandleConfig(stateCollection, bindings)) return true if ( stateCollection.parent.type !== "Property" || propertyName(stateCollection.parent) !== "states" || @@ -111,7 +114,7 @@ const isPropertyPlanningCallback = ( ) return false const name = propertyName(property) - return name === "initial" + return name === "initial" || name === "initialConfiguration" ? isMachineMakeConfig(property.parent, bindings) : (name !== undefined && statePlanningProperties.has(name) && isStateConfig(property.parent, bindings)) || isEventHandlerProperty(property, bindings) || diff --git a/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts b/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts index ba05b6e..d1d5bde 100644 --- a/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts +++ b/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts @@ -14,7 +14,7 @@ const rule = plugin.rules["no-async-planning-callback"] tester.run("no-async-planning-callback", rule, { valid: [ `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => from.effect("load", async () => undefined) } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("load", async () => undefined) } } })`, `import { Machine } from "@typeonce/effect-machine" const other = { resolve: (_callback: unknown) => undefined } other.resolve(async () => undefined)`, @@ -22,37 +22,52 @@ other.resolve(async () => undefined)`, Machine.make({ initial: (to) => to.Ready(), metadata: { initial: async () => undefined } })`, `import { Machine } from "@typeonce/effect-machine" const other = { handle: (_config: unknown) => undefined } -other.handle({ Ready: { entry: async () => undefined } })`, +other.handle({ states: { Ready: { entry: async () => undefined } } })`, `const machine = { initial: async () => undefined }`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => [ +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ from.effect("fetch", () => fetch("/api")), from.timer("delay", () => setTimeout(() => undefined, 1)) -] } })`, +] } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { entry: ({ fetch, setTimeout }) => { +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: ({ fetch, setTimeout }) => { fetch() setTimeout() -} } })`, +} } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { entry: () => { +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: () => { const later = () => Promise.resolve() return later -} } })`, +} } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { entry: () => +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: () => other.resolve(async () => fetch("/helper")) -} })` +} } })` ], invalid: [ { code: `import { Machine } from "@typeonce/effect-machine" +Machine.make({ root: Machine.state({}) }).handle({ on: { Update: (to) => to.self.update.from(async () => ({ count: 1 })) } })`, + errors: [{ messageId: "asyncPlanning" }] + }, + { + code: `import { Machine } from "@typeonce/effect-machine" +Machine.make({ root: Machine.state({}) }).handle({ on: { Update: (to) => to.none.guard(async () => true) } })`, + errors: [{ messageId: "asyncPlanning" }] + }, + { + code: `import { Machine } from "@typeonce/effect-machine" +Machine.make({ root: Machine.state({}), initialConfiguration: async (root) => root.resolve(() => undefined) })`, + errors: [{ messageId: "asyncPlanning" }] + }, + { + code: `import { Machine } from "@typeonce/effect-machine" Machine.make({ initial: async (to) => to.Ready() })`, errors: [{ messageId: "asyncPlanning" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { on: { Start: async (to) => to.full.Running() } } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { on: { Start: async (to) => to.branch.Running() } } } })`, errors: [{ messageId: "asyncPlanning" }] }, { @@ -62,24 +77,24 @@ Machine.make({ initial: (to) => to.Ready().resolve(async ({ target }) => target. }, { code: `import * as EM from "@typeonce/effect-machine" -EM.Machine.make({ initial: (to) => to.Loading() }).handle({ Loading: { invoke: (from) => from.effect("load", () => Promise.resolve()).onDone(async (to) => to.full.Done()) } })`, +EM.Machine.make({ initial: (to) => to.Loading() }).handle({ states: { Loading: { invoke: (from) => from.effect("load", () => Promise.resolve()).onDone(async (to) => to.branch.Done()) } } })`, errors: [{ messageId: "asyncPlanning" }] }, { code: `import { Machine } from "@typeonce/effect-machine" const definition = Machine.make({ initial: (to) => to.Parent() }) -definition.handle({ Parent: { states: { Child: { entry: async () => undefined } } } })`, +definition.handle({ states: { Parent: { states: { Child: { entry: async () => undefined } } } } })`, errors: [{ messageId: "asyncPlanning" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: async () => undefined, exit: async () => undefined, always: async (to) => to.none, choice: async (to) => to.none, invoke: async (from) => from.timer("tick", 1) -} })`, +} } })`, errors: Array.from({ length: 5 }, () => ({ messageId: "asyncPlanning" })) }, { @@ -97,7 +112,7 @@ Machine.make({ initial: (to) => { }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { initialize: ({ builder }) => { globalThis["fetch"]("/initialize") return builder.from() @@ -114,7 +129,7 @@ Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { requestIdleCallback(() => undefined) return to.none } } } -} })`, +} } })`, errors: Array.from({ length: 4 }, () => ({ messageId: "asyncOperation" })) }, { diff --git a/packages/oxlint-plugin/test/noBrowserApiInPlanning.test.ts b/packages/oxlint-plugin/test/noBrowserApiInPlanning.test.ts index 124701c..17d6ded 100644 --- a/packages/oxlint-plugin/test/noBrowserApiInPlanning.test.ts +++ b/packages/oxlint-plugin/test/noBrowserApiInPlanning.test.ts @@ -14,15 +14,15 @@ const rule = plugin.rules["no-browser-api-in-planning"] tester.run("no-browser-api-in-planning", rule, { valid: [ `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => from.effect("storage", () => localStorage.getItem("key")) } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("storage", () => localStorage.getItem("key")) } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { entry: ({ document, navigator }) => document.read(navigator) } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: ({ document, navigator }) => document.read(navigator) } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { entry: () => { +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: () => { const url = new URL("https://example.com") const params = new URLSearchParams(url.search) return structuredClone(params) -} } })`, +} } } })`, `import { Machine } from "@typeonce/effect-machine" Machine.make({ initial: (to) => { window.fetch("/api") @@ -47,10 +47,10 @@ Machine.make({ initial: (to) => { }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { output: () => sessionStorage.length, history: { recent: { default: (to) => location.pathname ? to.none : to.none } } -} })`, +} } })`, errors: Array.from({ length: 2 }, () => ({ messageId: "browserApi" })) } ] diff --git a/packages/oxlint-plugin/test/noConflictingInvocationIdentity.test.ts b/packages/oxlint-plugin/test/noConflictingInvocationIdentity.test.ts index 6fd8b33..0d09b1b 100644 --- a/packages/oxlint-plugin/test/noConflictingInvocationIdentity.test.ts +++ b/packages/oxlint-plugin/test/noConflictingInvocationIdentity.test.ts @@ -14,79 +14,79 @@ const rule = plugin.rules["no-conflicting-invocation-identity"] tester.run("no-conflicting-invocation-identity", rule, { valid: [ `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => [ +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ from.effect("load", load), from.timer("timeout", 1000) -] } })`, +] } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.One() }).handle({ +Machine.make({ initial: (to) => to.One() }).handle({ states: { One: { invoke: (from) => from.effect("load", load) }, Two: { invoke: (from) => from.effect("load", load) } -})`, +} })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => [ +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ from.effect(makeId(), load), from.effect(makeId(), load) -] } })`, +] } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => { +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => { let id = "first" const first = from.effect(id, load) id = "second" const second = from.effect(id, load) return [first, second] -} } })`, +} } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => from.effect("outer", () => [ +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("outer", () => [ from.effect("nested", load), from.effect("nested", load) -]) } })` +]) } } })` ], invalid: [ { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => [ +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ from.effect("load", load), from.timer("load", 1000) -] } })`, +] } } })`, errors: [{ messageId: "conflictingLifecycle" }] }, { code: `import { Machine } from "@typeonce/effect-machine" const address = Machine.childAddress("worker") -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => [ +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ from.logic("first", { address, logic }), from.logic("second", { address, logic }) -] } })`, +] } } })`, errors: [{ messageId: "conflictingAddress" }] }, { code: `import { Machine } from "@typeonce/effect-machine" const Child = Machine.child("worker", childMachine) -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => [ +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ from.child(Child), from.child(Child) -] } })`, +] } } })`, errors: [{ messageId: "conflictingBoth" }] }, { code: `import { Machine } from "@typeonce/effect-machine" const id = "same" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => { +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => { const first = from.effect(id, load).onDone((to) => to.none) const second = from.stream(\`same\`, stream) const invocations = [first, second] return invocations -} } })`, +} } } })`, errors: [{ messageId: "conflictingLifecycle" }] }, { code: `import { Machine } from "@typeonce/effect-machine" const Child = Machine.child("shared", childMachine) -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => [ +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ from.effect("shared", load), from.child(Child) -] } })`, +] } } })`, errors: [{ messageId: "conflictingLifecycle" }] } ] diff --git a/packages/oxlint-plugin/test/noNondeterministicPlanning.test.ts b/packages/oxlint-plugin/test/noNondeterministicPlanning.test.ts index d22d1c1..927559d 100644 --- a/packages/oxlint-plugin/test/noNondeterministicPlanning.test.ts +++ b/packages/oxlint-plugin/test/noNondeterministicPlanning.test.ts @@ -14,13 +14,13 @@ const rule = plugin.rules["no-nondeterministic-planning"] tester.run("no-nondeterministic-planning", rule, { valid: [ `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { entry: ({ event }) => new Date(event.timestamp) } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: ({ event }) => new Date(event.timestamp) } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { entry: ({ Date, Math }) => [Date.now(), Math.random()] } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: ({ Date, Math }) => [Date.now(), Math.random()] } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => from.effect("random", () => crypto.randomUUID()) } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("random", () => crypto.randomUUID()) } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { output: ({ state }) => Date.parse(state.createdAt) } })` +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { output: ({ state }) => Date.parse(state.createdAt) } } })` ], invalid: [ { @@ -44,11 +44,11 @@ Machine.make({ initial: (to) => { }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { initialize: ({ builder }) => globalThis.Date.now() ? builder.from() : builder.from(), onDone: (to) => window.Math.random() ? to.none : to.none, history: { recent: { default: (to) => self.crypto.randomUUID() ? to.none : to.none } } -} })`, +} } })`, errors: Array.from({ length: 3 }, () => ({ messageId: "nondeterministic" })) } ] diff --git a/packages/oxlint-plugin/test/noRedundantResolve.test.ts b/packages/oxlint-plugin/test/noRedundantResolve.test.ts index cb67ff3..5d8ea8d 100644 --- a/packages/oxlint-plugin/test/noRedundantResolve.test.ts +++ b/packages/oxlint-plugin/test/noRedundantResolve.test.ts @@ -21,7 +21,7 @@ function unrelated(M: any) { M.make({ initial: (to) => to.Ready().resolve(({ tar function unrelated(EM: any) { EM.Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from()) }) }`, `import { Machine } from "@typeonce/effect-machine" const definition = Machine.make({ initial: (to) => to.Ready() }) -function unrelated(definition: any) { definition.handle({ Ready: { on: { Reset: (to) => to.full.Ready().resolve(({ target }) => target.from()) } } }) }`, +function unrelated(definition: any) { definition.handle({ states: { Ready: { on: { Reset: (to) => to.branch.Ready().resolve(({ target }) => target.from()) } } } }) }`, `import { Machine } from "@typeonce/effect-machine" Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from({ id: "ready" })) })`, @@ -31,7 +31,7 @@ Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from() const other = { resolve: (_callback: unknown) => undefined } other.resolve(({ target }) => target.from())`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => from.effect("work", () => other.resolve(({ target }) => target.from())) } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("work", () => other.resolve(({ target }) => target.from())) } } })`, `const other = { resolve: (_callback: unknown) => undefined } other.resolve(({ target }) => target.from())` ], @@ -46,10 +46,10 @@ Machine.make({ initial: (to) => to.Ready() })`, { code: `import { Machine } from "@typeonce/effect-machine" const definition = Machine.make({ initial: (to) => to.Ready() }) -definition.handle({ Ready: { on: { Reset: (to) => to.full.Ready().resolve(({ target }) => target.from()) } } })`, +definition.handle({ states: { Ready: { on: { Reset: (to) => to.branch.Ready().resolve(({ target }) => target.from()) } } } })`, output: `import { Machine } from "@typeonce/effect-machine" const definition = Machine.make({ initial: (to) => to.Ready() }) -definition.handle({ Ready: { on: { Reset: (to) => to.full.Ready() } } })`, +definition.handle({ states: { Ready: { on: { Reset: (to) => to.branch.Ready() } } } })`, errors: [{ messageId: "redundantResolver" }] }, { @@ -67,27 +67,27 @@ EM.Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => /* preserv }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { on: { - Reset: (to) => to.full.Ready().resolve(({ target }) => target.from(), { reenter: true }) -} } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { on: { + Reset: (to) => to.branch.Ready().resolve(({ target }) => target.from(), { reenter: true }) +} } } })`, output: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { on: { - Reset: (to) => to.full.Ready().reenter() -} } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { on: { + Reset: (to) => to.branch.Ready().reenter() +} } } })`, errors: [{ messageId: "redundantReenterResolver" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { always: (to) => to.none.resolve(() => {}) } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none.resolve(() => {}) } } })`, output: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { always: (to) => to.none } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none } } })`, errors: [{ messageId: "redundantTargetlessResolver" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { always: (to) => to.none.resolve(() => {}, { reenter: true }) } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none.resolve(() => {}, { reenter: true }) } } })`, output: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { always: (to) => to.none.reenter() } })`, +Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none.reenter() } } })`, errors: [{ messageId: "redundantReenterResolver" }] } ] diff --git a/packages/oxlint-plugin/test/preferInlineHandle.test.ts b/packages/oxlint-plugin/test/preferInlineHandle.test.ts index 1210219..799d8a2 100644 --- a/packages/oxlint-plugin/test/preferInlineHandle.test.ts +++ b/packages/oxlint-plugin/test/preferInlineHandle.test.ts @@ -15,34 +15,34 @@ tester.run("prefer-inline-handle", rule, { valid: [ `import { Machine } from "@typeonce/effect-machine" export const definition = Machine.make({}) -export const machine = definition.handle({})`, +export const machine = definition.handle({ states: {} })`, `import { Machine } from "@typeonce/effect-machine" const definition = Machine.make({}) -export const first = definition.handle({}) -export const second = definition.handle({})`, +export const first = definition.handle({ states: {} }) +export const second = definition.handle({ states: {} })`, `import { Machine } from "@typeonce/effect-machine" -export const machine = Machine.make({}).handle({})`, +export const machine = Machine.make({}).handle({ states: {} })`, `import { Machine } from "other-package" const definition = Machine.make({}) -export const machine = definition.handle({})` +export const machine = definition.handle({ states: {} })` ], invalid: [ { code: `import { Machine } from "@typeonce/effect-machine" const definition = Machine.make({}) -export const machine = definition.handle({})`, +export const machine = definition.handle({ states: {} })`, errors: [{ messageId: "inlineHandle", line: 2, column: 6 }] }, { code: `import { Machine as StateMachine } from "@typeonce/effect-machine" const model = StateMachine.make({}) -model.handle({})`, +model.handle({ states: {} })`, errors: [{ messageId: "inlineHandle", line: 2, column: 6 }] }, { code: `import * as EM from "@typeonce/effect-machine" const model = EM.Machine.make({}) -export default model.handle({})`, +export default model.handle({ states: {} })`, errors: [{ messageId: "inlineHandle", line: 2, column: 6 }] } ] diff --git a/plans/root-machine-api.md b/plans/root-machine-api.md new file mode 100644 index 0000000..ee27e64 --- /dev/null +++ b/plans/root-machine-api.md @@ -0,0 +1,79 @@ +# Root machine API implementation + +This branch implements the agreed root-based public model. The milestones below +are implementation checkpoints, not compatibility APIs or separate releases. + +## Contract + +- Every machine has one explicit root descriptor. `Machine.make({ root })` + replaces the public top-level state map constructor. +- The root uses the empty absolute path. Its children retain paths such as + `Editing` and `Editing.Form`; no artificial path prefix is introduced. +- Root snapshots participate in the same atomic, compound, and parallel snapshot + model as every other active node. A root value is never an optional path lookup. +- `Machine.state` captures an immutable descriptor with typed snapshot helpers. + Nested nodes may be inline. `fields` generates a tagged schema; `schema` reuses + an existing schema unchanged. The two properties are mutually exclusive. +- Root initialization follows declared defaults. Explicit configuration + construction remains separately named. Required root and descendant values + must be supplied before a machine becomes executable. +- Root handlers are passed directly to `handle`. An implemented machine cannot + be handled again. Root work is scoped to the root and survives child changes. +- `self.update` replaces the handler owner's value. Retained ancestor updates + and coordinated destination/owner updates preserve existing atomicity and + conflict semantics. No partial merge or implicit sequential parallel writes. +- Direct target `from` and `decoded` take callbacks. Complex resolvers retain + explicit builders and queued commands. Guards decline with ancestor fallback. +- Named branches retain `target` and optional `title`. +- Public, internal, and emitted protocols remain distinct. Local field records + and explicitly imported schemas produce deferred, validated constructors. +- Atom bridges expose `result` and `snapshot`, with no separate `state` + projection. React ownership, registry identity, and child inactivity persist. +- Tests accept production event construction inputs and record decoded events. +- Cluster retains ownership of distributed identity and delivery. + +## Stage 1: contract and inference — implemented + +Prove root definitions, field/schema construction, root and child values, +initialization, ownership-aware targets, and invalid compositions with focused +type cases. Compare the model against Effect schemas and immutable descriptors. + +## Stage 2: execution and migration — implemented + +Implement the root in the generic planner and optimized strategies; migrate +runtime, snapshots, codecs, child lifecycles, testing, and integration boundaries. +Cover targetless/reentering transitions, parallel conflicts, raised events, +completion, invocations, and retained snapshots differentially. + +## Stage 3: authoring and React — implemented + +Complete direct protocols, fields, construction methods, guards, React state +rendering and context ownership; migrate examples and documentation; remove +deprecated aliases and update the public reference. Optional new testing DSLs +are not part of this work. + +## Resolved contracts + +- Reusing `machine.root` carries topology and schemas, never another machine's + explicit startup override or its initialization proof. +- Default constructors use schema make defaults when the complete make input + can be omitted. Required active descendant values still require an initializer. +- A compound executable root forwards its completed direct workflow's output + unless that workflow handles completion itself. Nested compound completion + continues to require a direct final child; parallel roots wait for all regions. +- History beneath a retained root preserves current root data. Root history can + restore the root's own data. Encoded snapshots use version 2 and reject version 1. +- Final state values cannot be updated. Root and active owner updates replace the + whole value and never implicitly reenter or restart scoped work. +- React providers own startup identity. Changing input props does not restart a + machine; a new key does. Inactive selected paths remain optional and startup + failures reach the React error boundary. +- Recursive builder and selector types have explicit variance where their + contracts require it. Existing type-performance budgets remain unchanged. + +## Release gate + +Run `pnpm check`, `pnpm perf:types`, and `pnpm perf:runtime`. Inspect CI +base-versus-branch performance and memory reports. Add a minor changeset and +complete the PR template. Merge only a current branch with every required check +green, then verify the remote merge and feature-branch deletion. diff --git a/scripts/devtools-pack-check.mjs b/scripts/devtools-pack-check.mjs index 5fc0aa1..ddf274e 100644 --- a/scripts/devtools-pack-check.mjs +++ b/scripts/devtools-pack-check.mjs @@ -82,13 +82,12 @@ import { Schema } from "effect" class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} -const states = Machine.states({ Idle }) +const root = Machine.state({ initial: "Idle", states: { Idle } }) export const machine = Machine.make({ id: "packed-fixture", - states: states.states, - events: Machine.events(), - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle())) + root, + events: Machine.events({}) }).handle({}) ` diff --git a/scripts/fixtures/consumer/consumer.ts b/scripts/fixtures/consumer/consumer.ts index e96a86b..608ea8b 100644 --- a/scripts/fixtures/consumer/consumer.ts +++ b/scripts/fixtures/consumer/consumer.ts @@ -18,39 +18,42 @@ const InternalEvent = Schema.TaggedUnion({ Loaded: { value: Schema.String } }) -const States = Machine.states(State.cases) -const PublicEvents = Machine.events(PublicEvent) -const InternalEvents = Machine.internalEvents(InternalEvent) +const States = Machine.state({ initial: "Idle", states: State.cases }) +const PublicEvents = Machine.eventsFromSchemas(PublicEvent) +const InternalEvents = Machine.internalEventsFromSchemas(InternalEvent) const machine = Machine.make({ id: "Consumer", - states: States.states, + root: States, events: PublicEvents, internalEvents: InternalEvents, - initial: (to) => to.Idle().resolve(({ target }) => target.decoded(State.cases.Idle.make({}))) + initialConfiguration: (root) => + root.resolve(({ target }) => target.from((to) => to.Idle.decoded(State.cases.Idle.make({})))) }).handle({ - Idle: { - on: { - Start: (to) => - to.branches({ - cached: { target: to.full.Loading() }, - measured: { target: to.none }, - named: { target: to.full.Done() }, - confirmed: { target: to.full.Idle() } - }).resolve(({ select }) => select.cached.decoded(State.cases.Loading.make({}))) - } - }, - Loading: { - invoke: (from) => [ - from.effect("fixture-load", () => Effect.succeed("ready")).onDone((to) => to.none), - from.timer("fixture-delay", "1 second").onDone((to) => to.none) - ], - on: { - Loaded: (to) => - to.full.Done().resolve(({ event, target }) => target.decoded(State.cases.Done.make({ value: event.value }))) - } - }, - Done: {} + states: { + Idle: { + on: { + Start: (to) => + to.branches({ + cached: { target: to.branch.Loading() }, + measured: { target: to.none }, + named: { target: to.branch.Done() }, + confirmed: { target: to.branch.Idle() } + }).resolve(({ select }) => select.cached.decoded(State.cases.Loading.make({}))) + } + }, + Loading: { + invoke: (from) => [ + from.effect("fixture-load", () => Effect.succeed("ready")).onDone((to) => to.none), + from.timer("fixture-delay", "1 second").onDone((to) => to.none) + ], + on: { + Loaded: (to) => + to.branch.Done().resolve(({ event, target }) => target.decoded(State.cases.Done.make({ value: event.value }))) + } + }, + Done: {} + } }) const atoms = AtomMachine.make(machine) diff --git a/scripts/fixtures/consumer/deep-bound.ts b/scripts/fixtures/consumer/deep-bound.ts index c67ae44..649149d 100644 --- a/scripts/fixtures/consumer/deep-bound.ts +++ b/scripts/fixtures/consumer/deep-bound.ts @@ -36,171 +36,189 @@ const ChildState = Schema.TaggedUnion({ Done: { value: Schema.String } }) -const ChildStates = Machine.states({ - Done: { - schema: ChildState.cases.Done, - type: "final", - output: Schema.String +const ChildStates = Machine.state({ + initial: "Done", + states: { + Done: { + schema: ChildState.cases.Done, + type: "final", + output: Schema.String + } } }) -const ChildParentEvents = Machine.events(Internal.cases.ChildNotice) +const ChildParentEvents = Machine.eventsFromSchemas(Internal.cases.ChildNotice) const childMachine = Machine.make({ - states: ChildStates.states, - events: Machine.events(), + root: ChildStates, + events: Machine.eventsFromSchemas(), parent: Machine.parent(ChildParentEvents), input: Schema.Struct({ value: Schema.String }), - initial: (to) => - to.Done().resolve(({ input, target }) => target.decoded(ChildState.cases.Done.make({ value: input.value }))) + initialConfiguration: (root) => + root.resolve(({ input, target }) => + target.from((to) => to.Done.decoded(ChildState.cases.Done.make({ value: input.value }))) + ) }).handle({ - Done: { - entry: ({ parent, state }, enqueue) => { - enqueue.sendTo(parent, ChildParentEvents.ChildNotice({ value: state.value })) - }, - output: ({ state }) => state.value + states: { + Done: { + entry: ({ parent, state }, enqueue) => { + enqueue.sendTo(parent, ChildParentEvents.ChildNotice({ value: state.value })) + }, + output: ({ state }) => state.value + } } }) const Child = Machine.child("child", childMachine) -const States = Machine.states({ - Idle: State.cases.Idle, - Ready: { - schema: State.cases.Ready, - initial: "Editor", - states: { - Editor: { - schema: State.cases.Editor, - initial: "Editing", - states: { - Editing: State.cases.Editing, - Saving: State.cases.Saving +const States = Machine.state({ + initial: "Idle", + states: { + Idle: State.cases.Idle, + Ready: { + schema: State.cases.Ready, + initial: "Editor", + states: { + Editor: { + schema: State.cases.Editor, + initial: "Editing", + states: { + Editing: State.cases.Editing, + Saving: State.cases.Saving + } } } + }, + Done: { + schema: State.cases.Done, + type: "final", + output: Schema.String } - }, - Done: { - schema: State.cases.Done, - type: "final", - output: Schema.String } }) -const Emissions = Machine.emittedEvents(Emitted.cases.Notice) +const Emissions = Machine.emittedEventsFromSchemas(Emitted.cases.Notice) const definition = Machine.make({ - states: States.states, - events: Machine.events(Event.cases.Begin, Event.cases.Save, ChildParentEvents), - internalEvents: Machine.internalEvents(Internal.cases.Loaded, Internal.cases.ChildCompleted), + root: States, + events: Machine.eventsFromSchemas(Event.cases.Begin, Event.cases.Save, ChildParentEvents), + internalEvents: Machine.internalEventsFromSchemas(Internal.cases.Loaded, Internal.cases.ChildCompleted), emittedEvents: Emissions, input: Schema.Struct({ seed: Schema.String }), - initial: (to) => to.Idle().resolve(({ input: { seed: _seed }, target }) => target.decoded(State.cases.Idle.make({}))) + initialConfiguration: (root) => + root.resolve(({ input: { seed: _seed }, target }) => + target.from((to) => to.Idle.decoded(State.cases.Idle.make({}))) + ) }) const machine = definition.handle({ - Idle: { - invoke: (from) => from.effect("deep-inline-invoke", () => Effect.asVoid(ExternalService)).onDone((to) => to.none), - on: { - Begin: (to) => - to.full.Ready().resolve(({ target }) => - target.decoded( - State.cases.Ready.make({}), - (ready) => - ready.Editor.decoded( - State.cases.Editor.make({}), - (editor) => editor.Editing.decoded(State.cases.Editing.make({ value: "ready" })) - ) - ) - ) - } - }, - Ready: { - states: { - Editor: { - states: { - Editing: { - on: { - Save: (to) => - to.local.Saving().resolve(({ event, target }) => - target.decoded(State.cases.Saving.make({ value: event.value })) - ), - Loaded: (to) => to.none - } - }, - Saving: { - invoke: (from) => - from.child(Child, { input: ({ state }) => ({ value: state.value }) }).onDone((to) => to.none), - on: { - ChildNotice: (to) => - to.local.Saving().resolve(({ event, target }, enqueue) => { - enqueue.emit(Emissions.Notice({ value: event.value })) - return target.decoded(State.cases.Saving.make({ value: event.value })) - }), - ChildCompleted: (to) => - to.full.Done().resolve(({ event, target }) => - target.decoded(State.cases.Done.make({ value: event.value })) + states: { + Idle: { + invoke: (from) => from.effect("deep-inline-invoke", () => Effect.asVoid(ExternalService)).onDone((to) => to.none), + on: { + Begin: (to) => + to.branch.Ready().resolve(({ target }) => + target.decoded( + State.cases.Ready.make({}), + (ready) => + ready.Editor.decoded( + State.cases.Editor.make({}), + (editor) => editor.Editing.decoded(State.cases.Editing.make({ value: "ready" })) ) + ) + ) + } + }, + Ready: { + states: { + Editor: { + states: { + Editing: { + on: { + Save: (to) => + to.local.Saving().resolve(({ event, target }) => + target.decoded(State.cases.Saving.make({ value: event.value })) + ), + Loaded: (to) => to.none + } + }, + Saving: { + invoke: (from) => + from.child(Child, { input: ({ state }) => ({ value: state.value }) }).onDone((to) => to.none), + on: { + ChildNotice: (to) => + to.local.Saving().resolve(({ event, target }, enqueue) => { + enqueue.emit(Emissions.Notice({ value: event.value })) + return target.decoded(State.cases.Saving.make({ value: event.value })) + }), + ChildCompleted: (to) => + to.branch.Done().resolve(({ event, target }) => + target.decoded(State.cases.Done.make({ value: event.value })) + ) + } } } } } + }, + Done: { + output: ({ state }) => state.value } - }, - Done: { - output: ({ state }) => state.value } }) const PackagedDeepState = Schema.TaggedStruct("PackagedDeepState", {}) -const PackagedDeepStates = Machine.states({ - n0: { - schema: PackagedDeepState, - initial: "n1", - states: { - n1: { - schema: PackagedDeepState, - initial: "n2", - states: { - n2: { - schema: PackagedDeepState, - initial: "n3", - states: { - n3: { - schema: PackagedDeepState, - initial: "n4", - states: { - n4: { - schema: PackagedDeepState, - initial: "n5", - states: { - n5: { - schema: PackagedDeepState, - initial: "n6", - states: { - n6: { - schema: PackagedDeepState, - initial: "n7", - states: { - n7: { - schema: PackagedDeepState, - initial: "n8", - states: { - n8: { - schema: PackagedDeepState, - initial: "n9", - states: { - n9: { - schema: PackagedDeepState, - initial: "n10", - states: { - n10: { - schema: PackagedDeepState, - initial: "n11", - states: { - n11: { - schema: PackagedDeepState, - initial: "n12", - states: { - n12: { - schema: PackagedDeepState, - type: "final", - output: Schema.String +const PackagedDeepStates = Machine.state({ + initial: "n0", + states: { + n0: { + schema: PackagedDeepState, + initial: "n1", + states: { + n1: { + schema: PackagedDeepState, + initial: "n2", + states: { + n2: { + schema: PackagedDeepState, + initial: "n3", + states: { + n3: { + schema: PackagedDeepState, + initial: "n4", + states: { + n4: { + schema: PackagedDeepState, + initial: "n5", + states: { + n5: { + schema: PackagedDeepState, + initial: "n6", + states: { + n6: { + schema: PackagedDeepState, + initial: "n7", + states: { + n7: { + schema: PackagedDeepState, + initial: "n8", + states: { + n8: { + schema: PackagedDeepState, + initial: "n9", + states: { + n9: { + schema: PackagedDeepState, + initial: "n10", + states: { + n10: { + schema: PackagedDeepState, + initial: "n11", + states: { + n11: { + schema: PackagedDeepState, + initial: "n12", + states: { + n12: { + schema: PackagedDeepState, + type: "final", + output: Schema.String + } } } } @@ -228,40 +246,42 @@ const PackagedDeepStates = Machine.states({ } }) const packagedDeepMachine = Machine.make({ - states: PackagedDeepStates.states, - events: Machine.events(), - initial: (to) => - to.n0.initial.resolve((): never => { + root: PackagedDeepStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (to) => + to.resolve((): never => { throw new Error("type-only packaged consumer fixture") }) }).handle({ - n0: { - states: { - n1: { - states: { - n2: { - states: { - n3: { - states: { - n4: { - states: { - n5: { - states: { - n6: { - states: { - n7: { - states: { - n8: { - states: { - n9: { - states: { - n10: { - states: { - n11: { - states: { - n12: { - entry: () => {}, - output: () => "packaged" + states: { + n0: { + states: { + n1: { + states: { + n2: { + states: { + n3: { + states: { + n4: { + states: { + n5: { + states: { + n6: { + states: { + n7: { + states: { + n8: { + states: { + n9: { + states: { + n10: { + states: { + n11: { + states: { + n12: { + entry: () => {}, + output: () => "packaged" + } } } } @@ -304,8 +324,8 @@ const runtime = Atom.runtime( const Bound = AtomMachine.bind(runtime) const machineAtom = Bound.make(machine, { seed: "initial" }) -type Snapshot = Machine.Machine.Snapshot -type StateSuccess = Atom.Success +type Snapshot = Machine.Snapshot +type StateSuccess = Atom.Success type SendEvent = typeof machineAtom.send extends Atom.Writable ? InputEvent : never type Output = typeof machineAtom extends AtomMachine.MachineAtom ? Value : never type Failure = Atom.Failure diff --git a/scripts/invoke-autocomplete.test.mjs b/scripts/invoke-autocomplete.test.mjs index 7eddf20..5da79b6 100644 --- a/scripts/invoke-autocomplete.test.mjs +++ b/scripts/invoke-autocomplete.test.mjs @@ -11,15 +11,18 @@ import { Effect, Schema, Stream } from "effect" import { Machine } from "./src/index.js" import { AtomMachine } from "./src/unstable/reactivity/index.js" +const rootCompletion = Machine.state({ /*root-properties*/ initial: "Idle", states: { Idle: {} } }) +void rootCompletion + class AtomIdle extends Schema.TaggedClass("AtomIdle")("AtomIdle", {}) {} class AtomReady extends Schema.TaggedClass("AtomReady")("AtomReady", {}) {} -const AtomStates = Machine.states({ AtomIdle, AtomReady }) +const AtomStates = Machine.state({ initial: "AtomIdle", states: { AtomIdle, AtomReady } }) const atomDefinition = Machine.make({ - states: AtomStates.states, - events: Machine.events(), + root: AtomStates, + events: Machine.eventsFromSchemas(), input: Schema.String, - initial: (to) => to.AtomIdle().resolve(({ target }) => target.decoded(new AtomIdle({}))) -}).handle({ AtomIdle: {}, AtomReady: {} }) + initialConfiguration: (root) => root.resolve(({ target }) => target.from(to => to.AtomIdle.decoded(new AtomIdle({})))) +}).handle({ states: { AtomIdle: {}, AtomReady: {} } }) AtomMachine.family(atomDefinition, { atoms: { @@ -30,16 +33,16 @@ AtomMachine.family(atomDefinition, { }) const atomChildDefinition = Machine.make({ - states: AtomStates.states, - events: Machine.events(), - initial: (to) => to.AtomIdle().resolve(({ target }) => target.decoded(new AtomIdle({}))) -}).handle({ AtomIdle: {}, AtomReady: {} }) + root: AtomStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => root.resolve(({ target }) => target.from(to => to.AtomIdle.decoded(new AtomIdle({})))) +}).handle({ states: { AtomIdle: {}, AtomReady: {} } }) const AtomChild = Machine.childFamily(atomChildDefinition) const atomParent = AtomMachine.make(Machine.make({ - states: AtomStates.states, - events: Machine.events(), - initial: (to) => to.AtomIdle().resolve(({ target }) => target.decoded(new AtomIdle({}))) -}).handle({ AtomIdle: {}, AtomReady: {} })) + root: AtomStates, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => root.resolve(({ target }) => target.from(to => to.AtomIdle.decoded(new AtomIdle({})))) +}).handle({ states: { AtomIdle: {}, AtomReady: {} } })) AtomMachine.familyChild(atomParent, { child: (id: string) => AtomChild(id), atoms: { @@ -52,71 +55,67 @@ AtomMachine.familyChild(atomParent, { atoms: { // @ts-expect-error empty paths keep the completion position unfiltered childSelectedCompletion: AtomMachine.selectChild(""), - // @ts-expect-error empty paths keep the completion position unfiltered childSnapshotCompletion: AtomMachine.selectSnapshotChild(""), - // @ts-expect-error empty paths keep the completion position unfiltered childMatchedCompletion: AtomMachine.matchesChild("") } }) -const States = Machine.states({ Loading: {}, Done: {}, Failed: {} }) +const States = Machine.state({ initial: "Loading", states: { Loading: {}, Done: {}, Failed: {} } }) const definition = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => - to./*initial-selector*/Loading()./*initial-operations*/resolve(({ /*initial-context*/ ...context }) => - context.target./*initial-exact-target*/from()) + root: States, + events: Machine.eventsFromSchemas(), + initialConfiguration: (root) => root./*initial-operations*/resolve(({ /*initial-context*/ ...context }) => context.target./*initial-exact-target*/from(to => to./*initial-selector*/Loading.from())) }) -definition.handle({ +definition.handle({ states: { Loading: { invoke: (from) => from./*invoke-sources*/effect("load", ({ /*invoke-source-context*/ ...context }) => Effect.fail("offline").pipe(Effect.as(context.event._tag))) .onDone((to) => - to.full./*done-target*/Done()./*selected-operations*/resolve(({ /*done-context*/ ...context }) => + to.branch./*done-target*/Done()./*selected-operations*/resolve(({ /*done-context*/ ...context }) => context.target./*done-exact-target*/from())) .onFailure((to) => - to.full.Failed().resolve(({ /*failure-context*/ ...context }) => + to.branch.Failed().resolve(({ /*failure-context*/ ...context }) => context.target.from())) }, Done: {}, Failed: {} -}) +} }) const requiredParentDefinition = Machine.make({ - states: States.states, - events: Machine.events(), - parent: Machine.parent(Machine.events()), - initial: (to) => to.Loading() + root: States, + events: Machine.eventsFromSchemas(), + parent: Machine.parent(Machine.eventsFromSchemas()), + initialConfiguration: (root) => root.resolve(({ target }) => target.from(to => to.Loading.from())) }) -requiredParentDefinition.handle({ +requiredParentDefinition.handle({ states: { Loading: { invoke: (from) => from.effect("required-parent", ({ /*required-parent-context*/ ...context }) => Effect.never) }, Done: {}, Failed: {} -}) +} }) const optionalParentDefinition = Machine.make({ - states: States.states, - events: Machine.events(), - parent: Machine.optionalParent(Machine.events()), - initial: (to) => to.Loading() + root: States, + events: Machine.eventsFromSchemas(), + parent: Machine.optionalParent(Machine.eventsFromSchemas()), + initialConfiguration: (root) => root.resolve(({ target }) => target.from(to => to.Loading.from())) }) -optionalParentDefinition.handle({ +optionalParentDefinition.handle({ states: { Loading: { invoke: (from) => from.effect("optional-parent", ({ /*optional-parent-context*/ ...context }) => Effect.never) }, Done: {}, Failed: {} -}) +} }) -definition.handle({ +definition.handle({ states: { Loading: { invoke: (from) => from.stream("updates", () => Stream.make(1)) @@ -126,9 +125,9 @@ definition.handle({ }, Done: {}, Failed: {} -}) +} }) -definition.handle({ +definition.handle({ states: { Loading: { invoke: (from) => from.effect("incomplete", () => Effect.fail("offline").pipe(Effect.as(1))) @@ -137,58 +136,58 @@ definition.handle({ }, Done: {}, Failed: {} -}) +} }) -definition.handle({ +definition.handle({ states: { Loading: { always: (to) => to./*transition-selector*/none } -}) +} }) -definition.handle({ +definition.handle({ states: { Loading: { always: (to) => to.none.resolve(({ /*targetless-context*/ ...context }) => undefined) } -}) +} }) -definition.handle({ +definition.handle({ states: { Loading: { always: (to) => - to./*target-scopes*/full.Done().resolve(({ /*transition-context*/ ...context }) => + to./*target-scopes*/branch.Done().resolve(({ /*transition-context*/ ...context }) => context.target./*transition-exact-target*/from()) } -}) +} }) -definition.handle({ +definition.handle({ states: { Loading: { always: (to) => to.branches({ ready: { title: "ready", - target: to./*branch-target-scopes*/full.Done() + target: to./*branch-target-scopes*/branch.Done() }, unchanged: { target: to.none } }).resolve(({ /*branch-resolve-context*/ ...context }) => context.select./*branch-select-keys*/ready.from()) } -}) +} }) -definition.handle({ +definition.handle({ states: { Loading: { always: (to) => to.none.resolve(({ /*required-context*/ ...context }) => undefined) } -}) +} }) -definition.handle({ +definition.handle({ states: { Loading: { always: (to) => to.none.resolve(({ /*declinable-context*/ ...context }) => context.decline(), { declinable: true }) } -}) +} }) ` @@ -387,3 +386,11 @@ test("contextually completes transition definitions while authoring", () => { assert.equal(selected.has("reenter"), true) }) + + +test("completes root definition fields and topology", () => { + const root = completions("root-properties") + for (const key of ["fields", "schema", "type", "output", "annotations"]) { + assert.equal(root.has(key), true, key) + } +}) diff --git a/scripts/react-pack-check.mjs b/scripts/react-pack-check.mjs index 1b3b166..5e1b247 100644 --- a/scripts/react-pack-check.mjs +++ b/scripts/react-pack-check.mjs @@ -76,7 +76,9 @@ try { "--input-type=module", "--eval", `const module = await import("@typeonce/effect-machine-react"); - if (typeof module.useMachineAtom !== "function") throw new Error("useMachineAtom is not exported");` + for (const name of ["useMachineAtom", "createMachineContext", "MachineState"]) { + if (typeof module[name] !== "function") throw new Error(name + " is not exported"); + }` ], { cwd: consumer }) console.log(`React package verification passed (${listing.length} files)`) diff --git a/scripts/runtime-performance-compatibility.test.mjs b/scripts/runtime-performance-compatibility.test.mjs index ff39a69..a9b5ad2 100644 --- a/scripts/runtime-performance-compatibility.test.mjs +++ b/scripts/runtime-performance-compatibility.test.mjs @@ -199,3 +199,43 @@ test("fails closed when a legacy capability cannot preserve lifecycle semantics" /legacy child invocation API cannot handle failures/ ) }) + +test("adapts root definitions while preserving benchmark startup and handler semantics", () => { + let captured + const schema = Symbol("schema") + const rootApi = { + state: (node) => ({ node }), + eventsFromSchemas: (...schemas) => schemas, + make: (config) => { + captured = config + return { handle: (handlers) => ({ config, handlers }) } + } + } + const api = makeEffectMachineBenchmarkApi(rootApi) + const states = api.states({ Ready: schema }) + const definition = api.make({ + states: states.states, + events: api.events(schema), + initial: api.initial({ + target: (to) => to.Ready(), + resolve: ({ input, target }) => target.from({ count: input }) + }) + }) + const handlers = { Ready: {} } + assert.deepEqual(definition.handle(handlers).handlers, { states: handlers }) + assert.deepEqual(captured.root.node, { initial: "Ready", states: { Ready: schema } }) + assert.deepEqual(captured.events, [schema]) + const snapshot = captured.initialConfiguration({ + resolve: (resolve) => resolve({ + input: 3, + target: { + from: (construct) => ({ + path: "", + value: undefined, + state: construct({ Ready: { from: (value) => ({ path: "Ready", value }) } }) + }) + } + }) + }) + assert.deepEqual(api.snapshot(snapshot), { path: "Ready", value: { count: 3 } }) +}) diff --git a/scripts/type-performance.mjs b/scripts/type-performance.mjs index dde2723..cff3ae6 100644 --- a/scripts/type-performance.mjs +++ b/scripts/type-performance.mjs @@ -49,7 +49,7 @@ const scenarios = [ }, { id: "define-states", - label: "Machine.states (3 states)", + label: "Machine.state (3 children)", file: "define-states.ts", control: "import-only", maxInstantiations: 3_800, From 4e632b8922e5cbd735c4f6f954270832c3e62231 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sun, 6 Sep 2026 14:37:59 +0200 Subject: [PATCH 4/4] perf: reuse immutable snapshot builders during startup --- .../effect-machine/perf/runtime/counter.mjs | 4 +- .../runtime/effect-machine-compatibility.mjs | 17 +++-- .../src/internal/machine/targetBuilder.ts | 76 +++++++++++-------- .../internal/machine/rootStrategies.test.ts | 42 +++++++++- ...runtime-performance-compatibility.test.mjs | 5 +- 5 files changed, 100 insertions(+), 44 deletions(-) diff --git a/packages/effect-machine/perf/runtime/counter.mjs b/packages/effect-machine/perf/runtime/counter.mjs index 4b153be..4aa8e78 100644 --- a/packages/effect-machine/perf/runtime/counter.mjs +++ b/packages/effect-machine/perf/runtime/counter.mjs @@ -218,11 +218,11 @@ const parallelCounterMachine = benchmarkApi.make({ Finish: benchmarkApi.transition({ target: (to) => to.full.Complete(), resolve: ({ snapshot, target }) => target.from({ - value: snapshot.states.Left.value.value + snapshot.states.Right.value.value + value: benchmarkApi.snapshot(snapshot).states.Left.value.value + benchmarkApi.snapshot(snapshot).states.Right.value.value }) }, ({ snapshot, target }) => target.full.Complete.from({ - value: snapshot.states.Left.value.value + snapshot.states.Right.value.value + value: benchmarkApi.snapshot(snapshot).states.Left.value.value + benchmarkApi.snapshot(snapshot).states.Right.value.value })) }, states: { diff --git a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs index 84baa17..04693ad 100644 --- a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs +++ b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs @@ -20,7 +20,7 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { const fluentTransition = (definition) => (to) => { const selection = selectInstruction(definition.target(hasRoot ? { ...to, full: to.branch } : to)) if (definition.resolve !== undefined) { - return selection.resolve(hasRoot ? (context, enqueue) => definition.resolve({ ...context, snapshot: context.snapshot?.path === "" ? context.snapshot.state : context.snapshot }, enqueue) : definition.resolve, { + return selection.resolve(definition.resolve, { ...(definition.reenter === true ? { reenter: true } : {}), ...(definition.declinable === true ? { declinable: true } : {}) }) @@ -49,14 +49,17 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { if (!hasRoot) return Machine.make(config) const { states: root, initial, ...rest } = config const definition = initial.benchmarkInitial + // Target selection belongs to definition time in both public APIs. + // Only the selected value constructor runs each time a machine starts. + const selectors = Object.fromEntries(Object.keys(root.node.states).map((key) => { + const selected = () => key + selected.initial = key + return [key, selected] + })) + const initialKey = selectInstruction(definition.target(selectors)) const machine = Machine.make({ ...rest, root, initialConfiguration: (root) => root.resolve((context) => context.target.from((tree) => { - const selectors = Object.fromEntries(Object.entries(tree).map(([key, builder]) => { - const selected = () => builder - selected.initial = builder - return [key, selected] - })) - const target = selectInstruction(definition.target(selectors)) + const target = tree[initialKey] return definition.resolve === undefined ? target.from() : definition.resolve({ ...context, target }) })) }) return { handle: (states) => machine.handle({ states }) } diff --git a/packages/effect-machine/src/internal/machine/targetBuilder.ts b/packages/effect-machine/src/internal/machine/targetBuilder.ts index ec68053..23c1c7b 100644 --- a/packages/effect-machine/src/internal/machine/targetBuilder.ts +++ b/packages/effect-machine/src/internal/machine/targetBuilder.ts @@ -56,7 +56,7 @@ const withInitial = ( valued ) Object.defineProperty(builder, "initial", { - value: initial, + value: Object.freeze(initial), enumerable: false }) return builder @@ -75,21 +75,22 @@ export const makeSnapshotBuilder = ( } const path = options.prefix === "" ? key : `${options.prefix}.${key}` if (pseudoType === "choice") { - builder[key] = () => Topology.makeChoiceTarget(path, getParentPathRuntime(path)) + builder[key] = Object.freeze(() => Topology.makeChoiceTarget(path, getParentPathRuntime(path))) continue } const node = Topology.getStateNodeDefinition(path, definition) const method = withFrom( - (value: unknown, selector?: (builder: unknown) => unknown) => - makeSnapshotForNode(definition, key, value, selector, options), + makeSnapshotFactory(definition, key, options), node.states === undefined ? "leaf" : "nested", node.schema !== undefined ) - builder[key] = node.states === undefined || options.mode !== "full" || options.prefix !== "" - ? method - : withInitial(method, path, node.schema !== undefined) + builder[key] = Object.freeze( + node.states === undefined || options.mode !== "full" || options.prefix !== "" + ? method + : withInitial(method, path, node.schema !== undefined) + ) } - return builder + return Object.freeze(builder) } const makeParallelSnapshotBuilder = ( @@ -153,39 +154,50 @@ const getParallelSnapshotBuilderRegions = ( return regions } -const makeSnapshotForNode = ( +/** + * A factory owns its lazily compiled child builder. The builder captures only + * immutable topology; every call constructs fresh values and configurations. + * Parallel builders remain per-call because they accumulate selected regions. + */ +const makeSnapshotFactory = ( definition: Machine.TaggedSchema | Machine.StateNodeConfig, key: string, - value: unknown, - selector: ((builder: unknown) => unknown) | undefined, options: SnapshotBuilderOptions -): Record => { +): (value: unknown, selector?: (builder: unknown) => unknown) => Record => { const path = options.prefix === "" ? key : `${options.prefix}.${key}` + const mode = options.mode const node = Topology.getStateNodeDefinition(path, definition) - const snapshot: Record = { - path, - value - } - if (node.states === undefined) { - return snapshot - } - if (selector === undefined) { - throw new Error(`Machine expected state "${path}" builder to provide active child states`) - } - if (node.type === "parallel") { - const builder = makeParallelSnapshotBuilder(node.states, { ...options, prefix: path }, {}) - const selected = selector(builder) - snapshot.states = getParallelSnapshotBuilderRegions(path, node.states, selected) + let childBuilder: unknown + return (value, selector) => { + const snapshot: Record = { path, value } + if (node.states === undefined) return snapshot + if (selector === undefined) { + throw new Error(`Machine expected state "${path}" builder to provide active child states`) + } + if (node.type === "parallel") { + const builder = makeParallelSnapshotBuilder(node.states, { mode, prefix: path }, {}) + snapshot.states = getParallelSnapshotBuilderRegions(path, node.states, selector(builder)) + return snapshot + } + if (childBuilder === undefined) { + const childStates = mode === "initial" && node.initial !== undefined + ? { [node.initial]: node.states[node.initial]! } + : node.states + childBuilder = makeSnapshotBuilder(childStates, { mode, prefix: path }) + } + snapshot.state = selector(childBuilder) return snapshot } - const childStates = options.mode === "initial" && node.initial !== undefined - ? { [node.initial]: node.states[node.initial]! } - : node.states - const selected = selector(makeSnapshotBuilder(childStates, { ...options, prefix: path })) - snapshot.state = selected - return snapshot } +const makeSnapshotForNode = ( + definition: Machine.TaggedSchema | Machine.StateNodeConfig, + key: string, + value: unknown, + selector: ((builder: unknown) => unknown) | undefined, + options: SnapshotBuilderOptions +): Record => makeSnapshotFactory(definition, key, options)(value, selector) + export const getTargetBuilderNode = ( stateNodes: Machine.StateNodes, path: string diff --git a/packages/effect-machine/test/internal/machine/rootStrategies.test.ts b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts index 3494397..9a83224 100644 --- a/packages/effect-machine/test/internal/machine/rootStrategies.test.ts +++ b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts @@ -1,5 +1,5 @@ -import { it } from "@effect/vitest" -import { Schema } from "effect" +import { assert, it } from "@effect/vitest" +import { Effect, Schema } from "effect" import { Machine } from "../../../src/index.js" import { verifyPlannerStrategies } from "./support/strategyDifferential.js" @@ -104,3 +104,41 @@ it.effect("keeps root values and child transitions equal to generic planning", ( ] }) }) + +it.effect("reuses only immutable startup builders and keeps input values independent", () => + Effect.gen(function*() { + const machine = Machine.make({ + root: Machine.state({ initial: "Idle", states: { Idle: { fields: { count: Schema.Number } }, Busy: {} } }), + input: Schema.Struct({ count: Schema.Number }), + events: Machine.events({ Noop: {} }), + initialConfiguration: (root) => + root.resolve(({ input, target }) => + target.from((tree) => { + // A retained builder cannot be changed to redirect another startup. + assert.strictEqual(Reflect.set(tree, "Idle", tree.Busy), false) + return tree.Idle.from({ count: input.count }) + }) + ) + }).handle({}) + const first = yield* Machine.planInitial(machine, { count: 1 }) + const second = yield* Machine.planInitial(machine, { count: 2 }) + assert.deepStrictEqual(first.state, { + path: "", + value: undefined, + state: { path: "Idle", value: { _tag: "Idle", count: 1 } } + }) + assert.deepStrictEqual(second.state, { + path: "", + value: undefined, + state: { path: "Idle", value: { _tag: "Idle", count: 2 } } + }) + for (const count of [3, 4]) { + yield* verifyPlannerStrategies({ + machine, + expected: "indexed-flat", + label: `independent startup ${count}`, + initialArgs: [{ count }], + events: [{ _tag: "Noop" }] + }) + } + })) diff --git a/scripts/runtime-performance-compatibility.test.mjs b/scripts/runtime-performance-compatibility.test.mjs index a9b5ad2..9e64047 100644 --- a/scripts/runtime-performance-compatibility.test.mjs +++ b/scripts/runtime-performance-compatibility.test.mjs @@ -202,6 +202,7 @@ test("fails closed when a legacy capability cannot preserve lifecycle semantics" test("adapts root definitions while preserving benchmark startup and handler semantics", () => { let captured + let selections = 0 const schema = Symbol("schema") const rootApi = { state: (node) => ({ node }), @@ -217,10 +218,11 @@ test("adapts root definitions while preserving benchmark startup and handler sem states: states.states, events: api.events(schema), initial: api.initial({ - target: (to) => to.Ready(), + target: (to) => { selections++; return to.Ready() }, resolve: ({ input, target }) => target.from({ count: input }) }) }) + assert.equal(selections, 1) const handlers = { Ready: {} } assert.deepEqual(definition.handle(handlers).handlers, { states: handlers }) assert.deepEqual(captured.root.node, { initial: "Ready", states: { Ready: schema } }) @@ -238,4 +240,5 @@ test("adapts root definitions while preserving benchmark startup and handler sem }) }) assert.deepEqual(api.snapshot(snapshot), { path: "Ready", value: { count: 3 } }) + assert.equal(selections, 1) })