From e556e634382eea78b268ca7edf446d219aee89e1 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sat, 1 Aug 2026 17:19:37 +0200 Subject: [PATCH] feat: add safe state construction --- .changeset/calm-machines-construct.md | 8 + README.md | 24 +- docs/agent-guide.md | 16 ++ src/Machine.ts | 365 ++++++++++++++++++++------ src/internal/machineModel.ts | 39 ++- src/internal/machinePlanner.ts | 2 +- test/Machine.test.ts | 292 +++++++++++++++++++++ typetest/Machine.tst.ts | 128 +++++++++ 8 files changed, 790 insertions(+), 84 deletions(-) create mode 100644 .changeset/calm-machines-construct.md diff --git a/.changeset/calm-machines-construct.md b/.changeset/calm-machines-construct.md new file mode 100644 index 0000000..a5ee3ea --- /dev/null +++ b/.changeset/calm-machines-construct.md @@ -0,0 +1,8 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add safe `.from` state construction to initial and transition target builders. +Constructor inputs are resolved through the selected state schema during +planning, preserving defaults and class identity while reporting validation +failures as `MachineSchemaDecodeError` values. diff --git a/README.md b/README.md index e5db57d..dfbcd02 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ const Counter = Machine.make({ id: "Counter", states: States.states, events: [Event.cases.Start], - initial: () => States.initial.Idle(State.cases.Idle.make({})) + initial: () => States.initial.Idle.from({}) }).handle({ Idle: { on: { - Start: ({ target }) => target.full.Running(State.cases.Running.make({})) + Start: ({ target }) => target.full.Running.from({}) } }, Running: {} @@ -69,6 +69,23 @@ const Counter = Machine.make({ `initial` is always a function. For a machine with an input schema, the initializer receives the decoded input. +Builder methods accept an already constructed state value directly, or expose +`.from` for constructing one safely from the state schema's make input: + +```ts +target.local.Running(decodedRunning) +target.local.Running.from({ startedAt: event.at }) +``` + +Use the direct call when a decoded value already exists. Use `.from` when +entering a state from fields. Construction runs through the schema's +`makeEffect` while the machine plans the configuration, so constructor +defaults and tagged-class identity are preserved and failed refinements become +`MachineSchemaDecodeError` failures instead of synchronous throws. The same +form is available on initial, local, branch, full, compound, parallel, and +final builders. A `.from` builder result is therefore a machine construction +instruction; it becomes a validated public snapshot when planning succeeds. + Tagged classes are equally valid when cases need class methods or nominal identity: @@ -157,8 +174,7 @@ Handlers implement behavior and output computation without repeating it: const machine = Machine.make({ states: States.states, events: [], - initial: () => - States.initial.Form(State.cases.Form.make({ draft: "" }), (form) => form.Editing(State.cases.Editing.make({}))) + initial: () => States.initial.Form.from({ draft: "" }, (form) => form.Editing.from({})) }).handle({ Form: { states: { diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 6b95710..0219cf9 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -230,6 +230,22 @@ Refresh: { Do not use `target.full` merely because it is easiest to discover. Prefer the narrowest builder that expresses the intended configuration change. +Every state builder method has two construction forms: + +```ts +target.local.Ready(decodedReady) +target.local.Ready.from({ value: event.value }) +``` + +The direct call accepts the schema's decoded `Type`. `.from` accepts its +`~type.make.in`, so callers do not need to invoke a TaggedUnion case's `make` +or instantiate a TaggedClass. The machine resolves `.from` with +`schema.makeEffect` during planning. Constructor defaults and class identity +are retained; refinement failures use `MachineSchemaDecodeError` at the state +boundary rather than throwing synchronously. This applies recursively to +initial, full, local, branch, compound, parallel, final, and `local.with` +builders. + ## Reading state and parents `Machine.defineStates` returns typed helpers: diff --git a/src/Machine.ts b/src/Machine.ts index 1e0581d..747bd8b 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -529,11 +529,27 @@ type ValidateEventProtocol< : [validation: EventProtocolError<"Public event tags must be unique", DuplicateInput>] const SnapshotBuilderStateTypeId: unique symbol = Symbol("effect/Machine/SnapshotBuilderState") +const SnapshotBuilderConstructionTypeId: unique symbol = Symbol("effect/Machine/SnapshotBuilderConstruction") -type SnapshotBuilderComplete = { +type SnapshotBuilderComplete = { readonly [SnapshotBuilderStateTypeId]: Regions + readonly [SnapshotBuilderConstructionTypeId]: Constructed } +type FromMethod, Result> = { + /** + * Constructs the selected state from its schema make input while the + * machine plans the resulting configuration. + * + * @since 4.0.0 + */ + readonly from: (...args: Arguments) => Machine.StateConstruction +} + +type ConstructionResult = Result | Machine.StateConstruction + +type UnwrapConstruction = Result extends Machine.StateConstruction ? Value : Result + type InitialSnapshotBuilderWithPrefix< States extends Machine.StateSchemas, Prefix extends string = "" @@ -545,9 +561,14 @@ type InitialSnapshotMethod< States extends Machine.StateSchemas, StateId extends Extract, Prefix extends string -> = ( - ...args: InitialSnapshotArguments -) => InitialSnapshotResult +> = + & (( + ...args: InitialSnapshotArguments + ) => InitialSnapshotResult) + & FromMethod< + InitialSnapshotFromArguments, + InitialSnapshotResult + > type InitialSnapshotArguments< States extends Machine.StateSchemas, @@ -572,6 +593,29 @@ type InitialSnapshotArguments< : [value: Machine.NodeSchema["Type"]] : never +type InitialSnapshotFromArguments< + States extends Machine.StateSchemas, + StateId extends Extract, + Prefix extends string, + Path extends string = Machine.JoinPath +> = States[StateId] extends infer Node ? + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? [ + input: Machine.NodeSchema["~type.make.in"], + states: ( + builder: InitialParallelBuilder + ) => SnapshotBuilderComplete, boolean> + ] + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? + Node extends { readonly initial: infer Initial extends Extract } ? [ + input: Machine.NodeSchema["~type.make.in"], + state: ( + builder: Pick, Initial> + ) => ConstructionResult> + ] + : never + : [input: Machine.NodeSchema["~type.make.in"]] + : never + type InitialSnapshotResult< States extends Machine.StateSchemas, StateId extends Extract, @@ -605,18 +649,32 @@ type InitialParallelBuilder< States extends Machine.StateSchemas, Prefix extends string, Remaining extends Extract = Extract, - Regions = {} + Regions = {}, + Constructed extends boolean = false > = - & SnapshotBuilderComplete + & SnapshotBuilderComplete & { - readonly [Key in Remaining]: ( - ...args: InitialSnapshotArguments - ) => InitialParallelBuilder< - States, - Prefix, - Exclude, - Regions & { readonly [Region in Key]: InitialSnapshotResult } - > + readonly [Key in Remaining]: + & (( + ...args: InitialSnapshotArguments + ) => InitialParallelBuilder< + States, + Prefix, + Exclude, + Regions & { readonly [Region in Key]: InitialSnapshotResult }, + Constructed + >) + & { + readonly from: ( + ...args: InitialSnapshotFromArguments + ) => InitialParallelBuilder< + States, + Prefix, + Exclude, + Regions & { readonly [Region in Key]: InitialSnapshotResult }, + true + > + } } type FullSnapshotBuilderWithPrefix< @@ -630,9 +688,14 @@ type FullSnapshotMethod< States extends Machine.StateSchemas, StateId extends Extract, Prefix extends string -> = ( - ...args: FullSnapshotArguments -) => FullSnapshotResult +> = + & (( + ...args: FullSnapshotArguments + ) => FullSnapshotResult) + & FromMethod< + FullSnapshotFromArguments, + FullSnapshotResult + > type FullSnapshotArguments< States extends Machine.StateSchemas, @@ -655,6 +718,27 @@ type FullSnapshotArguments< : [value: Machine.NodeSchema["Type"]] : never +type FullSnapshotFromArguments< + States extends Machine.StateSchemas, + StateId extends Extract, + Prefix extends string, + Path extends string = Machine.JoinPath +> = States[StateId] extends infer Node ? + Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? [ + input: Machine.NodeSchema["~type.make.in"], + states: ( + builder: FullParallelBuilder + ) => SnapshotBuilderComplete, boolean> + ] + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? [ + input: Machine.NodeSchema["~type.make.in"], + state: ( + builder: FullSnapshotBuilderWithPrefix + ) => ConstructionResult> + ] + : [input: Machine.NodeSchema["~type.make.in"]] + : never + type FullSnapshotResult< States extends Machine.StateSchemas, StateId extends Extract, @@ -666,18 +750,32 @@ type FullParallelBuilder< States extends Machine.StateSchemas, Prefix extends string, Remaining extends Extract = Extract, - Regions = {} + Regions = {}, + Constructed extends boolean = false > = - & SnapshotBuilderComplete + & SnapshotBuilderComplete & { - readonly [Key in Remaining]: ( - ...args: FullSnapshotArguments - ) => FullParallelBuilder< - States, - Prefix, - Exclude, - Regions & { readonly [Region in Key]: FullSnapshotResult } - > + readonly [Key in Remaining]: + & (( + ...args: FullSnapshotArguments + ) => FullParallelBuilder< + States, + Prefix, + Exclude, + Regions & { readonly [Region in Key]: FullSnapshotResult }, + Constructed + >) + & { + readonly from: ( + ...args: FullSnapshotFromArguments + ) => FullParallelBuilder< + States, + Prefix, + Exclude, + Regions & { readonly [Region in Key]: FullSnapshotResult }, + true + > + } } type ParentPath = Path extends `${infer Parent}.${infer Child}` @@ -746,35 +844,61 @@ type LocalTargetMethod< Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? - Source extends Path | `${Path}.${string}` ? >( - value: Machine.NodeSchema["Type"], - state: ( - builder: LocalTargetBuilderWithPrefix - ) => Result - ) => Result - : ( + Source extends Path | `${Path}.${string}` ? + & (>>( + value: Machine.NodeSchema["Type"], + state: ( + builder: LocalTargetBuilderWithPrefix + ) => Result + ) => Result) + & { + readonly from: >>( + input: Machine.NodeSchema["~type.make.in"], + state: ( + builder: LocalTargetBuilderWithPrefix + ) => Result + ) => Machine.StateConstruction> + } + : + & (( value: Machine.NodeSchema["Type"], states: ( builder: FullParallelBuilder ) => SnapshotBuilderComplete> - ) => Machine.Target> - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? < - Result extends LocalTargetResultWithPrefix< - AllStates, - Children, - Path + ) => Machine.Target>) + & FromMethod< + [ + input: Machine.NodeSchema["~type.make.in"], + states: ( + builder: FullParallelBuilder + ) => SnapshotBuilderComplete, boolean> + ], + Machine.Target> > - >( - value: Machine.NodeSchema["Type"], - state: ( - builder: LocalTargetBuilderWithPrefix - ) => Result - ) => Result - : (value: Machine.NodeSchema["Type"]) => Machine.Target> + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? + & (>>( + value: Machine.NodeSchema["Type"], + state: ( + builder: LocalTargetBuilderWithPrefix + ) => Result + ) => Result) + & { + readonly from: >>( + input: Machine.NodeSchema["~type.make.in"], + state: ( + builder: LocalTargetBuilderWithPrefix + ) => Result + ) => Machine.StateConstruction> + } + : + & ((value: Machine.NodeSchema["Type"]) => Machine.Target< + AllStates, + StateIdentifierFromPath + >) + & FromMethod< + [input: Machine.NodeSchema["~type.make.in"]], + Machine.Target> + > : never type LocalTargetBuilderForScope< @@ -790,12 +914,21 @@ type LocalTargetBuilderForScope< * * @since 4.0.0 */ - readonly with: >( - value: Machine.StateByIdentifier, - state: ( - builder: LocalTargetBuilderWithPrefix - ) => Result - ) => Result + readonly with: + & (>>( + value: Machine.StateByIdentifier, + state: ( + builder: LocalTargetBuilderWithPrefix + ) => Result + ) => Result) + & { + readonly from: >>( + input: Machine.SchemaByIdentifier["~type.make.in"], + state: ( + builder: LocalTargetBuilderWithPrefix + ) => Result + ) => Machine.StateConstruction> + } } : {} @@ -838,28 +971,62 @@ type BranchTargetMethod< > = States[StateId] extends infer Node ? Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? Source extends Path | `${Path}.${string}` ? - & (>( + & (>>( value: Machine.NodeSchema["Type"], state: ( builder: BranchTargetBuilderWithPrefix ) => Result ) => Result) + & { + readonly from: >>( + input: Machine.NodeSchema["~type.make.in"], + state: ( + builder: BranchTargetBuilderWithPrefix + ) => Result + ) => Machine.StateConstruction> + } & BranchTargetBuilderWithPrefix - : ( + : + & (( value: Machine.NodeSchema["Type"], states: ( builder: FullParallelBuilder ) => SnapshotBuilderComplete> - ) => Machine.Target> + ) => Machine.Target>) + & FromMethod< + [ + input: Machine.NodeSchema["~type.make.in"], + states: ( + builder: FullParallelBuilder + ) => SnapshotBuilderComplete, boolean> + ], + Machine.Target> + > : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - & (>( + & (>>( value: Machine.NodeSchema["Type"], state: ( builder: BranchTargetBuilderWithPrefix ) => Result ) => Result) + & { + readonly from: >>( + input: Machine.NodeSchema["~type.make.in"], + state: ( + builder: BranchTargetBuilderWithPrefix + ) => Result + ) => Machine.StateConstruction> + } & BranchTargetBuilderWithPrefix - : (value: Machine.NodeSchema["Type"]) => Machine.Target> + : + & ((value: Machine.NodeSchema["Type"]) => Machine.Target< + AllStates, + StateIdentifierFromPath + >) + & FromMethod< + [input: Machine.NodeSchema["~type.make.in"]], + Machine.Target> + > : never type BranchTargetBuilderForRoot< @@ -2337,6 +2504,20 @@ export declare namespace Machine { Tag extends TagOf > = Extract, { readonly _tag: Tag }> + /** + * Opaque state construction returned by a builder's `.from` method. + * + * The machine resolves the instruction through the selected state schema + * while planning. Its decoded value is intentionally unavailable until + * planning succeeds. + * + * @category models + * @since 4.0.0 + */ + export interface StateConstruction { + readonly [Model.StateConstructionTypeId]: Result + } + /** * Machine-bound target instruction accepted from transition handlers. * @@ -2714,7 +2895,8 @@ export declare namespace Machine { */ export type InitialResult = | Snapshot - | Effect.Effect, E, R> + | StateConstruction> + | Effect.Effect | StateConstruction>, E, R> /** * Return value accepted from transition handlers. @@ -2731,8 +2913,16 @@ export declare namespace Machine { export type HandlerResult = | Snapshot | Target> + | StateConstruction | Target>> | void - | Effect.Effect | Target> | void, E, R> + | Effect.Effect< + | Snapshot + | Target> + | StateConstruction | Target>> + | void, + E, + R + > /** * Extracts the union of handler return values from a handler map. @@ -3868,14 +4058,26 @@ type SnapshotBuilderOptions = { readonly prefix: string } +const withFrom = ) => unknown>( + method: Method +): Method & { readonly from: (input: unknown, ...args: ReadonlyArray) => unknown } => { + Object.defineProperty(method, "from", { + value: (input: unknown, ...args: ReadonlyArray) => + Model.markStateConstruction(method(Model.makeStateInput(input), ...args)), + enumerable: false + }) + return method as Method & { readonly from: (input: unknown, ...args: ReadonlyArray) => unknown } +} + const makeSnapshotBuilder = ( states: Machine.StateTree, options: SnapshotBuilderOptions ): unknown => { const builder: Record = {} for (const key of Object.keys(states)) { - builder[key] = (value: unknown, selector?: (builder: unknown) => unknown) => + builder[key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => makeSnapshotForNode(states[key], key, value, selector, options) + ) } return builder } @@ -3890,18 +4092,23 @@ const makeParallelSnapshotBuilder = ( value: regions, enumerable: false }) + Object.defineProperty(builder, SnapshotBuilderConstructionTypeId, { + value: Model.isStateConstruction(builder), + enumerable: false + }) for (const key of Object.keys(states)) { if (hasProperty(regions, key)) { continue } - builder[key] = (value: unknown, selector?: (builder: unknown) => unknown) => { + builder[key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { const nextRegions: Record = {} for (const regionKey of Object.keys(regions)) { nextRegions[regionKey] = regions[regionKey] } nextRegions[key] = makeSnapshotForNode(states[key], key, value, selector, options) - return makeParallelSnapshotBuilder(states, options, nextRegions) - } + const next = makeParallelSnapshotBuilder(states, options, nextRegions) + return Model.isStateConstruction(builder) ? Model.markStateConstruction(next) : next + }) } return builder } @@ -3946,14 +4153,16 @@ const makeSnapshotForNode = ( } if (node.type === "parallel") { const builder = makeParallelSnapshotBuilder(node.states, { ...options, prefix: path }, {}) - snapshot.states = getParallelSnapshotBuilderRegions(path, node.states, selector(builder)) - return snapshot + const selected = selector(builder) + snapshot.states = getParallelSnapshotBuilderRegions(path, node.states, selected) + return Model.isStateConstruction(selected) ? Model.markStateConstruction(snapshot) : snapshot } const childStates = options.mode === "initial" && node.initial !== undefined ? { [node.initial]: node.states[node.initial] } : node.states - snapshot.state = selector(makeSnapshotBuilder(childStates, { ...options, prefix: path })) - return snapshot + const selected = selector(makeSnapshotBuilder(childStates, { ...options, prefix: path })) + snapshot.state = selected + return Model.isStateConstruction(selected) ? Model.markStateConstruction(snapshot) : snapshot } const getTargetBuilderNode = ( @@ -4066,7 +4275,7 @@ const makeLocalTargetChildBuilder = ( const builder: Record = {} for (const childPath of parent.children) { const child = getTargetBuilderNode(stateNodes, childPath) - builder[child.key] = (value: unknown, selector?: (builder: unknown) => unknown) => { + builder[child.key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { if (child.type === "atomic" || child.type === "final") { return makeTargetWithValues(child.path, value, values) } @@ -4095,7 +4304,7 @@ const makeLocalTargetChildBuilder = ( extendTargetValues(values, child.path, value), source )) - } + }) } return builder } @@ -4110,12 +4319,12 @@ const makeLocalTargetBuilder = ( return {} } const builder = makeLocalTargetChildBuilder(states, stateNodes, scope, undefined, source) as Record - builder.with = (value: unknown, selector?: (builder: unknown) => unknown) => { + builder.with = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { if (selector === undefined) { throw new Error(`Machine expected target "${scope}" builder to provide an active child state`) } return selector(makeLocalTargetChildBuilder(states, stateNodes, scope, { [scope]: value }, source)) - } + }) return builder } @@ -4143,9 +4352,9 @@ const makeBranchTargetNodeBuilder = ( ): unknown => { const node = getTargetBuilderNode(stateNodes, path) if (node.type === "atomic" || node.type === "final") { - return (value: unknown) => makeTargetWithValues(node.path, value, values) + return withFrom((value: unknown) => makeTargetWithValues(node.path, value, values)) } - const builder = ((value: unknown, selector?: (builder: unknown) => unknown) => { + const builder = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => { if (node.type === "parallel") { if (source !== node.path && !source.startsWith(`${node.path}.`)) { return makeParallelTarget(states, node, value, selector, values) diff --git a/src/internal/machineModel.ts b/src/internal/machineModel.ts index 30532a7..103af88 100644 --- a/src/internal/machineModel.ts +++ b/src/internal/machineModel.ts @@ -14,6 +14,13 @@ import { MachineSchemaDecodeError, MachineSchemaEncodeError } from "./machineErr export const TargetTypeId = "~effect/Machine/Target" export const TargetSnapshotTypeId: unique symbol = Symbol("effect/Machine/TargetSnapshot") +export const StateInputTypeId: unique symbol = Symbol("effect/Machine/StateInput") +export const StateConstructionTypeId: unique symbol = Symbol("effect/Machine/StateConstruction") + +interface StateInput { + readonly [StateInputTypeId]: typeof StateInputTypeId + readonly input: unknown +} export const getStateNodeDefinition = ( path: string, @@ -144,6 +151,25 @@ export const makeTarget = < export const isTarget = (u: unknown): u is Machine.Target => hasProperty(u, TargetTypeId) +export const makeStateInput = (input: unknown): StateInput => ({ + [StateInputTypeId]: StateInputTypeId, + input +}) + +const isStateInput = (u: unknown): u is StateInput => hasProperty(u, StateInputTypeId) + +export const markStateConstruction = (value: A): A => { + if ((typeof value === "object" && value !== null) || typeof value === "function") { + Object.defineProperty(value, StateConstructionTypeId, { + value: StateConstructionTypeId, + enumerable: false + }) + } + return value +} + +export const isStateConstruction = (u: unknown): boolean => hasProperty(u, StateConstructionTypeId) + export const isSnapshot = (u: unknown): u is Machine.AtomicSnapshot => hasProperty(u, "path") && hasProperty(u, "value") @@ -289,7 +315,18 @@ export const decodeStateValue = ( node: Machine.StateNode, value: unknown ): Effect.Effect => - decodeBoundary(machine, node.schema, value, { boundary: "state", state: node.path }) + isStateInput(value) + ? node.schema.makeEffect(value.input).pipe( + Effect.mapError((cause) => + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "state", + state: node.path, + cause + }) + ) + ) + : decodeBoundary(machine, node.schema, value, { boundary: "state", state: node.path }) export const decodeOutputValue = ( machine: Machine.Any, diff --git a/src/internal/machinePlanner.ts b/src/internal/machinePlanner.ts index 987c252..280c412 100644 --- a/src/internal/machinePlanner.ts +++ b/src/internal/machinePlanner.ts @@ -969,7 +969,7 @@ export const planInitial: < ExcludeCompatibleRuntime, Machine.EmitOf> >) : result - const configuration = yield* normalizeConfigurationEffect(machine, state) + const configuration = yield* normalizeConfigurationEffect(machine, state as Machine.Snapshot) validateInitialConfiguration(machine, configuration) const actions = yield* deferredActions.read const raisedEvents = yield* deferredRaisedEvents.read diff --git a/test/Machine.test.ts b/test/Machine.test.ts index 14612c5..2d7588c 100644 --- a/test/Machine.test.ts +++ b/test/Machine.test.ts @@ -156,6 +156,14 @@ describe("Machine", () => { requestId: Schema.NonEmptyString }) {} + class DefaultedIdle extends Schema.TaggedClass("DefaultedIdle")("DefaultedIdle", { + id: Schema.String, + label: Schema.String.pipe( + Schema.optionalKey, + Schema.withConstructorDefault(Effect.succeed("default-label")) + ) + }) {} + class Success extends Schema.TaggedClass("Success")("Success", { requestId: Schema.String }) {} @@ -545,6 +553,290 @@ describe("Machine", () => { }) })) + describe("state builder from", () => { + it.effect("constructs TaggedClass initial state and applies constructor defaults", () => + Effect.gen(function*() { + const states = Machine.defineStates({ idle: DefaultedIdle }) + const machine = Machine.make({ + id: "from-default", + states: states.states, + events: [], + initial: () => states.initial.idle.from({ id: "idle-1" }) + }) + + const planned = yield* Machine.planInitial(machine) + + assert.instanceOf(planned.state.value, DefaultedIdle) + assert.deepStrictEqual(planned.state.value, new DefaultedIdle({ id: "idle-1", label: "default-label" })) + })) + + it.effect("constructs TaggedUnion states without requiring discriminator fields", () => + Effect.gen(function*() { + const State = Schema.TaggedUnion({ + Idle: {}, + Done: { requestId: Schema.String } + }) + const Event = Schema.TaggedUnion({ + Submit: { requestId: Schema.String } + }) + const states = Machine.defineStates({ + Idle: State.cases.Idle, + Done: { + schema: State.cases.Done, + type: "final" + } + }) + const machine = Machine.make({ + states: states.states, + events: [Event], + initial: () => states.initial.Idle.from({}) + }).handle({ + Idle: { + on: { + Submit: ({ event, target }) => target.full.Done.from({ requestId: event.requestId }) + } + }, + Done: {} + }) + const initial = yield* Machine.planInitial(machine) + + const planned = yield* Machine.plan( + machine, + initial.state, + Event.cases.Submit.make({ requestId: "request-1" }) + ) + + assert.deepStrictEqual(initial.state.value, State.cases.Idle.make({})) + assert.deepStrictEqual(planned.next.value, State.cases.Done.make({ requestId: "request-1" })) + assert.isTrue(planned.done) + })) + + it.effect("fails invalid refinement input through MachineSchemaDecodeError without throwing in the builder", () => + Effect.gen(function*() { + const states = Machine.defineStates({ NonEmptyIdle }) + const invalid = states.initial.NonEmptyIdle.from({ userId: "" }) + const machine = Machine.make({ + id: "from-refinement", + states: states.states, + events: [], + initial: () => invalid + }) + + const error = yield* Effect.flip(Machine.planInitial(machine)) + + assertMachineSchemaDecodeError(error, "state", { state: "NonEmptyIdle" }) + assert.strictEqual((error as Machine.MachineSchemaDecodeError).machineId, "from-refinement") + })) + + it.effect("fails invalid transition construction in the typed machine error channel", () => + Effect.gen(function*() { + const states = Machine.defineStates({ NonEmptyIdle, NonEmptyLoading }) + const machine = Machine.make({ + id: "from-transition-refinement", + states: states.states, + events: [NonEmptySubmit], + initial: () => states.initial.NonEmptyIdle.from({ userId: "user-1" }) + }).handle({ + NonEmptyIdle: { + on: { + NonEmptySubmit: ({ target }) => target.full.NonEmptyLoading.from({ requestId: "" }) + } + } + }) + const initial = yield* Machine.planInitial(machine) + + const error = yield* Effect.flip( + Machine.plan( + machine, + initial.state, + new NonEmptySubmit({ value: "request-1" }) + ) + ) + + assertMachineSchemaDecodeError(error, "state", { state: "NonEmptyLoading" }) + assert.strictEqual((error as Machine.MachineSchemaDecodeError).machineId, "from-transition-refinement") + })) + + it.effect("constructs complete compound and parallel targets from schema input", () => + Effect.gen(function*() { + const states = Machine.defineStates({ + idle: Idle, + fulfillment: { + schema: Fulfillment, + type: "parallel", + states: { + inventory: { + schema: Inventory, + initial: "checking", + states: { + checking: CheckingInventory, + reserved: InventoryReserved + } + }, + shipping: { + schema: Shipping, + initial: "quoting", + states: { + quoting: QuotingShipping, + quoted: ShippingQuoted + } + } + } + } + }) + const machine = Machine.make({ + states: states.states, + events: [Submit], + initial: () => states.initial.idle.from({ userId: "user-1" }) + }).handle({ + idle: { + on: { + Submit: ({ event, target }) => + target.full.fulfillment.from( + { id: event.value }, + (fulfillment) => + fulfillment + .inventory.from( + { warehouse: "warehouse-1" }, + (inventory) => inventory.reserved.from({ reservationId: event.value }) + ) + .shipping.from( + { address: "Main Street" }, + (shipping) => shipping.quoted.from({ quoteId: event.value }) + ) + ) + } + } + }) + const initial = yield* Machine.planInitial(machine) + + const planned = yield* Machine.plan(machine, initial.state, new Submit({ value: "order-1" })) + + assertParallelStateSnapshot(planned.next as any, "fulfillment", new Fulfillment({ id: "order-1" }), { + inventory: { + path: "fulfillment.inventory", + value: new Inventory({ warehouse: "warehouse-1" }), + state: { + path: "fulfillment.inventory.reserved", + value: new InventoryReserved({ reservationId: "order-1" }) + } + }, + shipping: { + path: "fulfillment.shipping", + value: new Shipping({ address: "Main Street" }), + state: { + path: "fulfillment.shipping.quoted", + value: new ShippingQuoted({ quoteId: "order-1" }) + } + } + }) + })) + + it.effect("constructs local parent replacement and leaf targets from schema input", () => + Effect.gen(function*() { + const states = Machine.defineStates({ + payment: { + schema: Payment, + initial: "entering", + states: { + entering: EnteringPayment, + authorized: AuthorizedPayment + } + } + }) + const machine = Machine.make({ + states: states.states, + events: [Submit], + initial: () => + states.initial.payment.from( + { id: "payment-1" }, + (payment) => payment.entering.from({ amount: 1 }) + ) + }).handle({ + payment: { + states: { + entering: { + on: { + Submit: ({ event, target }) => + target.local.with.from( + { id: "payment-2" }, + (payment) => payment.authorized.from({ code: event.value }) + ) + } + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + + const planned = yield* Machine.plan(machine, initial.state, new Submit({ value: "auth-1" })) + + assertCompoundStateSnapshot(planned.next as any, "payment", new Payment({ id: "payment-2" }), { + path: "payment.authorized", + value: new AuthorizedPayment({ code: "auth-1" }) + }) + })) + + it.effect("constructs cross-branch ancestor and leaf values from schema input", () => + Effect.gen(function*() { + const states = Machine.defineStates({ + workflow: { + schema: Payment, + initial: "idle", + states: { + idle: Idle, + checkout: { + schema: Fulfillment, + initial: "quoted", + states: { + quoted: ShippingQuoted + } + } + } + } + }) + const machine = Machine.make({ + states: states.states, + events: [Submit], + initial: () => + states.initial.workflow.from( + { id: "workflow-1" }, + (workflow) => workflow.idle.from({ userId: "user-1" }) + ) + }).handle({ + workflow: { + states: { + idle: { + on: { + Submit: ({ event, target }) => + target.branch.workflow.from( + { id: "workflow-2" }, + (workflow) => + workflow.checkout.from( + { id: "checkout-1" }, + (checkout) => checkout.quoted.from({ quoteId: event.value }) + ) + ) + } + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + + const planned = yield* Machine.plan(machine, initial.state, new Submit({ value: "quote-1" })) + + assertCompoundStateSnapshot(planned.next as any, "workflow", new Payment({ id: "workflow-2" }), { + path: "workflow.checkout", + value: new Fulfillment({ id: "checkout-1" }), + state: { + path: "workflow.checkout.quoted", + value: new ShippingQuoted({ quoteId: "quote-1" }) + } + }) + })) + }) + describe("runtime schema contracts", () => { it.effect("decodes input before initial state construction", () => Effect.gen(function*() { diff --git a/typetest/Machine.tst.ts b/typetest/Machine.tst.ts index 33f135d..8eb6580 100644 --- a/typetest/Machine.tst.ts +++ b/typetest/Machine.tst.ts @@ -1664,6 +1664,134 @@ describe("Machine", () => { expect(snapshot.states.sync.state.path).type.toBe<"up.sync.idle" | "up.sync.syncing">() }) + it("state builders construct from exact schema make input", () => { + const initial = UpStates.initial.up.from( + { id: "up-1" }, + (up) => + up + .auth.from( + { userId: "guest" }, + (auth) => auth.signedOut.from({}) + ) + .sync.from( + { enabled: true }, + (sync) => sync.idle.from({}) + ) + ) + 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 localWith = context.target.local.with.from( + { userId: "user-1" }, + (auth) => auth.signedIn.from({ userId: "user-1" }) + ) + const branch = context.target.branch.up.from( + { id: "up-2" }, + (up) => + up.auth.from( + { userId: "user-1" }, + (auth) => auth.signedIn.from({ userId: "user-1" }) + ) + ) + + expect(initial).type.toBeAssignableTo< + Machine.Machine.StateConstruction< + Machine.Machine.SnapshotByIdentifier + > + >() + expect(local).type.toBeAssignableTo< + Machine.Machine.StateConstruction< + Machine.Machine.Target + > + >() + expect(local).type.not.toHaveProperty("path") + expect(local).type.not.toHaveProperty("value") + expect(localWith).type.not.toHaveProperty("path") + expect(branch).type.not.toHaveProperty("path") + expect(full).type.toBeAssignableTo< + Machine.Machine.StateConstruction< + Machine.Machine.SnapshotByIdentifier + > + >() + expect(full).type.not.toHaveProperty("value") + + expect(UpStates.initial.up.from).type.not.toBeCallableWith( + { id: 1 }, + (up: ChildBuilder) => + up + .auth.from({ userId: "guest" }, (auth) => auth.signedOut.from({})) + .sync.from({ enabled: true }, (sync) => sync.idle.from({})) + ) + expect(context.target.local.signedIn.from).type.not.toBeCallableWith({}) + expect(context.target.local.signedIn.from).type.not.toBeCallableWith({ userId: 1 }) + expect(context.target.local.with.from).type.not.toBeCallableWith( + { id: "wrong-parent" }, + (auth: ChildBuilder) => auth.signedOut.from({}) + ) + }) + + it("from preserves parallel-region exhaustiveness", () => { + const context = null as unknown as NestedIdleContext + const activeContext = null as unknown as NestedActiveContext + const work = null as unknown as ChildBuilder + const afterAuth = work.auth.from( + { userId: "guest" }, + (auth) => auth.signedOut.from({}) + ) + const target = context.target.local.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( + { enabled: true }, + (sync) => sync.syncing.from({ requestId: "sync-1" }) + ) + + expect(afterAuth).type.not.toHaveProperty("auth") + expect(afterAuth).type.toHaveProperty("sync") + expect(target).type.toBeAssignableTo< + Machine.Machine.StateConstruction< + Machine.Machine.Target + > + >() + expect(target).type.not.toHaveProperty("path") + expect(partial).type.toBeAssignableTo< + Machine.Machine.StateConstruction< + Machine.Machine.Target + > + >() + expect(context.target.local.work.from).type.not.toBeCallableWith( + {}, + (work: ChildBuilder) => + work.auth.from({ userId: "guest" }, (auth) => auth.signedOut.from({})) + ) + }) + + it("from supports TaggedUnion constructor inputs without a tag", () => { + const State = Schema.TaggedUnion({ + Idle: {}, + Active: { requestId: Schema.String } + }) + const States = Machine.defineStates({ + Idle: State.cases.Idle, + Active: State.cases.Active + }) + const initial = States.initial.Idle.from({}) + + expect(initial).type.toBeAssignableTo< + Machine.Machine.StateConstruction< + Machine.Machine.AtomicSnapshot<"Idle", typeof State.cases.Idle.Type> + > + >() + expect(initial).type.not.toHaveProperty("value") + expect(States.initial.Active.from).type.toBeCallableWith({ requestId: "request-1" }) + expect(States.initial.Active.from).type.not.toBeCallableWith({}) + expect(States.initial.Active.from).type.not.toBeCallableWith({ requestId: 1 }) + }) + it("target.full requires every parallel region", () => { const context = null as unknown as SignInContext