From c73be6dfebb84f7c7e860023c773270c4f9e159c Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 9 Sep 2026 18:31:14 +0200 Subject: [PATCH 1/2] Move initial edges and data construction into handlers --- .changeset/handler-owned-initial-edges.md | 19 + README.md | 12 +- api-reference.config.json | 6 +- .../src/internal/browser/example-machine.ts | 121 +- .../browser/hierarchy-routing-example.ts | 41 +- .../browser/invoke-outcomes-example.ts | 174 +- .../browser/layout-resilience-example.ts | 89 +- .../browser/parallel-completion-example.ts | 158 +- .../browser/parallel-owner-routing-example.ts | 114 +- .../src/internal/browser/planner-example.ts | 83 +- .../browser/protocol-events-example.ts | 168 +- .../shared-terminal-routing-example.ts | 24 +- .../browser/transition-semantics-example.ts | 171 +- .../InteractiveTextVisualization.test.ts | 2 +- .../test/MachineAtom.test.tsx | 73 +- .../test/RootContext.test.tsx | 29 +- .../typetest/MachineAtom.tst.ts | 39 +- packages/effect-machine/README.md | 122 +- .../effect-machine/perf/runtime/counter.mjs | 3 + .../runtime/effect-machine-compatibility.mjs | 46 +- .../perf/types/adapter-readiness-control.ts | 18 +- .../perf/types/composition-control.ts | 23 +- .../effect-machine/perf/types/composition.ts | 84 +- .../perf/types/define-states.ts | 2 +- .../perf/types/definition-variants-control.ts | 11 +- .../perf/types/definition-variants.ts | 22 +- .../perf/types/dynamic-invoke-control.ts | 11 +- .../perf/types/dynamic-invoke.ts | 13 +- .../perf/types/exact-channels-control.ts | 11 +- .../perf/types/exact-channels.ts | 30 +- .../perf/types/handle-depth-24-control.ts | 31 +- .../perf/types/handle-depth-24.ts | 93 +- .../types/handle-depth-wide-16-control.ts | 23 +- .../perf/types/handle-depth-wide-16.ts | 39 +- packages/effect-machine/perf/types/handle.ts | 17 +- .../effect-machine/perf/types/make-control.ts | 2 +- packages/effect-machine/perf/types/make.ts | 2 +- .../perf/types/named-branches-control.ts | 9 +- .../perf/types/named-branches.ts | 9 +- .../types/transition-construction-control.ts | 4 +- .../perf/types/transition-construction.ts | 19 +- packages/effect-machine/src/Machine.ts | 1215 ++--- .../src/internal/machine/configuration.ts | 20 - .../src/internal/machine/executionPlan.ts | 12 +- .../internal/machine/initialDeclaration.ts | 157 + .../src/internal/machine/machine.ts | 217 +- .../src/internal/machine/planner.ts | 90 +- .../src/internal/machine/protocol.ts | 6 +- .../src/internal/machine/stateDefinition.ts | 16 +- .../src/internal/machine/targetReference.ts | 23 +- .../src/internal/machine/topology.ts | 5 +- .../internal/testing/machine/finiteModel.ts | 26 +- .../internal/testing/machine/verification.ts | 8 +- .../effect-machine/src/testing/MachineTest.ts | 178 +- .../src/unstable/cluster/ClusterMachine.ts | 21 +- .../src/unstable/reactivity/AtomMachine.ts | 65 +- packages/effect-machine/test/examples/can.ts | 11 +- .../effect-machine/test/examples/guard.ts | 7 +- .../machine/ProtocolValidation.test.ts | 12 +- .../test/internal/machine/activities.test.ts | 122 +- .../test/internal/machine/protocol.test.ts | 65 +- .../internal/machine/rootStrategies.test.ts | 95 +- .../machine/strategyDifferential.test.ts | 709 +-- .../transitionConstructionStrategies.test.ts | 35 +- .../machine/ActivityLifecycleModel.test.ts | 351 +- .../test/machine/Annotations.test.ts | 40 +- .../machine/AnnotationsVisualization.test.ts | 48 +- .../test/machine/Choice.test.ts | 343 +- .../test/machine/Declarative.test.ts | 87 +- .../test/machine/DeepHandlers.test.ts | 99 +- .../test/machine/DynamicChildren.test.ts | 285 +- .../test/machine/History.test.ts | 251 +- .../test/machine/InitialDeclaration.test.ts | 135 + .../test/machine/InitialEntry.test.ts | 235 +- .../test/machine/Inspection.test.ts | 74 +- .../test/machine/Invoke.test.ts | 178 +- .../test/machine/LiveInspection.test.ts | 105 +- .../test/machine/LocalTargetWith.test.ts | 59 +- .../test/machine/Machine.test.ts | 4508 +++++++++-------- .../test/machine/MachineReferences.test.ts | 236 +- .../test/machine/ProtocolOwnership.test.ts | 55 +- .../test/machine/PublicPrototype.test.ts | 17 +- .../test/machine/Resume.test.ts | 406 +- .../effect-machine/test/machine/Root.test.ts | 168 +- .../test/machine/RuntimeDifferential.test.ts | 220 +- .../test/machine/Scheduling.test.ts | 33 +- .../machine/SnapshotCodecAdversarial.test.ts | 306 +- .../test/machine/SnapshotContext.test.ts | 192 +- .../test/machine/SnapshotStructure.test.ts | 30 +- .../test/machine/StateDefinition.test.ts | 188 +- .../test/machine/StateUpdate.test.ts | 262 +- .../test/machine/StructuralStates.test.ts | 109 +- .../test/machine/Targets.test.ts | 6 +- .../test/machine/Totality.test.ts | 261 +- .../machine/TransitionConstruction.test.ts | 89 +- .../test/machine/Visualization.test.ts | 158 +- .../test/testing/Coverage.test.ts | 164 +- .../test/testing/Exploration.test.ts | 144 +- .../test/testing/Invariant.test.ts | 76 +- .../test/testing/MachineTest.test.ts | 148 +- .../effect-machine/test/testing/Probe.test.ts | 70 +- .../test/testing/ReferenceModel.test.ts | 280 +- .../test/testing/Runtime.test.ts | 541 +- .../test/testing/TraceValues.test.ts | 40 +- .../test/testing/Verification.test.ts | 402 +- .../unstable/cluster/ClusterMachine.test.ts | 206 +- .../unstable/reactivity/AtomMachine.test.ts | 432 +- .../typetest/machine/Activities.tst.ts | 27 +- .../typetest/machine/Annotations.tst.ts | 33 +- .../typetest/machine/Choice.tst.ts | 184 +- .../typetest/machine/ConsumerTypes.tst.ts | 53 +- .../typetest/machine/Declarative.tst.ts | 351 +- .../typetest/machine/DeepHandlers.tst.ts | 438 +- .../typetest/machine/DynamicChildren.tst.ts | 151 +- .../typetest/machine/EventByTag.tst.ts | 46 +- .../typetest/machine/EventConstructors.tst.ts | 115 +- .../typetest/machine/History.tst.ts | 866 +++- .../machine/InitialDeclaration.tst.ts | 158 + .../typetest/machine/InitialEntry.tst.ts | 95 +- .../typetest/machine/Inspection.tst.ts | 129 +- .../typetest/machine/Machine.tst.ts | 4083 +++++++++++---- .../typetest/machine/MachineReferences.tst.ts | 211 +- .../typetest/machine/Readiness.tst.ts | 143 +- .../typetest/machine/Resume.tst.ts | 33 +- .../typetest/machine/Root.tst.ts | 97 +- .../typetest/machine/SnapshotContext.tst.ts | 63 +- .../typetest/machine/StateDefinition.tst.ts | 61 +- .../typetest/machine/StateUpdate.tst.ts | 554 +- .../typetest/machine/StructuralStates.tst.ts | 143 +- .../machine/TransitionConstruction.tst.ts | 267 +- .../typetest/testing/Coverage.tst.ts | 43 +- .../typetest/testing/Exploration.tst.ts | 54 +- .../typetest/testing/Invariant.tst.ts | 30 +- .../typetest/testing/MachineTest.tst.ts | 110 +- .../typetest/testing/Probe.tst.ts | 37 +- .../typetest/testing/Verification.tst.ts | 27 +- .../unstable/cluster/ClusterMachine.tst.ts | 306 +- .../unstable/reactivity/AtomMachine.tst.ts | 313 +- packages/oxlint-plugin/README.md | 6 +- .../oxlint-plugin/src/internal/planning.ts | 31 +- .../test/noAsyncPlanningCallback.test.ts | 40 +- .../test/noBrowserApiInPlanning.test.ts | 12 +- .../noConflictingInvocationIdentity.test.ts | 20 +- .../test/noNondeterministicPlanning.test.ts | 14 +- .../test/noRedundantResolve.test.ts | 40 +- scripts/devtools-pack-check.mjs | 4 +- scripts/fixtures/consumer/consumer.ts | 23 +- scripts/fixtures/consumer/deep-bound.ts | 122 +- scripts/invoke-autocomplete.test.mjs | 72 +- scripts/oxlint-plugin-pack-check.mjs | 4 +- ...runtime-performance-compatibility.test.mjs | 29 + 151 files changed, 15811 insertions(+), 10946 deletions(-) create mode 100644 .changeset/handler-owned-initial-edges.md create mode 100644 packages/effect-machine/src/internal/machine/initialDeclaration.ts create mode 100644 packages/effect-machine/test/machine/InitialDeclaration.test.ts create mode 100644 packages/effect-machine/typetest/machine/InitialDeclaration.tst.ts diff --git a/.changeset/handler-owned-initial-edges.md b/.changeset/handler-owned-initial-edges.md new file mode 100644 index 00000000..72865501 --- /dev/null +++ b/.changeset/handler-owned-initial-edges.md @@ -0,0 +1,19 @@ +--- +"@typeonce/effect-machine": minor +"@typeonce/effect-machine-react": minor +"@typeonce/effect-machine-devtools": minor +"@typeonce/oxlint-plugin-effect-machine": minor +--- + +Declare initial edges and data together in `.handle`. Remove `initial` from `Machine.state`, remove `initial` and `initialConfiguration` from `Machine.make`, and move shared startup data into the root handler's `root` value or input callback. Each compound declares `initial: { target, data? }`; parallel handlers use an `initial` map of region data. Initial targets must be direct children and remain inspectable without executing constructors. + +```ts +const root = Machine.state({ states: { Locked: {}, Unlocked: {} } }) +const targets = Machine.targets(root) +const machine = Machine.make({ root, events: Machine.events({ Coin: {} }) }).handle({ + initial: { target: targets.root.Locked }, + states: { Locked: { on: { Coin: { target: targets.root.Unlocked } } } } +}) +``` + +Replace declarative `from` callbacks with `data`, which accepts literals or callbacks. Supply already-decoded values with `{ decoded: true, data: valueOrCallback }`. For root or region data that itself contains `decoded: true` and `data` fields, use a callback returning the ordinary schema input. Bound branch and history builders retain `.from` and `.decoded`. Replace command-producing `initialize` callbacks with `entry`/`exit` handlers or invocations; definitions become executable only after `.handle` captures their initial declarations. diff --git a/README.md b/README.md index da6db4ba..296787d4 100644 --- a/README.md +++ b/README.md @@ -11,21 +11,23 @@ Effect-native, schema-first, completely type-safe state machines and statecharts ```ts import { Machine } from "@typeonce/effect-machine" import { Effect } from "effect" - const Root = Machine.state({ - initial: "Locked", states: { Locked: {}, Unlocked: {} } }) const targets = Machine.targets(Root) const Events = Machine.events({ Coin: {}, Push: {} }) - -const Turnstile = Machine.make({ root: Root, events: Events }).handle({ +const Turnstile = Machine.make({ + root: Root, + events: Events +}).handle({ + initial: { + target: targets.root.Locked + }, states: { Locked: { on: { Coin: { target: targets.root.Unlocked } } }, Unlocked: { on: { Push: { target: targets.root.Locked } } } } }) - const program = Effect.gen(function*() { const ref = yield* Machine.start(Turnstile) yield* ref.send(Events.Coin()) diff --git a/api-reference.config.json b/api-reference.config.json index 8bd3212f..1d70cab8 100644 --- a/api-reference.config.json +++ b/api-reference.config.json @@ -136,16 +136,12 @@ { "owner": "make", "title": "Machine definition configuration", - "description": "Parameters accepted by `Machine.make` and its initial-state declaration.", + "description": "Protocols, root topology, registered sources, and named branches accepted by Machine.make.", "roots": [ { "declaration": "make", "parameter": "config", "label": "Configuration" - }, - { - "reflection": "Machine.InitialTransitionTarget", - "label": "Initial target resolution" } ] }, diff --git a/packages/devtools/src/internal/browser/example-machine.ts b/packages/devtools/src/internal/browser/example-machine.ts index 12253aaf..05fec15f 100644 --- a/packages/devtools/src/internal/browser/example-machine.ts +++ b/packages/devtools/src/internal/browser/example-machine.ts @@ -1,31 +1,41 @@ import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" - // Shared by the live browser UI and its project-inspection fixture. - class Application extends Schema.TaggedClass("Application")("Application", { workspace: Schema.String, revision: Schema.Number -}) {} +}) { +} class Workflow extends Schema.TaggedClass("Workflow")("Workflow", { document: Schema.String, unsavedChanges: Schema.Number -}) {} -class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} -class Running extends Schema.TaggedClass("Running")("Running", {}) {} -class Editing extends Schema.TaggedClass("Editing")("Editing", {}) {} -class Complete extends Schema.TaggedClass("Complete")("Complete", {}) {} -class Connection extends Schema.TaggedClass("Connection")("Connection", {}) {} -class Online extends Schema.TaggedClass("Online")("Online", {}) {} -class Offline extends Schema.TaggedClass("Offline")("Offline", {}) {} -class Disabled extends Schema.TaggedClass("Disabled")("Disabled", {}) {} -class Start extends Schema.TaggedClass("Start")("Start", {}) {} -class Finish extends Schema.TaggedClass("Finish")("Finish", {}) {} -class Disconnect extends Schema.TaggedClass("Disconnect")("Disconnect", {}) {} -class Refresh extends Schema.TaggedClass("Refresh")("Refresh", {}) {} - +}) { +} +class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { +} +class Running extends Schema.TaggedClass("Running")("Running", {}) { +} +class Editing extends Schema.TaggedClass("Editing")("Editing", {}) { +} +class Complete extends Schema.TaggedClass("Complete")("Complete", {}) { +} +class Connection extends Schema.TaggedClass("Connection")("Connection", {}) { +} +class Online extends Schema.TaggedClass("Online")("Online", {}) { +} +class Offline extends Schema.TaggedClass("Offline")("Offline", {}) { +} +class Disabled extends Schema.TaggedClass("Disabled")("Disabled", {}) { +} +class Start extends Schema.TaggedClass("Start")("Start", {}) { +} +class Finish extends Schema.TaggedClass("Finish")("Finish", {}) { +} +class Disconnect extends Schema.TaggedClass("Disconnect")("Disconnect", {}) { +} +class Refresh extends Schema.TaggedClass("Refresh")("Refresh", {}) { +} const States = Machine.state({ - initial: "application", states: { application: { schema: Application, @@ -33,12 +43,10 @@ const States = Machine.state({ states: { workflow: { schema: Workflow, - initial: "idle", states: { idle: Idle, running: { schema: Running, - initial: "editing", states: { editing: Editing, complete: { @@ -54,7 +62,6 @@ const States = Machine.state({ }, connection: { schema: Connection, - initial: "online", states: { online: Online, offline: Offline @@ -65,7 +72,6 @@ const States = Machine.state({ disabled: Disabled } }) - export const snapshot = { path: "" as const, value: undefined, @@ -86,12 +92,9 @@ export const snapshot = { } } } - -const initialWorkflow = (): Machine.Machine.CompleteSnapshotContaining< - { readonly "": typeof States.node }, - "application.workflow" -> => snapshot - +const initialWorkflow = (): Machine.Machine.CompleteSnapshotContaining<{ + readonly "": typeof States.node +}, "application.workflow"> => snapshot const targets1 = Machine.targets(States) export const machine = Machine.make({ branches: { @@ -99,16 +102,34 @@ export const machine = Machine.make({ destination: { target: targets1.root.application.workflow.running, update: targets1.root.application.workflow } } }, - id: "inspection-example", root: States, - events: Machine.eventsFromSchemas(Start, Finish, Disconnect, Refresh), - initialConfiguration: (to) => to.resolve(() => snapshot) + events: Machine.eventsFromSchemas(Start, Finish, Disconnect, Refresh) }).handle({ + initial: { + target: Machine.targets(States).root.application, + decoded: true, + data: new Application({ workspace: "effect-machine", revision: 7 }) + }, states: { application: { + initial: { + workflow: { + decoded: true, + data: new Workflow({ document: "Machine.ts", unsavedChanges: 2 }) + }, + connection: { + decoded: true, + data: new Connection({}) + } + }, states: { workflow: { + initial: { + target: Machine.targets(States).root.application.workflow.idle, + decoded: true, + data: new Idle({}) + }, history: { recent: { default: initialWorkflow @@ -120,42 +141,58 @@ export const machine = Machine.make({ Start: { branches: "transition1", resolve: ({ select: { destination: target } }) => - target.decoded( - new Running({}), - (running) => running.editing.decoded(new Editing({})) - ).update.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 3 })) + target.decoded(new Running({}), (running) => running.editing.decoded(new Editing({}))).update + .decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 3 })) }, Refresh: { update: targets1.root.application.workflow, - decoded: () => (new Workflow({ document: "Machine.ts", unsavedChanges: 0 })) + decoded: true, + data: () => (new Workflow({ document: "Machine.ts", unsavedChanges: 0 })) } } }, running: { - initialize: ({ builder }) => builder.decoded(new Editing({})), + initial: { + target: Machine.targets(States).root.application.workflow.running.editing, + decoded: true, + data: ({}) => new Editing({}) + }, states: { editing: { on: { Finish: { target: targets1.root.application.workflow.running.complete, - decoded: () => (new Complete({})) + decoded: true, + data: () => (new Complete({})) } } - } + }, + complete: {} } } } }, connection: { + initial: { + target: Machine.targets(States).root.application.connection.online, + decoded: true, + data: new Online({}) + }, states: { online: { on: { - Disconnect: { target: targets1.root.application.connection.offline, decoded: () => (new Offline({})) } + Disconnect: { + target: targets1.root.application.connection.offline, + decoded: true, + data: () => (new Offline({})) + } } - } + }, + offline: {} } } } - } + }, + disabled: {} } }) diff --git a/packages/devtools/src/internal/browser/hierarchy-routing-example.ts b/packages/devtools/src/internal/browser/hierarchy-routing-example.ts index f2506947..a720005a 100644 --- a/packages/devtools/src/internal/browser/hierarchy-routing-example.ts +++ b/packages/devtools/src/internal/browser/hierarchy-routing-example.ts @@ -1,21 +1,16 @@ import { Machine } from "@typeonce/effect-machine" import { Effect, Schema } from "effect" - const ReviewState = Schema.TaggedUnion({ Review: { title: Schema.String }, ReviewFailed: { message: Schema.String }, Complete: { slug: Schema.String } }) - const ReviewStates = Machine.state({ - initial: "Workflow", states: { Workflow: { - initial: "Review", states: { Review: { schema: ReviewState.cases.Review, - initial: "Form", states: { Form: {}, Failed: ReviewState.cases.ReviewFailed @@ -28,16 +23,11 @@ const ReviewStates = Machine.state({ } } }) - -const ReviewEvents = Machine.eventsFromSchemas( - Schema.TaggedUnion({ - Submit: { route: Schema.Literals(["save", "invalid"]) } - }) -) - +const ReviewEvents = Machine.eventsFromSchemas(Schema.TaggedUnion({ + Submit: { route: Schema.Literals(["save", "invalid"]) } +})) const saveReview: Effect.Effect = Effect.succeed("deterministic-chart") const publishReview = Effect.succeed("deterministic-chart") - const targets1 = Machine.targets(ReviewStates) export const hierarchyRoutingMachine = Machine.make({ branches: { @@ -47,23 +37,24 @@ export const hierarchyRoutingMachine = Machine.make({ } }, effects: { source1: Effect.suspend(() => saveReview), source2: Effect.suspend(() => publishReview) }, - id: "hierarchy-routing", root: ReviewStates, - events: ReviewEvents, - initialConfiguration: (root) => - root.resolve(({ target }) => - target.from((to) => - to.Workflow.from((workflow) => - workflow.Review.from({ title: "A deterministic chart" }, (review) => review.Form.from()) - ) - ) - ) + events: ReviewEvents }).handle({ + initial: { + target: Machine.targets(ReviewStates).root.Workflow + }, states: { Workflow: { + initial: { + target: Machine.targets(ReviewStates).root.Workflow.Review, + data: { title: "A deterministic chart" } + }, states: { Review: { + initial: { + target: Machine.targets(ReviewStates).root.Workflow.Review.Form + }, on: { Submit: { branches: "transition1", @@ -85,7 +76,7 @@ export const hierarchyRoutingMachine = Machine.make({ onDone: { target: targets1.root.Workflow.Publishing }, onFailure: { target: targets1.root.Workflow.Review.Failed, - from: () => ({ message: "The review could not be saved." }) + data: () => ({ message: "The review could not be saved." }) } } }, @@ -93,7 +84,7 @@ export const hierarchyRoutingMachine = Machine.make({ invoke: { src: "source2", id: "publish-review", - onDone: { target: targets1.root.Workflow.Complete, from: ({ output }) => ({ slug: output }) } + onDone: { target: targets1.root.Workflow.Complete, data: ({ output }) => ({ slug: output }) } } }, Complete: {} diff --git a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts index 0861d3ed..2380ea2b 100644 --- a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts +++ b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts @@ -1,36 +1,39 @@ import { Machine } from "@typeonce/effect-machine" import { Effect, Schema, Stream } from "effect" - class ChildWorking extends Schema.TaggedClass("InvokeGalleryChildWorking")("Working", { task: Schema.String -}) {} +}) { +} class ChildDone extends Schema.TaggedClass("InvokeGalleryChildDone")("Done", { result: Schema.String -}) {} -class FinishChild extends Schema.TaggedClass("InvokeGalleryFinishChild")("FinishChild", {}) {} - +}) { +} +class FinishChild extends Schema.TaggedClass("InvokeGalleryFinishChild")("FinishChild", {}) { +} const ChildStates = Machine.state({ - initial: "Working", states: { Working: ChildWorking, Done: { schema: ChildDone, type: "final", output: Schema.String } } }) - const targets1 = Machine.targets(ChildStates) export const invokeGalleryChildMachine = Machine.make({ id: "invoke-gallery-child", root: ChildStates, - events: Machine.eventsFromSchemas(FinishChild), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Working.decoded(new ChildWorking({ task: "render-preview" })))) + events: Machine.eventsFromSchemas(FinishChild) }).handle({ + initial: { + target: Machine.targets(ChildStates).root.Working, + decoded: true, + data: new ChildWorking({ task: "render-preview" }) + }, states: { Working: { on: { FinishChild: { target: targets1.root.Done, - decoded: ({ state }) => (new ChildDone({ result: `${state.task}:complete` })) + decoded: true, + data: ({ state }) => (new ChildDone({ result: `${state.task}:complete` })) } } }, @@ -39,64 +42,73 @@ export const invokeGalleryChildMachine = Machine.make({ } } }) - const InvokedChild = Machine.child("preview-worker", invokeGalleryChildMachine) - class Gallery extends Schema.TaggedClass("InvokeGallery")("Gallery", { selectedDemo: Schema.NullOr(Schema.String) -}) {} -class Choose extends Schema.TaggedClass("InvokeGalleryChoose")("Choose", {}) {} -class LoadingDocument extends Schema.TaggedClass("InvokeGalleryLoadingDocument")( - "LoadingDocument", - { request: Schema.String } -) {} -class StreamingUpdates extends Schema.TaggedClass("InvokeGalleryStreamingUpdates")( - "StreamingUpdates", - { values: Schema.Array(Schema.Number) } -) {} -class WaitingForTimeout extends Schema.TaggedClass("InvokeGalleryWaitingForTimeout")( - "WaitingForTimeout", - { delay: Schema.Literal("2 seconds") } -) {} -class WatchingProcess extends Schema.TaggedClass("InvokeGalleryWatchingProcess")( - "WatchingProcess", - { revision: Schema.Number } -) {} -class RunningChild extends Schema.TaggedClass("InvokeGalleryRunningChild")("RunningChild", {}) {} +}) { +} +class Choose extends Schema.TaggedClass("InvokeGalleryChoose")("Choose", {}) { +} +class LoadingDocument extends Schema.TaggedClass("InvokeGalleryLoadingDocument")("LoadingDocument", { + request: Schema.String +}) { +} +class StreamingUpdates + extends Schema.TaggedClass("InvokeGalleryStreamingUpdates")("StreamingUpdates", { + values: Schema.Array(Schema.Number) + }) +{ +} +class WaitingForTimeout + extends Schema.TaggedClass("InvokeGalleryWaitingForTimeout")("WaitingForTimeout", { + delay: Schema.Literal("2 seconds") + }) +{ +} +class WatchingProcess extends Schema.TaggedClass("InvokeGalleryWatchingProcess")("WatchingProcess", { + revision: Schema.Number +}) { +} +class RunningChild extends Schema.TaggedClass("InvokeGalleryRunningChild")("RunningChild", {}) { +} class Completed extends Schema.TaggedClass("InvokeGalleryCompleted")("Completed", { source: Schema.String, result: Schema.String -}) {} +}) { +} class Failed extends Schema.TaggedClass("InvokeGalleryFailed")("Failed", { source: Schema.String, message: Schema.String -}) {} - +}) { +} class RunEffect extends Schema.TaggedClass("InvokeGalleryRunEffect")("RunEffect", { request: Schema.String -}) {} -class RunStream extends Schema.TaggedClass("InvokeGalleryRunStream")("RunStream", {}) {} -class RunTimer extends Schema.TaggedClass("InvokeGalleryRunTimer")("RunTimer", {}) {} -class RunProcess extends Schema.TaggedClass("InvokeGalleryRunProcess")("RunProcess", {}) {} -class RunChild extends Schema.TaggedClass("InvokeGalleryRunChild")("RunChild", {}) {} -class Reset extends Schema.TaggedClass("InvokeGalleryReset")("Reset", {}) {} +}) { +} +class RunStream extends Schema.TaggedClass("InvokeGalleryRunStream")("RunStream", {}) { +} +class RunTimer extends Schema.TaggedClass("InvokeGalleryRunTimer")("RunTimer", {}) { +} +class RunProcess extends Schema.TaggedClass("InvokeGalleryRunProcess")("RunProcess", {}) { +} +class RunChild extends Schema.TaggedClass("InvokeGalleryRunChild")("RunChild", {}) { +} +class Reset extends Schema.TaggedClass("InvokeGalleryReset")("Reset", {}) { +} class StreamValue extends Schema.TaggedClass("InvokeGalleryStreamValue")("StreamValue", { value: Schema.Number -}) {} - +}) { +} 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.state({ - initial: "Gallery", states: { Gallery: { schema: Gallery, - initial: "Choose", states: { Choose, LoadingDocument, @@ -110,7 +122,6 @@ const GalleryStates = Machine.state({ Failed } }) - const targets2 = Machine.targets(GalleryStates) export const invokeOutcomesMachine = Machine.make({ branches: { @@ -120,8 +131,9 @@ export const invokeOutcomesMachine = Machine.make({ } }, effects: { - source1: ({ state }: { readonly state: LoadingDocument }) => - state.request === "fail" ? Effect.fail("document unavailable") : Effect.succeed("document loaded") + source1: ({ state }: { + readonly state: LoadingDocument + }) => state.request === "fail" ? Effect.fail("document unavailable") : Effect.succeed("document loaded") }, streams: { source2: Stream.suspend(() => @@ -131,44 +143,50 @@ export const invokeOutcomesMachine = Machine.make({ timers: { source3: "2 seconds" }, logic: { source4: processLogic }, children: { source5: InvokedChild }, - id: "invoke-outcomes", root: GalleryStates, events: GalleryEvents, - internalEvents: GalleryInternalEvents, - initialConfiguration: (root) => - root.resolve(({ target }) => - target.from((to) => - to.Gallery.decoded(new Gallery({ selectedDemo: null }), (gallery) => gallery.Choose.decoded(new Choose({}))) - ) - ) + internalEvents: GalleryInternalEvents }).handle({ + initial: { + target: Machine.targets(GalleryStates).root.Gallery, + decoded: true, + data: new Gallery({ selectedDemo: null }) + }, states: { Gallery: { - initialize: ({ builder }) => builder.decoded(new Choose({})), + initial: { + target: Machine.targets(GalleryStates).root.Gallery.Choose, + decoded: true, + data: ({}) => new Choose({}) + }, on: { - Reset: { target: targets2.root.Gallery.Choose, decoded: () => (new Choose({})) } + Reset: { target: targets2.root.Gallery.Choose, decoded: true, data: () => (new Choose({})) } }, states: { Choose: { on: { RunEffect: { target: targets2.root.Gallery.LoadingDocument, - decoded: ({ event }) => (new LoadingDocument({ request: event.request })) + decoded: true, + data: ({ event }) => (new LoadingDocument({ request: event.request })) }, RunStream: { target: targets2.root.Gallery.StreamingUpdates, - decoded: () => (new StreamingUpdates({ values: [] })) + decoded: true, + data: () => (new StreamingUpdates({ values: [] })) }, RunTimer: { target: targets2.root.Gallery.WaitingForTimeout, - decoded: () => (new WaitingForTimeout({ delay: "2 seconds" })) + decoded: true, + data: () => (new WaitingForTimeout({ delay: "2 seconds" })) }, RunProcess: { target: targets2.root.Gallery.WatchingProcess, - decoded: () => (new WatchingProcess({ revision: 1 })) + decoded: true, + data: () => (new WatchingProcess({ revision: 1 })) }, - RunChild: { target: targets2.root.Gallery.RunningChild, decoded: () => (new RunningChild({})) } + RunChild: { target: targets2.root.Gallery.RunningChild, decoded: true, data: () => (new RunningChild({})) } } }, LoadingDocument: { @@ -178,11 +196,13 @@ export const invokeOutcomesMachine = Machine.make({ input: (context) => context, onDone: { target: targets2.root.Completed, - decoded: ({ output }) => (new Completed({ source: "effect", result: output })) + decoded: true, + data: ({ output }) => (new Completed({ source: "effect", result: output })) }, onFailure: { target: targets2.root.Failed, - decoded: ({ error }) => (new Failed({ source: "effect", message: error })) + decoded: true, + data: ({ error }) => (new Failed({ source: "effect", message: error })) } } }, @@ -198,17 +218,20 @@ export const invokeOutcomesMachine = Machine.make({ }, onDone: { target: targets2.root.Completed, - decoded: ({ state }) => (new Completed({ source: "stream", result: state.values.join(", ") })) + decoded: true, + data: ({ state }) => (new Completed({ source: "stream", result: state.values.join(", ") })) }, onFailure: { target: targets2.root.Failed, - decoded: ({ error }) => (new Failed({ source: "stream", message: error })) + decoded: true, + data: ({ error }) => (new Failed({ source: "stream", message: error })) } }, on: { StreamValue: { target: targets2.root.Gallery.StreamingUpdates, - decoded: ({ event, state }) => (new StreamingUpdates({ values: [...state.values, event.value] })) + decoded: true, + data: ({ event, state }) => (new StreamingUpdates({ values: [...state.values, event.value] })) } } }, @@ -218,7 +241,8 @@ export const invokeOutcomesMachine = Machine.make({ id: "request-timeout", onDone: { target: targets2.root.Completed, - decoded: () => (new Completed({ source: "timer", result: "timeout elapsed" })) + decoded: true, + data: () => (new Completed({ source: "timer", result: "timeout elapsed" })) } } }, @@ -229,7 +253,8 @@ export const invokeOutcomesMachine = Machine.make({ address: Machine.childAddress("status-worker"), onFailure: { target: targets2.root.Failed, - decoded: ({ error }) => (new Failed({ source: "process", message: String(error) })) + decoded: true, + data: ({ error }) => (new Failed({ source: "process", message: String(error) })) }, onSnapshot: { branches: "transition13", @@ -245,7 +270,8 @@ export const invokeOutcomesMachine = Machine.make({ src: "source5", onDone: { target: targets2.root.Completed, - decoded: ({ output }) => (new Completed({ source: "child machine", result: output })) + decoded: true, + data: ({ output }) => (new Completed({ source: "child machine", result: output })) } } } @@ -253,12 +279,12 @@ export const invokeOutcomesMachine = Machine.make({ }, Completed: { on: { - Reset: { initial: targets2.root.Gallery, decoded: () => (new Gallery({ selectedDemo: null })) } + Reset: { initial: targets2.root.Gallery, decoded: true, data: () => (new Gallery({ selectedDemo: null })) } } }, Failed: { on: { - Reset: { initial: targets2.root.Gallery, decoded: () => (new Gallery({ selectedDemo: null })) } + Reset: { initial: targets2.root.Gallery, decoded: true, data: () => (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 cd6ef6ea..72044c12 100644 --- a/packages/devtools/src/internal/browser/layout-resilience-example.ts +++ b/packages/devtools/src/internal/browser/layout-resilience-example.ts @@ -1,9 +1,7 @@ import { Machine } from "@typeonce/effect-machine" import { Effect, Schema } from "effect" - const Mode = Schema.Literals(["login", "signup"]) const LoginMethod = Schema.Literals(["code", "password"]) - const AuthState = Schema.TaggedUnion({ Editing: { mode: Mode, @@ -19,16 +17,12 @@ const AuthState = Schema.TaggedUnion({ }, Navigating: { href: Schema.String } }) - const AuthStates = Machine.state({ - initial: "Editing", states: { Editing: { schema: AuthState.cases.Editing, - initial: "Form", states: { Form: { - initial: "Ready", states: { Ready: {}, Failed: AuthState.cases.EditingFailed @@ -40,7 +34,6 @@ const AuthStates = Machine.state({ }, Verification: { schema: AuthState.cases.Verification, - initial: "CodeEntry", states: { CodeEntry: {} } @@ -48,16 +41,12 @@ const AuthStates = Machine.state({ Navigating: AuthState.cases.Navigating } }) - -const AuthEvents = Machine.eventsFromSchemas( - Schema.TaggedUnion({ - EmailChanged: { value: Schema.String }, - LoginMethodChanged: { value: LoginMethod }, - PasswordChanged: { value: Schema.String }, - Submit: { route: Schema.Literals(["verification", "login", "invalid"]) } - }) -) - +const AuthEvents = Machine.eventsFromSchemas(Schema.TaggedUnion({ + EmailChanged: { value: Schema.String }, + LoginMethodChanged: { value: LoginMethod }, + PasswordChanged: { value: Schema.String }, + Submit: { route: Schema.Literals(["verification", "login", "invalid"]) } +})) const targets1 = Machine.targets(AuthStates) export const layoutResilienceMachine = Machine.make({ branches: { @@ -71,53 +60,68 @@ export const layoutResilienceMachine = Machine.make({ } }, effects: { - source1: ({ containingState }: { readonly containingState: { readonly email: string } }) => + source1: ({ containingState }: { + readonly containingState: { + readonly email: string + } + }) => containingState.email === "fail" ? Effect.fail("invalid credentials") : Effect.succeed("/creator"), - source2: ({ containingState }: { readonly containingState: { readonly email: string } }) => + source2: ({ containingState }: { + readonly containingState: { + readonly email: string + } + }) => containingState.email === "fail" ? Effect.fail("verification unavailable") : Effect.succeed(undefined) }, - id: "layout-resilience", root: AuthStates, - events: AuthEvents, - 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())) - ) - ) + events: AuthEvents }).handle({ + initial: { + target: Machine.targets(AuthStates).root.Editing, + data: { + mode: "login", + email: "", + loginMethod: "code", + password: "" + } + }, states: { Editing: { + initial: { + target: Machine.targets(AuthStates).root.Editing.Form + }, states: { Form: { + initial: { + target: Machine.targets(AuthStates).root.Editing.Form.Ready + }, on: { EmailChanged: { update: targets1.root.Editing, - from: ({ ancestors: { "Editing": current }, event }) => ({ ...current, email: event.value }) + data: ({ ancestors: { "Editing": current }, event }) => ({ ...current, email: event.value }) }, LoginMethodChanged: { update: targets1.root.Editing, - from: ({ ancestors: { "Editing": current }, event }) => ({ ...current, loginMethod: event.value }) + data: ({ ancestors: { "Editing": current }, event }) => ({ ...current, loginMethod: event.value }) }, PasswordChanged: { update: targets1.root.Editing, - from: ({ ancestors: { "Editing": current }, event }) => ({ ...current, password: event.value }) + data: ({ ancestors: { "Editing": current }, event }) => ({ ...current, password: event.value }) }, Submit: { branches: "transition4", resolve: ({ event, select }) => { - if (event.route === "login") return select.login.from() - if (event.route === "verification") return select.requestVerification.from() + 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." }) } } @@ -132,10 +136,10 @@ export const layoutResilienceMachine = Machine.make({ src: "source1", id: "submit-login", input: (context) => context, - onDone: { target: targets1.root.Navigating, from: ({ output }) => ({ href: output }) }, + onDone: { target: targets1.root.Navigating, data: ({ output }) => ({ href: output }) }, onFailure: { target: targets1.root.Editing.Form.Failed, - from: () => ({ message: "Email or password is incorrect." }) + data: () => ({ message: "Email or password is incorrect." }) } } }, @@ -146,7 +150,7 @@ export const layoutResilienceMachine = Machine.make({ input: (context) => context, onDone: { initial: targets1.root.Verification, - from: ({ containingState }) => ({ + data: ({ containingState }) => ({ mode: containingState.mode, email: containingState.email, code: "" @@ -154,13 +158,16 @@ export const layoutResilienceMachine = Machine.make({ }, onFailure: { target: targets1.root.Editing.Form.Failed, - from: () => ({ message: "The verification code could not be sent." }) + data: () => ({ message: "The verification code could not be sent." }) } } } } }, Verification: { + initial: { + target: Machine.targets(AuthStates).root.Verification.CodeEntry + }, states: { CodeEntry: {} } diff --git a/packages/devtools/src/internal/browser/parallel-completion-example.ts b/packages/devtools/src/internal/browser/parallel-completion-example.ts index d9f9a936..42abb3bd 100644 --- a/packages/devtools/src/internal/browser/parallel-completion-example.ts +++ b/packages/devtools/src/internal/browser/parallel-completion-example.ts @@ -1,70 +1,82 @@ import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" - class Cart extends Schema.TaggedClass("ParallelCart")("Cart", { items: Schema.Number -}) {} +}) { +} class Order extends Schema.TaggedClass("ParallelOrder")("Order", { orderId: Schema.String, total: Schema.Number -}) {} +}) { +} class Payment extends Schema.TaggedClass("ParallelPayment")("Payment", { attempts: Schema.Number -}) {} -class AwaitingAuthorization extends Schema.TaggedClass("ParallelAwaitingAuthorization")( - "AwaitingAuthorization", - {} -) {} +}) { +} +class AwaitingAuthorization + extends Schema.TaggedClass("ParallelAwaitingAuthorization")("AwaitingAuthorization", {}) +{ +} class Authorized extends Schema.TaggedClass("ParallelAuthorized")("Authorized", { authorizationId: Schema.String -}) {} +}) { +} class Fulfillment extends Schema.TaggedClass("ParallelFulfillment")("Fulfillment", { warehouse: Schema.String -}) {} -class WaitingForPayment extends Schema.TaggedClass("ParallelWaitingForPayment")( - "WaitingForPayment", - {} -) {} +}) { +} +class WaitingForPayment + extends Schema.TaggedClass("ParallelWaitingForPayment")("WaitingForPayment", {}) +{ +} class Packing extends Schema.TaggedClass("ParallelPacking")("Packing", { packageCount: Schema.Number -}) {} +}) { +} class Shipped extends Schema.TaggedClass("ParallelShipped")("Shipped", { trackingCode: Schema.String -}) {} +}) { +} class OrderComplete extends Schema.TaggedClass("ParallelOrderComplete")("OrderComplete", { orderId: Schema.String -}) {} -class OrderCancelled extends Schema.TaggedClass("ParallelOrderCancelled")( - "OrderCancelled", - { reason: Schema.String } -) {} - +}) { +} +class OrderCancelled + extends Schema.TaggedClass("ParallelOrderCancelled")("OrderCancelled", { reason: Schema.String }) +{ +} class Checkout extends Schema.TaggedClass("ParallelCheckout")("Checkout", { orderId: Schema.String, total: Schema.Number -}) {} +}) { +} class Authorize extends Schema.TaggedClass("ParallelAuthorize")("Authorize", { authorizationId: Schema.String -}) {} -class DeclinePayment extends Schema.TaggedClass("ParallelDeclinePayment")( - "DeclinePayment", - { reason: Schema.String } -) {} -class Pack extends Schema.TaggedClass("ParallelPack")("Pack", { packages: Schema.Number }) {} -class Ship extends Schema.TaggedClass("ParallelShip")("Ship", { trackingCode: Schema.String }) {} +}) { +} +class DeclinePayment + extends Schema.TaggedClass("ParallelDeclinePayment")("DeclinePayment", { reason: Schema.String }) +{ +} +class Pack extends Schema.TaggedClass("ParallelPack")("Pack", { packages: Schema.Number }) { +} +class Ship extends Schema.TaggedClass("ParallelShip")("Ship", { trackingCode: Schema.String }) { +} class CompleteAll extends Schema.TaggedClass("ParallelCompleteAll")("CompleteAll", { authorizationId: Schema.String, trackingCode: Schema.String -}) {} +}) { +} class CancelOrder extends Schema.TaggedClass("ParallelCancelOrder")("CancelOrder", { reason: Schema.String -}) {} -class RetryOrder extends Schema.TaggedClass("ParallelRetryOrder")("RetryOrder", {}) {} -class AutoShip extends Schema.TaggedClass("ParallelAutoShip")("AutoShip", {}) {} - +}) { +} +class RetryOrder extends Schema.TaggedClass("ParallelRetryOrder")("RetryOrder", {}) { +} +class AutoShip extends Schema.TaggedClass("ParallelAutoShip")("AutoShip", {}) { +} const ParallelInternalEvents = Machine.internalEventsFromSchemas(AutoShip) const ParallelStates = Machine.state({ - initial: "Cart", states: { Cart, Order: { @@ -73,7 +85,6 @@ const ParallelStates = Machine.state({ states: { payment: { schema: Payment, - initial: "AwaitingAuthorization", states: { AwaitingAuthorization, Authorized: { schema: Authorized, type: "final" } @@ -81,7 +92,6 @@ const ParallelStates = Machine.state({ }, fulfillment: { schema: Fulfillment, - initial: "WaitingForPayment", states: { WaitingForPayment, Packing, @@ -94,11 +104,9 @@ const ParallelStates = Machine.state({ Cancelled: OrderCancelled } }) - const targets1 = Machine.targets(ParallelStates) export const parallelCompletionMachine = Machine.make({ timers: { source1: "5 seconds" }, - id: "parallel-completion", root: ParallelStates, events: Machine.eventsFromSchemas( @@ -111,66 +119,86 @@ export const parallelCompletionMachine = Machine.make({ CancelOrder, RetryOrder ), - internalEvents: ParallelInternalEvents, - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Cart.decoded(new Cart({ items: 2 })))) + internalEvents: ParallelInternalEvents }).handle({ + initial: { + target: Machine.targets(ParallelStates).root.Cart, + decoded: true, + data: new Cart({ items: 2 }) + }, states: { Cart: { on: { Checkout: { initial: targets1.root.Order, - decoded: ({ event }) => (new Order({ orderId: event.orderId, total: event.total })) + decoded: true, + data: ({ event }) => (new Order({ orderId: event.orderId, total: event.total })) } } }, Order: { - initialize: ({ builder }) => builder.payment.from({ attempts: 0 }).fulfillment.from({ warehouse: "north" }), + initial: { payment: ({}) => ({ attempts: 0 }), fulfillment: ({}) => ({ warehouse: "north" }) }, on: { CancelOrder: { target: targets1.root.Cancelled, - decoded: ({ event }) => (new OrderCancelled({ reason: event.reason })) + decoded: true, + data: ({ event }) => (new OrderCancelled({ reason: event.reason })) } }, - onDone: { target: targets1.root.Complete, decoded: () => (new OrderComplete({ orderId: "completed-order" })) }, + onDone: { + target: targets1.root.Complete, + decoded: true, + data: () => (new OrderComplete({ orderId: "completed-order" })) + }, states: { payment: { - initialize: ({ builder }) => builder.from(), + initial: { + target: Machine.targets(ParallelStates).root.Order.payment.AwaitingAuthorization + }, states: { AwaitingAuthorization: { on: { Authorize: { target: targets1.root.Order.payment.Authorized, - decoded: ({ event }) => (new Authorized({ authorizationId: event.authorizationId })) + decoded: true, + data: ({ event }) => (new Authorized({ authorizationId: event.authorizationId })) }, CompleteAll: { target: targets1.root.Order.payment.Authorized, - decoded: ({ event }) => (new Authorized({ authorizationId: event.authorizationId })) + decoded: true, + data: ({ event }) => (new Authorized({ authorizationId: event.authorizationId })) }, DeclinePayment: { target: targets1.root.Cancelled, - decoded: ({ event }) => (new OrderCancelled({ reason: event.reason })) + decoded: true, + data: ({ event }) => (new OrderCancelled({ reason: event.reason })) } } - } + }, + Authorized: {} } }, fulfillment: { - initialize: ({ builder }) => builder.from(), + initial: { + target: Machine.targets(ParallelStates).root.Order.fulfillment.WaitingForPayment + }, states: { WaitingForPayment: { on: { Authorize: { target: targets1.root.Order.fulfillment.Packing, - decoded: () => (new Packing({ packageCount: 1 })) + decoded: true, + data: () => (new Packing({ packageCount: 1 })) }, Pack: { target: targets1.root.Order.fulfillment.Packing, - decoded: ({ event }) => (new Packing({ packageCount: event.packages })) + decoded: true, + data: ({ event }) => (new Packing({ packageCount: event.packages })) }, CompleteAll: { target: targets1.root.Order.fulfillment.Shipped, - decoded: ({ event }) => (new Shipped({ trackingCode: event.trackingCode })) + decoded: true, + data: ({ event }) => (new Shipped({ trackingCode: event.trackingCode })) } } }, @@ -188,18 +216,22 @@ export const parallelCompletionMachine = Machine.make({ on: { AutoShip: { target: targets1.root.Order.fulfillment.Shipped, - decoded: () => (new Shipped({ trackingCode: "automatic" })) + decoded: true, + data: () => (new Shipped({ trackingCode: "automatic" })) }, Ship: { target: targets1.root.Order.fulfillment.Shipped, - decoded: ({ event }) => (new Shipped({ trackingCode: event.trackingCode })) + decoded: true, + data: ({ event }) => (new Shipped({ trackingCode: event.trackingCode })) }, CompleteAll: { target: targets1.root.Order.fulfillment.Shipped, - decoded: ({ event }) => (new Shipped({ trackingCode: event.trackingCode })) + decoded: true, + data: ({ event }) => (new Shipped({ trackingCode: event.trackingCode })) } } - } + }, + Shipped: {} } } } @@ -209,7 +241,11 @@ export const parallelCompletionMachine = Machine.make({ }, Cancelled: { on: { - RetryOrder: { initial: targets1.root.Order, decoded: () => (new Order({ orderId: "retry", total: 0 })) } + RetryOrder: { + initial: targets1.root.Order, + decoded: true, + data: () => (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 72feeb6d..a08b49d8 100644 --- a/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts +++ b/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts @@ -1,29 +1,35 @@ import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" - -class Print extends Schema.TaggedClass("ParallelOwnerPrint")("Print", {}) {} -class Options extends Schema.TaggedClass("ParallelOwnerOptions")("Options", {}) {} -class OptionsReady extends Schema.TaggedClass("ParallelOwnerOptionsReady")("Ready", {}) {} -class Operation extends Schema.TaggedClass("ParallelOwnerOperation")("Operation", {}) {} -class Active extends Schema.TaggedClass("ParallelOwnerActive")("Active", {}) {} -class Printing extends Schema.TaggedClass("ParallelOwnerPrinting")("Printing", {}) {} -class ChildIdle extends Schema.TaggedClass("ParallelOwnerChildIdle")("Idle", {}) {} -class ChildSignal extends Schema.TaggedClass("ParallelOwnerChildSignal")("ChildSignal", {}) {} -class PrintRequested extends Schema.TaggedClass("ParallelOwnerPrintRequested")( - "PrintRequested", - {} -) {} - +class Print extends Schema.TaggedClass("ParallelOwnerPrint")("Print", {}) { +} +class Options extends Schema.TaggedClass("ParallelOwnerOptions")("Options", {}) { +} +class OptionsReady extends Schema.TaggedClass("ParallelOwnerOptionsReady")("Ready", {}) { +} +class Operation extends Schema.TaggedClass("ParallelOwnerOperation")("Operation", {}) { +} +class Active extends Schema.TaggedClass("ParallelOwnerActive")("Active", {}) { +} +class Printing extends Schema.TaggedClass("ParallelOwnerPrinting")("Printing", {}) { +} +class ChildIdle extends Schema.TaggedClass("ParallelOwnerChildIdle")("Idle", {}) { +} +class ChildSignal extends Schema.TaggedClass("ParallelOwnerChildSignal")("ChildSignal", {}) { +} +class PrintRequested extends Schema.TaggedClass("ParallelOwnerPrintRequested")("PrintRequested", {}) { +} const ChildParentEvents = Machine.eventsFromSchemas(ChildSignal) -const ParallelOwnerChildStates = Machine.state({ initial: "Idle", states: { Idle: ChildIdle } }) - +const ParallelOwnerChildStates = Machine.state({ states: { Idle: ChildIdle } }) export const parallelOwnerRoutingChildMachine = Machine.make({ effects: { - source1: ({ - parent - }: Machine.Machine.InvokeContext< + source1: ({ parent }: Machine.Machine.InvokeContext< { - readonly "": { readonly initial: "Idle"; readonly states: { readonly Idle: typeof ChildIdle } } & { + readonly "": { + readonly initial: "Idle" + readonly states: { + readonly Idle: typeof ChildIdle + } + } & { readonly "~effect/Machine/ExplicitInitial": true } }, @@ -31,16 +37,24 @@ export const parallelOwnerRoutingChildMachine = Machine.make({ readonly [], "Idle", readonly [], - Machine.Machine.ParentEventSchemas<"required", readonly [typeof ChildSignal]> + Machine.Machine.ParentEventSchemas< + "required", + readonly [ + typeof ChildSignal + ] + > >) => parent.send(ChildParentEvents.ChildSignal()) }, - id: "parallel-owner-routing-child", root: ParallelOwnerChildStates, events: Machine.eventsFromSchemas(), - parent: Machine.parent(ChildParentEvents), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new ChildIdle({})))) + parent: Machine.parent(ChildParentEvents) }).handle({ + initial: { + target: Machine.targets(ParallelOwnerChildStates).root.Idle, + decoded: true, + data: new ChildIdle({}) + }, states: { Idle: { invoke: { @@ -53,11 +67,8 @@ export const parallelOwnerRoutingChildMachine = Machine.make({ } } }) - const ParallelOwnerChild = Machine.child("parallel-owner-child", parallelOwnerRoutingChildMachine) - const ParallelOwnerStates = Machine.state({ - initial: "Print", states: { Print: { schema: Print, @@ -65,14 +76,12 @@ const ParallelOwnerStates = Machine.state({ states: { Options: { schema: Options, - initial: "Ready", states: { Ready: OptionsReady } }, Operation: { schema: Operation, - initial: "Active", states: { Active, Printing @@ -82,42 +91,51 @@ const ParallelOwnerStates = Machine.state({ } } }) - const targets2 = Machine.targets(ParallelOwnerStates) export const parallelOwnerRoutingMachine = Machine.make({ children: { source1: ParallelOwnerChild }, - id: "parallel-owner-routing", 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({}))) - ) - ) - ) + events: Machine.eventsFromSchemas(PrintRequested, ChildParentEvents) }).handle({ + initial: { + target: Machine.targets(ParallelOwnerStates).root.Print, + decoded: true, + data: new Print({}) + }, states: { Print: { - initialize: ({ builder }) => builder.Options.from({}).Operation.from({}), + initial: { Options: ({}) => ({}), Operation: ({}) => ({}) }, invoke: { src: "source1", - onFailure: { target: targets2.root.Print.Operation.Active, decoded: () => (new Active({})) } + onFailure: { target: targets2.root.Print.Operation.Active, decoded: true, data: () => (new Active({})) } }, on: { - PrintRequested: { target: targets2.root.Print.Operation.Printing, decoded: () => (new Printing({})) } + PrintRequested: { + target: targets2.root.Print.Operation.Printing, + decoded: true, + data: () => (new Printing({})) + } }, states: { Options: { - initialize: ({ builder }) => builder.from({}) + initial: { + target: Machine.targets(ParallelOwnerStates).root.Print.Options.Ready, + data: ({}) => ({}) + }, + states: { + Ready: {} + } }, Operation: { - initialize: ({ builder }) => builder.from({}) + initial: { + target: Machine.targets(ParallelOwnerStates).root.Print.Operation.Active, + data: ({}) => ({}) + }, + states: { + Active: {}, + Printing: {} + } } } } diff --git a/packages/devtools/src/internal/browser/planner-example.ts b/packages/devtools/src/internal/browser/planner-example.ts index af54a45e..34dc415f 100644 --- a/packages/devtools/src/internal/browser/planner-example.ts +++ b/packages/devtools/src/internal/browser/planner-example.ts @@ -1,14 +1,15 @@ import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" import * as Effect from "effect/Effect" - -class Idle extends Schema.TaggedClass("PlannerIdle")("Idle", { owner: Schema.String }) {} +class Idle extends Schema.TaggedClass("PlannerIdle")("Idle", { owner: Schema.String }) { +} class Working extends Schema.TaggedClass("PlannerWorking")("Working", { owner: Schema.String, job: Schema.String -}) {} -class Finished extends Schema.TaggedClass("PlannerFinished")("Finished", { job: Schema.String }) {} - +}) { +} +class Finished extends Schema.TaggedClass("PlannerFinished")("Finished", { job: Schema.String }) { +} const Owner = Schema.NonEmptyString.annotate({ title: "Owner", description: "A non-empty name carried into the initial Idle state." @@ -33,7 +34,6 @@ const Route = Schema.Union([ title: "Route", description: "Choose a literal direct route or provide a queue." }) - class Begin extends Schema.TaggedClass("PlannerBegin")("Begin", { job: Schema.NonEmptyString.annotate({ title: "Job", @@ -59,23 +59,56 @@ class Begin extends Schema.TaggedClass("PlannerBegin")("Begin", { ), labels: Schema.optionalKey(Labels), route: Schema.optionalKey(Route) -}) {} -class Cancel extends Schema.TaggedClass("PlannerCancel")("Cancel", { reason: Schema.String }) {} -class AutoFinish extends Schema.TaggedClass("PlannerAutoFinish")("AutoFinish", {}) {} -class Planned extends Schema.TaggedClass("PlannerPlanned")("Planned", { job: Schema.String }) {} - +}) { +} +class Cancel extends Schema.TaggedClass("PlannerCancel")("Cancel", { reason: Schema.String }) { +} +class AutoFinish extends Schema.TaggedClass("PlannerAutoFinish")("AutoFinish", {}) { +} +class Planned extends Schema.TaggedClass("PlannerPlanned")("Planned", { job: Schema.String }) { +} const Events = Machine.eventsFromSchemas(Begin, Cancel) const InternalEvents = Machine.internalEventsFromSchemas(AutoFinish) const Emissions = Machine.emittedEventsFromSchemas(Planned) const States = Machine.state({ - initial: "Idle", + fields: { + input: Schema.toType(Schema.Struct({ + owner: Owner, + attempts: Attempts, + notifications: Schema.Boolean.annotate({ + title: "Notifications", + description: "A required boolean with false as a valid value." + }), + mode: Schema.Literals(["guided", "automatic"]).annotate({ + title: "Mode", + description: "A fixed set of startup modes." + }), + note: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(40)).annotate({ + title: "Note", + description: "Optional startup text limited to forty characters." + }) + ), + labels: Schema.optionalKey(Labels), + preferences: Schema.optionalKey( + Schema.Struct({ + retries: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 3 })).annotate({ + title: "Retries" + }), + dryRun: Schema.Boolean.annotate({ title: "Dry run" }) + }).annotate({ + title: "Preferences", + description: "An optional nested object." + }) + ) + })) + }, states: { Idle, Working, Finished: { schema: Finished, type: "final", output: Schema.String } } }) - const targets1 = Machine.targets(States) export const plannerMachine = Machine.make({ branches: { @@ -85,7 +118,6 @@ export const plannerMachine = Machine.make({ } }, effects: { source1: Effect.suspend(() => Effect.never) }, - id: "planner-example", root: States, events: Events, @@ -120,10 +152,14 @@ export const plannerMachine = Machine.make({ description: "An optional nested object." }) ) - }), - initialConfiguration: (root) => - root.resolve(({ input, target }) => target.from((to) => to.Idle.decoded(new Idle({ owner: input.owner })))) + }) }).handle({ + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: ({ root: { input: input } }) => new Idle({ owner: input.owner }) + }, + root: ({ input }) => ({ input }), states: { Idle: { on: { @@ -132,7 +168,9 @@ export const plannerMachine = Machine.make({ 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()) + 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) @@ -144,10 +182,15 @@ export const plannerMachine = Machine.make({ Working: { invoke: { src: "source1", id: "monitor-job" }, on: { - AutoFinish: { target: targets1.root.Finished, decoded: ({ state }) => (new Finished({ job: state.job })) }, + AutoFinish: { + target: targets1.root.Finished, + decoded: true, + data: ({ state }) => (new Finished({ job: state.job })) + }, Cancel: { target: targets1.root.Idle, - decoded: ({ event, state }) => (new Idle({ owner: `${state.owner} · ${event.reason}` })) + decoded: true, + data: ({ event, state }) => (new Idle({ owner: `${state.owner} · ${event.reason}` })) } } }, diff --git a/packages/devtools/src/internal/browser/protocol-events-example.ts b/packages/devtools/src/internal/browser/protocol-events-example.ts index b9e24bf2..7d79dddc 100644 --- a/packages/devtools/src/internal/browser/protocol-events-example.ts +++ b/packages/devtools/src/internal/browser/protocol-events-example.ts @@ -1,53 +1,55 @@ import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" - class ChildProgress extends Schema.TaggedClass("ProtocolChildProgress")("ChildProgress", { percent: Schema.Number -}) {} +}) { +} class ChildFinished extends Schema.TaggedClass("ProtocolChildFinished")("ChildFinished", { result: Schema.String -}) {} +}) { +} class ChildProblem extends Schema.TaggedClass("ProtocolChildProblem")("ChildProblem", { message: Schema.String -}) {} - +}) { +} const ParentEvents = Machine.eventsFromSchemas(ChildProgress, ChildFinished, ChildProblem) - -class ChildIdle extends Schema.TaggedClass("ProtocolChildIdle")("Idle", {}) {} +class ChildIdle extends Schema.TaggedClass("ProtocolChildIdle")("Idle", {}) { +} class ChildWorking extends Schema.TaggedClass("ProtocolChildWorking")("Working", { job: Schema.String, progress: Schema.Number -}) {} +}) { +} class ChildDone extends Schema.TaggedClass("ProtocolChildDone")("Done", { result: Schema.String -}) {} +}) { +} class ChildCancelled extends Schema.TaggedClass("ProtocolChildCancelled")("Cancelled", { reason: Schema.String -}) {} - +}) { +} class BeginChildWork extends Schema.TaggedClass("ProtocolBeginChildWork")("BeginChildWork", { job: Schema.String -}) {} -class CancelChildWork extends Schema.TaggedClass("ProtocolCancelChildWork")( - "CancelChildWork", - { reason: Schema.String } -) {} +}) { +} +class CancelChildWork + extends Schema.TaggedClass("ProtocolCancelChildWork")("CancelChildWork", { reason: Schema.String }) +{ +} class Heartbeat extends Schema.TaggedClass("ProtocolHeartbeat")("Heartbeat", { percent: Schema.Number -}) {} -class CommitChildWork extends Schema.TaggedClass("ProtocolCommitChildWork")( - "CommitChildWork", - {} -) {} +}) { +} +class CommitChildWork extends Schema.TaggedClass("ProtocolCommitChildWork")("CommitChildWork", {}) { +} class ChildTrace extends Schema.TaggedClass("ProtocolChildTrace")("ChildTrace", { message: 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, @@ -55,7 +57,6 @@ const ChildStates = Machine.state({ Cancelled: { schema: ChildCancelled, type: "final", output: Schema.String } } }) - const targets1 = Machine.targets(ChildStates) export const requiredParentChildMachine = Machine.make({ branches: { @@ -63,22 +64,23 @@ export const requiredParentChildMachine = Machine.make({ transition3: { destination: { target: targets1.root.Working } } }, effects: { - source1: ( - { parent, state }: { - readonly parent: Machine.MachineTarget> - readonly state: ChildWorking - } - ) => parent.send(ParentEvents.ChildProgress({ percent: state.progress })) + source1: ({ parent, state }: { + readonly parent: Machine.MachineTarget> + readonly state: ChildWorking + }) => parent.send(ParentEvents.ChildProgress({ percent: state.progress })) }, - id: "required-parent-child", root: ChildStates, events: ChildEvents, internalEvents: ChildInternalEvents, emittedEvents: ChildEmissions, - parent: Machine.parent(ParentEvents), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new ChildIdle({})))) + parent: Machine.parent(ParentEvents) }).handle({ + initial: { + target: Machine.targets(ChildStates).root.Idle, + decoded: true, + data: new ChildIdle({}) + }, states: { Idle: { on: { @@ -100,7 +102,8 @@ export const requiredParentChildMachine = Machine.make({ onDone: { none: true }, onFailure: { target: targets1.root.Cancelled, - decoded: ({ error }) => (new ChildCancelled({ reason: String(error) })) + decoded: true, + data: ({ error }) => (new ChildCancelled({ reason: String(error) })) } }, on: { @@ -113,11 +116,13 @@ export const requiredParentChildMachine = Machine.make({ }, CommitChildWork: { target: targets1.root.Done, - decoded: ({ state }) => (new ChildDone({ result: `${state.job}:complete` })) + decoded: true, + data: ({ state }) => (new ChildDone({ result: `${state.job}:complete` })) }, CancelChildWork: { target: targets1.root.Cancelled, - decoded: ({ event }) => (new ChildCancelled({ reason: event.reason })) + decoded: true, + data: ({ event }) => (new ChildCancelled({ reason: event.reason })) } } }, @@ -129,24 +134,26 @@ export const requiredParentChildMachine = Machine.make({ } } }) - const ProtocolChild = Machine.child("protocol-child", requiredParentChildMachine) - -class ParentIdle extends Schema.TaggedClass("ProtocolParentIdle")("Idle", {}) {} +class ParentIdle extends Schema.TaggedClass("ProtocolParentIdle")("Idle", {}) { +} class Supervising extends Schema.TaggedClass("ProtocolSupervising")("Supervising", { latestProgress: Schema.Number -}) {} +}) { +} class ParentComplete extends Schema.TaggedClass("ProtocolParentComplete")("Complete", { result: Schema.String -}) {} +}) { +} class ParentFailed extends Schema.TaggedClass("ProtocolParentFailed")("Failed", { message: Schema.String -}) {} -class LaunchChild extends Schema.TaggedClass("ProtocolLaunchChild")("LaunchChild", {}) {} -class ResetParent extends Schema.TaggedClass("ProtocolResetParent")("ResetParent", {}) {} - +}) { +} +class LaunchChild extends Schema.TaggedClass("ProtocolLaunchChild")("LaunchChild", {}) { +} +class ResetParent extends Schema.TaggedClass("ProtocolResetParent")("ResetParent", {}) { +} const ParentStates = Machine.state({ - initial: "Idle", states: { Idle: ParentIdle, Supervising, @@ -154,7 +161,6 @@ const ParentStates = Machine.state({ Failed: ParentFailed } }) - const targets2 = Machine.targets(ParentStates) export const parentProtocolMachine = Machine.make({ branches: { @@ -162,41 +168,56 @@ export const parentProtocolMachine = Machine.make({ transition3: { destination: { target: targets2.root.Complete } } }, children: { source1: ProtocolChild }, - id: "parent-child-protocol", root: ParentStates, - events: Machine.eventsFromSchemas(LaunchChild, ResetParent, ParentEvents), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new ParentIdle({})))) + events: Machine.eventsFromSchemas(LaunchChild, ResetParent, ParentEvents) }).handle({ + initial: { + target: Machine.targets(ParentStates).root.Idle, + decoded: true, + data: new ParentIdle({}) + }, states: { Idle: { on: { - LaunchChild: { target: targets2.root.Supervising, decoded: () => (new Supervising({ latestProgress: 0 })) } + LaunchChild: { + target: targets2.root.Supervising, + decoded: true, + data: () => (new Supervising({ latestProgress: 0 })) + } } }, Supervising: { invoke: { src: "source1", - onDone: { target: targets2.root.Complete, decoded: ({ output }) => (new ParentComplete({ result: output })) }, + onDone: { + target: targets2.root.Complete, + decoded: true, + data: ({ output }) => (new ParentComplete({ result: output })) + }, onFailure: { target: targets2.root.Failed, - decoded: ({ error }) => (new ParentFailed({ message: String(error) })) + decoded: true, + data: ({ error }) => (new ParentFailed({ message: String(error) })) } }, on: { ChildProgress: { target: targets2.root.Supervising, - decoded: ({ event }) => (new Supervising({ latestProgress: event.percent })) + decoded: true, + data: ({ event }) => (new Supervising({ latestProgress: event.percent })) }, ChildFinished: { target: targets2.root.Complete, - decoded: ({ event }) => (new ParentComplete({ result: event.result })) + decoded: true, + data: ({ event }) => (new ParentComplete({ result: event.result })) }, ChildProblem: { target: targets2.root.Failed, - decoded: ({ event }) => (new ParentFailed({ message: event.message })) + decoded: true, + data: ({ event }) => (new ParentFailed({ message: event.message })) }, - ResetParent: { target: targets2.root.Idle, decoded: () => (new ParentIdle({})) } + ResetParent: { target: targets2.root.Idle, decoded: true, data: () => (new ParentIdle({})) } } }, Complete: { @@ -204,34 +225,35 @@ export const parentProtocolMachine = Machine.make({ }, Failed: { on: { - ResetParent: { target: targets2.root.Idle, decoded: () => (new ParentIdle({})) } + ResetParent: { target: targets2.root.Idle, decoded: true, data: () => (new ParentIdle({})) } } } } }) - -class Detached extends Schema.TaggedClass("OptionalParentDetached")("Detached", {}) {} +class Detached extends Schema.TaggedClass("OptionalParentDetached")("Detached", {}) { +} class Published extends Schema.TaggedClass("OptionalParentPublished")("Published", { deliveredToParent: Schema.Boolean -}) {} -class PublishOutside extends Schema.TaggedClass("OptionalParentPublishOutside")( - "PublishOutside", - { result: Schema.String } -) {} - -const OptionalParentStates = Machine.state({ initial: "Detached", states: { Detached, Published } }) - +}) { +} +class PublishOutside extends Schema.TaggedClass("OptionalParentPublishOutside")("PublishOutside", { + result: Schema.String +}) { +} +const OptionalParentStates = Machine.state({ states: { Detached, Published } }) const targets3 = Machine.targets(OptionalParentStates) export const optionalParentMachine = Machine.make({ branches: { transition1: { destination: { target: targets3.root.Published } } }, - id: "optional-parent-protocol", root: OptionalParentStates, events: Machine.eventsFromSchemas(PublishOutside), - parent: Machine.optionalParent(ParentEvents), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Detached.decoded(new Detached({})))) + parent: Machine.optionalParent(ParentEvents) }).handle({ + initial: { + target: Machine.targets(OptionalParentStates).root.Detached, + decoded: true, + data: new Detached({}) + }, states: { Detached: { on: { 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 6fa8cfe5..daab9e75 100644 --- a/packages/devtools/src/internal/browser/shared-terminal-routing-example.ts +++ b/packages/devtools/src/internal/browser/shared-terminal-routing-example.ts @@ -1,8 +1,6 @@ import { Machine } from "@typeonce/effect-machine" import { Effect, Schema } from "effect" - const ReplicationStates = Machine.state({ - initial: "Connecting", states: { Connecting: {}, IdentifyingSource: {}, @@ -19,17 +17,12 @@ const ReplicationStates = Machine.state({ Failed: {} } }) - -const ReplicationEvents = Machine.eventsFromSchemas( - Schema.TaggedUnion({ - Retry: {}, - SessionUnavailable: {}, - StopRequested: {} - }) -) - +const ReplicationEvents = Machine.eventsFromSchemas(Schema.TaggedUnion({ + Retry: {}, + SessionUnavailable: {}, + StopRequested: {} +})) const operation: Effect.Effect = Effect.succeed(undefined) - const targets1 = Machine.targets(ReplicationStates) export const sharedTerminalRoutingMachine = Machine.make({ effects: { @@ -43,12 +36,13 @@ export const sharedTerminalRoutingMachine = Machine.make({ source8: operation, source9: operation }, - id: "shared-terminal-routing", root: ReplicationStates, - events: ReplicationEvents, - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Connecting.from())) + events: ReplicationEvents }).handle({ + initial: { + target: Machine.targets(ReplicationStates).root.Connecting + }, states: { Connecting: { invoke: { diff --git a/packages/devtools/src/internal/browser/transition-semantics-example.ts b/packages/devtools/src/internal/browser/transition-semantics-example.ts index ceafbccf..5e02476d 100644 --- a/packages/devtools/src/internal/browser/transition-semantics-example.ts +++ b/packages/devtools/src/internal/browser/transition-semantics-example.ts @@ -1,92 +1,105 @@ import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" - class Workspace extends Schema.TaggedClass("TransitionWorkspace")("Workspace", { revision: Schema.Number, preferredRoute: Schema.Literals(["draft", "review"]) -}) {} +}) { +} class Draft extends Schema.TaggedClass("TransitionDraft")("Draft", { text: Schema.String, autosaves: Schema.Number -}) {} -class AutoSaving extends Schema.TaggedClass("TransitionAutoSaving")("AutoSaving", {}) {} +}) { +} +class AutoSaving extends Schema.TaggedClass("TransitionAutoSaving")("AutoSaving", {}) { +} class Review extends Schema.TaggedClass("TransitionReview")("Review", { requestedBy: Schema.String -}) {} +}) { +} class Checking extends Schema.TaggedClass("TransitionChecking")("Checking", { checks: Schema.Array(Schema.String) -}) {} -class ChangesRequested extends Schema.TaggedClass("TransitionChangesRequested")( - "ChangesRequested", - { reason: Schema.String } -) {} +}) { +} +class ChangesRequested extends Schema.TaggedClass("TransitionChangesRequested")("ChangesRequested", { + reason: Schema.String +}) { +} class Approved extends Schema.TaggedClass("TransitionApproved")("Approved", { reviewer: Schema.String -}) {} -class WorkspaceFinished extends Schema.TaggedClass("TransitionWorkspaceFinished")( - "Finished", - { result: Schema.String } -) {} +}) { +} +class WorkspaceFinished + extends Schema.TaggedClass("TransitionWorkspaceFinished")("Finished", { result: Schema.String }) +{ +} class Paused extends Schema.TaggedClass("TransitionPaused")("Paused", { reason: Schema.String -}) {} +}) { +} class Published extends Schema.TaggedClass("TransitionPublished")("Published", { result: Schema.String -}) {} +}) { +} class Disabled extends Schema.TaggedClass("TransitionDisabled")("Disabled", { reason: Schema.String -}) {} - +}) { +} class Create extends Schema.TaggedClass("TransitionCreate")("Create", { text: Schema.String, route: Schema.Literals(["draft", "review"]) -}) {} -class Edit extends Schema.TaggedClass("TransitionEdit")("Edit", { text: Schema.String }) {} -class Save extends Schema.TaggedClass("TransitionSave")("Save", {}) {} +}) { +} +class Edit extends Schema.TaggedClass("TransitionEdit")("Edit", { text: Schema.String }) { +} +class Save extends Schema.TaggedClass("TransitionSave")("Save", {}) { +} class Submit extends Schema.TaggedClass("TransitionSubmit")("Submit", { mode: Schema.Literals(["review", "publish"]), requestedBy: Schema.String -}) {} +}) { +} class Approve extends Schema.TaggedClass("TransitionApprove")("Approve", { reviewer: Schema.String -}) {} +}) { +} class Reject extends Schema.TaggedClass("TransitionReject")("Reject", { reason: Schema.String -}) {} -class Revise extends Schema.TaggedClass("TransitionRevise")("Revise", {}) {} -class Pause extends Schema.TaggedClass("TransitionPause")("Pause", { reason: Schema.String }) {} -class ResumeShallow extends Schema.TaggedClass("TransitionResumeShallow")( - "ResumeShallow", - {} -) {} -class ResumeDeep extends Schema.TaggedClass("TransitionResumeDeep")("ResumeDeep", {}) {} -class Restart extends Schema.TaggedClass("TransitionRestart")("Restart", {}) {} -class Refresh extends Schema.TaggedClass("TransitionRefresh")("Refresh", {}) {} -class Ignore extends Schema.TaggedClass("TransitionIgnore")("Ignore", {}) {} +}) { +} +class Revise extends Schema.TaggedClass("TransitionRevise")("Revise", {}) { +} +class Pause extends Schema.TaggedClass("TransitionPause")("Pause", { reason: Schema.String }) { +} +class ResumeShallow extends Schema.TaggedClass("TransitionResumeShallow")("ResumeShallow", {}) { +} +class ResumeDeep extends Schema.TaggedClass("TransitionResumeDeep")("ResumeDeep", {}) { +} +class Restart extends Schema.TaggedClass("TransitionRestart")("Restart", {}) { +} +class Refresh extends Schema.TaggedClass("TransitionRefresh")("Refresh", {}) { +} +class Ignore extends Schema.TaggedClass("TransitionIgnore")("Ignore", {}) { +} class MaybeHandle extends Schema.TaggedClass("TransitionMaybeHandle")("MaybeHandle", { accept: Schema.Boolean -}) {} -class BumpWorkspace extends Schema.TaggedClass("TransitionBumpWorkspace")( - "BumpWorkspace", - {} -) {} +}) { +} +class BumpWorkspace extends Schema.TaggedClass("TransitionBumpWorkspace")("BumpWorkspace", {}) { +} class Archive extends Schema.TaggedClass("TransitionArchive")("Archive", { reason: 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, @@ -103,7 +116,6 @@ const TransitionStates = Machine.state({ Published: { schema: Published, type: "final", output: Schema.String } } }) - const defaultWorkspaceSnapshot = () => ({ path: "" as const, value: undefined, @@ -116,7 +128,6 @@ const defaultWorkspaceSnapshot = () => ({ } } }) - const targets1 = Machine.targets(TransitionStates) export const transitionSemanticsMachine = Machine.make({ branches: { @@ -131,7 +142,6 @@ export const transitionSemanticsMachine = Machine.make({ transition14: { destination: { history: targets1.root.Workspace.recent } }, transition15: { destination: { history: targets1.root.Workspace.exact } } }, - id: "transition-semantics", root: TransitionStates, events: Machine.eventsFromSchemas( @@ -151,27 +161,42 @@ export const transitionSemanticsMachine = Machine.make({ MaybeHandle, BumpWorkspace, Archive - ), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Paused.decoded(new Paused({ reason: "not started" })))) + ) }).handle({ + initial: { + target: Machine.targets(TransitionStates).root.Paused, + decoded: true, + data: new Paused({ reason: "not started" }) + }, states: { Workspace: { + initial: { + target: Machine.targets(TransitionStates).root.Workspace.Routing + }, history: { recent: { default: defaultWorkspaceSnapshot }, exact: { default: defaultWorkspaceSnapshot } }, on: { - Pause: { target: targets1.root.Paused, decoded: ({ event }) => (new Paused({ reason: event.reason })) }, + Pause: { + target: targets1.root.Paused, + decoded: true, + data: ({ event }) => (new Paused({ reason: event.reason })) + }, BumpWorkspace: { update: targets1.root.Workspace, - decoded: ({ state: current }) => (new Workspace({ + decoded: true, + data: ({ state: current }) => (new Workspace({ revision: current.revision + 1, preferredRoute: current.preferredRoute })) } }, - onDone: { target: targets1.root.Published, decoded: () => (new Published({ result: "workspace published" })) }, + onDone: { + target: targets1.root.Published, + decoded: true, + data: () => (new Published({ result: "workspace published" })) + }, states: { Routing: { choice: { @@ -186,9 +211,10 @@ export const transitionSemanticsMachine = Machine.make({ on: { Edit: { target: targets1.root.Workspace.Draft, - decoded: ({ event, state }) => (new Draft({ text: event.text, autosaves: state.autosaves })) + decoded: true, + data: ({ event, state }) => (new Draft({ text: event.text, autosaves: state.autosaves })) }, - Save: { target: targets1.root.Workspace.AutoSaving, decoded: () => (new AutoSaving({})) }, + Save: { target: targets1.root.Workspace.AutoSaving, decoded: true, data: () => (new AutoSaving({})) }, Submit: { branches: "transition7", resolve: ({ event, select }) => @@ -208,25 +234,33 @@ export const transitionSemanticsMachine = Machine.make({ AutoSaving: { always: { target: targets1.root.Workspace.Draft, - decoded: () => (new Draft({ text: "Autosaved draft", autosaves: 1 })) + decoded: true, + data: () => (new Draft({ text: "Autosaved draft", autosaves: 1 })) } }, Review: { - initialize: ({ builder }) => builder.decoded(new Checking({ checks: ["types", "tests"] })), + initial: { + target: Machine.targets(TransitionStates).root.Workspace.Review.Checking, + decoded: true, + data: ({}) => new Checking({ checks: ["types", "tests"] }) + }, onDone: { target: targets1.root.Workspace.Finished, - decoded: () => (new WorkspaceFinished({ result: "approved review" })) + decoded: true, + data: () => (new WorkspaceFinished({ result: "approved review" })) }, states: { Checking: { on: { Approve: { target: targets1.root.Workspace.Review.Approved, - decoded: ({ event }) => (new Approved({ reviewer: event.reviewer })) + decoded: true, + data: ({ event }) => (new Approved({ reviewer: event.reviewer })) }, Reject: { target: targets1.root.Workspace.Review.ChangesRequested, - decoded: ({ event }) => (new ChangesRequested({ reason: event.reason })) + decoded: true, + data: ({ event }) => (new ChangesRequested({ reason: event.reason })) } } }, @@ -234,25 +268,30 @@ export const transitionSemanticsMachine = Machine.make({ on: { Revise: { target: targets1.root.Workspace.Draft, - decoded: () => (new Draft({ text: "Revised draft", autosaves: 0 })) + decoded: true, + data: () => (new Draft({ text: "Revised draft", autosaves: 0 })) } } - } + }, + Approved: {} } - } + }, + Finished: {} } }, Paused: { on: { Create: { initial: targets1.root.Workspace, - decoded: ({ event }) => (new Workspace({ revision: 0, preferredRoute: event.route })) + decoded: true, + data: ({ event }) => (new Workspace({ revision: 0, preferredRoute: event.route })) }, ResumeShallow: { branches: "transition14", resolve: ({ select: { destination: target } }) => target() }, ResumeDeep: { branches: "transition15", resolve: ({ select: { destination: target } }) => target() }, Restart: { initial: targets1.root.Workspace, - decoded: () => (new Workspace({ revision: 0, preferredRoute: "draft" })) + decoded: true, + data: () => (new Workspace({ revision: 0, preferredRoute: "draft" })) } } }, diff --git a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts index 1233c239..e3309503 100644 --- a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts +++ b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts @@ -19,7 +19,7 @@ describe("Interactive text visualization", () => { assert.deepStrictEqual(document.initial, { target: "", - selection: { path: "", kind: "state", scope: "initial" } + selection: { path: "", kind: "initial", scope: "initial" } }) assert.deepStrictEqual(document.roots, [""]) assert.deepStrictEqual(idle?.transitionIds, [ diff --git a/packages/effect-machine-react/test/MachineAtom.test.tsx b/packages/effect-machine-react/test/MachineAtom.test.tsx index 2b446bd5..c00e3be2 100644 --- a/packages/effect-machine-react/test/MachineAtom.test.tsx +++ b/packages/effect-machine-react/test/MachineAtom.test.tsx @@ -1,5 +1,4 @@ // @vitest-environment jsdom - import { RegistryContext, useAtomSuspense } from "@effect/atom-react" import { act, cleanup, render, screen, waitFor } from "@testing-library/react" import { Effect, Option, Schema } from "effect" @@ -10,53 +9,53 @@ import { afterEach, assert, describe, it } from "vitest" import { Machine } from "../../effect-machine/src/index.js" import { AtomMachine } from "../../effect-machine/src/unstable/reactivity/index.js" import { useMachineAtom } from "../src/index.js" - class Active extends Schema.TaggedClass("Active")("Active", { value: Schema.Number }) {} class Increment extends Schema.TaggedClass("Increment")("Increment", {}) {} - -const States = Machine.state({ initial: "Active", states: { Active } }) - +const States = Machine.state({ fields: { seed: Schema.Number }, states: { Active } }) const trackedMachine = (onStart: () => void) => { const targets1 = Machine.targets(States) return Machine.make({ root: States, events: Machine.eventsFromSchemas(Increment), - input: Schema.Number, - initialConfiguration: (root) => - root.resolve(({ input, target }) => { - onStart() - return target.from((to) => to.Active.decoded(new Active({ value: input }))) - }) + input: Schema.Number }).handle({ + root: ({ input }) => ({ seed: input }), + initial: { target: targets1.root.Active, data: ({ root }) => ({ value: root.seed }) }, + entry: () => { + onStart() + return undefined + }, states: { Active: { on: { - Increment: { target: targets1.root.Active, decoded: ({ state }) => (new Active({ value: state.value + 1 })) } + Increment: { + target: targets1.root.Active, + decoded: true, + data: ({ state }) => (new Active({ value: state.value + 1 })) + } } } } }) } - afterEach(cleanup) - describe("useMachineAtom", () => { it("keeps the owner unsubscribed while a state-path reader updates", async () => { const machine = trackedMachine(() => {}) - const registry = AtomRegistry.make({ defaultIdleTTL: 1_000 }) + const registry = AtomRegistry.make({ defaultIdleTTL: 1000 }) const makeOwned = () => AtomMachine.make(machine, 0) let current: ReturnType | undefined let ownerRenders = 0 let readerRenders = 0 - - function Reader({ owned }: { readonly owned: NonNullable }) { + function Reader({ owned }: { + readonly owned: NonNullable + }) { readerRenders++ const active = useAtomSuspense(AtomMachine.select(owned, "Active")).value return {Option.getOrThrow(active).value} } - function Owner() { ownerRenders++ const owned = useMachineAtom(() => AtomMachine.make(machine, 0)) @@ -67,29 +66,23 @@ describe("useMachineAtom", () => { ) } - const view = render( ) - await waitFor(() => assert.strictEqual(screen.getByTestId("value").textContent, "0")) const initialOwnerRenders = ownerRenders const initialReaderRenders = readerRenders - await act(() => { registry.set(current!.send, new Increment({})) }) - await waitFor(() => assert.strictEqual(screen.getByTestId("value").textContent, "1")) assert.strictEqual(ownerRenders, initialOwnerRenders) assert.ok(readerRenders > initialReaderRenders) - view.unmount() registry.dispose() }) - it("owns one committed machine without making startup input reactive", async () => { let starts = 0 const machine = trackedMachine(() => { @@ -97,16 +90,16 @@ describe("useMachineAtom", () => { }) const makeOwned = (input: number) => AtomMachine.make(machine, input) let current: ReturnType | undefined - const registry = AtomRegistry.make({ defaultIdleTTL: 1_000 }) - - function Owner({ input }: { readonly input: number }) { + const registry = AtomRegistry.make({ defaultIdleTTL: 1000 }) + function Owner({ input }: { + readonly input: number + }) { const owned = useMachineAtom(() => AtomMachine.make(machine, input)) React.useEffect(() => { current = owned }, [owned]) return null } - const view = render( @@ -114,14 +107,12 @@ describe("useMachineAtom", () => { ) - await waitFor(() => assert.strictEqual(starts, 1)) const first = current! assert.deepStrictEqual((await Effect.runPromise(AtomRegistry.getResult(registry, first.result))).state, { path: "Active", value: new Active({ value: 1 }) }) - view.rerender( @@ -129,11 +120,9 @@ describe("useMachineAtom", () => { ) - assert.strictEqual(current, first) assert.strictEqual(starts, 1) assert.strictEqual((await Effect.runPromise(AtomRegistry.getResult(registry, first.result))).state.value.value, 1) - view.rerender( @@ -141,32 +130,27 @@ describe("useMachineAtom", () => { ) - await waitFor(() => assert.strictEqual(starts, 2)) assert.notStrictEqual(current, first) assert.strictEqual( (await Effect.runPromise(AtomRegistry.getResult(registry, current!.result))).state.value.value, 2 ) - view.unmount() registry.dispose() }) - it("runs the same machine atom independently in each registry", async () => { let starts = 0 const machine = trackedMachine(() => { starts++ }) const bridge = AtomMachine.make(machine, 1) - const firstRegistry = AtomRegistry.make({ defaultIdleTTL: 1_000 }) - const secondRegistry = AtomRegistry.make({ defaultIdleTTL: 1_000 }) - + const firstRegistry = AtomRegistry.make({ defaultIdleTTL: 1000 }) + const secondRegistry = AtomRegistry.make({ defaultIdleTTL: 1000 }) function Owner() { useMachineAtom(() => bridge) return null } - const view = render( <> @@ -177,23 +161,19 @@ describe("useMachineAtom", () => { ) - await waitFor(() => assert.strictEqual(starts, 2)) const first = await Effect.runPromise(AtomRegistry.getResult(firstRegistry, bridge.ref)) const second = await Effect.runPromise(AtomRegistry.getResult(secondRegistry, bridge.ref)) assert.notStrictEqual(first, second) - view.unmount() firstRegistry.dispose() secondRegistry.dispose() }) - it("releases its mount when the owner unmounts", async () => { const machine = trackedMachine(() => {}) const registry = AtomRegistry.make({ defaultIdleTTL: 0, timeoutResolution: 1 }) const makeOwned = () => AtomMachine.make(machine, 1) let current: ReturnType | undefined - function Owner() { const owned = useMachineAtom(() => AtomMachine.make(machine, 1)) React.useEffect(() => { @@ -201,41 +181,34 @@ describe("useMachineAtom", () => { }, [owned]) return null } - const view = render( ) - await waitFor(() => assert.ok(current !== undefined)) const ref = await Effect.runPromise(AtomRegistry.getResult(registry, current!.ref)) view.unmount() - await waitFor(async () => { assert.strictEqual((await Effect.runPromise(ref.snapshot)).status, "stopped") }) registry.dispose() }) - it("does not start the machine during server rendering", () => { let starts = 0 const machine = trackedMachine(() => { starts++ }) const registry = AtomRegistry.make() - function Owner() { useMachineAtom(() => AtomMachine.make(machine, 1)) return null } - renderToString( ) - assert.strictEqual(starts, 0) registry.dispose() }) diff --git a/packages/effect-machine-react/test/RootContext.test.tsx b/packages/effect-machine-react/test/RootContext.test.tsx index 601dd8d6..5f38f612 100644 --- a/packages/effect-machine-react/test/RootContext.test.tsx +++ b/packages/effect-machine-react/test/RootContext.test.tsx @@ -1,5 +1,4 @@ // @vitest-environment jsdom - import { RegistryContext } from "@effect/atom-react" import { act, cleanup, render, screen, waitFor } from "@testing-library/react" import { Schema } from "effect" @@ -9,22 +8,26 @@ 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 root1 = Machine.state({ fields: { count: Schema.Number }, initial: "Open", states: { Open: {}, Closed: {} } }) +const root1 = Machine.state({ fields: { count: Schema.Number }, states: { Open: {}, Closed: {} } }) const targets1 = Machine.targets(root1) const counter = Machine.make({ root: root1, events: Events, - input: Schema.Number, - initial: (root) => root.from(({ input }) => ({ count: input })) + input: Schema.Number }).handle({ - on: { Increment: { update: targets1.root, from: ({ root: current }) => ({ count: current.count + 1 }) } }, - states: { Open: { on: { Close: { target: targets1.root.Closed } } } } + initial: { + target: Machine.targets(root1).root.Open + }, + root: ({ input }) => ({ count: input }), + on: { Increment: { update: targets1.root, data: ({ root: current }) => ({ count: current.count + 1 }) } }, + states: { + Open: { on: { Close: { target: targets1.root.Closed } } }, + 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 @@ -75,11 +78,12 @@ it("keeps a provider's input and bridge stable until its React key changes", asy 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 }) { + function Reader({ index }: { + readonly index: number + }) { const machine = Counter.useMachine() bridges[index] = machine return ( @@ -110,11 +114,12 @@ it("keeps independent providers isolated in the same registry", async () => { 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 { + class Boundary extends React.Component { state = { failed: false } static getDerivedStateFromError(error: unknown) { caught = error diff --git a/packages/effect-machine-react/typetest/MachineAtom.tst.ts b/packages/effect-machine-react/typetest/MachineAtom.tst.ts index 44aeef52..136b7c39 100644 --- a/packages/effect-machine-react/typetest/MachineAtom.tst.ts +++ b/packages/effect-machine-react/typetest/MachineAtom.tst.ts @@ -4,17 +4,18 @@ import { expect } from "tstyche" import { Machine } from "../../effect-machine/src/index.js" import { AtomMachine } from "../../effect-machine/src/unstable/reactivity/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.state({ initial: "Idle", states: { Idle } }) - +const States = Machine.state({ states: { Idle } }) const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(Continue), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) + events: Machine.eventsFromSchemas(Continue) }).handle({ + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, states: { Idle: { on: { @@ -24,36 +25,40 @@ const machine = Machine.make({ } }) 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: "" }) }) + events: Machine.events({ Close: {} }) +}).handle({ + initial: { + target: Machine.targets(Root).root.Editing, + data: ({}) => ({ draft: "" }) + }, + root: ({ input }) => ({ count: input }), + states: { Editing: {}, Closed: {} } +}) 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<{ + 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", diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 0169e651..3f0ad31a 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -47,23 +47,21 @@ Declare the root once, derive its references, then add behavior with event maps: ```ts import { Machine } from "@typeonce/effect-machine" import { Effect, Schema } from "effect" - const Root = Machine.state({ fields: { count: Schema.Number } }) const targets = Machine.targets(Root) const Events = Machine.events({ Increment: { by: Schema.Number } }) const Counter = Machine.make({ root: Root, - events: Events, - initial: (root) => root.from(() => ({ count: 0 })) + events: Events }).handle({ + root: () => ({ count: 0 }), on: { Increment: { update: targets.root, - from: ({ root, event }) => ({ count: root.count + event.by }) + data: ({ root, event }) => ({ count: root.count + event.by }) } } }) - const program = Effect.scoped(Effect.gen(function*() { const ref = yield* Machine.start(Counter) yield* ref.send(Events.Increment({ by: 1 })) @@ -86,11 +84,63 @@ input, sources, and named branch groups. `.handle` implements behavior in one nested object and returns an executable machine. One definition can create independent implementations by calling `.handle` more than once. +### Declare startup data and initial edges + +The root descriptor contains schemas and hierarchy. `make` registers protocols +and programs. `.handle` selects initial children and constructs their values: + +```ts +const Root = Machine.state({ + fields: { seed: Schema.Number }, + states: { + Loading: { fields: { count: Schema.Number } }, + Ready: {} + } +}) +const targets = Machine.targets(Root) +const machine = Machine.make({ root: Root, input: Schema.Number, events: Machine.events({}) }).handle({ + root: ({ input }) => ({ seed: input }), + initial: { target: targets.root.Loading, data: ({ root }) => ({ count: root.seed }) } +}) +``` + +Only the root constructor receives startup input. Child constructors receive +root data, their owning state's data, ancestors, and the lifecycle event. +Each compound declares an initial edge targeting one direct child; that edge +also supplies the child's required values. Nested compounds declare their own +edges in their handlers. There is no separate `initialConfiguration` override. + +A parallel handler's `initial` is a map of region data. It enters all regions: + +```ts +const Root = Machine.state({ + type: "parallel", + states: { + Editor: { fields: { draft: Schema.String }, states: { Editing: {}, Saved: {} } }, + Network: { fields: { online: Schema.Boolean } } + } +}) +const targets = Machine.targets(Root) +const machine = Machine.make({ root: Root, events: Machine.events({}) }).handle({ + initial: { Editor: { draft: "" }, Network: { online: false } }, + states: { Editor: { initial: { target: targets.root.Editor.Editing } } } +}) +``` + +Root and region data accept the short literal or callback form. For decoded +values, use `{ decoded: true, data: valueOrCallback }`. This descriptor accepts +only those two keys. If your domain data itself has `decoded: true` and `data` +fields, return it from a callback: callback results are always ordinary schema +input. Required data is checked at compile time; schema-less regions accept no data. + +Use root or state `entry`/`exit` for synchronous commands and `invoke` for +asynchronous work. Constructors only produce values. An unhandled definition +cannot be started, planned, or registered as a child machine. + ### Construct state values ```ts const Root = Machine.state({ - initial: "Idle", states: { Idle: {}, Loading: { fields: { query: Schema.String } }, @@ -100,26 +150,34 @@ const Root = Machine.state({ }) const targets = Machine.targets(Root) const Events = Machine.events({ Search: { query: Schema.String }, Reset: {} }) - -const Search = Machine.make({ root: Root, events: Events }).handle({ +const Search = Machine.make({ + root: Root, + events: Events +}).handle({ + initial: { + target: targets.root.Idle + }, states: { Idle: { on: { Search: { target: targets.root.Loading, - from: ({ event }) => ({ query: event.query }) + data: ({ event }) => ({ query: event.query }) } } }, - Loading: { on: { Reset: { target: targets.root.Idle } } } + Loading: { on: { Reset: { target: targets.root.Idle } } }, + Ready: {}, + Failed: {} } }) ``` -`from` accepts the schema's make input, including defaults and transformations. -`decoded` accepts its decoded value, preserving class instances. The planner -validates both boundaries and reports `MachineSchemaDecodeError`. Omit construction -only when the selected builder supports empty default construction. +`data` accepts a value or a synchronous callback returning the schema's make +input, including constructor defaults and transformations. Add `decoded: true` +when supplying the already-decoded type, including class instances. Both paths +validate values and report `MachineSchemaDecodeError`. Omit data only when the +selected state supports empty construction. Schema-less states do not accept data. A callback receives the specific event and source-state types, plus `root`, `containingState`, `ancestors`, and the appropriate machine references. A @@ -143,6 +201,7 @@ const machine = Machine.make({ } } }).handle({ + initial: { target: targets.root.Idle }, states: { Idle: { on: { @@ -164,8 +223,8 @@ payload belonging to another branch. A single-target group works the same way; there is no second path declaration in the resolver. For a compound target, `select.checkout.from(parentValues, child => child.Review.from(childValues))` constructs the explicitly selected subtree. Parallel constructors require every -entered region. Required initializers and final output implementations remain -part of machine readiness checking. +entered region. Every compound has an initial edge, including inactive branches. Final outputs, +history defaults, and choices remain part of machine readiness checking. Use `guard: context => boolean` to decline before construction or commands. For a resolver that can decline, explicitly add `declinable: true`; only then may @@ -179,11 +238,11 @@ All references come from the same root descriptor supplied to `make`: | Declaration | Meaning | | ----------------------------------------------------- | ------------------------------------------------------------------------- | -| `{ target: targets.root.Checkout.Review, from: ... }` | Enter a declared destination with its value. | -| `{ initial: targets.root.Checkout, from: ... }` | Enter the declared initial configuration of a compound or parallel state. | +| `{ target: targets.root.Checkout.Review, data: ... }` | Enter a declared destination with its value. | +| `{ initial: targets.root.Checkout, data: ... }` | Enter the declared initial configuration of a compound or parallel state. | | `{ history: targets.root.Checkout.recent }` | Restore a declared history state. | -| `{ update: targets.root.Checkout, from: ... }` | Replace a retained active owner's value. | -| `{ update: targets.root, from: ... }` | Replace root data and retain active descendants. | +| `{ update: targets.root.Checkout, data: ... }` | Replace a retained active owner's value. | +| `{ update: targets.root, data: ... }` | Replace root data and retain active descendants. | | `{ none: true }` | Accept an event without changing the configuration. | An atomic transition can enter a destination and update one retained owner: @@ -192,7 +251,7 @@ An atomic transition can enter a destination and update one retained owner: Save: { target: targets.root.Checkout.Saving, update: targets.root.Checkout, - from: ({ event, ancestors }) => ({ + data: ({ event, ancestors }) => ({ target: { request: event.request }, update: { ...ancestors.Checkout, revision: event.revision } }) @@ -206,8 +265,8 @@ A retained owner must be active for that source and remain active through the transition. A sibling region's value cannot be updated through this operation. The runtime transition API does not replace arbitrary complete root -configurations. `initialConfiguration` and history defaults retain complete -configuration construction for startup and restoration. +configurations. Startup follows the initial declarations in `.handle`; history +defaults retain complete subtree construction for restoration. ### Protocols and ownership @@ -232,7 +291,7 @@ adapter. The same root supports compound and parallel states, eventless `always` transitions, ordered named choices, final states, `onDone` completion, -initializers, shallow and deep history, and retained snapshots. The topology +initial declarations, shallow and deep history, and retained snapshots. The topology is captured directly from declarations; visualization and branch coverage do not execute user resolvers or depend on TypeScript source extraction. @@ -257,15 +316,21 @@ const Search = Machine.make({ logic: { worker: workerLogic }, children: { validation: ValidationChild } }).handle({ + initial: { + target: Machine.targets(Root).root.Idle + }, states: { + Idle: {}, Loading: { invoke: { src: "load", input: ({ state }) => state.query, - onDone: { target: targets.root.Ready, from: ({ output }) => ({ items: output }) }, - onFailure: { target: targets.root.Failed, from: ({ error }) => ({ message: error.message }) } + onDone: { target: targets.root.Ready, data: ({ output }) => ({ items: output }) }, + onFailure: { target: targets.root.Failed, data: ({ error }) => ({ message: error.message }) } } - } + }, + Ready: {}, + Failed: {} } }) ``` @@ -290,8 +355,7 @@ and cancellation boundaries; a typed `onFailure` handles the source's declared runtime error channel. ```ts -invoke: ; -;[ +const invocations = [ { src: "updates", onElement: { diff --git a/packages/effect-machine/perf/runtime/counter.mjs b/packages/effect-machine/perf/runtime/counter.mjs index 06b1e04b..62261946 100644 --- a/packages/effect-machine/perf/runtime/counter.mjs +++ b/packages/effect-machine/perf/runtime/counter.mjs @@ -55,6 +55,7 @@ export const counterMachine = benchmarkApi.make({ events: benchmarkApi.events(CounterEvent.cases.Increment, CounterEvent.cases.Finish), initial: benchmarkApi.initial({ target: (to) => to.Count(), + values: { Count: { value: 0 } }, resolve: ({ target }) => target.from(CounterState.cases.Count.make({ value: 0 })) }, () => CounterStates.initial.Count.from({ value: 0 })) }).handle({ @@ -144,6 +145,7 @@ const hierarchicalCounterMachine = benchmarkApi.make({ events: benchmarkApi.events(HierarchicalEvent.cases.Increment, HierarchicalEvent.cases.Finish), initial: benchmarkApi.initial({ target: (to) => to.Active.initial, + values: { "Active.Count": { value: 0 } }, resolve: ({ target }) => target.from((active) => active.Count.from({ value: 0 })) }, () => HierarchicalStates.initial.Active.from( @@ -200,6 +202,7 @@ const parallelCounterMachine = benchmarkApi.make({ events: benchmarkApi.events(HierarchicalEvent.cases.IncrementLeft, HierarchicalEvent.cases.IncrementRight, HierarchicalEvent.cases.Finish), initial: benchmarkApi.initial({ target: (to) => to.Active.initial, + values: { "Active.Left": { value: 0 }, "Active.Right": { value: 0 } }, resolve: ({ target }) => target.from((active) => active diff --git a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs index 868afac4..a4954fd4 100644 --- a/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs +++ b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs @@ -9,6 +9,15 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { // The legacy process constructor takes `(initial, transition)`. The static // definition constructor is deliberately unary and returns its config. const hasObjectTransitions = typeof Machine.targets === "function" + const hasHandlerInitial = hasObjectTransitions && typeof Machine.state === "function" && (() => { + try { + Machine.state({ states: { Probe: {} } }) + return true + } catch { + return false + } + })() + const rootDefinitions = new WeakMap() const transitionDefinition = Symbol("benchmark transition") const invocationDefinition = Symbol("benchmark invocation") const hasRoot = typeof Machine.eventsFromSchemas === "function" @@ -91,7 +100,7 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { const target = selectInstruction(definition.target({ full: selectorsForRoot, branch: selectorsForRoot, local: siblings })) return { target, - ...(definition.from !== undefined ? { from: definition.from } : definition.resolve === undefined ? {} : { from: (context) => definition.resolve({ ...context, target: { from: (value) => value } }) }), + ...(definition.from !== undefined ? { [hasHandlerInitial ? "data" : "from"]: definition.from } : definition.resolve === undefined ? {} : { [hasHandlerInitial ? "data" : "from"]: (context) => definition.resolve({ ...context, target: { from: (value) => value } }) }), ...(definition.reenter === true ? { reenter: true } : {}) } } @@ -112,12 +121,43 @@ export const makeEffectMachineBenchmarkApi = (Machine) => { })) return { handle: (states) => { const handlers = walk(states, targets.root) + if (hasHandlerInitial) { + const declared = rootDefinitions.get(root) + if (declared === undefined) throw new Error("Missing benchmark root declaration") + const initialize = (node, handler, path, refs) => { + if (node.states === undefined) return handler + const result = { ...handler } + if (node.type === "parallel") { + result.initial = Object.fromEntries(Object.keys(node.states).filter(key => Object.hasOwn(definition.values ?? {}, path ? `${path}.${key}` : key)).map(key => [key, definition.values[path ? `${path}.${key}` : key]])) + } else { + const key = path === "" ? initialKey : node.initial + if (key === undefined) throw new Error("Missing benchmark initial child") + const childPath = path ? `${path}.${key}` : key + result.initial = { target: refs[key], ...(Object.hasOwn(definition.values ?? {}, childPath) ? { data: definition.values[childPath] } : {}) } + } + result.states = Object.fromEntries(Object.entries(node.states).map(([key, child]) => [key, initialize(child, handler?.states?.[key] ?? {}, path ? `${path}.${key}` : key, refs[key])])) + return result + } + return Machine.make({ ...rest, root, children }).handle(initialize(declared, { states: handlers }, "", targets.root)) + } return Machine.make({ ...rest, root, initialConfiguration, children }).handle({ states: handlers }) } } }, - states: (definitions) => - hasRoot ? { states: Machine.state({ initial: Object.keys(definitions)[0], states: definitions }) } : typeof Machine.states === "function" ? Machine.states(definitions) : Machine.defineStates(definitions), + states: (definitions) => { + if (hasHandlerInitial) { + const stripInitial = (node) => { + if (node.states === undefined) return node + const { initial, states, ...rest } = node + return { ...rest, states: Object.fromEntries(Object.entries(states).map(([key, child]) => [key, stripInitial(child)])) } + } + const node = { states: definitions } + const root = Machine.state(stripInitial(node)) + rootDefinitions.set(root, node) + return { states: root } + } + return 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), diff --git a/packages/effect-machine/perf/types/adapter-readiness-control.ts b/packages/effect-machine/perf/types/adapter-readiness-control.ts index ced9d686..2dc52736 100644 --- a/packages/effect-machine/perf/types/adapter-readiness-control.ts +++ b/packages/effect-machine/perf/types/adapter-readiness-control.ts @@ -1,16 +1,12 @@ import { Schema } from "effect" import { Machine } from "../../dist/index.js" - export const Flow = Schema.TaggedStruct("Flow", {}) export const Idle = Schema.TaggedStruct("Idle", {}) export const Ready = Schema.TaggedStruct("Ready", {}) - export const States = Machine.state({ - initial: "Ready", states: { Flow: { schema: Flow, - initial: "Idle", states: { Idle, Route: { @@ -25,22 +21,26 @@ export const States = Machine.state({ Ready } }) - export const snapshot = { path: "" as const, value: undefined, state: { path: "Ready" as const, value: Ready.make({}) } } - const targets1 = Machine.targets(States) export const machine = Machine.make({ id: "perf-readiness", root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Ready.from(Ready.make({})))) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(States).root.Ready, + data: Ready.make({}) + }, states: { Flow: { + initial: { + target: Machine.targets(States).root.Flow.Idle + }, history: { recent: { default: ({ target }) => @@ -50,7 +50,7 @@ export const machine = Machine.make({ states: { Idle: {}, Route: { - choice: { target: targets1.root.Ready, from: () => (Ready.make({})) } + choice: { target: targets1.root.Ready, data: () => (Ready.make({})) } } } }, diff --git a/packages/effect-machine/perf/types/composition-control.ts b/packages/effect-machine/perf/types/composition-control.ts index d5b54613..a39dffab 100644 --- a/packages/effect-machine/perf/types/composition-control.ts +++ b/packages/effect-machine/perf/types/composition-control.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" import { Machine } from "../../dist/index.js" - export const App = Schema.TaggedStruct("App", {}) export const Workspace = Schema.TaggedStruct("Workspace", {}) export const Editor = Schema.TaggedStruct("Editor", {}) @@ -9,18 +8,14 @@ export const EditorDone = Schema.TaggedStruct("EditorDone", { value: Schema.Stri export const Sync = Schema.TaggedStruct("Sync", {}) export const SyncIdle = Schema.TaggedStruct("SyncIdle", {}) export const SyncDone = Schema.TaggedStruct("SyncDone", { value: Schema.Number }) - export const WorkspaceOutput = Schema.Struct({ Editor: Schema.String, Sync: Schema.Number }) - export const States = Machine.state({ - initial: "App", states: { App: { schema: App, - initial: "Workspace", states: { Workspace: { schema: Workspace, @@ -29,7 +24,6 @@ export const States = Machine.state({ states: { Editor: { schema: Editor, - initial: "Editing", states: { Editing, Done: { @@ -41,7 +35,6 @@ export const States = Machine.state({ }, Sync: { schema: Sync, - initial: "Idle", states: { Idle: SyncIdle, Done: { @@ -64,21 +57,7 @@ export const States = Machine.state({ } } }) - export const machine = Machine.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({}))) - )) - ) - ) + events: Machine.eventsFromSchemas() }) diff --git a/packages/effect-machine/perf/types/composition.ts b/packages/effect-machine/perf/types/composition.ts index 59cb31ef..50ac29c6 100644 --- a/packages/effect-machine/perf/types/composition.ts +++ b/packages/effect-machine/perf/types/composition.ts @@ -1,56 +1,44 @@ import { Context, Data, Effect } from "effect" import { Machine } from "../../dist/index.js" import { App, Editing, Editor, EditorDone, States, Sync, SyncDone, SyncIdle, Workspace } from "./composition-control.js" - -type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? - true : - false +type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) + ? true + : false type Expect = Value type IsAny = 0 extends 1 & Value ? true : false - -class CompositionService extends Context.Service()( - "perf/composition/CompositionService" -) {} -class CompositionFailure extends Data.TaggedError("CompositionFailure")<{}> {} - +class CompositionService extends Context.Service()("perf/composition/CompositionService") { +} +class CompositionFailure extends Data.TaggedError("CompositionFailure")<{}> { +} const targets = Machine.targets(States) const machine = Machine.make({ branches: { enter: { app: { target: targets.root.App } } }, - 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({}))) - )) - ) - ) + events: Machine.eventsFromSchemas() }) - const handled = machine.handle({ + initial: { + target: Machine.targets(States).root.App, + data: App.make({}) + }, states: { App: { + initial: { + target: Machine.targets(States).root.App.Workspace, + data: Workspace.make({}) + }, states: { Workspace: { + initial: { Editor: Editor.make({}), Sync: Sync.make({}) }, 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({}))) - )) + 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({}))))) ) } }, @@ -60,6 +48,10 @@ const handled = machine.handle({ }), states: { Editor: { + initial: { + target: Machine.targets(States).root.App.Workspace.Editor.Editing, + data: Editing.make({}) + }, states: { Editing: { entry: () => {} @@ -70,6 +62,10 @@ const handled = machine.handle({ } }, Sync: { + initial: { + target: Machine.targets(States).root.App.Workspace.Sync.Idle, + data: SyncIdle.make({}) + }, states: { Idle: {}, Done: { @@ -84,32 +80,32 @@ const handled = machine.handle({ branches: "enter", resolve: ({ select }) => select.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({}))) - )) + 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({}))))) } } } } } }) - type ErrorIsExact = Expect, never>> type ServicesAreExact = Expect, never>> type OutputIsExact = Expect< Equal< - Machine.Machine.OutputByIdentifier<{ readonly "": typeof States.node }, "App.Workspace">, - { readonly Editor: string; readonly Sync: number } + Machine.Machine.OutputByIdentifier<{ + readonly "": typeof States.node + }, "App.Workspace">, + { + readonly Editor: string + readonly Sync: number + } > > type EveryStateIsHandled = Expect, never>> type ErrorIsNotAny = Expect>, false>> type ServicesAreNotAny = Expect>, false>> - void EditorDone void SyncDone void Machine.planInitial(handled) diff --git a/packages/effect-machine/perf/types/define-states.ts b/packages/effect-machine/perf/types/define-states.ts index f08b3c39..7c578716 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.state({ initial: "Idle", states: State.cases }) +const States = Machine.state({ 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 ec015a3a..95e58d4e 100644 --- a/packages/effect-machine/perf/types/definition-variants-control.ts +++ b/packages/effect-machine/perf/types/definition-variants-control.ts @@ -1,19 +1,15 @@ import { Schema } from "effect" import { Machine } from "../../dist/index.js" - export const Flow = Schema.TaggedStruct("Flow", {}) export const Idle = Schema.TaggedStruct("Idle", {}) export const Running = Schema.TaggedStruct("Running", {}) 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.state({ - initial: "Flow", states: { Flow: { schema: Flow, - initial: "Idle", states: { Idle, Running, @@ -33,12 +29,7 @@ export const States = Machine.state({ } } }) - export const machine = Machine.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({})))) - ) + events: Machine.eventsFromSchemas(Start, Finish) }) diff --git a/packages/effect-machine/perf/types/definition-variants.ts b/packages/effect-machine/perf/types/definition-variants.ts index 75f5ba75..b5dfbc9a 100644 --- a/packages/effect-machine/perf/types/definition-variants.ts +++ b/packages/effect-machine/perf/types/definition-variants.ts @@ -1,16 +1,16 @@ import { Machine } from "../../dist/index.js" import { Done, Flow, Idle, machine, Running, States } from "./definition-variants-control.js" - -type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? - true : - false +type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) + ? true + : false type Expect = Value type IsAny = 0 extends 1 & Value ? true : false - const targets = Machine.targets(States) const complete = machine.handle({ + initial: { target: targets.root.Flow }, states: { Flow: { + initial: { target: targets.root.Flow.Idle }, history: { recent: { default: ({ target }) => @@ -28,7 +28,7 @@ const complete = machine.handle({ }, Running: { on: { - Finish: { target: targets.root.Flow.Done, from: ({ event }) => ({ value: event.value }) } + Finish: { target: targets.root.Flow.Done, data: ({ event }) => ({ value: event.value }) } } }, Done: { @@ -38,10 +38,11 @@ const complete = machine.handle({ } } }) - const idleOnly = machine.handle({ + initial: { target: targets.root.Flow }, states: { Flow: { + initial: { target: targets.root.Flow.Idle }, states: { Idle: { on: { @@ -52,28 +53,27 @@ const idleOnly = machine.handle({ } } }) - const runningOnly = machine.handle({ + initial: { target: targets.root.Flow }, states: { Flow: { + initial: { target: targets.root.Flow.Idle }, states: { Running: { on: { - Finish: { target: targets.root.Flow.Done, from: ({ event }) => ({ value: event.value }) } + Finish: { target: targets.root.Flow.Done, data: ({ event }) => ({ value: event.value }) } } } } } } }) - type ErrorIsExact = Expect, never>> type ServicesAreExact = Expect, never>> type OutputIsExact = Expect, string>> type EveryStateIsHandled = Expect, never>> type ErrorIsNotAny = Expect>, false>> type ServicesAreNotAny = Expect>, false>> - void idleOnly void runningOnly void Machine.planInitial(complete) diff --git a/packages/effect-machine/perf/types/dynamic-invoke-control.ts b/packages/effect-machine/perf/types/dynamic-invoke-control.ts index 66716d3f..b6080909 100644 --- a/packages/effect-machine/perf/types/dynamic-invoke-control.ts +++ b/packages/effect-machine/perf/types/dynamic-invoke-control.ts @@ -1,19 +1,12 @@ import { Effect, Schema } from "effect" import { Machine } from "../../dist/index.js" - export class LoadError { readonly _tag = "LoadError" } - export const Loading = Schema.TaggedStruct("Loading", { userId: Schema.String }) - -export const States = Machine.state({ initial: "Loading", states: { Loading } }) - +export const States = Machine.state({ states: { Loading } }) export const loadUser = (userId: string) => Effect.fail(new LoadError()).pipe(Effect.as({ id: userId, name: "Ada" })) - export const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Loading.from(Loading.make({ userId: "user-1" })))) + events: Machine.eventsFromSchemas() }) diff --git a/packages/effect-machine/perf/types/dynamic-invoke.ts b/packages/effect-machine/perf/types/dynamic-invoke.ts index 8ecc5355..f5ec9e79 100644 --- a/packages/effect-machine/perf/types/dynamic-invoke.ts +++ b/packages/effect-machine/perf/types/dynamic-invoke.ts @@ -1,21 +1,19 @@ import { Machine } from "../../dist/index.js" import { LoadError, Loading, loadUser, States } from "./dynamic-invoke-control.js" - interface User { readonly id: string readonly name: string } - const machine = Machine.make({ effects: { "load-user": loadUser }, - root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Loading.from(Loading.make({ userId: "user-1" })))) + events: Machine.eventsFromSchemas() }) - const invoked = machine.handle({ + initial: { + target: Machine.targets(States).root.Loading, + data: Loading.make({ userId: "user-1" }) + }, states: { Loading: { invoke: { @@ -41,5 +39,4 @@ const invoked = machine.handle({ } } }) - void invoked diff --git a/packages/effect-machine/perf/types/exact-channels-control.ts b/packages/effect-machine/perf/types/exact-channels-control.ts index 5be33702..b02a956e 100644 --- a/packages/effect-machine/perf/types/exact-channels-control.ts +++ b/packages/effect-machine/perf/types/exact-channels-control.ts @@ -1,15 +1,15 @@ import { Schema } from "effect" import { Machine } from "../../dist/index.js" - export const Idle = Schema.TaggedStruct("Idle", { value: Schema.Number }) export const Done = Schema.TaggedStruct("Done", { value: Schema.String }) export const Start = Schema.TaggedStruct("Start", { value: Schema.String }) 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.state({ - initial: "Idle", + fields: { + input: Schema.toType(Input) + }, states: { Idle, Done: { @@ -19,13 +19,10 @@ export const States = Machine.state({ } } }) - export const machine = Machine.make({ root: States, events: Machine.eventsFromSchemas(Start), internalEvents: Machine.internalEventsFromSchemas(Loaded), emittedEvents: Machine.emittedEventsFromSchemas(Notice), - input: Input, - initialConfiguration: (root) => - root.resolve(({ input, target }) => target.from((to) => to.Idle.from(Idle.make({ value: input.seed })))) + input: Input }) diff --git a/packages/effect-machine/perf/types/exact-channels.ts b/packages/effect-machine/perf/types/exact-channels.ts index 62c26f74..bbb45a02 100644 --- a/packages/effect-machine/perf/types/exact-channels.ts +++ b/packages/effect-machine/perf/types/exact-channels.ts @@ -1,27 +1,23 @@ import { Effect } from "effect" import { Machine } from "../../dist/index.js" import { Done, Idle, Input, Loaded, Notice, Start, States } from "./exact-channels-control.js" - -type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? - true : - false +type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) + ? true + : false type Expect = Value type IsAny = 0 extends 1 & Value ? true : false - const targets = Machine.targets(States) const machine = Machine.make({ branches: { finish: { done: { target: targets.root.Done } } }, - root: States, events: Machine.eventsFromSchemas(Start), internalEvents: Machine.internalEventsFromSchemas(Loaded), emittedEvents: Machine.emittedEventsFromSchemas(Notice), - input: Input, - initialConfiguration: (root) => - root.resolve(({ input, target }) => target.from((to) => to.Idle.from(Idle.make({ value: input.seed })))) + input: Input }) - const complete = machine.handle({ + root: ({ input }) => ({ input }), + initial: { target: targets.root.Idle, data: ({ root }) => ({ value: root.input.seed }) }, states: { Idle: { entry: () => {}, @@ -33,7 +29,7 @@ const complete = machine.handle({ return select.done.from(Done.make({ value: event.value })) } }, - Loaded: { target: targets.root.Done, from: ({ event }) => ({ value: event.value }) } + Loaded: { target: targets.root.Done, data: ({ event }) => ({ value: event.value }) } } }, Done: { @@ -41,11 +37,16 @@ const complete = machine.handle({ } } }) - type InputSchemaIsExact = Expect< - Equal["Type"], { readonly seed: number }> + Equal["Type"], { + readonly seed: number + }> +> +type InputIsExact = Expect< + Equal, { + readonly seed: number + }> > -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>> @@ -58,7 +59,6 @@ type OutputStatesAreExact = Expect>, false>> type ServicesAreNotAny = Expect>, false>> type OutputIsNotAny = Expect>, false>> - void States void Machine.planInitial(complete, { seed: 1 }) void Machine.start(complete, { seed: 1 }) 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 27816ca1..c48271b4 100644 --- a/packages/effect-machine/perf/types/handle-depth-24-control.ts +++ b/packages/effect-machine/perf/types/handle-depth-24-control.ts @@ -4,103 +4,78 @@ import { Machine } from "../../dist/index.js" export const NodeState = Schema.TaggedStruct("Node", {}) 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, @@ -161,9 +136,5 @@ export const States = Machine.state({ export const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (to) => - to.resolve((): never => { - throw new Error("type-performance fixture") - }) + events: Machine.eventsFromSchemas() }) diff --git a/packages/effect-machine/perf/types/handle-depth-24.ts b/packages/effect-machine/perf/types/handle-depth-24.ts index a1e1cfc9..86724758 100644 --- a/packages/effect-machine/perf/types/handle-depth-24.ts +++ b/packages/effect-machine/perf/types/handle-depth-24.ts @@ -1,6 +1,6 @@ import { Context, Data, Effect } from "effect" import { Machine } from "../../dist/index.js" -import { machine } from "./handle-depth-24-control.js" +import { machine, States } from "./handle-depth-24-control.js" type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? true : @@ -10,55 +10,146 @@ type Expect = Value class DeepService extends Context.Service()("perf/depth-24/DeepService") {} class DeepFailure extends Data.TaggedError("DeepFailure")<{}> {} +const targets = Machine.targets(States) const handled = machine.handle({ + initial: { target: targets.root.n0 }, states: { n0: { + initial: { target: targets.root.n0.n1 }, states: { n1: { + initial: { target: targets.root.n0.n1.n2 }, states: { n2: { + initial: { target: targets.root.n0.n1.n2.n3 }, states: { n3: { + initial: { target: targets.root.n0.n1.n2.n3.n4 }, states: { n4: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5 }, states: { n5: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5.n6 }, states: { n6: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7 }, states: { n7: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8 }, states: { n8: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9 }, states: { n9: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10 + }, states: { n10: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10.n11 + }, states: { n11: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10.n11 + .n12 + }, states: { n12: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10 + .n11.n12.n13 + }, states: { n13: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9 + .n10.n11.n12.n13.n14 + }, states: { n14: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7 + .n8.n9.n10.n11.n12.n13.n14.n15 + }, states: { n15: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6 + .n7.n8.n9.n10.n11.n12.n13.n14.n15.n16 + }, states: { n16: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5 + .n6.n7.n8.n9.n10.n11.n12.n13.n14.n15.n16.n17 + }, states: { n17: { + initial: { + target: targets.root.n0.n1.n2.n3 + .n4.n5.n6.n7.n8.n9.n10.n11.n12.n13.n14 + .n15.n16.n17.n18 + }, states: { n18: { + initial: { + target: targets.root.n0.n1.n2 + .n3.n4.n5.n6.n7.n8.n9.n10.n11.n12 + .n13.n14.n15.n16.n17.n18.n19 + }, states: { n19: { + initial: { + target: targets.root.n0.n1 + .n2.n3.n4.n5.n6.n7.n8.n9.n10.n11 + .n12.n13.n14.n15.n16.n17.n18.n19 + .n20 + }, states: { n20: { + initial: { + target: targets.root + .n0.n1.n2.n3.n4.n5.n6.n7.n8 + .n9.n10.n11.n12.n13.n14.n15 + .n16.n17.n18.n19.n20.n21 + }, states: { n21: { + initial: { + target: + Machine.targets(States) + .root.n0.n1.n2.n3.n4.n5 + .n6.n7.n8.n9.n10.n11.n12 + .n13.n14.n15.n16.n17.n18 + .n19.n20.n21.n22 + }, states: { n22: { + initial: { + target: Machine.targets( + States + ).root.n0.n1.n2.n3.n4 + .n5.n6.n7.n8.n9.n10 + .n11.n12.n13.n14.n15 + .n16.n17.n18.n19.n20 + .n21.n22.n23 + }, states: { n23: { + initial: { + target: + Machine.targets( + States + ).root.n0.n1.n2.n3 + .n4.n5.n6.n7.n8 + .n9.n10.n11.n12 + .n13.n14.n15.n16 + .n17.n18.n19.n20 + .n21.n22.n23.n24 + }, states: { n24: { entry: () => {}, 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 f55e35b6..3edd021c 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 @@ -4,71 +4,54 @@ import { Machine } from "../../dist/index.js" export const NodeState = Schema.TaggedStruct("Node", {}) 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, @@ -144,9 +127,5 @@ export const States = Machine.state({ export const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (to) => - to.resolve((): never => { - throw new Error("type-performance fixture") - }) + events: Machine.eventsFromSchemas() }) 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 6630fbe7..f1d40656 100644 --- a/packages/effect-machine/perf/types/handle-depth-wide-16.ts +++ b/packages/effect-machine/perf/types/handle-depth-wide-16.ts @@ -1,6 +1,6 @@ import { Context, Data, Effect } from "effect" import { Machine } from "../../dist/index.js" -import { machine } from "./handle-depth-wide-16-control.js" +import { machine, States } from "./handle-depth-wide-16-control.js" type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? true : @@ -10,39 +10,76 @@ type Expect = Value class DeepService extends Context.Service()("perf/depth-wide-16/DeepService") {} class DeepFailure extends Data.TaggedError("DeepFailure")<{}> {} +const targets = Machine.targets(States) const handled = machine.handle({ + initial: { target: targets.root.n0 }, states: { n0: { + initial: { target: targets.root.n0.n1 }, states: { n1: { + initial: { target: targets.root.n0.n1.n2 }, states: { n2: { + initial: { target: targets.root.n0.n1.n2.n3 }, states: { n3: { + initial: { target: targets.root.n0.n1.n2.n3.n4 }, states: { n4: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5 }, states: { n5: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5.n6 }, states: { n6: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7 }, states: { n7: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8 }, states: { n8: { + initial: { target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9 }, states: { n9: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10 + }, states: { n10: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10.n11 + }, states: { n11: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10.n11 + .n12 + }, states: { n12: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10 + .n11.n12.n13 + }, states: { n13: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9 + .n10.n11.n12.n13.n14 + }, states: { n14: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6.n7 + .n8.n9.n10.n11.n12.n13.n14.n15 + }, states: { n15: { + initial: { + target: targets.root.n0.n1.n2.n3.n4.n5.n6 + .n7.n8.n9.n10.n11.n12.n13.n14.n15.n16 + }, states: { n16: { entry: () => {}, diff --git a/packages/effect-machine/perf/types/handle.ts b/packages/effect-machine/perf/types/handle.ts index 7a75f14b..850ae10f 100644 --- a/packages/effect-machine/perf/types/handle.ts +++ b/packages/effect-machine/perf/types/handle.ts @@ -1,34 +1,33 @@ import { Schema } from "effect" import { Machine } from "../../dist/index.js" - const State = Schema.TaggedUnion({ Idle: {}, Running: {}, Done: { value: Schema.String } }) - const Event = Schema.TaggedUnion({ Start: {}, Finish: { value: Schema.String } }) - -const States = Machine.state({ initial: "Idle", states: State.cases }) - +const States = Machine.state({ states: State.cases }) const targets1 = Machine.targets(States) -const machine = Machine.make({ root: States, events: Machine.eventsFromSchemas(Event) }).handle({ +const machine = Machine.make({ + root: States, + events: Machine.eventsFromSchemas(Event) +}).handle({ + initial: { target: targets1.root.Idle }, states: { Idle: { on: { - Start: { target: targets1.root.Running, from: () => (State.cases.Running.make({})) } + Start: { target: targets1.root.Running, data: () => (State.cases.Running.make({})) } } }, Running: { on: { - Finish: { target: targets1.root.Done, from: ({ event }) => (State.cases.Done.make({ value: event.value })) } + Finish: { target: targets1.root.Done, data: ({ event }) => (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 bbf282f2..be554094 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.state({ initial: "Idle", states: State.cases }) +const States = Machine.state({ 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 bcea564c..ec51d22b 100644 --- a/packages/effect-machine/perf/types/make.ts +++ b/packages/effect-machine/perf/types/make.ts @@ -12,7 +12,7 @@ const Event = Schema.TaggedUnion({ Finish: { value: Schema.String } }) -const States = Machine.state({ initial: "Idle", states: State.cases }) +const States = Machine.state({ states: State.cases }) const machine = Machine.make({ root: States, diff --git a/packages/effect-machine/perf/types/named-branches-control.ts b/packages/effect-machine/perf/types/named-branches-control.ts index 194470c5..ee6c4b31 100644 --- a/packages/effect-machine/perf/types/named-branches-control.ts +++ b/packages/effect-machine/perf/types/named-branches-control.ts @@ -1,18 +1,13 @@ import { Schema } from "effect" import { Machine } from "../../dist/index.js" - export const State = Schema.TaggedUnion({ Idle: {}, Text: { value: Schema.String }, Count: { value: Schema.Number } }) - export const Route = Schema.TaggedStruct("Route", { value: Schema.String }) -export const States = Machine.state({ initial: "Idle", states: State.cases }) - +export const States = Machine.state({ states: State.cases }) export const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(Route), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Idle.from(State.cases.Idle.make({})))) + events: Machine.eventsFromSchemas(Route) }) diff --git a/packages/effect-machine/perf/types/named-branches.ts b/packages/effect-machine/perf/types/named-branches.ts index 98801a6f..0c01db9f 100644 --- a/packages/effect-machine/perf/types/named-branches.ts +++ b/packages/effect-machine/perf/types/named-branches.ts @@ -1,6 +1,5 @@ import { Machine } from "../../dist/index.js" import { Route, State, States } from "./named-branches-control.js" - const targets = Machine.targets(States) const machine = Machine.make({ branches: { @@ -18,14 +17,11 @@ const machine = Machine.make({ unchanged: { none: true } } }, - root: States, - events: Machine.eventsFromSchemas(Route), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Idle.from(State.cases.Idle.make({})))) + events: Machine.eventsFromSchemas(Route) }) - const handled = machine.handle({ + initial: { target: targets.root.Idle }, states: { Idle: { on: { @@ -65,5 +61,4 @@ const handled = machine.handle({ Count: {} } }) - void Machine.planInitial(handled) diff --git a/packages/effect-machine/perf/types/transition-construction-control.ts b/packages/effect-machine/perf/types/transition-construction-control.ts index e27f4449..4859c610 100644 --- a/packages/effect-machine/perf/types/transition-construction-control.ts +++ b/packages/effect-machine/perf/types/transition-construction-control.ts @@ -6,11 +6,9 @@ export class Saved extends Schema.TaggedClass("Saved")("Saved", { text: S export const States = Machine.state({ schema: Root, - initial: "Idle", states: { Idle: {}, Saved: { schema: Saved } } }) export const machine = Machine.make({ root: States, - events: Machine.events({ Save: { text: Schema.String }, Retry: {}, Reset: {} }), - initial: (root) => root.from(() => ({ count: 0 })) + events: Machine.events({ Save: { text: Schema.String }, Retry: {}, Reset: {} }) }) diff --git a/packages/effect-machine/perf/types/transition-construction.ts b/packages/effect-machine/perf/types/transition-construction.ts index 0c2c57e6..888c816f 100644 --- a/packages/effect-machine/perf/types/transition-construction.ts +++ b/packages/effect-machine/perf/types/transition-construction.ts @@ -1,18 +1,18 @@ import { Schema } from "effect" import { Machine } from "../../dist/index.js" import { Root, Saved, States } from "./transition-construction-control.js" - const targets = Machine.targets(States) const machine = Machine.make({ branches: { reset: { idle: { target: targets.root.Idle }, same: { none: true } } }, - root: States, - events: Machine.events({ Save: { text: Schema.String }, Retry: {}, Reset: {} }), - initial: (root) => root.from(() => ({ count: 0 })) + events: Machine.events({ Save: { text: Schema.String }, Retry: {}, Reset: {} }) }) - const handled = machine.handle({ - on: { Reset: { update: targets.root, guard: ({ root }) => root.count > 0, from: () => ({ count: 0 }) } }, + initial: { + target: Machine.targets(States).root.Idle + }, + root: () => ({ count: 0 }), + on: { Reset: { update: targets.root, guard: ({ root }) => root.count > 0, data: () => ({ count: 0 }) } }, states: { Idle: { on: { @@ -20,7 +20,7 @@ const handled = machine.handle({ target: targets.root.Saved, update: targets.root, guard: ({ event }) => event.text.length > 0, - from: ({ root, event }) => ({ target: { text: event.text }, update: { count: root.count + 1 } }) + data: ({ root, event }) => ({ target: { text: event.text }, update: { count: root.count + 1 } }) } } }, @@ -30,12 +30,13 @@ const handled = machine.handle({ target: targets.root.Saved, update: targets.root, reenter: true, - decoded: ({ root, event }) => ({ + decoded: true, + data: ({ root, event }) => ({ target: new Saved({ text: event.text }), update: new Root({ count: root.count + 1 }) }) }, - Retry: { target: targets.root.Saved, reenter: true, from: ({ state }) => ({ text: state.text }) }, + Retry: { target: targets.root.Saved, reenter: true, data: ({ state }) => ({ text: state.text }) }, Reset: { branches: "reset", reenter: true, diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index f9ac1990..e2b10ca8 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -120,20 +120,19 @@ type IsAny = 0 extends (1 & A) ? true : false * ```ts * import { Machine } from "@typeonce/effect-machine" * import { Schema } from "effect" - * * const events = Machine.events({ Add: { by: Schema.Number } }) * const Root = Machine.state({ fields: { count: Schema.Number } }) * const targets = Machine.targets(Root) * export const counter = Machine.make({ * root: Root, - * events, - * initial: (root) => root.from(() => ({ count: 0 })) + * events * }).handle({ + * root: () => ({ count: 0 }), * on: { * Add: { * update: targets.root, * guard: ({ event }) => event.by > 0, - * from: ({ root, event }) => ({ count: root.count + event.by }) + * data: ({ root, event }) => ({ count: root.count + event.by }) * } * } * }) @@ -284,21 +283,24 @@ export interface Definition< ParentEvents extends ReadonlyArray = readonly [], Registrations extends Machine.Registrations = {} > extends - Machine< - States, - Events, - Input, - Machine.StateIdentifier, - never, - never, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - never, - InputEvents, - ParentEvents + Omit< + Machine< + States, + Events, + Input, + Machine.StateIdentifier, + never, + never, + InitialE, + InitialR, + FinalStates, + Output, + Emits, + never, + InputEvents, + ParentEvents + >, + TypeId | "stateNodes" | "makeTargetBuilder" | "handlers" | "initial" | "initialDefinition" > { /** @@ -353,7 +355,10 @@ export declare namespace Definition { * @category models * @since 0.15.0 */ - export interface Any extends Machine.Any { + export interface Any + extends + Omit + { readonly handle: any } } @@ -668,7 +673,6 @@ type IncompatibleRuntime = Requirements extends Run : never const InvokeTypeId: typeof internal.InvokeTypeId = internal.InvokeTypeId -declare const InitialBuilderTypeId: unique symbol type StateDefinitionError< Message extends string, @@ -835,19 +839,10 @@ type ValidateCompoundStateNode< Node extends Machine.StateNodeConfig, Children extends Machine.StateSchemas, Path extends PropertyKey -> = Node extends { readonly initial: infer Initial } ? - Initial extends ActiveStateKey | ChoiceStateKey ? - & ValidateInitialSelectorChild - & ValidateWithSelectorChild - & { - readonly states: ValidateStateTree> - } - : StateDefinitionError< - "Compound initial must be one of its direct child keys", - Path, - Extract - > - : StateDefinitionError<"Compound states must declare an initial child", Path> +> = "initial" extends keyof Node ? StateDefinitionError<"Declare initial edges in handle", Path> + : ValidateInitialSelectorChild & ValidateWithSelectorChild & { + readonly states: ValidateStateTree> + } type ValidateStateNodeWithoutChildren = "initial" extends keyof Node ? StateDefinitionError<"Atomic states cannot declare an initial child"> @@ -1125,149 +1120,6 @@ type ConstructionSelectorFromCallable = {} extends Input state: (builder: Builder) => Selected ) => Machine.StateConstruction> -type InitialSnapshotBuilderWithPrefix< - States extends Machine.StateSchemas, - Prefix extends string = "" -> = - & { - readonly [Key in ActiveStateKey]: InitialSnapshotMethod - } - & { - readonly [Key in ChoiceStateKey]: () => Machine.ChoiceTargetInstruction> - } - -type InitialSnapshotMethod< - States extends Machine.StateSchemas, - StateId extends ActiveStateKey, - Prefix extends string -> = Machine.NodeSchema extends never ? FromMethod< - InitialSnapshotFromArguments, - InitialSnapshotResult - > - : - & DecodedMethod< - InitialSnapshotArguments, - InitialSnapshotResult - > - & FromMethod< - InitialSnapshotFromArguments, - InitialSnapshotResult - > - -type InitialSnapshotArguments< - States extends Machine.StateSchemas, - StateId extends ActiveStateKey, - 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 } ? - WithNodeValue - ) => SnapshotBuilderComplete> - ]> - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - Node extends { readonly initial: infer Initial extends ActiveStateKey | ChoiceStateKey } ? - WithNodeValue, Initial> - ) => InitialSelectableResult - ]> - : never - : WithNodeValue - : never - -type InitialSnapshotFromArguments< - States extends Machine.StateSchemas, - StateId extends ActiveStateKey, - 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 } ? - WithNodeInput - ) => SnapshotBuilderComplete, boolean> - ]> - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - Node extends { readonly initial: infer Initial extends ActiveStateKey | ChoiceStateKey } ? - WithNodeInput, Initial> - ) => ConstructionResult> - ]> - : never - : WithNodeInput - : never - -type InitialSnapshotResult< - States extends Machine.StateSchemas, - StateId extends ActiveStateKey, - 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 } ? - Machine.ParallelSnapshot< - Path, - NodeValue, - InitialSnapshotRegionsWithPrefix - > - : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - Node extends { readonly initial: infer Initial extends ActiveStateKey | ChoiceStateKey } ? - Machine.CompoundSnapshot< - Path, - NodeValue, - InitialSelectableResult - > - : never - : Machine.AtomicSnapshot> - : never - -type InitialSelectableResult< - States extends Machine.StateSchemas, - StateId extends ActiveStateKey | ChoiceStateKey, - Prefix extends string -> = StateId extends ChoiceStateKey ? Machine.ChoiceTargetInstruction> - : StateId extends ActiveStateKey ? InitialSnapshotResult - : never - -type InitialSnapshotRegionsWithPrefix< - States extends Machine.StateSchemas, - Prefix extends string -> = { - readonly [Key in ActiveStateKey]: InitialSnapshotResult -} - -type InitialParallelBuilder< - States extends Machine.StateSchemas, - Prefix extends string, - Remaining extends ActiveStateKey = ActiveStateKey, - Regions = {}, - Constructed extends boolean = false -> = - & SnapshotBuilderComplete - & { - readonly [Key in Remaining]: NodeBuilderMethod< - States[Key], - InitialSnapshotArguments, - InitialParallelBuilder< - States, - Prefix, - Exclude, - Regions & { readonly [Region in Key]: InitialSnapshotResult }, - Constructed - >, - InitialSnapshotFromArguments, - InitialParallelBuilder< - States, - Prefix, - Exclude, - Regions & { readonly [Region in Key]: InitialSnapshotResult }, - true - > - > - } - type FullSnapshotBuilderWithPrefix< in out States extends Machine.StateSchemas, in out Prefix extends string = "" @@ -1843,16 +1695,9 @@ type InitializerClosureForNode< 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 ? - RequiresInitialValue extends true ? Extract> : never - : never) - | (Initial extends ActiveStateKey ? InitializerClosureForNode< - AllStates, - Children[Initial], - Machine.JoinPath - > - : never) + : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? + | Extract> + | InitializerClosuresForChildren : never : never @@ -3170,7 +3015,6 @@ export declare namespace Machine { /** Compound states are ordinary active states. */ readonly type?: "active" /** Direct child selected when the compound state is entered initially. */ - readonly initial: string /** Nested state nodes owned by this compound state. */ readonly states: StateTree /** Schema-backed states take their annotations from the schema. */ @@ -3179,7 +3023,6 @@ export declare namespace Machine { | { readonly schema?: never readonly type?: "active" - readonly initial: string readonly states: StateTree /** Descriptive metadata for a schema-less state. */ readonly annotations?: SchemaLessStateAnnotations @@ -3305,7 +3148,6 @@ export declare namespace Machine { * @category utility types * @since 0.4.0 */ - type InitialBuilder = InitialSnapshotBuilderWithPrefix /** * Typed snapshot access shared by state descriptors. @@ -3876,19 +3718,20 @@ export declare namespace Machine { }) & EnsureChoiceImplementations - /** Owners whose declared default descendants require supplied values. */ + /** Owners that require a handler-owned initial edge or region data. */ export type RequiredInitializers = string extends keyof States ? never : - "" extends keyof States ? States[""] extends { readonly "~effect/Machine/ExplicitInitial": true } ? never - : InitializerClosureForNode : + "" extends keyof States ? 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> - } + > = [UnhandledStates] extends [never] ? unknown + : [Extract, UnhandledStates>] extends [never] ? unknown : + { + readonly "~effect/Machine/MissingInitialImplementation": Extract, UnhandledStates> + } /** * Extracts a state-tree node by state path. @@ -4843,15 +4686,6 @@ export declare namespace Machine { type RootBuilder = Builder extends { readonly "": infer Root } ? Root : never - /** Definition-time selector that can choose only a valid top-level initial entry. */ - type InitialTargetSelector = { - readonly [Key in Extract, keyof InitialBuilder>]: States[Key] extends - { readonly states: StateSchemas } ? { - readonly initial: SelectionValue[Key], Key, "initial", "initial"> - } - : SelectionMethod[Key], Key, "state", "initial"> - } - /** * Context passed to a state/event handler. * @@ -5213,16 +5047,7 @@ export declare namespace Machine { * @category utility types * @since 0.4.0 */ - export type InitialResult = - | Snapshot - | InitialSnapshot - | InitialTarget> - | StateConstruction | InitialSnapshot | InitialTarget>> - - /** Initial snapshots may transiently terminate in a declared choice node. */ - export type InitialSnapshot = { - readonly [StateId in ActiveStateKey]: InitialSnapshotResult - }[ActiveStateKey] + export type InitialResult = InitialTarget> /** * Return value accepted from transition handlers. @@ -5308,10 +5133,6 @@ export declare namespace Machine { export type StateActionReturn = Key extends keyof Config ? NonNullable extends (...args: any) => infer Ret ? Ret : never : never - /** Extracts the return value from an implicit initial child implementation. */ - export type StateInitializeReturn = Config extends { readonly initialize?: infer Initialize } - ? NonNullable extends (...args: any) => infer Ret ? Ret : never - : never /** Extracts the return values from a state's history defaults. */ export type HistoryDefaultReturn = Config extends { readonly history?: infer History } ? { readonly [Key in keyof NonNullable]: NonNullable[Key] extends { @@ -5543,7 +5364,7 @@ export declare namespace Machine { * @category utility types * @since 0.4.0 */ - export type ConfigServices = + export type ConfigServices = Config extends unknown ? [keyof Config] extends [never] ? never : | Effect.Services> | Effect.Services> | Effect.Services> @@ -5552,6 +5373,7 @@ export declare namespace Machine { | Effect.Services> | Effect.Services> | InvokeRequirements + : never /** The only transition value accepted by machine handler APIs. */ type TransitionConfig< @@ -5713,104 +5535,6 @@ export declare namespace Machine { Branches extends Readonly> > = Omit & { readonly select: BranchSelectors } - 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 - } - - /** @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. */ - readonly resolve: ( - resolve: (context: { - /** Decoded value supplied when the machine is started. */ - readonly input: Input - /** Builder specialized to the selected initial destination. */ - readonly target: SelectionBuilder - }) => SelectedTargetResult - ) => InitialBuilderEvidence - } - - type InitialSelectorNode = Node extends (...args: infer Args) => infer Selection ? - Selection extends TargetSelection ? - & ((...args: Args) => InitialTransitionTarget) - & { - readonly [Key in keyof Node]: InitialSelectorNode - } - : never - : Node extends TargetSelection ? InitialTransitionTarget - : { - readonly [Key in keyof Node]: InitialSelectorNode - } - - /** Definition-time selector for a machine's top-level initial entry. */ - export type InitialSelector = InitialSelectorNode< - Input, - InitialTargetSelector - > - - /** Target-first initial-entry declaration accepted by {@link make}. */ - export type InitialBuilderInput = ( - 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> - export type InvokeTransition< States extends StateSchemas, Events extends ReadonlyArray, @@ -5984,120 +5708,6 @@ export declare namespace Machine { readonly output?: never } - /** - * Values constructed for a state's schema-valued direct initial children. - * - * @category utility types - * @since 0.13.0 - */ - export type StateInitializeValue< - States extends StateSchemas, - StateId extends StateIdentifier - > = NodeByIdentifier extends infer Node ? - Node extends { readonly type: "parallel"; readonly states: infer Children extends StateSchemas } ? { - readonly [Key in ActiveStateKey as NodeSchema extends never ? never : Key]: NodeValue< - Children[Key] - > - } - : Node extends { readonly states: infer Children extends StateSchemas; readonly initial: infer Initial } ? - Initial extends ActiveStateKey ? NodeSchema extends never ? never - : { readonly [Key in Initial]: NodeValue } - : never - : never - : never - - type StateInitializeCompoundBuilder = Node extends { - readonly states: infer Children extends StateSchemas - } ? Initial extends ActiveStateKey ? NodeSchema extends never ? never - : NodeBuilderMethod< - Children[Initial], - readonly [value: NodeValue], - SnapshotBuilderComplete<{ readonly [Key in Initial]: NodeValue }>, - readonly [input: NodeMakeInput], - SnapshotBuilderComplete<{ readonly [Key in Initial]: NodeValue }, true> - > - : never - : never - - type StateInitializeParallelBuilder< - Children extends StateSchemas, - Remaining extends ActiveStateKey = { - readonly [Key in ActiveStateKey]: NodeSchema extends never ? never : Key - }[ActiveStateKey], - Values = {}, - Constructed extends boolean = false - > = - & SnapshotBuilderComplete - & { - readonly [Key in Remaining]: NodeBuilderMethod< - Children[Key], - readonly [value: NodeValue], - StateInitializeParallelBuilder< - Children, - Exclude, - Values & { readonly [Region in Key]: NodeValue }, - Constructed - >, - readonly [input: NodeMakeInput], - StateInitializeParallelBuilder< - Children, - Exclude, - Values & { readonly [Region in Key]: NodeValue }, - true - > - > - } - - /** - * Builder bound to the direct initial child or regions owned by a state. - * - * @category utility types - * @since 0.13.0 - */ - export type StateInitializeBuilder< - States extends StateSchemas, - StateId extends StateIdentifier, - Node = NodeByIdentifier - > = Node extends { readonly type: "parallel"; readonly states: infer Children extends StateSchemas } ? - StateInitializeParallelBuilder - : Node extends { readonly initial: infer Initial } ? StateInitializeCompoundBuilder - : never - - /** - * Context passed to an implicit child-state initializer. - * - * @category models - * @since 0.13.0 - */ - export type StateInitializeContext< - States extends StateSchemas, - Events extends ReadonlyArray, - Emits extends ReadonlyArray, - StateId extends StateIdentifier, - InputEvents extends ReadonlyArray = Events, - ParentEvents extends ReadonlyArray = readonly [] - > = StateActionContext & { - readonly builder: StateInitializeBuilder - } - - /** - * Initial child value implementation for a compound or parallel state. - * - * @category models - * @since 0.13.0 - */ - export type StateInitializeHandler< - States extends StateSchemas, - Events extends ReadonlyArray, - Emits extends ReadonlyArray, - StateId extends StateIdentifier, - InputEvents extends ReadonlyArray = Events, - ParentEvents extends ReadonlyArray = readonly [] - > = ( - context: StateInitializeContext, - enqueue: Enqueue, EmittedEventOf> - ) => SnapshotBuilderComplete, boolean> - /** Context used only when a history node has no previously captured record. */ export interface HistoryDefaultContext< in out States extends StateSchemas, @@ -6186,13 +5796,12 @@ export declare namespace Machine { readonly on?: never readonly onDone?: never readonly output?: never - readonly initialize?: never } type HandlerChildren = Node extends { readonly states: infer Children extends StateSchemas } ? Children : never type HandlerConfigPart = { - readonly [Key in keyof Config as Key extends "states" ? never : Key]: Config[Key] + readonly [Key in keyof Config as Key extends "states" | "initial" | "root" ? never : Key]: Config[Key] } type HandlerNodeChildrenConfig = "states" extends keyof Config ? @@ -6239,7 +5848,8 @@ export declare namespace Machine { | "entry" | "exit" | "history" - | "initialize" + | "initial" + | "root" | "invoke" | "on" | "onDone" @@ -6443,9 +6053,9 @@ export declare namespace Machine { Config > = [HistoryIdentifier] extends [never] ? unknown : StateId extends RequiredHistoryInitializers | RequiredInitializers ? - "initialize" extends keyof Config ? unknown : { - readonly initialize: HandlerValidationError< - "State requires initialize for shallow history restoration", + "initial" extends keyof Config ? unknown : { + readonly initial: HandlerValidationError< + "State requires an initial declaration for shallow history restoration", StateId > } @@ -6470,6 +6080,7 @@ export declare namespace Machine { & HandlerChildrenValidation & HandlerOutputRequirementValidation & HandlerRequiredHistoryInitializeValidation + & ValidateConstructionDeclarations & HandlerRuntimeValidation : unknown @@ -6518,7 +6129,7 @@ export declare namespace Machine { Required extends string ? HandlerConfigAtPath<{ readonly "": Config }, Required> extends infer NodeConfig ? [NodeConfig] extends [never] ? RootInitializeError - : "initialize" extends keyof NodeConfig ? never + : "initial" extends keyof NodeConfig ? never : RootInitializeError : never : unknown @@ -6540,53 +6151,71 @@ export declare namespace Machine { }> } - type RootHandlerValidation< + type HandlerTreeValidation< States extends StateSchemas, + Node, Events extends ReadonlyArray, InputEvents extends ReadonlyArray, Emits extends ReadonlyArray, + Path extends StateNodeIdentifier, Config, OutputStates extends StateIdentifier > = & HandlerNodeValidation< States, - States[""], + Node, Events, InputEvents, Emits, - Extract<"", StateNodeIdentifier>, + Path, 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 - > + & (Config extends { readonly states: infer Handlers } + ? Node extends { readonly states: infer Children extends StateSchemas } ? { + readonly states: { + readonly [K in keyof Handlers]: K extends keyof Children ? HandlerTreeValidation< + States, + Children[K], + Events, + InputEvents, + Emits, + Extract>, StateNodeIdentifier>, + Handlers[K], + OutputStates + > : + unknown + } + } : + unknown + : unknown) - type HandlerHasRequiredInitial< - AllStates extends StateSchemas, - StateId extends StateIdentifier, - Config - > = StateId extends RequiredHistoryInitializers | RequiredInitializers - ? "initialize" extends keyof Config ? true : false + type RootHandlerValidation< + States extends StateSchemas, + Events extends ReadonlyArray, + InputEvents extends ReadonlyArray, + Emits extends ReadonlyArray, + Config, + OutputStates extends StateIdentifier + > = HandlerTreeValidation< + States, + States[""], + Events, + InputEvents, + Emits, + Extract<"", StateNodeIdentifier>, + Config, + OutputStates + > + + type HandlerHasRequiredInitial< + Node, + Config + > = Node extends { readonly states: infer Children extends StateSchemas } + ? Node extends { readonly type: "parallel" } + ? HasRequiredActiveChild extends true ? "initial" extends keyof Config ? true : false : true + : "initial" extends keyof Config ? true + : false : true type HandlerHasRequiredHistoryDefaults = Node extends { @@ -6614,15 +6243,13 @@ export declare namespace Machine { Node, StateId extends StateIdentifier, Config - > = [HistoryIdentifier | ChoiceIdentifier | RequiredInitializers] extends [never] ? - StateId - : HandlerHasRequiredInitial extends true ? - HandlerHasRequiredHistoryDefaults extends true ? - HandlerHasRequiredChoices extends true ? StateId : never - : never + > = HandlerHasRequiredInitial extends true ? + HandlerHasRequiredHistoryDefaults extends true ? + HandlerHasRequiredChoices extends true ? StateId : never + : never : never - type HandlerConfigError = + type HandlerConfigError = Config extends unknown ? [keyof Config] extends [never] ? never : | Effect.Error> | Effect.Error> | Effect.Error> @@ -6631,16 +6258,17 @@ export declare namespace Machine { | Effect.Error> | Effect.Error> | InvokeError + : never type HandlerNodeEvidence< AllStates extends StateSchemas, - Config, + Node, StateId extends StateNodeIdentifier, - NodeConfig = HandlerConfigAtPath + NodeConfig > = [NodeConfig] extends [never] ? never : { readonly stateId: StateId extends StateIdentifier ? HandlerImplementedStateId< AllStates, - HandlerNodeByPath, + Node, StateId, NodeConfig > @@ -6657,11 +6285,25 @@ export declare namespace Machine { // Compute every accumulated channel from one normalized node union instead // of repeating recursive walks for states, errors, services, and outputs. - type HandlerTreeEvidence = StateNodeIdentifier extends - infer StateId extends StateNodeIdentifier ? - StateId extends StateNodeIdentifier ? HandlerNodeEvidence - : never - : never + type HandlerTreeEvidence< + AllStates extends StateSchemas, + Config, + Nodes extends StateSchemas = AllStates, + Prefix extends string = "" + > = { + readonly [K in Extract]: + | HandlerNodeEvidence< + AllStates, + Nodes[K], + Extract, StateNodeIdentifier>, + Config[K] + > + | (Nodes[K] extends { readonly states: infer Children extends StateSchemas } + ? Config[K] extends { readonly states: infer Handlers } + ? HandlerTreeEvidence> + : never + : never) + }[Extract] type HandleTreeResult< AllStates extends StateSchemas, @@ -6730,9 +6372,7 @@ export declare namespace Machine { Sources[K] extends (...args: never[]) => unknown ? Parameters["length"] extends 1 ? unknown : never : unknown } - type ReferenceRoot = "~effect/Machine/ExplicitInitial" extends keyof S[""] - ? Extract, StateNodeConfig> - : Extract + type ReferenceRoot = Extract type ReferenceAt | HistoryIdentifier> = TargetReference< ReferenceRoot, P, @@ -6752,7 +6392,7 @@ export declare namespace Machine { } & ( | { - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target: ReferenceUnion> /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update?: ReferenceUnion> @@ -6766,7 +6406,7 @@ export declare namespace Machine { | { /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update: ReferenceUnion> - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target?: never /** Enters a compound or parallel subtree through its declared initialization. */ readonly initial?: never @@ -6778,7 +6418,7 @@ export declare namespace Machine { | { /** Enters a compound or parallel subtree through its declared initialization. */ readonly initial: ReferenceUnion, "">> - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target?: never /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update?: never @@ -6790,7 +6430,7 @@ export declare namespace Machine { | { /** Restores the declared history reference, using its fallback on first entry. */ readonly history: ReferenceUnion> - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target?: never /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update?: never @@ -6802,7 +6442,7 @@ export declare namespace Machine { | { /** Accepts the event without selecting a new destination. */ readonly none: true - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target?: never /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update?: never @@ -6841,7 +6481,7 @@ export declare namespace Machine { > type RefPath = R extends TargetReference ? P : never type SelectionOf, D> = D extends { - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target: infer Ref } ? RefPath extends infer P extends StateNodeIdentifier ? D extends { /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ @@ -6930,7 +6570,7 @@ export declare namespace Machine { type InlineDestination, C> = { readonly [P in DestinationPath]: & { - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target: ReferenceAt /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update?: never @@ -6963,7 +6603,7 @@ export declare namespace Machine { readonly [K in M]: ( context: C ) => { - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target: BuilderArgs, M>[0] /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update: BuilderArgs, M>[0] @@ -6998,7 +6638,7 @@ export declare namespace Machine { readonly [P in DestinationPath]: { readonly [Owner in Extract, ParentStateIdentifier

& ValuedStateIdentifier>]: & { - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target: ReferenceAt /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update: ReferenceAt @@ -7027,7 +6667,7 @@ export declare namespace Machine { readonly reenter?: P extends Src ? never : boolean /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update: ReferenceAt - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target?: never /** Enters a compound or parallel subtree through its declared initialization. */ readonly initial?: never @@ -7051,7 +6691,7 @@ export declare namespace Machine { & { /** Enters a compound or parallel subtree through its declared initialization. */ readonly initial: ReferenceAt - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target?: never /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update?: never @@ -7071,7 +6711,7 @@ export declare namespace Machine { }[Exclude, "">] type BranchSelections, Group> = { readonly [K in Extract]: { - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target: SelectionOf } } @@ -7112,7 +6752,7 @@ export declare namespace Machine { & { /** Named groups of inspectable destinations used by transition resolvers. */ readonly branches: K - /** Selects the declared descendant destination. Supply required values with from or decoded. */ + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ readonly target?: never /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update?: never @@ -7134,6 +6774,13 @@ export declare namespace Machine { readonly resolve: ObjectBranchResolver[K]>, true> } }[Extract, string>] + type DataValue = A | ((context: C) => A) + type DeclarationData = T extends { readonly from: (context: infer C) => infer A } + ? Omit & { readonly data: DataValue; readonly decoded?: false } + : T extends { readonly decoded: (context: infer C) => infer A } + ? Omit & { readonly data: DataValue; readonly decoded: true } + : T extends unknown ? Omit & { readonly data?: never; readonly decoded?: never } + : never /** * A transition with an inspectable destination and source-specific construction. * @@ -7151,60 +6798,62 @@ export declare namespace Machine { Acceptance extends TransitionAcceptance = "required" > = & ObjectPolicy - & ( - | InlineDestination - | InlineUpdate - | InlineCombined - | InlineInitial - | { - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history: ReferenceUnion> - /** Selects the declared descendant destination. Supply required values with from or decoded. */ - readonly target?: never - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - /** Named groups of inspectable destinations used by transition resolvers. */ - readonly branches?: never - /** Selects one declared branch and may enqueue synchronous commands. */ - readonly resolve?: never - /** Constructs schema make input from the typed source context. */ - readonly from?: never - /** Supplies an already decoded schema value from the typed source context. */ - readonly decoded?: never - /** Set to true when the resolver can explicitly return decline(). */ - readonly declinable?: never - } - | (Src extends ChoiceIdentifier ? never : { - /** Accepts the event without selecting a new destination. */ - readonly none: true - /** Selects the declared descendant destination. Supply required values with from or decoded. */ - readonly target?: never - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update?: never - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: never - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: never - /** Named groups of inspectable destinations used by transition resolvers. */ - readonly branches?: never - /** Constructs schema make input from the typed source context. */ - readonly from?: never - /** Supplies an already decoded schema value from the typed source context. */ - readonly decoded?: never - /** Set to true when the resolver can explicitly return decline(). */ - readonly declinable?: "declinable" extends Acceptance ? boolean : false - /** Selects one declared branch and may enqueue synchronous commands. */ - readonly resolve?: ( - context: C & DeclineCapability, - enqueue: Enqueue, EmittedEventOf> - ) => undefined | Declined - }) - | ObjectBranches - ) + & DeclarationData< + ( + | InlineDestination + | InlineUpdate + | InlineCombined + | InlineInitial + | { + /** Restores the declared history reference, using its fallback on first entry. */ + readonly history: ReferenceUnion> + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ + readonly target?: never + /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ + readonly update?: never + /** Enters a compound or parallel subtree through its declared initialization. */ + readonly initial?: never + /** Accepts the event without selecting a new destination. */ + readonly none?: never + /** Named groups of inspectable destinations used by transition resolvers. */ + readonly branches?: never + /** Selects one declared branch and may enqueue synchronous commands. */ + readonly resolve?: never + /** Constructs schema make input from the typed source context. */ + readonly from?: never + /** Supplies an already decoded schema value from the typed source context. */ + readonly decoded?: never + /** Set to true when the resolver can explicitly return decline(). */ + readonly declinable?: never + } + | (Src extends ChoiceIdentifier ? never : { + /** Accepts the event without selecting a new destination. */ + readonly none: true + /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ + readonly target?: never + /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ + readonly update?: never + /** Enters a compound or parallel subtree through its declared initialization. */ + readonly initial?: never + /** Restores the declared history reference, using its fallback on first entry. */ + readonly history?: never + /** Named groups of inspectable destinations used by transition resolvers. */ + readonly branches?: never + /** Constructs schema make input from the typed source context. */ + readonly from?: never + /** Supplies an already decoded schema value from the typed source context. */ + readonly decoded?: never + /** Set to true when the resolver can explicitly return decline(). */ + readonly declinable?: "declinable" extends Acceptance ? boolean : false + /** Selects one declared branch and may enqueue synchronous commands. */ + readonly resolve?: ( + context: C & DeclineCapability, + enqueue: Enqueue, EmittedEventOf> + ) => undefined | Declined + }) + | ObjectBranches + ) + > type SourceValue = { readonly [Kind in "effects" | "streams" | "timers" | "logic" | "children"]: K extends keyof Registered ? Registered[K] @@ -7388,6 +7037,128 @@ export declare namespace Machine { : {}) > }[Extract, string>] + type OrdinaryData = A extends unknown ? + & A + & ( + | { readonly decoded?: false | undefined } + | { readonly data?: never } + ) : + never + type ShortConstruction = [NodeSchema] extends [never] ? never + : OrdinaryData> | ((context: C) => NodeMakeInput) | { + readonly decoded: true + readonly data: DataValue> + } + type InitialData = [NodeSchema] extends [never] ? { readonly data?: never; readonly decoded?: never } + : + | ( + & { readonly decoded?: false } + & ({} extends NodeMakeInput ? { readonly data?: DataValue> } + : { readonly data: DataValue> }) + ) + | { readonly decoded: true; readonly data: DataValue> } + type InitialEdge, C> = Node extends + { readonly states: infer Children extends StateSchemas } ? { + readonly [K in ActiveStateKey | ChoiceStateKey]: + & { + readonly target: TargetReference< + ReferenceRoot, + JoinPath, + Children[K] extends ChoiceStateNodeConfig ? "choice" : "state" + > + } + & InitialData + }[ActiveStateKey | ChoiceStateKey] + : never + type InitialRegions = + & { readonly [K in ActiveStateKey as [NodeSchema] extends [never] ? K : never]?: never } + & { + readonly [ + K in ActiveStateKey as [NodeSchema] extends [never] ? never + : {} extends NodeMakeInput ? never + : K + ]: ShortConstruction + } + & { + readonly [ + K in ActiveStateKey as [NodeSchema] extends [never] ? never + : {} extends NodeMakeInput ? K + : never + ]?: ShortConstruction + } + type RequiresInitialHandler = Node extends { readonly states: StateSchemas } ? true : false + type ChildHandlerMap< + S extends StateSchemas, + Children extends StateSchemas, + Ev extends ReadonlyArray, + Em extends ReadonlyArray, + Src extends StateNodeIdentifier, + In extends ReadonlyArray, + Pa extends ReadonlyArray, + R + > = + & { + readonly [ + K in ActiveStateKey | ChoiceStateKey as RequiresInitialHandler extends true ? K + : never + ]: StateHandler, StateNodeIdentifier>, In, Pa, R> + } + & { + readonly [ + K in ActiveStateKey | ChoiceStateKey as RequiresInitialHandler extends true + ? never + : K + ]?: StateHandler, StateNodeIdentifier>, In, Pa, R> + } + type ChildHandlers< + S extends StateSchemas, + Children extends StateSchemas, + Ev extends ReadonlyArray, + Em extends ReadonlyArray, + Src extends StateNodeIdentifier, + In extends ReadonlyArray, + Pa extends ReadonlyArray, + R + > = [ + { + [K in ActiveStateKey | ChoiceStateKey]: RequiresInitialHandler extends true ? K + : never + }[ActiveStateKey | ChoiceStateKey] + ] extends [never] ? { readonly states?: ChildHandlerMap } + : { readonly states: ChildHandlerMap } + type InitialHandler, C> = Node extends + { readonly type: "parallel"; readonly states: infer Children extends StateSchemas } + ? {} extends InitialRegions ? { readonly initial?: InitialRegions } + : { readonly initial: InitialRegions } + : Node extends { readonly states: StateSchemas } ? { readonly initial: InitialEdge } + : { readonly initial?: never } + type RootConstruction = [NodeSchema] extends [never] ? + { /** Root-owned data is available only when the root declares a schema. */ readonly root?: never } + : {} extends NodeMakeInput ? { + /** Constructs root-owned data once from startup input. */ readonly root?: ShortConstruction< + { readonly input: Input }, + Node + > + } + : { + /** Constructs root-owned data once from startup input. */ readonly root: ShortConstruction< + { readonly input: Input }, + Node + > + } + type ExactConstruction = C extends { readonly decoded: true; readonly data: unknown } + ? { readonly [K in Exclude]: never } + : unknown + type ValidateInitialDeclaration = Node extends { + readonly type: "parallel" + readonly states: infer Children extends StateSchemas + } ? { readonly [K in keyof C]: K extends ActiveStateKey ? ExactConstruction : never } + : { readonly [K in Exclude]: never } + type ValidateConstructionDeclarations = { + readonly [K in Extract]: K extends "root" ? ExactConstruction + : K extends "initial" ? ValidateInitialDeclaration + : unknown + } type ObjectLifecycle< S extends StateSchemas, Node, @@ -7407,8 +7178,7 @@ export declare namespace Machine { & (Node extends { readonly type: "final" } ? & { /** Runs synchronously on exit and may enqueue commands. */ - readonly exit?: never /** Constructs required values for the declared initial child or parallel regions. */ - readonly initialize?: never + readonly exit?: never } & OutputHandlerConfig> : { @@ -7417,14 +7187,11 @@ export declare namespace Machine { context: StateActionContext, enqueue: Enqueue, EmittedEventOf> ) => StateActionResult - /** Constructs required values for the declared initial child or parallel regions. */ - readonly initialize?: StateInitializeHandler } & ActiveOutputHandlerConfig) : { /** Runs synchronously on entry and may enqueue commands. */ readonly entry?: never /** Runs synchronously on exit and may enqueue commands. */ - readonly exit?: never /** Constructs required values for the declared initial child or parallel regions. */ - readonly initialize?: never /** Constructs the decoded output declared by the state schema. */ + readonly exit?: never /** Constructs the decoded output declared by the state schema. */ readonly output?: never } /** @@ -7444,6 +7211,15 @@ export declare namespace Machine { R > = & ObjectLifecycle + & InitialHandler< + S, + Node, + Src, + StateActionContext>, In, Pa> & { + /** Root-owned data constructed before initial descendant values. */ + readonly root: StateByIdentifier>> + } + > & (Src extends ChoiceIdentifier ? { /** Required total transition for a transient choice state. */ readonly choice: Transition, Ev, Em, R> @@ -7528,21 +7304,9 @@ export declare namespace Machine { readonly states: infer Children extends StateSchemas } ? { /** Child handlers nested according to the declared root topology. */ - readonly states?: { - readonly [K in ActiveStateKey | ChoiceStateKey]?: StateHandler< - S, - Children[K], - Ev, - Em, - Extract, StateNodeIdentifier>, - In, - Pa, - R - > - } /** Restores the declared history reference, using its fallback on first entry. */ readonly history?: HistoryDefaultConfig - } : + } & ChildHandlers : { /** Child handlers nested according to the declared root topology. */ readonly states?: never /** Restores the declared history reference, using its fallback on first entry. */ @@ -7560,7 +7324,7 @@ export declare namespace Machine { | "history" | "none" | "branches" - | "from" + | "data" | "decoded" | "resolve" | "guard" @@ -7670,9 +7434,12 @@ export declare namespace Machine { config: & C & (C extends (...args: never[]) => unknown ? never : unknown) - & ([Ev[number] | keyof Registered | SourceKeys] extends [never] - ? StateHandler>, In, Pa, R> - : HandlerShape>, In, Pa, R>>) + & HandlerShape< + C, + & StateHandler>, In, Pa, R> + & RootConstruction + > + & RootConstruction & ValidateObjectConfig, R> & RootHandlerValidation< S, @@ -7941,24 +7708,23 @@ export const isFinal: < ) => state is Machine.SnapshotContainingFinal = internal.isFinal as any /** - * Defines one reusable active state while preserving its exact child topology. + * Defines root or nested state schemas while preserving the exact child topology. * - * Prefer declaring state nodes inline in {@link states}. Use this constructor - * only when the same state definition is mounted more than once. - * Tagged schemas are already reusable and do not need this constructor. + * Pass the root descriptor to {@link make} and {@link targets}. Nested states + * can be declared inline or reused by mounting a descriptor in multiple places. + * Initial edges and value constructors belong in the handler tree. * * **Example** (Repeated compound state) * * ```ts - * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" + * import { Schema } from "effect" * * const TradingState = Schema.TaggedUnion({ * InSession: {}, * Applying: {} * }) * const TradingSlot = Machine.state({ - * initial: "Idle", * states: { * Idle: {}, * InSession: TradingState.cases.InSession, @@ -7966,10 +7732,12 @@ export const isFinal: < * } * }) * - * const States = Machine.state({ initial: "slot1", states: { - * slot1: TradingSlot, - * slot2: TradingSlot - * } }) + * const States = Machine.state({ + * states: { + * slot1: TradingSlot, + * slot2: TradingSlot + * } + * }) * ``` * * @category constructors @@ -8142,83 +7910,8 @@ type MakeResult< } > -type RootInitialization = - & { readonly initialConfiguration?: never } - & (Machine.NodeSchema extends never ? { - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: Machine.RootInitialBuilderInput - } - : {} extends NodeMakeInput ? { - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial?: Machine.RootInitialBuilderInput - } - : { - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial: Machine.RootInitialBuilderInput - }) - /** @inline */ interface Make { - /** @param config Complete starting configuration, including root and descendant values. */ - < - const Root extends Machine.StateNodeConfig, - 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, - const Effects extends Machine.EffectSources = {}, - const Streams extends Machine.StreamSources = {}, - const Timers extends Machine.TimerSources = {}, - const Logics extends Machine.LogicSources = {}, - const Children extends Machine.ChildSources = {}, - const Branches extends Machine.BranchDefinitions> = {} - >( - config: - & MakeConfig< - Root, - InputEvents, - Emits, - Input, - InitialE, - InitialR, - InternalEvents, - ParentDeclaration, - Effects, - Streams, - Timers, - Logics, - Children, - Branches - > - & UniqueSourceNames, NoInfer, NoInfer, NoInfer, NoInfer> - & { - /** 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, - Effects, - Streams, - Timers, - Logics, - Children, - Branches - > /** @param config Complete schema-first machine definition. */ < const Root extends Machine.StateNodeConfig, @@ -8254,10 +7947,6 @@ interface Make { Branches > & UniqueSourceNames, NoInfer, NoInfer, NoInfer, NoInfer> - & Exclude< - RootInitialization, Input["Type"]>, - { readonly initialConfiguration: unknown } - > ): MakeResult< Root, InputEvents, @@ -8304,35 +7993,40 @@ interface Make { * **Example** (Typed counter machine) * * ```ts - * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" - * + * import { Schema } from "effect" * class Count extends Schema.TaggedClass("Count")("Count", { * value: Schema.Number - * }) {} - * + * }) { + * } * class Increment extends Schema.TaggedClass("Increment")("Increment", { * by: Schema.Number - * }) {} - * - * const States = Machine.state({ initial: "Count", states: { Count } }) + * }) { + * } + * const States = Machine.state({ states: { Count } }) * const Events = Machine.eventsFromSchemas(Increment) * const targets = Machine.targets(States) - * * const counter = Machine.make({ * root: States, - * events: Events, - * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Count.decoded(new Count({ value: 0 })))) - * }).handle({ states: { - * Count: { - * on: { - * Increment: { - * update: targets.root.Count, - * decoded: ({ event, state }) => new Count({ value: state.value + event.by }) + * events: Events + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Count, + * decoded: true, + * data: new Count({ value: 0 }) + * }, + * states: { + * Count: { + * on: { + * Increment: { + * update: targets.root.Count, + * decoded: true, + * data: ({ event, state }) => new Count({ value: state.value + event.by }) + * } * } * } * } - * } }) + * }) * ``` * * @see {@link state} for typed state-tree helpers. @@ -8553,17 +8247,22 @@ export const emittedEventsFromSchemas: { * **Example** * * ```ts - * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Effect, Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const encoded = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) * return yield* Machine.encodeSnapshot(machine, initial.state) @@ -8635,17 +8334,22 @@ export const encodeSnapshot: < * **Example** * * ```ts - * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Effect, Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const roundTrip = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) * const encoded = yield* Machine.encodeSnapshot(machine, initial.state) @@ -8743,17 +8447,22 @@ type ValidateTransitionBranchRecord = [keyof Branches] extends [never] * **Example** * * ```ts - * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Effect, Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const initialState = Effect.map(Machine.planInitial(machine), (plan) => plan.state) * ``` * @@ -9032,12 +8741,15 @@ export const enabled: < * import { Effect, Schema } from "effect" * * const internalEvents = Machine.internalEventsFromSchemas(Schema.TaggedStruct("Loaded", {})) + * const Root = Machine.state({ states: { Idle: {} } }) * const machine = Machine.make({ - * root: Machine.state({ initial: "Idle", states: { Idle: {} } }), + * root: Root, * events: Machine.eventsFromSchemas(), - * internalEvents, - * initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.from())) - * }).handle({ states: { Idle: { on: { Loaded: { none: true } } } } }) + * internalEvents + * }).handle({ + * initial: { target: Machine.targets(Root).root.Idle }, + * states: { Idle: { on: { Loaded: { none: true } } } } + * }) * * export const canLoad = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) @@ -9148,27 +8860,32 @@ export const can: { * **Example** * * ```ts - * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" - * - * class Off extends Schema.TaggedClass("Off")("Off", {}) {} - * class On extends Schema.TaggedClass("On")("On", {}) {} - * class Toggle extends Schema.TaggedClass("Toggle")("Toggle", {}) {} - * const States = Machine.state({ initial: "Off", states: { Off, On } }) + * import { Effect, Schema } from "effect" + * class Off extends Schema.TaggedClass("Off")("Off", {}) { + * } + * class On extends Schema.TaggedClass("On")("On", {}) { + * } + * class Toggle extends Schema.TaggedClass("Toggle")("Toggle", {}) { + * } + * const States = Machine.state({ states: { Off, On } }) * const targets = Machine.targets(States) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(Toggle), - * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Off.from())) - * }).handle({ states: { - * Off: { - * on: { - * Toggle: { target: targets.root.On } - * } + * events: Machine.eventsFromSchemas(Toggle) + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Off * }, - * On: {} - * } }) - * + * states: { + * Off: { + * on: { + * Toggle: { target: targets.root.On } + * } + * }, + * On: {} + * } + * }) * const nextState = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) * return (yield* Machine.plan(machine, initial.state, new Toggle({}))).next @@ -9546,17 +9263,22 @@ export const prepare: < * **Example** * * ```ts - * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Effect, Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const state = Effect.gen(function*() { * const ref = yield* Machine.start(machine) * return yield* ref.state @@ -9657,17 +9379,22 @@ export const start: < * **Example** * * ```ts - * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Effect, Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const resumed = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) * const encoded = yield* Machine.encodeSnapshot(machine, initial.state) diff --git a/packages/effect-machine/src/internal/machine/configuration.ts b/packages/effect-machine/src/internal/machine/configuration.ts index e8419541..e63731fc 100644 --- a/packages/effect-machine/src/internal/machine/configuration.ts +++ b/packages/effect-machine/src/internal/machine/configuration.ts @@ -672,26 +672,6 @@ export const normalizeConfigurationEffect = { - for (const path of configuration.active) { - const node = getNode(machine, path) - if (node.type === "compound") { - const child = node.children.find((child) => configuration.active.has(child)) - const initialNode = node.initial === undefined ? undefined : getNode(machine, node.initial) - if (initialNode?.type === "choice" ? child === undefined : child !== node.initial) { - throw new Error(`Machine initial state "${node.path}" must enter initial child "${node.initial}"`) - } - } - if (node.type === "parallel") { - for (const child of node.children) { - if (!configuration.active.has(child)) { - throw new Error(`Machine initial state "${node.path}" must enter child region "${child}"`) - } - } - } - } -} - /** Capture every history register whose owning parent exits in this microstep. * The control record is deliberately independent from effects/actions: it is * part of the logical snapshot and is therefore preserved by pure planning. */ diff --git a/packages/effect-machine/src/internal/machine/executionPlan.ts b/packages/effect-machine/src/internal/machine/executionPlan.ts index e2a64c9e..a0aa539e 100644 --- a/packages/effect-machine/src/internal/machine/executionPlan.ts +++ b/packages/effect-machine/src/internal/machine/executionPlan.ts @@ -20,11 +20,11 @@ import { normalizeTargetConfigurationSync, type PlanningMachineReferences, snapshotFromConfiguration, - validateInitialConfiguration, withMachineReferences } from "./configuration.js" import { InfiniteTransitionError, StoppedError } from "./errors.js" import { type CapturedStateConfig, toImpl } from "./implementation.js" +import { isDataInitializer } from "./initialDeclaration.js" import * as InvocationEvent from "./invocationEvent.js" import { broadenTransitionBoundary, @@ -213,7 +213,8 @@ const compileIndexedExecutionDescriptor = ( // 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 && - (toImpl(machine).handlers[""] === undefined || Reflect.ownKeys(toImpl(machine).handlers[""]!).length === 0) && + (toImpl(machine).handlers[""] === undefined || + Reflect.ownKeys(toImpl(machine).handlers[""]!).every((key) => key === "initialize")) && nodes.slice(1).every((node) => node.parent === "" && (node.type === "atomic" || node.type === "final")) && [...transitionsByPath.values()].every((events) => [...events.values()].every((transition) => @@ -1130,10 +1131,10 @@ const makeIndexedExecutionPlan = ( snapshot: (state) => snapshotFromIndexedState(indexed, state as OwnedIndexedState), plan: (state, event, retainMicrosteps = false, machineReferences) => planIndexedState(machine, indexed, state as OwnedIndexedState, event, retainMicrosteps, machineReferences), - // Initializers may enqueue commands and emissions. Managed startup owns that - // work through the generic initial planner; indexed event execution remains valid. + // Only captured data constructors can use indexed startup. Unknown initializer + // protocols retain generic startup so commands and emissions cannot be dropped. ...(Object.values(toImpl(machine).handlers as Record).some((config) => - "initialize" in config + config.initialize !== undefined && !isDataInitializer(config.initialize) ) ? {} : { @@ -1169,7 +1170,6 @@ const makeIndexedExecutionPlan = ( 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) diff --git a/packages/effect-machine/src/internal/machine/initialDeclaration.ts b/packages/effect-machine/src/internal/machine/initialDeclaration.ts new file mode 100644 index 00000000..25a06e8f --- /dev/null +++ b/packages/effect-machine/src/internal/machine/initialDeclaration.ts @@ -0,0 +1,157 @@ +/** Captures handler-owned initial edges before compiling executable topology. */ +import { hasProperty } from "effect/Predicate" +import * as Schema from "effect/Schema" +import type { Machine, State } from "../../Machine.js" +import { SnapshotBuilderStateTypeId } from "./symbols.js" +import * as Reference from "./targetReference.js" +import * as Topology from "./topology.js" + +const initializers = new WeakSet() + +export const isDataInitializer = (value: object): boolean => initializers.has(value) + +type Context = Readonly> +type Constructor = (context: Context) => unknown +const record = (value: unknown, label: string): Readonly> => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`Machine ${label} must be an object`) + } + return value as Readonly> +} +const evaluate = (value: unknown): Constructor => typeof value === "function" ? value as Constructor : () => value + +/** Only the outer declaration is interpreted; callback results are ordinary data. */ +export const construction = (value: unknown): Constructor => { + const descriptor = typeof value === "object" && value !== null && + hasProperty(value, "decoded") && value.decoded === true && hasProperty(value, "data") + if (descriptor) { + const config = record(value, "decoded construction") + if (Reflect.ownKeys(config).some((key) => key !== "decoded" && key !== "data")) { + throw new Error("Machine decoded construction accepts only decoded and data") + } + return evaluate(config.data) + } + const resolve = evaluate(value) + return (context) => Topology.makeStateInput(value === undefined ? {} : resolve(context)) +} + +export const edgeConstruction = (config: Readonly>): Constructor => { + if (config.decoded !== undefined && typeof config.decoded !== "boolean") { + throw new Error("Machine decoded construction flag must be a boolean") + } + if (config.decoded === true) { + if (!hasProperty(config, "data")) throw new Error("Machine decoded construction requires data") + return evaluate(config.data) + } + const resolve = evaluate(config.data) + return (context) => Topology.makeStateInput(hasProperty(config, "data") ? resolve(context) : {}) +} + +interface Captured { + readonly node: Machine.StateNodeConfig | Machine.TaggedSchema + readonly handlers: Record +} +export const capture = ( + root: State, + handler: Readonly> +): Captured => { + const visit = ( + node: Machine.StateNodeConfig | Machine.TaggedSchema, + raw: Readonly>, + path: string + ): Captured => { + if (hasProperty(raw, "initialize")) throw new Error("Machine child construction belongs in initial declarations") + if (path !== "" && hasProperty(raw, "root")) { + throw new Error("Machine root construction belongs at the root handler") + } + const { initial, root: _root, states: rawChildren, ...handlers } = raw + if (Schema.isSchema(node) || !("states" in node)) { + if (initial !== undefined) throw new Error(`Machine state "${path}" cannot declare initial children`) + if (rawChildren !== undefined) throw new Error(`Machine state "${path}" has no children`) + return { node, handlers } + } + const childHandlers = rawChildren === undefined ? {} : record(rawChildren, "child handlers") + for (const key of Object.keys(childHandlers)) { + if (!Object.prototype.hasOwnProperty.call(node.states, key)) throw new Error(`Machine unknown child "${key}"`) + } + const states: Record = Object.create(null) + const children: Record = Object.create(null) + const constructors: Record = Object.create(null) + const parallel = node.type === "parallel" + let initialKey: string | undefined + if (parallel) { + const regions = initial === undefined ? {} : record(initial, "parallel initial regions") + for (const [key, value] of Object.entries(regions)) { + const child = node.states[key] + if (child === undefined || (!Schema.isSchema(child) && (child.type === "history" || child.type === "choice"))) { + throw new Error(`Machine invalid initial region "${key}"`) + } + if (!Schema.isSchema(child) && (!("schema" in child) || child.schema === undefined)) { + throw new Error(`Machine schema-less region "${key}" cannot construct data`) + } + constructors[key] = construction(value) + } + } else { + const edge = record(initial, `initial edge for "${path}"`) + if (Reflect.ownKeys(edge).some((key) => !["target", "data", "decoded"].includes(String(key)))) { + throw new Error("Machine initial edge accepts only target, data, and decoded") + } + if (!hasProperty(edge.target, Reference.TypeId)) throw new Error("Machine initial requires a target reference") + const ref = (edge.target as Reference.Reference)[Reference.TypeId] + const prefix = path === "" ? "" : `${path}.` + const key = ref.path.slice(prefix.length) + if ( + ref.root !== root || !ref.path.startsWith(prefix) || key.includes(".") || !(key in node.states) || + ref.kind === "history" + ) { + throw new Error(`Machine initial target must be a direct child of "${path}"`) + } + initialKey = key + if (ref.kind === "choice" && (hasProperty(edge, "data") || hasProperty(edge, "decoded"))) { + throw new Error("Machine choice initial edges cannot construct data") + } + const construct = edgeConstruction(edge) + const child = node.states[key]! + if ( + hasProperty(edge, "data") && !Schema.isSchema(child) && (!("schema" in child) || child.schema === undefined) + ) { + throw new Error(`Machine schema-less initial child "${key}" cannot construct data`) + } + if (hasProperty(edge, "data")) constructors[key] = construct + } + for (const [key, child] of Object.entries(node.states)) { + const childPath = path === "" ? key : `${path}.${key}` + const captured = visit( + child, + childHandlers[key] === undefined ? {} : record(childHandlers[key], "state handler"), + childPath + ) + states[key] = captured.node + // History configuration is owned by its parent, not a state handler. + if ( + (Schema.isSchema(child) || child.type !== "history") && + (childHandlers[key] !== undefined || (!Schema.isSchema(child) && "states" in child)) + ) children[key] = captured.handlers + } + const compiled = Object.freeze({ + ...node, + states: Object.freeze(states), + ...(parallel ? {} : { initial: initialKey }) + }) + const entries = Object.entries(constructors) + const initialize = (context: Context) => { + const ancestors = typeof context.ancestors === "object" && context.ancestors !== null + ? context.ancestors as Readonly> : + {} + const scoped = { ...context, root: path === "" ? context.state : ancestors[""] } + const values = Object.fromEntries(entries.map(([key, construct]) => [key, construct(scoped)])) + return { [SnapshotBuilderStateTypeId]: values } + } + initializers.add(initialize) + return { + node: compiled, + handlers: { ...handlers, ...(entries.length === 0 ? {} : { initialize }), states: children } + } + } + return visit(root.node, handler, "") +} diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index 29b42381..fde94964 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -31,6 +31,7 @@ import * as Configuration from "./configuration.js" import * as Declaration from "./declaration.js" import type { ChildAlreadyExistsError, InfiniteTransitionError, StartupError } from "./errors.js" import type { CapturedStateConfig } from "./implementation.js" +import * as InitialDeclaration from "./initialDeclaration.js" import * as InvocationDefinition from "./invocationDefinition.js" import * as internalPlanner from "./planner.js" import * as internalProcess from "./process.js" @@ -41,13 +42,7 @@ import * as internalRuntime from "./runtimeProtocol.js" import * as Serialization from "./serialization.js" import * as StateDefinition from "./stateDefinition.js" import { ChildMachineLogicTypeId } from "./symbols.js" -import { - getLocalTargetScope, - getTargetBuilderNode, - makeSnapshotBuilder, - makeTargetBuilder, - withFrom -} from "./targetBuilder.js" +import { getLocalTargetScope, makeTargetBuilder, withFrom } from "./targetBuilder.js" import * as TargetReference from "./targetReference.js" import * as Topology from "./topology.js" @@ -97,7 +92,7 @@ const Proto = { } const makeWithHandlers = ( - self: Definition.Any, + self: Machine.Any, handlers: Readonly> ): Machine.Any => { const machine = Object.create(Proto) @@ -139,14 +134,6 @@ type CapturedNamedBranch = { readonly selection: Topology.TargetSelection } -const InitialBuilderDescriptorTypeId: unique symbol = Symbol("effect/Machine/InitialBuilderDescriptor") - -type InitialBuilderDescriptor = { - readonly [InitialBuilderDescriptorTypeId]: typeof InitialBuilderDescriptorTypeId - readonly selection: Topology.TargetSelection - readonly resolve: (context: any) => unknown -} - const plainTargetSelection = (selection: Topology.TargetSelection): Topology.TargetSelection => selection.kind === "none" ? Topology.noneTargetSelection @@ -166,47 +153,6 @@ const constructSelectionValue = ( return context.target[method](values.target).update(context.owner[method](values.update)) } -const makeInitialBuilderDescriptor = ( - selection: Topology.TargetSelection, - resolve: (context: any) => unknown -): InitialBuilderDescriptor => - Object.freeze({ - [InitialBuilderDescriptorTypeId]: InitialBuilderDescriptorTypeId, - selection: plainTargetSelection(selection), - resolve - }) - -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) - }) - -const decorateInitialSelectorNode = (node: unknown): unknown => { - if (Topology.isTargetSelection(node)) return decorateInitialSelection(node) - if (typeof node === "function") { - const wrapped = ((...args: ReadonlyArray) => decorateInitialSelection(node(...args))) as - & ((...args: ReadonlyArray) => unknown) - & Record - for (const key of Object.keys(node)) { - wrapped[key] = decorateInitialSelectorNode((node as unknown as Record)[key]) - } - return Object.freeze(wrapped) - } - if (typeof node === "object" && node !== null) { - const wrapped: Record = {} - for (const key of Object.keys(node)) { - wrapped[key] = decorateInitialSelectorNode((node as Record)[key]) - } - return Object.freeze(wrapped) - } - return node -} - const transitionTargetSelection = ( selection: Topology.TargetSelection ): Machine.TransitionTargetSelection => @@ -221,12 +167,6 @@ const selectionUpdates = (selection: Topology.TargetSelection): ReadonlyArray Topology.makeTargetSelection(kind, path, scope) - const makeStateUpdateSelection = ( path: string, scope: "local" | "branch" @@ -611,7 +551,7 @@ const normalizeObjectTransition = ( "history", "none", "branches", - "from", + "data", "decoded", "resolve", "guard", @@ -623,20 +563,21 @@ const normalizeObjectTransition = ( throw new Error("Machine transition contains an unknown field") } } - for (const key of ["from", "decoded", "resolve", "guard"]) { + for (const key of ["resolve", "guard"]) { if (config[key] !== undefined && typeof config[key] !== "function") { throw new Error(`Machine transition ${key} must be a function`) } } const guard = typeof config.guard === "function" ? config.guard : undefined const resolve = typeof config.resolve === "function" ? config.resolve : undefined - const construct = typeof config.from === "function" - ? config.from - : typeof config.decoded === "function" - ? config.decoded - : undefined - const methods = ["from", "decoded", "resolve"].filter((key) => config[key] !== undefined) - if (methods.length > 1) throw new Error("Machine transition construction methods are mutually exclusive") + if (config.decoded !== undefined && typeof config.decoded !== "boolean") { + throw new Error("Machine decoded must be a boolean construction flag") + } + const hasData = Object.prototype.hasOwnProperty.call(config, "data") + if (config.decoded === true && !hasData) throw new Error("Machine decoded construction requires data") + if (hasData && resolve !== undefined) throw new Error("Machine data and resolve are mutually exclusive") + const data = config.data + const construct = typeof data === "function" ? data : () => data const guarded = guard !== undefined const declinable = guarded || config.declinable === true if (typeof config.branches === "string") { @@ -645,7 +586,7 @@ const normalizeObjectTransition = ( throw new Error("Machine branching transition requires a registered group and resolver") } if ( - ["target", "update", "initial", "history", "none", "from", "decoded"].some((key) => config[key] !== undefined) + ["target", "update", "initial", "history", "none", "data", "decoded"].some((key) => config[key] !== undefined) ) throw new Error("Machine branching transition cannot redeclare its destination") const entries = Object.fromEntries( Object.entries(group).map(([key, spec]) => [key, { @@ -672,7 +613,7 @@ const normalizeObjectTransition = ( if (resolve !== undefined && selection.kind !== "none") { throw new Error("Machine advanced construction requires a declared branch group") } - const method = config.from !== undefined ? "from" : config.decoded !== undefined ? "decoded" : undefined + const method = hasData ? config.decoded === true ? "decoded" : "from" : undefined return { target: () => selection, reenter: config.reenter, @@ -911,9 +852,26 @@ const flattenHandlers = ( const makeHandle = (self: Definition.Any, declaration: Declaration.Declaration): Definition.Any["handle"] => ((config: Record) => { + const captured = InitialDeclaration.capture(declaration.root, config) + const compiled = Object.assign(Object.create(Proto), self) + Protocol.copyProtocol(self, compiled) + compiled.states = Object.freeze({ "": captured.node }) + compiled.stateNodes = Topology.compileStateNodes(compiled.states) + if (compiled.stateNodes.byPath.get("").schema === undefined && config.root !== undefined) { + throw new Error("Machine schema-less root cannot construct data") + } + const constructRoot = compiled.stateNodes.byPath.get("").schema === undefined + ? () => undefined + : InitialDeclaration.construction(config.root) + compiled.initial = (input: unknown) => Topology.makeInitialTarget("", constructRoot({ input })) + compiled.initialDefinition = Object.freeze({ + target: "", + selection: Object.freeze({ path: "", kind: "initial", scope: "initial" }) + }) + compiled.makeTargetBuilder = makeTargetBuilder(compiled.states, compiled.stateNodes) const handlers: Record = Object.create(null) - flattenHandlers(handlers, self.stateNodes, self.states, "", declaration, { "": config }) - return makeWithHandlers(self, handlers) + flattenHandlers(handlers, compiled.stateNodes, compiled.states, "", declaration, { "": captured.handlers }) + return makeWithHandlers(compiled, handlers) }) as Definition.Any["handle"] export const isMachine = ( @@ -953,96 +911,6 @@ export const isFinal = < state: Machine.Snapshot ): state is Machine.SnapshotContainingFinal => internalPlanner.isFinal(machine as any, state) -const getInitialSelectionBuilder = ( - initialBuilder: Record, - selection: Topology.TargetSelection -): Record => { - const path = selection.path - if (path === undefined || path.includes(".")) { - throw new Error("Machine initial target must select one top-level state") - } - const builder = initialBuilder[path] - if (typeof builder !== "object" || builder === null || typeof builder.from !== "function") { - throw new Error(`Machine could not construct selected initial state "${path}"`) - } - return builder -} - -const captureInitialBranch = ( - definition: unknown, - initialBuilder: Record, - configuration: boolean -): { - readonly selection: Topology.TargetSelection - readonly resolve?: (context: any) => unknown - readonly builder: Record -} => { - if (definition !== undefined && typeof definition !== "function") { - throw new Error("Machine initial definition must be a target-first callback") - } - 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)) { - selection = plainTargetSelection(result) - } else if (hasProperty(result, InitialBuilderDescriptorTypeId)) { - const descriptor = result as InitialBuilderDescriptor - selection = descriptor.selection - resolve = descriptor.resolve - } else { - throw new Error("Machine initial definition must select exactly one target") - } - if (selection.kind !== "state" && selection.kind !== "initial") { - throw new Error("Machine initial target must select a top-level state or its declared initial entry") - } - const captured = { - selection, - builder: getInitialSelectionBuilder(initialBuilder, selection) - } - return resolve === undefined ? Object.freeze(captured) : Object.freeze({ ...captured, resolve }) -} - -const validateInitialSelection = (result: unknown, selection: Topology.TargetSelection): void => { - if ( - typeof result !== "object" || result === null || !hasProperty(result, "path") || result.path !== selection.path - ) { - const resultPath = typeof result === "object" && result !== null && hasProperty(result, "path") - ? String(result.path) - : "" - throw new Error(`Machine initial resolver selected "${selection.path}" but constructed "${resultPath}"`) - } -} - -const compileInitial = ( - definition: unknown, - states: Machine.StateTree, - stateNodes: Machine.StateNodes, - configuration = false -): { - readonly initial: (input?: unknown) => unknown - readonly definition: Machine.InitialDefinition -} => { - 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 - ? constructSelectedTarget(branch.builder) - : branch.resolve({ input, target: branch.builder }) - validateInitialSelection(result, branch.selection) - return result - }, - definition: Object.freeze({ - target: branch.selection.path!, - selection: transitionTargetSelection(branch.selection) as Machine.InitialDefinition["selection"] - }) - } -} - export const state = (node: unknown): State => { const captured = StateDefinition.captureRoot(node) as Machine.StateNodeConfig const access = makeStateHelpers() @@ -1115,17 +983,18 @@ export const make = < readonly logic?: unknown readonly children?: unknown readonly branches?: unknown - readonly initialConfiguration?: unknown readonly events: Machine.EventProtocol<"public", InputEvents> readonly internalEvents?: Machine.EventProtocol<"internal", InternalEvents> readonly emittedEvents?: Machine.EventProtocol<"emitted", Emits> readonly parent?: ParentDeclaration readonly input?: Input - readonly initial: unknown } ): MakeResult => { + if (Object.hasOwn(config, "initial") || Object.hasOwn(config, "initialConfiguration")) { + throw new Error("Machine initial edges and root data belong in handle") + } const states = Object.freeze({ "": config.root.node }) - const self = Object.create(Proto) + const self = Object.create(PipeablePrototype) self.states = states self.root = config.root self.events = config.events @@ -1134,16 +1003,6 @@ export const make = < self.parent = config.parent self.input = config.input self.id = config.id - self.stateNodes = Topology.compileStateNodes(states) - 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) self.handlers = Object.create(null) self.handle = makeHandle(self, Declaration.capture(config.root, config)) Protocol.setProtocol(self) diff --git a/packages/effect-machine/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts index ae211f8c..00e81ac7 100644 --- a/packages/effect-machine/src/internal/machine/planner.ts +++ b/packages/effect-machine/src/internal/machine/planner.ts @@ -35,8 +35,7 @@ import { normalizeTargetConfigurationSync, pathDepth, snapshotFromConfiguration, - snapshotFromConfigurationAtPath, - validateInitialConfiguration + snapshotFromConfigurationAtPath } from "./configuration.js" import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError, StoppedError } from "./errors.js" import { type CapturedStateConfig, toImpl } from "./implementation.js" @@ -300,11 +299,13 @@ const completeHistoryConfiguration = ( ) let next = current for (const routed of [choice.target, ...choice.additionalTargets]) { - if (routed === undefined || isHistoryTarget(routed)) { + if (routed === undefined) { throw new Error(`Machine initial choice "${child.path}" must resolve to an active state target`) } const resolved = isInitialTarget(routed) ? resolveInitialTarget(machine, next, routed, event) + : isHistoryTarget(routed) + ? resolveHistoryTarget(machine, next, routed, event) : undefined next = normalizeTargetConfigurationSync( machine, @@ -1720,77 +1721,13 @@ export const planInitialSync = < 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 - : resolveChoiceTarget( - machine, - emptyConfiguration, - state, - InitialEvent - ) - const initialHistoryActions: Array = [] - const initialHistoryRaisedEvents: Array = [] - const initialHistoryEmittedEvents: Array = [] - const initialHistoryChoiceTransitions: Array = [] - const resolvedInitialTargets: Array = [] - for ( - const target of choiceResolution !== undefined - ? [choiceResolution.target, ...choiceResolution.additionalTargets] - : rootResolution === undefined - ? [] - : [rootResolution.target] - ) { - if (!isHistoryTarget(target)) { - resolvedInitialTargets.push(target) - continue - } - const history = resolveHistoryTarget(machine, emptyConfiguration, target, InitialEvent) - resolvedInitialTargets.push(history.target) - initialHistoryActions.push(...history.commands) - initialHistoryRaisedEvents.push(...history.raisedEvents) - initialHistoryEmittedEvents.push(...history.emittedEvents) - initialHistoryChoiceTransitions.push(...history.transitions) - } - let resolvedConfiguration = resolvedInitialTargets.length === 0 - ? normalizeConfigurationSync(machine, state as Machine.Snapshot) - : normalizeTargetConfigurationSync( - machine, - emptyConfiguration, - resolvedInitialTargets[0] as - | Machine.Snapshot - | Machine.Target> - ) - for (const additionalTarget of resolvedInitialTargets.slice(1)) { - resolvedConfiguration = normalizeTargetConfigurationSync( - machine, - resolvedConfiguration, - additionalTarget as Machine.Snapshot | Machine.Target> - ) - } - const configuration: ActiveConfiguration = resolvedConfiguration - if (machine.initialDefinition.selection.kind === "initial") validateInitialConfiguration(machine, configuration) + const rootResolution = resolveInitialTarget(machine, emptyConfiguration, initial, InitialEvent) + const configuration = normalizeTargetConfigurationSync(machine, emptyConfiguration, rootResolution.target) 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 - ] + const commands = rootResolution.commands + const raisedEvents = rootResolution.raisedEvents + const emittedEvents = rootResolution.emittedEvents const entry = collectStateActions( machine, configuration, @@ -1805,17 +1742,12 @@ export const planInitialSync = < [...entry.commands], [...raisedEvents, ...entry.raisedEvents] as Array>, [...emittedEvents, ...entry.emittedEvents], - choiceResolution === undefined && (rootResolution?.transitions.length ?? 0) === 0 && - initialHistoryChoiceTransitions.length === 0 ? + rootResolution.transitions.length === 0 ? [] : [{ next: configuration, event: InitialEvent, - transitions: [ - ...(rootResolution?.transitions ?? []), - ...(choiceResolution?.transitions ?? []), - ...initialHistoryChoiceTransitions - ], + transitions: rootResolution.transitions, commands, raisedEvents: [ ...raisedEvents diff --git a/packages/effect-machine/src/internal/machine/protocol.ts b/packages/effect-machine/src/internal/machine/protocol.ts index e6b7571e..eb142654 100644 --- a/packages/effect-machine/src/internal/machine/protocol.ts +++ b/packages/effect-machine/src/internal/machine/protocol.ts @@ -82,7 +82,7 @@ const getBoundaryResultDecoder = (schema: Schema.Top): BoundaryResultDecoder => const MachineProtocolTypeId = Symbol.for("effect/Machine/protocol") -const getProtocolSchemas = (machine: Machine.Any): MachineProtocolSchemas => { +const getProtocolSchemas = (machine: object): MachineProtocolSchemas => { const protocol = (machine as any)[MachineProtocolTypeId] as MachineProtocolSchemas | undefined if (protocol === undefined) { throw new Error("Machine protocol is unavailable") @@ -90,7 +90,7 @@ const getProtocolSchemas = (machine: Machine.Any): MachineProtocolSchemas => { return protocol } -const setProtocolSchemas = (machine: Machine.Any, protocol: MachineProtocolSchemas): void => { +const setProtocolSchemas = (machine: object, protocol: MachineProtocolSchemas): void => { Object.defineProperty(machine, MachineProtocolTypeId, { value: protocol, enumerable: false @@ -173,7 +173,7 @@ export const setProtocol = (machine: Machine.Any): void => { }) } -export const copyProtocol = (source: Machine.Any, target: Machine.Any): void => +export const copyProtocol = (source: object, target: object): void => setProtocolSchemas(target, getProtocolSchemas(source)) export const getEventName = (event: unknown): string | undefined => diff --git a/packages/effect-machine/src/internal/machine/stateDefinition.ts b/packages/effect-machine/src/internal/machine/stateDefinition.ts index 4cc12d94..277bf9a4 100644 --- a/packages/effect-machine/src/internal/machine/stateDefinition.ts +++ b/packages/effect-machine/src/internal/machine/stateDefinition.ts @@ -4,7 +4,7 @@ import * as SchemaAST from "effect/SchemaAST" export const StateNodePropertyPolicy = { atomic: ["schema", "type", "annotations"], final: ["schema", "type", "output", "annotations"], - compound: ["schema", "type", "initial", "states", "annotations"], + compound: ["schema", "type", "states", "annotations"], parallel: ["schema", "type", "output", "states", "annotations"], history: ["type", "history", "annotations"], choice: ["type", "annotations"] @@ -266,9 +266,6 @@ const validateStateTree = ( fail(boundary, `${path}.output`, "state output must be an Effect Schema") } if (kind === "compound") { - if (typeof node.initial !== "string") { - fail(boundary, `${path}.initial`, "compound states must declare an initial child key") - } validateTargetSelectorChildKey( boundary, path, @@ -285,18 +282,7 @@ const validateStateTree = ( "schema-backed compound child states cannot use the reserved local target selector key \"with\"" ) } - const initialKey = node.initial as string validateStateTree(boundary, node.states, path, true) - if (!hasOwn(node.states as object, initialKey)) { - fail(boundary, `${path}.initial`, `compound initial child "${initialKey}" does not exist`) - } - const initial = (node.states as Readonly>)[initialKey] - if ( - !Schema.isSchema(initial) && typeof initial === "object" && initial !== null && - (initial as Readonly>).type === "history" - ) { - fail(boundary, `${path}.initial`, "compound initial children cannot be history states") - } continue } if (kind === "parallel") { diff --git a/packages/effect-machine/src/internal/machine/targetReference.ts b/packages/effect-machine/src/internal/machine/targetReference.ts index a7047457..4e2f68df 100644 --- a/packages/effect-machine/src/internal/machine/targetReference.ts +++ b/packages/effect-machine/src/internal/machine/targetReference.ts @@ -1,5 +1,5 @@ +import * as Schema from "effect/Schema" import type { Machine, State } from "../../Machine.js" -import * as Topology from "./topology.js" /** Reference metadata is separate from child names, including names such as `path`. */ export const TypeId: unique symbol = Symbol.for("effect/Machine/TargetReference") @@ -14,9 +14,24 @@ export interface Reference { /** Captures an immutable reference tree without allocating or executing a machine. */ export const make = (root: State): { readonly root: Reference } => { - const nodes = Topology.compileStateNodes({ "": root.node }) + const nodes: Array<{ path: string; key: string; parent: string | undefined; type: string }> = [] + const visit = ( + node: Machine.StateNodeConfig | Machine.TaggedSchema, + path: string, + key: string, + parent: string | undefined + ): void => { + const type = !Schema.isSchema(node) && "type" in node ? node.type ?? "active" : "active" + nodes.push({ path, key, parent, type }) + if (!Schema.isSchema(node) && "states" in node) { + for (const [childKey, child] of Object.entries(node.states)) { + visit(child, path === "" ? childKey : `${path}.${childKey}`, childKey, path) + } + } + } + visit(root.node, "", "", undefined) const references = new Map() - for (const node of nodes.byPath.values()) { + for (const node of nodes) { const reference = Object.create(null) Object.defineProperty(reference, TypeId, { value: Object.freeze({ @@ -27,7 +42,7 @@ export const make = (root: State): { readonly root: Ref }) references.set(node.path, reference) } - for (const node of nodes.byPath.values()) { + for (const node of nodes) { if (node.parent !== undefined) { Object.defineProperty(references.get(node.parent), node.key, { value: references.get(node.path), diff --git a/packages/effect-machine/src/internal/machine/topology.ts b/packages/effect-machine/src/internal/machine/topology.ts index a569fbb1..18019c15 100644 --- a/packages/effect-machine/src/internal/machine/topology.ts +++ b/packages/effect-machine/src/internal/machine/topology.ts @@ -330,7 +330,8 @@ export const getStateNodeDefinition = ( states: definition.states } } - if (typeof definition.initial !== "string") { + const initial = "initial" in definition ? definition.initial : undefined + if (typeof initial !== "string") { throw new Error(`Machine.make expected compound state "${path}" to declare an initial child`) } return { @@ -339,7 +340,7 @@ export const getStateNodeDefinition = ( annotations, type: "compound", history: undefined, - initial: definition.initial, + initial, states: definition.states } } diff --git a/packages/effect-machine/src/internal/testing/machine/finiteModel.ts b/packages/effect-machine/src/internal/testing/machine/finiteModel.ts index f06b3910..0062ffae 100644 --- a/packages/effect-machine/src/internal/testing/machine/finiteModel.ts +++ b/packages/effect-machine/src/internal/testing/machine/finiteModel.ts @@ -1203,7 +1203,6 @@ const makeStateTree = ( } else if (node._tag === "Compound") { tree[node.key] = { schema, - initial: node.initial, states: makeStateTree(node.states, path) } } else { @@ -1473,17 +1472,20 @@ const makeHandlers = ( ...(Object.keys(history).length === 0 ? {} : { history }), ...(node._tag === "Compound" ? { - initialize: ({ builder }: any) => builder.decoded(stateValue(byPath.get(`${path}.${node.initial}`)!)) + initial: { + target: `${path}.${node.initial}`.split(".").reduce((ref, key) => ref[key], references), + ...(byPath.get(`${path}.${node.initial}`)!.node._tag === "Choice" ? {} : { + decoded: true, + data: stateValue(byPath.get(`${path}.${node.initial}`)!) + }) + } } : { - initialize: ({ builder }: any) => + initial: Object.fromEntries( node.states .filter((child) => child._tag !== "History" && child._tag !== "Choice") - .reduce( - (current: any, child) => - current[child.key].decoded(stateValue(byPath.get(`${path}.${child.key}`)!)), - builder - ) + .map((child) => [child.key, { decoded: true, data: stateValue(byPath.get(`${path}.${child.key}`)!) }]) + ) }), states: makeHandlers(node.states, path, byPath, transitions, references, branches) } @@ -1508,7 +1510,7 @@ 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.state({ initial: model.initial, states: stateTree } as any) + const defined = Machine.state({ states: stateTree } as any) const eventSchemas = model.events.map((event) => Schema.TaggedStruct(event, {})) const initial = byPath.get(model.initial)! const transitions = new Map(model.transitions.map((transition) => [ @@ -1524,7 +1526,11 @@ export const compileModel = (model: FiniteModel): Machine.Machine.Any => { } as any) return machine.handle( { - initialize: ({ builder }: any) => builder.decoded(stateValue(initial)), + initial: { + target: (Machine.targets(defined).root as unknown as Record)[model.initial], + decoded: true, + data: stateValue(initial) + }, states: handlers } as any ) as Machine.Machine.Any diff --git a/packages/effect-machine/src/internal/testing/machine/verification.ts b/packages/effect-machine/src/internal/testing/machine/verification.ts index 05f44e66..7e9b753d 100644 --- a/packages/effect-machine/src/internal/testing/machine/verification.ts +++ b/packages/effect-machine/src/internal/testing/machine/verification.ts @@ -1738,7 +1738,13 @@ export const verify = ( 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) { + const routed = initialChoiceRouteRoot() !== undefined && + trace.initial.plan.microsteps[0]?.transitions.some((transition) => { + const target = transition.resolvedTarget ?? transition.target + return target !== undefined && child !== undefined && + isDescendantOrSelf(String(target), child) + }) + if (byPath.get(node.initial ?? "")?.type !== "choice" && child !== node.initial && !routed) { add( "definitions.initial", initialLocation, diff --git a/packages/effect-machine/src/testing/MachineTest.ts b/packages/effect-machine/src/testing/MachineTest.ts index fb4032a0..e3b39266 100644 --- a/packages/effect-machine/src/testing/MachineTest.ts +++ b/packages/effect-machine/src/testing/MachineTest.ts @@ -232,26 +232,29 @@ export interface Scenarios { * **Example** * * ```ts - * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * class Reset extends Schema.TaggedClass("Reset")("Reset", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * class Reset extends Schema.TaggedClass("Reset")("Reset", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(Reset), - * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) - * }).handle({ states: { - * Idle: { - * on: { - * Reset: (to) => - * to.branch.Idle().resolve(({ target }) => target.from()) + * events: Machine.eventsFromSchemas(Reset) + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: { + * on: { + * Reset: (to) => to.branch.Idle().resolve(({ target }) => target.from()) + * } * } * } - * } }) - * + * }) * const generated = MachineTest.scenarios(machine, { maxEvents: 5 }) * ``` * @@ -491,18 +494,23 @@ export { ProbeUnavailableError } from "../internal/testing/machine/probe.js" * **Example** * * ```ts - * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Effect, Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const program = Effect.gen(function*() { * const ref = yield* Machine.start(machine) * return yield* MachineTest.probe(machine, ref) @@ -1100,20 +1108,27 @@ export const Invariant: { * **Example** * * ```ts - * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * + * import { Schema } from "effect" * class Count extends Schema.TaggedClass("Count")("Count", { * value: Schema.Number - * }) {} - * const States = Machine.state({ initial: "Count", states: { Count } }) + * }) { + * } + * const States = Machine.state({ states: { Count } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Count.decoded(new Count({ value: 0 })))) - * }).handle({ states: { Count: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Count, + * decoded: true, + * data: new Count({ value: 0 }) + * }, + * states: { + * Count: {} + * } + * }) * const nonNegative = MachineTest.invariants(machine).state( * "count is non-negative", * ({ snapshot }) => snapshot.value.value >= 0 @@ -1431,29 +1446,34 @@ export type ExploreOptions("Count")("Count", { * value: Schema.Number - * }) {} - * class Increment extends Schema.TaggedClass("Increment")("Increment", {}) {} - * const States = Machine.state({ initial: "Count", states: { Count } }) + * }) { + * } + * class Increment extends Schema.TaggedClass("Increment")("Increment", {}) { + * } + * const States = Machine.state({ states: { Count } }) * const machine = Machine.make({ * 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.branch.Count().resolve(({ state, target }) => - * target.decoded(new Count({ value: state.value + 1 }))) + * events: Machine.eventsFromSchemas(Increment) + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Count, + * decoded: true, + * data: new Count({ value: 0 }) + * }, + * states: { + * Count: { + * on: { + * Increment: (to) => + * 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({})] : [], * stateKey: ({ snapshot }) => snapshot.value.value @@ -1623,18 +1643,23 @@ export type RunServices = IsAny< * **Example** * * ```ts - * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const trace = MachineTest.run(machine, { events: [] }) * ``` * @@ -1889,22 +1914,24 @@ export interface Coverage { * **Example** * * ```ts - * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Effect, Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * 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: [] }), - * (trace) => MachineTest.coverage(machine, trace) - * ) + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) + * const report = Effect.map(MachineTest.run(machine, { events: [] }), (trace) => MachineTest.coverage(machine, trace)) * ``` * * @category verification @@ -2115,18 +2142,23 @@ export interface VerifyOptions { * **Example** * * ```ts - * import { Effect, Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Effect, Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const checked = Effect.gen(function*() { * const trace = yield* MachineTest.run(machine, { events: [] }) * yield* MachineTest.verify(machine, trace) diff --git a/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts b/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts index cfeac2f5..57ce171c 100644 --- a/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts +++ b/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts @@ -341,18 +341,23 @@ export const layerMemory: Layer.Layer = internal.layerMemory * **Example** * * ```ts - * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { ClusterMachine } from "@typeonce/effect-machine/cluster" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const adapter = ClusterMachine.make("IdleMachine", machine, { version: "1" }) * ``` * diff --git a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts index d495ac41..84ed0108 100644 --- a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts +++ b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts @@ -384,21 +384,28 @@ type EnsureValuedSelectorPath = [Path] extends [Valu * **Example** * * ```ts - * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { AtomMachine } from "@typeonce/effect-machine/reactivity" - * + * import { Schema } from "effect" * class Count extends Schema.TaggedClass("Count")("Count", { * value: Schema.Number - * }) {} - * const States = Machine.state({ initial: "Count", states: { Count } }) + * }) { + * } + * const States = Machine.state({ states: { Count } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Count.decoded(new Count({ value: 0 })))) - * }).handle({ states: { Count: {} } }) + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Count, + * decoded: true, + * data: new Count({ value: 0 }) + * }, + * states: { + * Count: {} + * } + * }) * const machineAtom = AtomMachine.make(machine) - * * const countAtom = AtomMachine.select(machineAtom, "Count") * ``` * @@ -624,19 +631,24 @@ export const selectSnapshotChild: { * **Example** * * ```ts - * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { AtomMachine } from "@typeonce/effect-machine/reactivity" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) - * }).handle({ states: { Idle: {} } }) + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const machineAtom = AtomMachine.make(machine) - * * const isIdleAtom = AtomMachine.matches(machineAtom, "Idle") * ``` * @@ -1102,18 +1114,23 @@ export const familyChild: < * **Example** * * ```ts - * import { Schema } from "effect" * import { Machine } from "@typeonce/effect-machine" * import { AtomMachine } from "@typeonce/effect-machine/reactivity" - * - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - * const States = Machine.state({ initial: "Idle", states: { Idle } }) + * import { Schema } from "effect" + * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + * } + * const States = Machine.state({ states: { Idle } }) * const machine = Machine.make({ * root: States, - * events: Machine.eventsFromSchemas(), - * initialConfiguration: root => root.resolve(({ target }) => target.from(tree => tree.Idle.from())) - * }).handle({ states: { Idle: {} } }) - * + * events: Machine.eventsFromSchemas() + * }).handle({ + * initial: { + * target: Machine.targets(States).root.Idle + * }, + * states: { + * Idle: {} + * } + * }) * const machineAtom = AtomMachine.make(machine) * ``` * diff --git a/packages/effect-machine/test/examples/can.ts b/packages/effect-machine/test/examples/can.ts index db9c34b6..53a25df8 100644 --- a/packages/effect-machine/test/examples/can.ts +++ b/packages/effect-machine/test/examples/can.ts @@ -2,12 +2,15 @@ import { Machine } from "@typeonce/effect-machine" import { Effect, Schema } from "effect" const internalEvents = Machine.internalEventsFromSchemas(Schema.TaggedStruct("Loaded", {})) +const Root = Machine.state({ states: { Idle: {} } }) const machine = Machine.make({ - root: Machine.state({ initial: "Idle", states: { Idle: {} } }), + root: Root, events: Machine.eventsFromSchemas(), - internalEvents, - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.from())) -}).handle({ states: { Idle: { on: { Loaded: { none: true } } } } }) + internalEvents +}).handle({ + initial: { target: Machine.targets(Root).root.Idle }, + states: { Idle: { on: { Loaded: { none: true } } } } +}) export const canLoad = Effect.gen(function*() { const initial = yield* Machine.planInitial(machine) diff --git a/packages/effect-machine/test/examples/guard.ts b/packages/effect-machine/test/examples/guard.ts index 1e7e823f..7abd8cbc 100644 --- a/packages/effect-machine/test/examples/guard.ts +++ b/packages/effect-machine/test/examples/guard.ts @@ -1,19 +1,18 @@ import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" - const events = Machine.events({ Add: { by: Schema.Number } }) const Root = Machine.state({ fields: { count: Schema.Number } }) const targets = Machine.targets(Root) export const counter = Machine.make({ root: Root, - events, - initial: (root) => root.from(() => ({ count: 0 })) + events }).handle({ + root: () => ({ count: 0 }), on: { Add: { update: targets.root, guard: ({ event }) => event.by > 0, - from: ({ root, event }) => ({ count: root.count + event.by }) + data: ({ root, event }) => ({ count: root.count + event.by }) } } }) diff --git a/packages/effect-machine/test/internal/machine/ProtocolValidation.test.ts b/packages/effect-machine/test/internal/machine/ProtocolValidation.test.ts index 956e1271..53565fe8 100644 --- a/packages/effect-machine/test/internal/machine/ProtocolValidation.test.ts +++ b/packages/effect-machine/test/internal/machine/ProtocolValidation.test.ts @@ -3,7 +3,6 @@ import { Effect, Schema } from "effect" import { Machine } from "../../../src/index.js" import * as Configuration from "../../../src/internal/machine/configuration.js" import * as ExecutionPlan from "../../../src/internal/machine/executionPlan.js" - describe("event validation across execution strategies", () => { for (const strategy of ["generic", "auto"] as const) { it.effect(`rejects mutated decoded events in the ${strategy} planner`, () => @@ -11,13 +10,16 @@ describe("event validation across execution strategies", () => { const Set = Schema.TaggedStruct("Set", { value: Schema.Int }) const events = Machine.eventsFromSchemas(Set) let retained: unknown - const root1 = Machine.state({ initial: "Ready", states: { Ready: Schema.TaggedStruct("Ready", {}) } }) + const root1 = Machine.state({ states: { Ready: Schema.TaggedStruct("Ready", {}) } }) const machine = Machine.make({ root: root1, - events, - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Ready.decoded({ _tag: "Ready" }))) + events }).handle({ + initial: { + target: Machine.targets(root1).root.Ready, + decoded: true, + data: { _tag: "Ready" } + }, states: { Ready: { on: { diff --git a/packages/effect-machine/test/internal/machine/activities.test.ts b/packages/effect-machine/test/internal/machine/activities.test.ts index 92a7a17c..5367043b 100644 --- a/packages/effect-machine/test/internal/machine/activities.test.ts +++ b/packages/effect-machine/test/internal/machine/activities.test.ts @@ -5,27 +5,31 @@ import { Machine } from "../../../src/index.js" import { activityDefinitions } from "../../../src/internal/machine/activities.js" import { makeMermaidRenderer } from "../../machine/visualization/mermaid.js" import { makeTextRenderer } from "../../machine/visualization/text.js" - class Loading extends Schema.TaggedClass("Loading")("Loading", {}) {} class Dynamic extends Schema.TaggedClass("Dynamic")("Dynamic", {}) {} class ChildIdle extends Schema.TaggedClass("ChildIdle")("ChildIdle", {}) {} class WorkSucceeded extends Schema.TaggedClass("WorkSucceeded")("WorkSucceeded", {}) {} class WorkFailed extends Schema.TaggedClass("WorkFailed")("WorkFailed", {}) {} class LoadTimedOut extends Schema.TaggedClass("LoadTimedOut")("LoadTimedOut", {}) {} - -const childStates = Machine.state({ initial: "ChildIdle", states: { ChildIdle } }) +const childStates = Machine.state({ states: { ChildIdle } }) const childMachine = Machine.make({ id: "document-worker", root: childStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.ChildIdle.decoded(new ChildIdle({})))) + events: Machine.eventsFromSchemas() +}).handle({ + initial: { + target: Machine.targets(childStates).root.ChildIdle, + decoded: true, + data: new ChildIdle({}) + }, + states: { + ChildIdle: {} + } }) const child = Machine.child("child", childMachine) - let dynamicFactoryEvaluations = 0 const timerDuration = "10 seconds" -const activityStates = Machine.state({ initial: "Loading", states: { Loading, Dynamic } }) +const activityStates = Machine.state({ states: { Loading, Dynamic } }) const activityMachine = Machine.make({ effects: { source2: Effect.suspend(() => Effect.fail("unavailable").pipe(Effect.as(1))) }, streams: { source4: Stream.suspend(() => Stream.empty) }, @@ -41,12 +45,15 @@ const activityMachine = Machine.make({ }) }, children: { source5: child }, - id: "activity-inspection", root: activityStates, - events: Machine.eventsFromSchemas(WorkSucceeded, WorkFailed, LoadTimedOut), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Loading.decoded(new Loading({})))) + events: Machine.eventsFromSchemas(WorkSucceeded, WorkFailed, LoadTimedOut) }).handle({ + initial: { + target: Machine.targets(activityStates).root.Loading, + decoded: true, + data: new Loading({}) + }, states: { Loading: { invoke: [ @@ -67,16 +74,11 @@ const activityMachine = Machine.make({ } } }) - -const renderActivityMachine = makeTextRenderer< - typeof activityMachine, - Machine.Snapshot ->(Machine) +const renderActivityMachine = makeTextRenderer>(Machine) const renderMermaidActivityMachine = makeMermaidRenderer< typeof activityMachine, Machine.Snapshot >(Machine) - const machine = { stateNodes: { byPath: new Map([ @@ -108,11 +110,14 @@ 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< + Machine.Machine.StateIdentifier<{ + readonly "": typeof activityStates.node + }> + > > = [ { source: "Loading", @@ -148,7 +153,6 @@ describe("machine activity metadata", () => { type: "process" } ] - assert.deepStrictEqual(Machine.activityDefinitions(activityMachine), expected) assert.deepStrictEqual(Machine.activityDefinitions(activityMachine), expected) assert.deepStrictEqual(JSON.parse(JSON.stringify(Machine.activityDefinitions(activityMachine))), expected) @@ -157,48 +161,42 @@ describe("machine activity metadata", () => { assert.strictEqual(timer.duration, Duration.format(Duration.fromInputUnsafe(timerDuration))) assert.strictEqual(dynamicFactoryEvaluations, 0) }) - it("only reports public definitions owned by real active state nodes", () => { const paths = new Set(Machine.stateNodes(activityMachine).map(({ path }) => path)) assert(Machine.activityDefinitions(activityMachine).every(({ source }) => paths.has(source))) }) - - it.effect.prop( - "keeps generated timer ids, durations, events, and owners aligned with helper declarations", - { - durationMillis: FastCheck.integer({ min: 0, max: 604_800_000 }), - idSuffix: FastCheck.nat({ max: 1_000_000 }) - }, - ({ durationMillis, idSuffix }) => - Effect.sync(() => { - const id = `generated-timer-${idSuffix}` - const generated = Machine.make({ - timers: { source1: durationMillis }, - - root: activityStates, - events: Machine.eventsFromSchemas(LoadTimedOut), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Loading.decoded(new Loading({})))) - }).handle({ - states: { - Loading: { - invoke: { src: "source1", id: id, onDone: { none: true } } - } - } - }) - const definition = Machine.activityDefinitions(generated)[0] - - assert.deepStrictEqual(definition, { - source: "Loading", - id, - type: "timer", - duration: Duration.format(Duration.fromInputUnsafe(durationMillis)) - }) - assert(Machine.stateNodes(generated).some(({ path }) => path === definition?.source)) - }), - { fastCheck: { numRuns: 100, seed: 68_241 } } - ) - + it.effect.prop("keeps generated timer ids, durations, events, and owners aligned with helper declarations", { + durationMillis: FastCheck.integer({ min: 0, max: 604800000 }), + idSuffix: FastCheck.nat({ max: 1000000 }) + }, ({ durationMillis, idSuffix }) => + Effect.sync(() => { + const id = `generated-timer-${idSuffix}` + const generated = Machine.make({ + timers: { source1: durationMillis }, + root: activityStates, + events: Machine.eventsFromSchemas(LoadTimedOut) + }).handle({ + initial: { + target: Machine.targets(activityStates).root.Loading, + decoded: true, + data: new Loading({}) + }, + states: { + Loading: { + invoke: { src: "source1", id: id, onDone: { none: true } } + }, + Dynamic: {} + } + }) + const definition = Machine.activityDefinitions(generated)[0] + assert.deepStrictEqual(definition, { + source: "Loading", + id, + type: "timer", + duration: Duration.format(Duration.fromInputUnsafe(durationMillis)) + }) + assert(Machine.stateNodes(generated).some(({ path }) => path === definition?.source)) + }), { fastCheck: { numRuns: 100, seed: 68241 } }) it("collects static descriptors in topology and declaration order", () => { assert.deepStrictEqual(activityDefinitions(machine), [ { @@ -223,7 +221,6 @@ describe("machine activity metadata", () => { { source: "Dynamic", id: "runtime-dependent", type: "process" } ]) }) - it("does not evaluate source factories while inspecting", () => { let evaluations = 0 const dynamic = { @@ -241,7 +238,6 @@ describe("machine activity metadata", () => { } } } - assert.deepStrictEqual(activityDefinitions(dynamic), [{ source: "Active", id: "runtime-dependent", @@ -254,7 +250,6 @@ describe("machine activity metadata", () => { }]) assert.strictEqual(evaluations, 0) }) - it("only reports definitions owned by compiled state nodes", () => { const withUnknownHandler = { ...machine, @@ -265,14 +260,11 @@ describe("machine activity metadata", () => { } } } - const definitions = activityDefinitions(withUnknownHandler) const paths = new Set(Array.from(withUnknownHandler.stateNodes.byPath.values(), ({ path }) => path)) - assert(definitions.every(({ source }) => paths.has(source))) assert.notInclude(definitions.map((definition) => "id" in definition ? definition.id : undefined), "orphan") }) - it("renders activities beneath their owning state", () => { assert.strictEqual( renderActivityMachine(activityMachine, { @@ -298,7 +290,6 @@ describe("machine activity metadata", () => { ].join("\n") ) }) - it("renders state-owned activities inside their Mermaid state", () => { const rendered = renderMermaidActivityMachine(activityMachine, { path: "" as const, @@ -308,7 +299,6 @@ describe("machine activity metadata", () => { value: new Loading({}) } }) - assert.include( rendered, "state_1: process / poll-server · effect / load-document · timer / load-timeout (10s) · stream / updates · machine / child → document-worker" diff --git a/packages/effect-machine/test/internal/machine/protocol.test.ts b/packages/effect-machine/test/internal/machine/protocol.test.ts index 8f728554..aadebb41 100644 --- a/packages/effect-machine/test/internal/machine/protocol.test.ts +++ b/packages/effect-machine/test/internal/machine/protocol.test.ts @@ -2,59 +2,58 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Schema } from "effect" import { Machine } from "../../../src/index.js" import { decodeEvent } from "../../../src/internal/machine/protocol.js" - class ProtocolIdle extends Schema.TaggedClass("ProtocolIdle")("ProtocolIdle", {}) {} - const PublicEvent = Schema.TaggedStruct("PublicEvent", { value: Schema.String }) const InternalEvent = Schema.TaggedStruct("InternalEvent", { value: Schema.String }) - describe("machine protocols", () => { it("rejects forged, misclassified, and overlapping event descriptors", () => { - const states = Machine.state({ initial: "ProtocolIdle", states: { ProtocolIdle } }) - - assert.throws( - () => Machine.make({ root: states, events: [PublicEvent] as any }), - /expected an event protocol/ - ) + const states = Machine.state({ states: { ProtocolIdle } }) + assert.throws(() => Machine.make({ root: states, events: [PublicEvent] as any }), /expected an event protocol/) assert.throws( () => Machine.make({ root: states, events: Machine.internalEventsFromSchemas(PublicEvent) as any }), /expected a public event protocol/ ) - assert.throws( - () => - Machine.make({ - root: states, - events: Machine.eventsFromSchemas(PublicEvent), - internalEvents: Machine.internalEventsFromSchemas(PublicEvent) as any - }), - /must be disjoint/ - ) + assert.throws(() => + Machine.make({ + root: states, + events: Machine.eventsFromSchemas(PublicEvent), + internalEvents: Machine.internalEventsFromSchemas(PublicEvent) as any + }), /must be disjoint/) }) - it.effect("keeps the complete event protocol private across handler clones", () => Effect.gen(function*() { - const states = Machine.state({ initial: "ProtocolIdle", states: { ProtocolIdle } }) + const states = Machine.state({ states: { ProtocolIdle } }) const machine = Machine.make({ 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: {} }) - + internalEvents: Machine.internalEventsFromSchemas(InternalEvent) + }).handle({ + initial: { + target: Machine.targets(states).root.ProtocolIdle, + decoded: true, + data: new ProtocolIdle({}) + }, + states: { + ProtocolIdle: {} + } + }) assert.strictEqual(Object.hasOwn(machine, "eventSchemas"), false) assert.deepStrictEqual( - yield* decodeEvent( - machine, - { _tag: "PublicEvent", value: "public" } - ), + yield* decodeEvent< + readonly [ + typeof PublicEvent, + typeof InternalEvent + ] + >(machine, { _tag: "PublicEvent", value: "public" }), { _tag: "PublicEvent", value: "public" } ) assert.deepStrictEqual( - yield* decodeEvent( - machine, - { _tag: "InternalEvent", value: "internal" } - ), + yield* decodeEvent< + readonly [ + typeof PublicEvent, + typeof InternalEvent + ] + >(machine, { _tag: "InternalEvent", value: "internal" }), { _tag: "InternalEvent", value: "internal" } ) })) diff --git a/packages/effect-machine/test/internal/machine/rootStrategies.test.ts b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts index 031e10a7..653fe955 100644 --- a/packages/effect-machine/test/internal/machine/rootStrategies.test.ts +++ b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts @@ -2,22 +2,29 @@ import { assert, it } from "@effect/vitest" import { Effect, Schema } from "effect" import { Machine } from "../../../src/index.js" import { verifyPlannerStrategies } from "./support/strategyDifferential.js" - it.effect("retains independent callback contexts across root, inline, and named transitions", () => Effect.gen(function*() { - const root = Machine.state({ fields: { count: Schema.Number }, initial: "Idle", states: { Idle: {} } }) + const root = Machine.state({ fields: { count: Schema.Number }, states: { Idle: {} } }) const targets = Machine.targets(root) - const retained: Array<{ readonly root: { readonly count: number }; readonly snapshot: unknown }> = [] + const retained: Array<{ + readonly root: { + readonly count: number + } + readonly snapshot: unknown + }> = [] const machine = Machine.make({ root, events: Machine.events({ Increment: {}, Observe: {}, IncrementRoot: {} }), - initial: (root) => root.from(() => ({ count: 0 })), branches: { observe: { stay: { none: true } } } }).handle({ + initial: { + target: Machine.targets(root).root.Idle + }, + root: () => ({ count: 0 }), on: { IncrementRoot: { update: targets.root, - from: (context) => { + data: (context) => { retained.push(context) return { count: context.root.count + 1 } } @@ -28,7 +35,7 @@ it.effect("retains independent callback contexts across root, inline, and named on: { Increment: { update: targets.root, - from: (context) => { + data: (context) => { retained.push(context) return { count: context.root.count + 1 } } @@ -60,18 +67,18 @@ it.effect("retains independent callback contexts across root, inline, and named }) } })) - it.effect("constructs schema defaults throughout a parallel root", () => { + const InitialRoot1 = Machine.state({ + type: "parallel", + states: { + Left: { schema: Schema.TaggedStruct("Left", {}), states: { Idle: { fields: {} } } }, + Right: { fields: {} } + } + }) const machine = Machine.make({ - root: Machine.state({ - type: "parallel", - states: { - Left: { schema: Schema.TaggedStruct("Left", {}), initial: "Idle", states: { Idle: { fields: {} } } }, - Right: { fields: {} } - } - }), + root: InitialRoot1, events: Machine.events({ Noop: {} }) - }) + }).handle({ states: { Left: { initial: { target: Machine.targets(InitialRoot1).root.Left.Idle } } } }) return verifyPlannerStrategies({ machine, expected: "indexed-hierarchical", @@ -79,14 +86,16 @@ it.effect("constructs schema defaults throughout a parallel root", () => { events: [{ _tag: "Noop" }] }) }) - it.effect("compares structural leaves, reentry, raised events and completion with generic planning", () => { - const root1 = Machine.state({ initial: "Idle", states: { Idle: {}, Busy: {}, Done: { type: "final" } } }) + const root1 = Machine.state({ states: { Idle: {}, Busy: {}, Done: { type: "final" } } }) const targets1 = Machine.targets(root1) const machine = Machine.make({ root: root1, events: Machine.events({ Begin: {}, Reenter: {}, Finish: {}, Complete: {} }) }).handle({ + initial: { + target: Machine.targets(root1).root.Idle + }, states: { Idle: { on: { Begin: { target: targets1.root.Busy } } }, Busy: { @@ -100,7 +109,8 @@ it.effect("compares structural leaves, reentry, raised events and completion wit }, Complete: { target: targets1.root.Done } } - } + }, + Done: {} } }) return verifyPlannerStrategies({ @@ -114,17 +124,16 @@ it.effect("compares structural leaves, reentry, raised events and completion wit ] }) }) - it.effect("keeps flat root updates and retained snapshots equal to generic planning", () => { const root = Machine.state({ fields: { count: Schema.Number } }) const targets2 = Machine.targets(root) const machine = Machine.make({ root, - events: Machine.events({ Increment: { by: Schema.Number }, Noop: {}, Reenter: {} }), - initial: (root) => root.from(() => ({ count: 0 })) + events: Machine.events({ Increment: { by: Schema.Number }, Noop: {}, Reenter: {} }) }).handle({ + root: () => ({ count: 0 }), on: { - Increment: { update: targets2.root, from: ({ root: current, event }) => ({ count: current.count + event.by }) }, + Increment: { update: targets2.root, data: ({ root: current, event }) => ({ count: current.count + event.by }) }, Noop: { none: true }, Reenter: { none: true, reenter: true } } @@ -141,17 +150,19 @@ it.effect("keeps flat root updates and retained snapshots equal to generic plann ] }) }) - 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 root = Machine.state({ fields: { count: Schema.Number }, states: { Idle: {}, Busy: {} } }) const targets3 = Machine.targets(root) const machine = Machine.make({ root, - events: Machine.events({ Increment: { by: Schema.Number }, Start: {}, Stop: {} }), - initial: (root) => root.from(() => ({ count: 0 })) + events: Machine.events({ Increment: { by: Schema.Number }, Start: {}, Stop: {} }) }).handle({ + initial: { + target: Machine.targets(root).root.Idle + }, + root: () => ({ count: 0 }), on: { - Increment: { update: targets3.root, from: ({ root: current, event }) => ({ count: current.count + event.by }) } + Increment: { update: targets3.root, data: ({ root: current, event }) => ({ count: current.count + event.by }) } }, states: { Idle: { on: { Start: { target: targets3.root.Busy } } }, @@ -170,38 +181,38 @@ 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 InitialRoot2 = Machine.state({ + fields: { count: Schema.Number }, + states: { Idle: { fields: { count: Schema.Number } }, Busy: {} } + }) const machine = Machine.make({ - root: Machine.state({ initial: "Idle", states: { Idle: { fields: { count: Schema.Number } }, Busy: {} } }), + root: InitialRoot2, 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({}) + events: Machine.events({ Noop: {} }) + }).handle({ + root: ({ input }) => ({ count: input.count }), + initial: { target: Machine.targets(InitialRoot2).root.Idle, data: ({ root }) => ({ count: root.count }) } + }) + const targets = Machine.targets(InitialRoot2) + assert.strictEqual(Reflect.set(targets.root, "Idle", targets.root.Busy), false) const first = yield* Machine.planInitial(machine, { count: 1 }) const second = yield* Machine.planInitial(machine, { count: 2 }) assert.deepStrictEqual(first.state, { path: "", - value: undefined, + value: { _tag: "", count: 1 }, state: { path: "Idle", value: { _tag: "Idle", count: 1 } } }) assert.deepStrictEqual(second.state, { path: "", - value: undefined, + value: { _tag: "", count: 2 }, state: { path: "Idle", value: { _tag: "Idle", count: 2 } } }) for (const count of [3, 4]) { yield* verifyPlannerStrategies({ machine, - expected: "indexed-flat", + expected: "indexed-hierarchical", label: `independent startup ${count}`, initialArgs: [{ count }], events: [{ _tag: "Noop" }] diff --git a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts index 12ff7072..8b5fecbe 100644 --- a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts +++ b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts @@ -12,7 +12,6 @@ import { prepareWithRuntimeStrategy, verifyPlannerStrategies } from "./support/strategyDifferential.js" - class Count extends Schema.TaggedClass("StrategyCount")("Count", { value: Schema.Number }) {} @@ -26,10 +25,8 @@ class Finish extends Schema.TaggedClass("StrategyFinish")("Finish", {}) class Select extends Schema.TaggedClass("CoverageSelect")("Select", { value: Schema.Int }) {} - const branchMachine = Machine.make({ branches: { transition1: { negative: { none: true }, zero: { none: true }, positive: { none: true } } }, - root: StartupStates, - events: Machine.eventsFromSchemas(Select), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.count.decoded(new Count({ value: 0 })))) + events: Machine.eventsFromSchemas(Select) }).handle({ + initial: { + target: Machine.targets(StartupStates).root.count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { count: { on: { @@ -115,20 +127,22 @@ const branchMachine = Machine.make({ } } }) - const Tick = Symbol.for("MachineTestCoverage/Tick") const TickEvent = Schema.Struct({ _tag: Schema.UniqueSymbol(Tick) }) const ChoiceEvent = Schema.Struct({ _tag: Schema.Union([Schema.Literal("Alpha"), Schema.Literal("Beta")]) }) const OpenEvent = Schema.Struct({ _tag: Schema.String }) -const EventStates = Machine.state({ initial: "count", states: { count: Count } }) +const EventStates = Machine.state({ states: { count: Count } }) const finiteEventMachine = Machine.make({ root: EventStates, - events: Machine.eventsFromSchemas(TickEvent, ChoiceEvent), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.count.decoded(new Count({ value: 0 })))) + events: Machine.eventsFromSchemas(TickEvent, ChoiceEvent) }).handle({ + initial: { + target: Machine.targets(EventStates).root.count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { count: { on: { @@ -141,13 +155,20 @@ const finiteEventMachine = Machine.make({ }) const openEventMachine = Machine.make({ 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 }) - + events: Machine.eventsFromSchemas(OpenEvent) +}).handle({ + initial: { + target: Machine.targets(EventStates).root.count, + decoded: true, + data: new Count({ value: 0 }) + }, + states: { + count: {} + } +}) +const event = (_tag: string): { + readonly _tag: string +} => ({ _tag }) const parallelModel: MachineTest.FiniteModel = { roots: [{ _tag: "Parallel", @@ -194,7 +215,6 @@ const parallelModel: MachineTest.FiniteModel = { } ] } - const historyModel: MachineTest.FiniteModel = { roots: [ { @@ -219,7 +239,6 @@ const historyModel: MachineTest.FiniteModel = { { source: "outside", trigger: { type: "event", event: "Resume" }, target: "owner.exact", reenter: false } ] } - describe("MachineTest trace coverage", () => { it.effect("turns definition-aware state, transition, and event misses into hits", () => Effect.gen(function*() { @@ -229,10 +248,11 @@ describe("MachineTest trace coverage", () => { const finishTrace = yield* MachineTest.run(counterMachine, { events: [new Finish({})] }) - const partial = MachineTest.coverage(counterMachine, addTrace) assert.strictEqual(partial.events.available, true) - if (!partial.events.available) return + if (!partial.events.available) { + return + } assert.strictEqual(partial.transitions.definitions.hits[0]?.acceptance, "declinable") assert.strictEqual(partial.transitions.branches.hits[0]?.acceptance, "declinable") assert.deepStrictEqual(partial.states.activation.misses.map(({ path }) => path), ["done"]) @@ -245,10 +265,11 @@ describe("MachineTest trace coverage", () => { ) assert.deepStrictEqual(partial.events.misses, [{ tag: "Finish", count: 0 }]) assert.strictEqual(partial.logicalConfigurations.hit, 2) - const combined = MachineTest.coverage(counterMachine, [addTrace, finishTrace]) assert.strictEqual(combined.events.available, true) - if (!combined.events.available) return + if (!combined.events.available) { + return + } assert.strictEqual(combined.states.activation.missing, 0) assert.strictEqual(combined.transitions.definitions.missing, 0) assert.strictEqual(combined.transitions.branches.missing, 0) @@ -258,18 +279,15 @@ describe("MachineTest trace coverage", () => { assert.strictEqual(combined.scenarios.traces, 2) assert.strictEqual(combined.scenarios.events, 2) })) - it.effect("attributes identical targetless results to their exact named branches", () => Effect.gen(function*() { const negative = yield* MachineTest.run(branchMachine, { events: [new Select({ value: -1 })] }) const zero = yield* MachineTest.run(branchMachine, { events: [new Select({ value: 0 })] }) const positive = yield* MachineTest.run(branchMachine, { events: [new Select({ value: 1 })] }) - const partial = MachineTest.coverage(branchMachine, negative) assert.strictEqual(partial.transitions.definitions.hit, 1) assert.deepStrictEqual(partial.transitions.branches.hits.map(({ branchIndex }) => branchIndex), [0]) assert.deepStrictEqual(partial.transitions.branches.misses.map(({ branchIndex }) => branchIndex), [1, 2]) - const complete = MachineTest.coverage(branchMachine, [negative, zero, positive]) assert.strictEqual(complete.transitions.definitions.missing, 0) assert.strictEqual(complete.transitions.branches.missing, 0) @@ -287,7 +305,6 @@ describe("MachineTest trace coverage", () => { ] ) })) - it.effect("covers finite decoded symbol and union tags and diagnoses open tag spaces", () => Effect.gen(function*() { const finiteTrace = yield* MachineTest.run(finiteEventMachine, { @@ -300,7 +317,6 @@ describe("MachineTest trace coverage", () => { assert.deepStrictEqual(finite.events.hits.map(({ tag }) => tag), [Tick, "Alpha"]) assert.deepStrictEqual(finite.events.misses, [{ tag: "Beta", count: 0 }]) } - const openTrace = yield* MachineTest.run(openEventMachine, { events: [{ _tag: "Dynamic" }] }) const open = MachineTest.coverage(openEventMachine, openTrace) assert.strictEqual(open.events.available, false) @@ -310,7 +326,6 @@ describe("MachineTest trace coverage", () => { assert.strictEqual(open.events.diagnostics.length, 1) } })) - it.effect("reports parallel completion and history evidence without inferring unobserved behavior", () => Effect.gen(function*() { const parallelMachine = MachineTest.compileModel(parallelModel) @@ -323,7 +338,6 @@ describe("MachineTest trace coverage", () => { assert.ok(parallelCoverage.completion.paths.includes("workflow.left")) assert.strictEqual(parallelCoverage.microsteps.eventTriggered, 2) assert.strictEqual(parallelCoverage.transitions.branches.hit, 2) - const historyMachine = MachineTest.compileModel(historyModel) const history = yield* MachineTest.run(historyMachine, { events: [event("Next"), event("Leave"), event("Resume")] @@ -333,12 +347,9 @@ describe("MachineTest trace coverage", () => { assert.deepStrictEqual(historyCoverage.history.recorded, [{ path: "owner.exact" as const, modes: ["deep"] }]) assert.strictEqual(historyCoverage.history.targets, 1) assert.strictEqual(historyCoverage.history.resolvedTargets, 1) - assert.ok( - historyCoverage.transitions.branches.hits.some(({ branch }) => branch.selection.kind === "history") - ) + assert.ok(historyCoverage.transitions.branches.hits.some(({ branch }) => branch.selection.kind === "history")) })) }) - describe("MachineTest observed graph", () => { it.effect("deduplicates portable snapshots while preserving concrete startup and event edges", () => Effect.gen(function*() { @@ -349,17 +360,13 @@ describe("MachineTest observed graph", () => { events: [new Add({ amount: 1 })] }) const observed = yield* MachineTest.observedGraph(counterMachine, [first, second]) - assert.strictEqual(Graph.nodeCount(observed.graph), 2) assert.strictEqual(Graph.edgeCount(observed.graph), 4) const edges = Array.from(Graph.edges(observed.graph), ([, edge]) => edge.data) assert.strictEqual(edges.filter(({ _tag }) => _tag === "Startup").length, 2) assert.strictEqual(edges.filter(({ _tag }) => _tag === "Event").length, 2) assert.ok(edges.filter(({ _tag }) => _tag === "Event").every((edge) => edge.microsteps.length === 1)) - assert.ok( - edges.flatMap(({ microsteps }) => microsteps).every(({ next }) => observed.nodesById.has(next)) - ) - + assert.ok(edges.flatMap(({ microsteps }) => microsteps).every(({ next }) => observed.nodesById.has(next))) const nodes = Array.from(observed.graph) 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)! @@ -370,14 +377,14 @@ describe("MachineTest observed graph", () => { cost: () => 1 }) assert.ok(Option.isSome(shortest)) - if (Option.isSome(shortest)) assert.strictEqual(shortest.value.distance, 1) + if (Option.isSome(shortest)) { + assert.strictEqual(shortest.value.distance, 1) + } })) - it.effect("separates pre-settled startup sources from post-startup path starts", () => Effect.gen(function*() { const trace = yield* MachineTest.run(startupMachine, { events: [] }) const observed = yield* MachineTest.observedGraph(startupMachine, trace) - assert.strictEqual(observed.startupSources.length, 1) assert.strictEqual(observed.starts.length, 1) assert.notStrictEqual(observed.startupSources[0], observed.starts[0]) @@ -385,7 +392,6 @@ describe("MachineTest observed graph", () => { assert.strictEqual(startup._tag, "Startup") assert.strictEqual(startup.microsteps.length, 1) })) - it.effect("retains complete parallel configurations and completion on macrostep edges", () => Effect.gen(function*() { const machine = MachineTest.compileModel(parallelModel) @@ -394,19 +400,19 @@ describe("MachineTest observed graph", () => { }) const observed = yield* MachineTest.observedGraph(machine, trace) const nodes = Array.from(observed.graph, ([, node]) => node) - assert.ok(nodes.some(({ configuration }) => configuration.includes("workflow.left.done") && configuration.includes("workflow.right.idle") )) const eventEdges = Array.from(Graph.edges(observed.graph), ([, edge]) => - edge.data).filter( - (edge): edge is Extract => edge._tag === "Event" + edge.data).filter((edge): edge is Extract => + edge._tag === "Event" ) assert.strictEqual(eventEdges.length, 2) assert.strictEqual(eventEdges[0]!.completion.done, false) assert.strictEqual(eventEdges[1]!.completion.done, true) })) - it.effect("keeps equal active paths with different history records as distinct logical nodes", () => Effect.gen(function*() { const machine = MachineTest.compileModel(historyModel) @@ -415,29 +421,22 @@ describe("MachineTest observed graph", () => { events: [event("Next"), event("Leave")] }) const observed = yield* MachineTest.observedGraph(machine, [rememberedA, rememberedB]) - const outside = Array.from(observed.graph, ([, node]) => node).filter( - ({ configuration }) => configuration.length === 2 && configuration[0] === "" && configuration[1] === "outside" + const outside = Array.from(observed.graph, ([, node]) => node).filter(({ configuration }) => + configuration.length === 2 && configuration[0] === "" && configuration[1] === "outside" ) - assert.strictEqual(outside.length, 2) - assert.notStrictEqual( - JSON.stringify(outside[0]!.snapshot.history), - JSON.stringify(outside[1]!.snapshot.history) - ) + assert.notStrictEqual(JSON.stringify(outside[0]!.snapshot.history), JSON.stringify(outside[1]!.snapshot.history)) assert.notStrictEqual(outside[0]!.id, outside[1]!.id) })) - it.effect("does not merge colliding non-JSON encoded values", () => Effect.gen(function*() { const makePayload = () => function collision() {} const first = yield* MachineTest.run(opaqueMachine, { input: makePayload(), events: [] }) const second = yield* MachineTest.run(opaqueMachine, { input: makePayload(), events: [] }) const observed = yield* MachineTest.observedGraph(opaqueMachine, [first, second]) - assert.strictEqual(Graph.nodeCount(observed.graph), 2) assert.ok(Array.from(observed.graph, ([, node]) => node).every((node) => node.encoded === undefined)) assert.strictEqual(MachineTest.coverage(opaqueMachine, [first, second]).logicalConfigurations.hit, 2) - const firstBuffer = yield* MachineTest.run(opaqueMachine, { input: new Uint8Array([1]).buffer, events: [] @@ -448,9 +447,6 @@ describe("MachineTest observed graph", () => { }) const buffers = yield* MachineTest.observedGraph(opaqueMachine, [firstBuffer, secondBuffer]) assert.strictEqual(Graph.nodeCount(buffers.graph), 2) - assert.strictEqual( - MachineTest.coverage(opaqueMachine, [firstBuffer, secondBuffer]).logicalConfigurations.hit, - 2 - ) + assert.strictEqual(MachineTest.coverage(opaqueMachine, [firstBuffer, secondBuffer]).logicalConfigurations.hit, 2) })) }) diff --git a/packages/effect-machine/test/testing/Exploration.test.ts b/packages/effect-machine/test/testing/Exploration.test.ts index 2871ee0f..e4e49f45 100644 --- a/packages/effect-machine/test/testing/Exploration.test.ts +++ b/packages/effect-machine/test/testing/Exploration.test.ts @@ -2,11 +2,9 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Schema } from "effect" import { Machine } from "../../src/index.js" import { MachineTest } from "../../src/testing/index.js" - class Counter extends Schema.TaggedClass("Counter")("Counter", { count: Schema.Int }) {} - class Increment extends Schema.TaggedClass("Increment")("Increment", {}) {} class Reset extends Schema.TaggedClass("Reset")("Reset", {}) {} class Corrupt extends Schema.TaggedClass("Corrupt")("Corrupt", {}) {} @@ -14,40 +12,44 @@ class Select extends Schema.TaggedClass("VerificationSelect")("Select", { value: Schema.Int }) {} - const NavigationStates = Machine.state({ - initial: "off", states: { off: Off, app: { schema: App, - initial: "one", states: { one: One, two: Two @@ -33,14 +29,15 @@ const NavigationStates = Machine.state({ } } }) - const targets1 = Machine.targets(NavigationStates) const navigationMachine = Machine.make({ branches: { transition1: { destination: { target: targets1.root.app } } }, - root: NavigationStates, events: Machine.eventsFromSchemas(Go) }).handle({ + initial: { + target: Machine.targets(NavigationStates).root.off + }, states: { off: { on: { @@ -50,21 +47,32 @@ const navigationMachine = Machine.make({ target.decoded(new App({}), (app) => app.two.decoded(new Two({}))) } } + }, + app: { + initial: { + target: Machine.targets(NavigationStates).root.app.one + }, + states: { + one: {}, + two: {} + } } } }) - const targets2 = Machine.targets(NavigationStates) const raisedNavigationMachine = Machine.make({ branches: { transition1: { destination: { target: targets2.root.app } }, transition2: { destination: { target: targets2.root.app } } }, - root: NavigationStates, - events: Machine.eventsFromSchemas(Go), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.off.decoded(new Off({})))) + events: Machine.eventsFromSchemas(Go) }).handle({ + initial: { + target: Machine.targets(NavigationStates).root.off, + decoded: true, + data: new Off({}) + }, states: { off: { always: { @@ -79,42 +87,56 @@ const raisedNavigationMachine = Machine.make({ target.decoded(new App({}), (app) => app.one.decoded(new One({}))) } } + }, + app: { + initial: { + target: Machine.targets(NavigationStates).root.app.one + }, + states: { + one: {}, + two: {} + } } } }) - -const CounterStates = Machine.state({ initial: "counter", states: { counter: Counter } }) - +const CounterStates = Machine.state({ states: { counter: Counter } }) const targets3 = Machine.targets(CounterStates) const counterMachine = Machine.make({ branches: { transition1: { destination: { target: targets3.root.counter } } }, - root: CounterStates, - events: Machine.eventsFromSchemas(Increment, Noop), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.counter.decoded(new Counter({ count: 0 })))) + events: Machine.eventsFromSchemas(Increment, Noop) }).handle({ + initial: { + target: Machine.targets(CounterStates).root.counter, + decoded: true, + data: new Counter({ count: 0 }) + }, states: { counter: { on: { - Increment: { target: targets3.root.counter, decoded: ({ state }) => (new Counter({ count: state.count + 1 })) }, + Increment: { + target: targets3.root.counter, + decoded: true, + data: ({ state }) => (new Counter({ count: state.count + 1 })) + }, Noop: { none: true } } } } }) - const targets4 = Machine.targets(CounterStates) const conditionalMachine = Machine.make({ branches: { transition1: { negative: { none: true }, zero: { target: targets4.root.counter }, positive: { none: true } } }, - root: CounterStates, - events: Machine.eventsFromSchemas(Select), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.counter.decoded(new Counter({ count: 0 })))) + events: Machine.eventsFromSchemas(Select) }).handle({ + initial: { + target: Machine.targets(CounterStates).root.counter, + decoded: true, + data: new Counter({ count: 0 }) + }, states: { counter: { on: { @@ -131,15 +153,16 @@ const conditionalMachine = Machine.make({ } } }) - const invokedMachine = Machine.make({ effects: { source1: Effect.suspend(() => Effect.succeed(1)), source2: Effect.suspend(() => Effect.succeed(2)) }, - root: CounterStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.counter.decoded(new Counter({ count: 0 })))) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(CounterStates).root.counter, + decoded: true, + data: new Counter({ count: 0 }) + }, states: { counter: { invoke: [{ src: "source1", id: "first", onDone: { none: true } }, { @@ -150,16 +173,12 @@ const invokedMachine = Machine.make({ } } }) - class StartupA extends Schema.TaggedClass("StartupA")("StartupA", {}) {} class StartupB extends Schema.TaggedClass("StartupB")("StartupB", {}) {} - const RoutedStartupStates = Machine.state({ - initial: "b", states: { a: { schema: StartupA, - initial: "route", states: { route: { type: "choice" }, second: { type: "choice" } @@ -168,44 +187,44 @@ const RoutedStartupStates = Machine.state({ b: StartupB } }) - const targets6 = Machine.targets(RoutedStartupStates) const routedStartupMachine = Machine.make({ branches: { transition1: { destination: { target: targets6.root.a.second } }, transition2: { destination: { target: targets6.root.b } } }, - root: RoutedStartupStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.a.decoded(new StartupA({}), (a) => a.route()))) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(RoutedStartupStates).root.a, + decoded: true, + data: new StartupA({}) + }, states: { a: { + initial: { + target: Machine.targets(RoutedStartupStates).root.a.route + }, states: { route: { choice: { branches: "transition1", resolve: ({ select: { destination: target } }) => target() } }, second: { - choice: { target: targets6.root.b, decoded: () => (new StartupB({})) } + choice: { target: targets6.root.b, decoded: true, data: () => (new StartupB({})) } } } }, 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.state({ - initial: "flow", states: { flow: { schema: ChoiceFlow, - initial: "ready", states: { ready: ChoiceReady, first: { type: "choice" }, @@ -215,23 +234,27 @@ const ChoiceResolutionStates = Machine.state({ } } }) - const targets7 = Machine.targets(ChoiceResolutionStates) const choiceResolutionMachine = Machine.make({ branches: { transition1: { destination: { target: targets7.root.flow.first } }, transition2: { destination: { target: targets7.root.flow.second } } }, - 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({})))) - ) + events: Machine.eventsFromSchemas(Route) }).handle({ + initial: { + target: Machine.targets(ChoiceResolutionStates).root.flow, + decoded: true, + data: new ChoiceFlow({}) + }, states: { flow: { + initial: { + target: Machine.targets(ChoiceResolutionStates).root.flow.ready, + decoded: true, + data: new ChoiceReady({}) + }, states: { ready: { on: { @@ -242,27 +265,32 @@ const choiceResolutionMachine = Machine.make({ choice: { branches: "transition2", resolve: ({ select: { destination: target } }) => target() } }, second: { - choice: { target: targets7.root.flow.routed, decoded: () => (new ChoiceRouted({})) } + choice: { target: targets7.root.flow.routed, decoded: true, data: () => (new ChoiceRouted({})) } }, routed: {} } } } }) - const targets8 = Machine.targets(NavigationStates) const reentryMachine = Machine.make({ branches: { transition1: { destination: { target: targets8.root.app } } }, - 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({})))) - ) + events: Machine.eventsFromSchemas(Restart) }).handle({ + initial: { + target: Machine.targets(NavigationStates).root.app, + decoded: true, + data: new App({}) + }, states: { + off: {}, app: { + initial: { + target: Machine.targets(NavigationStates).root.app.one, + decoded: true, + data: new One({}) + }, on: { Restart: { branches: "transition1", @@ -270,17 +298,18 @@ const reentryMachine = Machine.make({ resolve: ({ select: { destination: target } }) => target.decoded(new App({}), (app) => app.one.decoded(new One({}))) } + }, + states: { + one: {}, + two: {} } } } }) - class Dashboard extends Schema.TaggedClass("Dashboard")("Dashboard", {}) {} class Left extends Schema.TaggedClass("Left")("Left", {}) {} class Right extends Schema.TaggedClass("Right")("Right", {}) {} - const ParallelStates = Machine.state({ - initial: "dashboard", states: { dashboard: { schema: Dashboard, @@ -292,21 +321,25 @@ const ParallelStates = Machine.state({ } } }) - const parallelMachine = Machine.make({ 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({ states: { dashboard: {} } }) - + events: Machine.eventsFromSchemas() +}).handle({ + initial: { + target: Machine.targets(ParallelStates).root.dashboard, + decoded: true, + data: new Dashboard({}) + }, + states: { + dashboard: { + initial: { left: { decoded: true, data: new Left({}) }, right: { decoded: true, data: new Right({}) } }, + states: { + left: {}, + right: {} + } + } + } +}) class Workspace extends Schema.TaggedClass("Workspace")("Workspace", {}) {} class Editor extends Schema.TaggedClass("Editor")("Editor", {}) {} class Editing extends Schema.TaggedClass("Editing")("Editing", { @@ -315,17 +348,13 @@ class Editing extends Schema.TaggedClass("Editing")("Editing", { class Away extends Schema.TaggedClass("Away")("Away", {}) {} class Leave extends Schema.TaggedClass("Leave")("Leave", {}) {} class Resume extends Schema.TaggedClass("Resume")("Resume", {}) {} - const HistoryStates = Machine.state({ - initial: "away", states: { workspace: { schema: Workspace, - initial: "editor", states: { editor: { schema: Editor, - initial: "editing", states: { editing: Editing } @@ -342,57 +371,64 @@ const HistoryStates = Machine.state({ away: Away } }) - const targets9 = Machine.targets(HistoryStates) const historyMachine = Machine.make({ branches: { transition1: { destination: { target: targets9.root.away } }, transition2: { destination: { history: targets9.root.workspace.exact } } }, - 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 })) - )) - ) - ) + events: Machine.eventsFromSchemas(Leave, Resume) }).handle({ + initial: { + target: Machine.targets(HistoryStates).root.workspace, + decoded: true, + data: new Workspace({}) + }, states: { workspace: { + initial: { + target: Machine.targets(HistoryStates).root.workspace.editor, + decoded: true, + data: new Editor({}) + }, 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 })) - )) + 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 })) - )) + to.workspace.decoded( + new Workspace({}), + (workspace) => + workspace.editor.decoded(new Editor({}), (editor) => + editor.editing.decoded(new Editing({ revision: 0 }))) + ) ) } }, on: { - Leave: { target: targets9.root.away, decoded: () => (new Away({})) } + Leave: { target: targets9.root.away, decoded: true, data: () => (new Away({})) } }, states: { editor: { - initialize: ({ builder }) => builder.decoded(new Editing({ revision: 0 })) + initial: { + target: Machine.targets(HistoryStates).root.workspace.editor.editing, + decoded: true, + data: ({}) => new Editing({ revision: 0 }) + }, + states: { + editing: {} + } } } }, @@ -403,15 +439,11 @@ const historyMachine = Machine.make({ } } }) - const StructuralHistoryStates = Machine.state({ - initial: "away", states: { workspace: { - initial: "editor", states: { editor: { - initial: "idle", states: { idle: {} } }, exact: { type: "history", history: "deep" } @@ -420,7 +452,6 @@ const StructuralHistoryStates = Machine.state({ away: {} } }) - const structuralHistoryInitial = () => ({ path: "" as const, value: undefined, @@ -434,34 +465,42 @@ const structuralHistoryInitial = () => ({ } } }) - const targets10 = Machine.targets(StructuralHistoryStates) const structuralHistoryMachine = Machine.make({ branches: { transition1: { destination: { target: targets10.root.away } } }, - 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()))) - ) + events: Machine.eventsFromSchemas(Leave) }).handle({ + initial: { + target: Machine.targets(StructuralHistoryStates).root.workspace + }, states: { workspace: { + initial: { + target: Machine.targets(StructuralHistoryStates).root.workspace.editor + }, history: { exact: { default: structuralHistoryInitial } }, on: { Leave: { target: targets10.root.away } + }, + states: { + editor: { + initial: { + target: Machine.targets(StructuralHistoryStates).root.workspace.editor.idle + }, + states: { + idle: {} + } + } } - } + }, + away: {} } }) - class Finished extends Schema.TaggedClass("Finished")("Finished", {}) {} - const CompletionStates = Machine.state({ - initial: "finished", states: { finished: { schema: Finished, @@ -470,29 +509,27 @@ const CompletionStates = Machine.state({ } } }) - const completionMachine = Machine.make({ root: CompletionStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.finished.decoded(new Finished({})))) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(CompletionStates).root.finished, + decoded: true, + data: new Finished({}) + }, states: { finished: { output: () => "complete" } } }) - class Workflow extends Schema.TaggedClass("Workflow")("Workflow", {}) {} class Archived extends Schema.TaggedClass("Archived")("Archived", {}) {} - const DoneTransitionStates = Machine.state({ - initial: "workflow", states: { workflow: { schema: Workflow, - initial: "finished", states: { finished: { schema: Finished, @@ -504,62 +541,66 @@ const DoneTransitionStates = Machine.state({ archived: Archived } }) - const targets11 = Machine.targets(DoneTransitionStates) const doneTransitionMachine = Machine.make({ branches: { transition1: { destination: { target: targets11.root.archived } } }, - root: DoneTransitionStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => - target.from((to) => - to.workflow.decoded(new Workflow({}), (workflow) => workflow.finished.decoded(new Finished({}))) - ) - ) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(DoneTransitionStates).root.workflow, + decoded: true, + data: new Workflow({}) + }, states: { workflow: { - onDone: { target: targets11.root.archived, decoded: () => (new Archived({})) }, + initial: { + target: Machine.targets(DoneTransitionStates).root.workflow.finished, + decoded: true, + data: new Finished({}) + }, + onDone: { target: targets11.root.archived, decoded: true, data: () => (new Archived({})) }, states: { finished: { output: () => "workflow-output" } } - } + }, + archived: {} } }) - const nestedCompletionMachine = Machine.make({ root: DoneTransitionStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => - target.from((to) => - to.workflow.decoded(new Workflow({}), (workflow) => workflow.finished.decoded(new Finished({}))) - ) - ) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(DoneTransitionStates).root.workflow, + decoded: true, + data: new Workflow({}) + }, states: { workflow: { + initial: { + target: Machine.targets(DoneTransitionStates).root.workflow.finished, + decoded: true, + data: new Finished({}) + }, states: { finished: { output: () => "nested-output" } } - } + }, + archived: {} } }) - const laws = (error: MachineTest.VerificationError): ReadonlyArray => error.violations.map((violation) => violation.law) - describe("MachineTest.verify", () => { it.effect("accepts structural configurations and history without invented values", () => Effect.gen(function*() { const trace = yield* MachineTest.run(structuralHistoryMachine, { events: [new Leave({})] }) yield* MachineTest.verify(structuralHistoryMachine, trace) - const valuedState = { ...trace, final: { ...trace.final, state: { ...trace.final.state, value: { _tag: "Invented" } } } @@ -570,7 +611,6 @@ describe("MachineTest.verify", () => { assert.ok( stateError.violations.some((violation) => violation.law === "configuration.schema" && violation.path === "away") ) - const final = trace.final as any const exact = final.history["workspace.exact"] const valuedHistory = { @@ -593,7 +633,6 @@ describe("MachineTest.verify", () => { historyError.violations.some((violation) => violation.law === "history.value" && violation.path === "workspace") ) })) - it.effect("accepts valid compound, parallel, history, and completion traces", () => Effect.gen(function*() { const navigation = yield* MachineTest.run(navigationMachine, { events: [new Go({})] }) @@ -603,7 +642,6 @@ describe("MachineTest.verify", () => { const completion = yield* MachineTest.run(completionMachine, { events: [] }) const doneTransition = yield* MachineTest.run(doneTransitionMachine, { events: [] }) const nestedCompletion = yield* MachineTest.run(nestedCompletionMachine, { events: [] }) - yield* MachineTest.verify(navigationMachine, navigation) yield* MachineTest.verify(raisedNavigationMachine, raisedNavigation) yield* MachineTest.verify(parallelMachine, parallel) @@ -612,7 +650,6 @@ describe("MachineTest.verify", () => { yield* MachineTest.verify(doneTransitionMachine, doneTransition) yield* MachineTest.verify(nestedCompletionMachine, nestedCompletion) })) - it.effect("allows same-path value updates while rejecting implausible changed no-ops", () => Effect.gen(function*() { const updated = yield* MachineTest.run(counterMachine, { events: [new Increment({})] }) @@ -622,7 +659,6 @@ describe("MachineTest.verify", () => { assert.deepStrictEqual(updateMicrostep.entryPaths, []) assert.strictEqual((updated.final.state.value as Counter).count, 1) yield* MachineTest.verify(counterMachine, updated) - const noop = yield* MachineTest.run(counterMachine, { events: [new Noop({})] }) const step = noop.steps[0]! const microstep = step.plan.microsteps[0]! @@ -641,7 +677,6 @@ describe("MachineTest.verify", () => { }).pipe(Effect.flip) assert.include(laws(error), "microsteps.changed") })) - it.effect("requires lifecycle boundaries to match retained reentry scopes", () => Effect.gen(function*() { const reentry = yield* MachineTest.run(reentryMachine, { events: [new Restart({})] }) @@ -668,7 +703,6 @@ describe("MachineTest.verify", () => { assert.ok( missingError.violations.some((violation) => violation.law === "microsteps.reentry" && violation.path === "app") ) - const updated = yield* MachineTest.run(counterMachine, { events: [new Increment({})] }) const updateStep = updated.steps[0]! const updateMicrostep = updateStep.plan.microsteps[0]! @@ -694,7 +728,6 @@ describe("MachineTest.verify", () => { violation.law === "microsteps.reentry" && violation.path === "counter" )) })) - it.effect("reports omitted parallel regions and duplicate active states", () => Effect.gen(function*() { const trace = yield* MachineTest.run(parallelMachine, { events: [] }) @@ -710,7 +743,6 @@ describe("MachineTest.verify", () => { laws: ["configuration"] }).pipe(Effect.flip) assert.include(laws(omittedError), "configuration.parallel") - const duplicate = { ...trace, final: { @@ -723,7 +755,6 @@ describe("MachineTest.verify", () => { }).pipe(Effect.flip) assert.include(laws(duplicateError), "configuration.duplicate") })) - it.effect("reports reversed entry order with its event and microstep", () => Effect.gen(function*() { const trace = yield* MachineTest.run(navigationMachine, { events: [new Go({})] }) @@ -739,7 +770,6 @@ describe("MachineTest.verify", () => { } }] } as typeof trace - const error = yield* MachineTest.verify(navigationMachine, reversed, { laws: ["microsteps"] }).pipe(Effect.flip) @@ -747,12 +777,10 @@ describe("MachineTest.verify", () => { assert.strictEqual(violation?.eventIndex, 0) assert.strictEqual(violation?.microstepIndex, 0) })) - it.effect("reports unknown and active history paths", () => Effect.gen(function*() { const trace = yield* MachineTest.run(historyMachine, { events: [] }) const workspace = trace.final as any - const unknown = { ...trace, final: { @@ -764,7 +792,6 @@ describe("MachineTest.verify", () => { laws: ["configuration"] }).pipe(Effect.flip) assert.include(laws(unknownError), "configuration.path") - const activeHistory = { ...trace, final: { @@ -779,7 +806,6 @@ describe("MachineTest.verify", () => { violation.law === "configuration.path" && violation.path === "workspace.recent" )) })) - it.effect("reports invalid remembered values in public history metadata", () => Effect.gen(function*() { const trace = yield* MachineTest.run(historyMachine, { events: [new Leave({})] }) @@ -801,7 +827,6 @@ describe("MachineTest.verify", () => { } } } as typeof trace - const error = yield* MachineTest.verify(historyMachine, corrupted, { laws: ["history"] }).pipe(Effect.flip) @@ -809,7 +834,6 @@ describe("MachineTest.verify", () => { error.violations.some((violation) => violation.law === "history.value" && violation.path === "workspace.editor") ) })) - it.effect("distinguishes shallow containment from complete deep history", () => Effect.gen(function*() { const trace = yield* MachineTest.run(historyMachine, { events: [new Leave({})] }) @@ -840,14 +864,12 @@ describe("MachineTest.verify", () => { } } } as typeof trace - const error = yield* MachineTest.verify(historyMachine, corrupted, { laws: ["history"] }).pipe(Effect.flip) assert.include(laws(error), "history.shallow") assert.include(laws(error), "history.deep") })) - it.effect("rejects orphan descendants in deep history", () => Effect.gen(function*() { const trace = yield* MachineTest.run(historyMachine, { events: [new Leave({})] }) @@ -862,14 +884,11 @@ describe("MachineTest.verify", () => { "workspace.exact": { ...deep, active: deep.active.filter((path: string) => path !== "workspace.editor"), - values: Object.fromEntries( - Object.entries(deep.values).filter(([path]) => path !== "workspace.editor") - ) + values: Object.fromEntries(Object.entries(deep.values).filter(([path]) => path !== "workspace.editor")) } } } } as typeof trace - const error = yield* MachineTest.verify(historyMachine, corrupted, { laws: ["history"] }).pipe(Effect.flip) @@ -877,7 +896,6 @@ describe("MachineTest.verify", () => { violation.law === "history.deep" && violation.message.includes("without ancestor") )) })) - it.effect("reports inconsistent done and output data", () => Effect.gen(function*() { const trace = yield* MachineTest.run(completionMachine, { events: [] }) @@ -892,14 +910,12 @@ describe("MachineTest.verify", () => { } } } as unknown as typeof trace - const error = yield* MachineTest.verify(completionMachine, corrupted, { laws: ["completion"] }).pipe(Effect.flip) assert.include(laws(error), "completion.done") assert.include(laws(error), "completion.output") })) - it.effect("requires every nested completion in settled snapshots", () => Effect.gen(function*() { const trace = yield* MachineTest.run(nestedCompletionMachine, { events: [] }) @@ -912,12 +928,13 @@ describe("MachineTest.verify", () => { ...trace.initial.plan, state: { ...state, - completed: state.completed.filter(({ path }: { readonly path: string }) => path !== "workflow.finished") + completed: state.completed.filter(({ path }: { + readonly path: string + }) => path !== "workflow.finished") } } } } as typeof trace - const error = yield* MachineTest.verify(nestedCompletionMachine, corrupted, { laws: ["completion"] }).pipe(Effect.flip) @@ -925,7 +942,6 @@ describe("MachineTest.verify", () => { violation.law === "completion.record" && violation.path === "workflow.finished" )) })) - it.effect("binds retained targets to the exact selected branch and selection scope", () => Effect.gen(function*() { const trace = yield* MachineTest.run(navigationMachine, { events: [new Go({})] }) @@ -945,7 +961,6 @@ describe("MachineTest.verify", () => { } }] } as typeof trace - const error = yield* MachineTest.verify(navigationMachine, corrupted, { laws: ["definitions"] }).pipe(Effect.flip) @@ -954,14 +969,12 @@ describe("MachineTest.verify", () => { assert.strictEqual(violation?.microstepIndex, 0) assert.strictEqual(violation?.path, "off") })) - it.effect("rejects invalid named-branch evidence", () => Effect.gen(function*() { const trace = yield* MachineTest.run(conditionalMachine, { events: [new Select({ value: -1 })] }) const step = trace.steps[0]! const microstep = step.plan.microsteps[0]! const transition = microstep.transitions[0]! - const invalidIndex = { ...trace, steps: [{ @@ -979,7 +992,6 @@ describe("MachineTest.verify", () => { laws: ["definitions"] }).pipe(Effect.flip) assert.include(laws(indexError), "definitions.branchIndex") - const wrongKey = { ...trace, steps: [{ @@ -997,7 +1009,6 @@ describe("MachineTest.verify", () => { laws: ["definitions"] }).pipe(Effect.flip) assert.include(laws(keyError), "definitions.branchKey") - const crossBranch = { ...trace, steps: [{ @@ -1016,11 +1027,9 @@ describe("MachineTest.verify", () => { }).pipe(Effect.flip) assert.include(laws(branchError), "definitions.selection") })) - it.effect("accepts an exact initial choice route to another root", () => Effect.gen(function*() { const trace = yield* MachineTest.run(routedStartupMachine, { events: [] }) - assert.deepStrictEqual(trace.initial.startingConfiguration, ["", "b"]) assert.deepStrictEqual( trace.initial.plan.microsteps[0]?.transitions.map(({ branchIndex, source, target, trigger }) => ({ @@ -1036,7 +1045,6 @@ describe("MachineTest.verify", () => { ) yield* MachineTest.verify(routedStartupMachine, trace, { laws: ["definitions"] }) })) - it.effect("rejects a startup root without an exact initial choice route", () => Effect.gen(function*() { const trace = yield* MachineTest.run(navigationMachine, { events: [new Go({})] }) @@ -1047,7 +1055,6 @@ describe("MachineTest.verify", () => { startingState: trace.final } } as typeof trace - const error = yield* MachineTest.verify(navigationMachine, corrupted, { laws: ["definitions"] }).pipe(Effect.flip) @@ -1055,7 +1062,6 @@ describe("MachineTest.verify", () => { assert.strictEqual(violation?.eventIndex, undefined) assert.strictEqual(violation?.path, "") })) - it.effect("binds resolved targets to direct and chained choice evidence", () => Effect.gen(function*() { const trace = yield* MachineTest.run(choiceResolutionMachine, { events: [new Route({})] }) @@ -1074,7 +1080,6 @@ describe("MachineTest.verify", () => { ] ) yield* MachineTest.verify(choiceResolutionMachine, trace, { laws: ["definitions"] }) - const transition = microstep.transitions[0]! const corrupted = { ...trace, @@ -1093,7 +1098,6 @@ describe("MachineTest.verify", () => { laws: ["definitions"] }).pipe(Effect.flip) assert.include(laws(error), "definitions.resolution") - const missingRoute = { ...trace, steps: [{ @@ -1111,7 +1115,6 @@ describe("MachineTest.verify", () => { law === "definitions.resolution" && message.includes("without an exact retained route") )) })) - it.effect("rejects a wrong active descendant as a resolved state target", () => Effect.gen(function*() { const trace = yield* MachineTest.run(navigationMachine, { events: [new Go({})] }) @@ -1131,14 +1134,12 @@ describe("MachineTest.verify", () => { } }] } as typeof trace - const error = yield* MachineTest.verify(navigationMachine, corrupted, { laws: ["definitions"] }).pipe(Effect.flip) const violation = error.violations.find(({ law }) => law === "definitions.resolution") assert.strictEqual(violation?.path, "app.one") })) - it.effect("validates every simultaneous transition resolution independently", () => Effect.gen(function*() { const machine = MachineTest.compileModel({ @@ -1191,7 +1192,6 @@ describe("MachineTest.verify", () => { const step = trace.steps[0]! const microstep = step.plan.microsteps[0]! assert.strictEqual(microstep.transitions.length, 2) - const corrupted = { ...trace, steps: [{ @@ -1213,19 +1213,17 @@ describe("MachineTest.verify", () => { }).pipe(Effect.flip) assert.include(laws(error), "definitions.resolution") })) - it.effect("binds history resolution to the declared owner", () => Effect.gen(function*() { const trace = yield* MachineTest.run(historyMachine, { events: [new Leave({}), new Resume({})] }) const step = trace.steps[1]! const microstep = step.plan.microsteps[0]! const transition = microstep.transitions[0]! - assert.deepStrictEqual( - { target: transition.target, resolvedTarget: transition.resolvedTarget }, - { target: "workspace.exact", resolvedTarget: "workspace" } - ) + assert.deepStrictEqual({ target: transition.target, resolvedTarget: transition.resolvedTarget }, { + target: "workspace.exact", + resolvedTarget: "workspace" + }) yield* MachineTest.verify(historyMachine, trace, { laws: ["definitions"] }) - const corrupted = { ...trace, steps: [trace.steps[0]!, { @@ -1244,7 +1242,6 @@ describe("MachineTest.verify", () => { }).pipe(Effect.flip) assert.include(laws(error), "definitions.resolution") })) - it.effect("distinguishes invoke definitions by lifecycle id and outcome", () => Effect.gen(function*() { const trace = yield* MachineTest.run(invokedMachine, { events: [] }) @@ -1280,7 +1277,6 @@ describe("MachineTest.verify", () => { } } yield* MachineTest.verify(invokedMachine, withInvoke, { laws: ["definitions"] }) - const wrongId = { ...withInvoke, initial: { @@ -1302,12 +1298,10 @@ describe("MachineTest.verify", () => { }).pipe(Effect.flip) assert.include(laws(error), "definitions.transition") })) - const generatedNavigation = MachineTest.scenarios(navigationMachine, { minEvents: 0, maxEvents: 20 }) - it.effect.prop( "verifies schema-derived scenarios after every shrink", { scenario: generatedNavigation.arbitrary }, diff --git a/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts b/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts index f0701d4c..d3fe4553 100644 --- a/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts +++ b/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts @@ -13,49 +13,44 @@ import { } from "effect/unstable/cluster" import { Machine } from "../../../src/index.js" import { ClusterMachine } from "../../../src/unstable/cluster/index.js" - class Count extends Schema.TaggedClass("Count")("Count", { value: Schema.NumberFromString }) {} - class Done extends Schema.TaggedClass("Done")("Done", { value: Schema.NumberFromString }) {} - class Increment extends Schema.TaggedClass("Increment")("Increment", { by: Schema.Number, block: Schema.Boolean }) {} - class Fail extends Schema.TaggedClass("Fail")("Fail", {}) {} class Finish extends Schema.TaggedClass("Finish")("Finish", {}) {} class RaiseFromAction extends Schema.TaggedClass("RaiseFromAction")("RaiseFromAction", {}) {} class SpawnFromAction extends Schema.TaggedClass("SpawnFromAction")("SpawnFromAction", {}) {} - class Changed extends Schema.TaggedClass("Changed")("Changed", { value: Schema.Number }) {} - const CounterStates = Machine.state({ - initial: "Count", states: { Count, Done: { schema: Done, type: "final" } } }) - +const InitialRoot1 = Machine.state({ states: { Count } }) const UnsupportedChildMachine = Machine.make({ - 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 }) } - })) + root: InitialRoot1, + events: Machine.eventsFromSchemas() +}).handle({ + initial: { + target: Machine.targets(InitialRoot1).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, + states: { + Count: {} + } }) const UnsupportedChild = Machine.child("unsupported", UnsupportedChildMachine) - const makeCounter = (state: { readonly gate: Latch.Latch initialEntries: number @@ -71,14 +66,16 @@ const makeCounter = (state: { transition4: { destination: { target: targets1.root.Count } }, transition5: { destination: { target: targets1.root.Count } } }, - id: "Counter", 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 })))) + emittedEvents: Machine.emittedEventsFromSchemas(Changed) }).handle({ + initial: { + target: Machine.targets(CounterStates).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { Count: { entry: () => { @@ -104,7 +101,11 @@ const makeCounter = (state: { return target.decoded(current) } }, - Finish: { target: targets1.root.Done, decoded: ({ state: current }) => (new Done({ value: current.value })) }, + Finish: { + target: targets1.root.Done, + decoded: true, + data: ({ state: current }) => (new Done({ value: current.value })) + }, RaiseFromAction: { branches: "transition4", resolve: ({ state: current, select: { destination: target } }, enqueue) => { @@ -125,9 +126,7 @@ const makeCounter = (state: { } }) } - const storageKey = (entityType: string, entityId: string): string => `${entityType}\u0000${entityId}` - const makeTestStorage = () => { const entries = new Map() const requests = new Map>() @@ -135,7 +134,6 @@ const makeTestStorage = () => { let commits = 0 let loads = 0 let failNextCommit = false - const service = ClusterMachine.Storage.of({ load: (address, requestId) => MessageStorage.MemoryTransaction.use((inTransaction) => @@ -173,7 +171,6 @@ const makeTestStorage = () => { }).pipe(ClusterError.PersistenceError.refail) ) }) - return { service, entries, @@ -198,9 +195,13 @@ const makeTestStorage = () => { readonly commits: number }) { entries.clear() - for (const [key, value] of snapshot.entries) entries.set(key, value) + for (const [key, value] of snapshot.entries) { + entries.set(key, value) + } requests.clear() - for (const [key, value] of snapshot.requests) requests.set(key, value) + for (const [key, value] of snapshot.requests) { + requests.set(key, value) + } commits = snapshot.commits }, failNextCommit() { @@ -208,7 +209,6 @@ const makeTestStorage = () => { } } } - const config = (entityMaxIdleTime: Duration.Input = "10 minutes") => ShardingConfig.layer({ entityMailboxCapacity: 10, @@ -218,7 +218,6 @@ const config = (entityMaxIdleTime: Duration.Input = "10 minutes") => sendRetryInterval: 100, refreshAssignmentsInterval: 0 }) - const makeLayer = ( bridge: ClusterMachine.ClusterMachine, storage: ClusterMachine.Storage["Service"], @@ -236,7 +235,6 @@ const makeLayer = ( withTransaction })) ).pipe(Layer.provideMerge(MessageStorage.MemoryDriver.layer)) - return bridge.toLayer(enqueue === undefined ? undefined : { enqueue }).pipe( Layer.provide(Layer.succeed(ClusterMachine.Storage, storage)), Layer.provideMerge(Sharding.layer), @@ -247,11 +245,9 @@ const makeLayer = ( Layer.provide(config(entityMaxIdleTime)) ) } - const assertAccepted = (result: ClusterMachine.Accepted | ClusterMachine.Rejected) => { assert.instanceOf(result, ClusterMachine.Accepted) } - const assertRejected = ( result: ClusterMachine.Accepted | ClusterMachine.Rejected, reason: ClusterMachine.RejectionReason @@ -259,7 +255,6 @@ const assertRejected = ( assert.instanceOf(result, ClusterMachine.Rejected) assert.strictEqual(result.reason, reason) } - describe("ClusterMachine", () => { it.effect("initializes, persists transformed state, and restores after passivation", () => Effect.gen(function*() { @@ -270,24 +265,17 @@ describe("ClusterMachine", () => { const storage = makeTestStorage() const emitted: Array = [] const emissionTransactions: Array = [] - const layer = makeLayer( - bridge, - storage.service, - (event) => - MessageStorage.MemoryTransaction.use((inTransaction) => - Effect.sync(() => { - emissionTransactions.push(inTransaction) - emitted.push(event) - }) - ), - 0 - ) - + const layer = makeLayer(bridge, storage.service, (event) => + MessageStorage.MemoryTransaction.use((inTransaction) => + Effect.sync(() => { + emissionTransactions.push(inTransaction) + emitted.push(event) + }) + ), 0) yield* Effect.gen(function*() { yield* TestClock.adjust(1) const makeClient = yield* bridge.entity.client const client = makeClient("counter-1") - assertAccepted(yield* client.send(new Increment({ by: 2, block: false }))) assert.strictEqual(state.initialEntries, 1) assert.strictEqual(state.actions, 1) @@ -297,11 +285,14 @@ describe("ClusterMachine", () => { _tag: "MachineSnapshot", active: [{ path: "" }, { path: "Count" as const, value: { _tag: "Count", value: "2" } }] }) - yield* TestClock.adjust(5000) yield* Effect.yieldNow - assert.strictEqual(yield* Sharding.Sharding.pipe(Effect.flatMap((sharding) => sharding.activeEntityCount)), 0) - + assert.strictEqual( + yield* Sharding.Sharding.pipe(Effect.flatMap((sharding) => + sharding.activeEntityCount + )), + 0 + ) assertAccepted(yield* client.send(new Increment({ by: 3, block: false }))) assert.strictEqual(state.initialEntries, 1) assert.strictEqual(state.actions, 2) @@ -315,14 +306,12 @@ describe("ClusterMachine", () => { assert.isTrue(emissionTransactions.every(Boolean)) }).pipe(Effect.provide(layer)) })) - it.effect("serializes events for one entity", () => Effect.gen(function*() { const gate = yield* Latch.make() const state = { gate, initialEntries: 0, actions: 0, inFlight: 0, maxInFlight: 0 } const bridge = ClusterMachine.make("SerializedCounter", makeCounter(state), { version: "1" }) const storage = makeTestStorage() - yield* Effect.gen(function*() { yield* TestClock.adjust(1) const makeClient = yield* bridge.entity.client @@ -335,7 +324,6 @@ describe("ClusterMachine", () => { Effect.forkChild({ startImmediately: true }) ) yield* Effect.yieldNow - assertAccepted(yield* Fiber.join(first)) assertAccepted(yield* Fiber.join(second)) assert.strictEqual(state.maxInFlight, 1) @@ -348,7 +336,6 @@ describe("ClusterMachine", () => { }]) }).pipe(Effect.provide(makeLayer(bridge, storage.service, () => Effect.void))) })) - it.effect("suppresses emissions when checkpoint persistence fails", () => Effect.gen(function*() { const gate = yield* Latch.make() @@ -357,78 +344,73 @@ describe("ClusterMachine", () => { const storage = makeTestStorage() const emitted: Array = [] storage.failNextCommit() - yield* Effect.gen(function*() { yield* TestClock.adjust(1) const makeClient = yield* bridge.entity.client const client = makeClient("counter-1") - assertRejected(yield* client.send(new Increment({ by: 1, block: false })), "PersistenceFailure") assert.strictEqual(storage.entries.size, 0) assert.strictEqual(storage.commits, 0) assert.deepStrictEqual(emitted, []) - assertAccepted(yield* client.send(new Increment({ by: 1, block: false }))) assert.strictEqual(storage.commits, 1) assert.deepStrictEqual(emitted, [new Changed({ value: 1 })]) - }).pipe(Effect.provide(makeLayer( - bridge, - storage.service, - (event) => Effect.sync(() => emitted.push(event)) - ))) + }).pipe(Effect.provide(makeLayer(bridge, storage.service, (event) => Effect.sync(() => emitted.push(event))))) })) - it.effect("rejects snapshot encoding failures before persistence", () => Effect.gen(function*() { interface OpaqueState { readonly _tag: "OpaqueState" readonly resource: object } - - const OpaqueState = Schema.toCodecJson( - Schema.declare((value): value is OpaqueState => - typeof value === "object" && value !== null && "_tag" in value && value._tag === "OpaqueState" - ) - ) - const opaqueStates = Machine.state({ initial: "OpaqueState", states: { OpaqueState } }) - const resource: { self?: unknown } = {} + const OpaqueState = Schema.toCodecJson(Schema.declare((value): value is OpaqueState => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "OpaqueState" + )) + const opaqueStates = Machine.state({ states: { OpaqueState } }) + const resource: { + self?: unknown + } = {} resource.self = resource const targets2 = Machine.targets(opaqueStates) const opaqueMachine = Machine.make({ root: opaqueStates, - events: Machine.eventsFromSchemas(Fail), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.OpaqueState.decoded({ _tag: "OpaqueState", resource }))) + events: Machine.eventsFromSchemas(Fail) }).handle({ + initial: { + target: Machine.targets(opaqueStates).root.OpaqueState, + decoded: true, + data: { _tag: "OpaqueState", resource } + }, states: { OpaqueState: { on: { - Fail: { target: targets2.root.OpaqueState, decoded: ({ state: current }) => current } + Fail: { + target: targets2.root.OpaqueState, + decoded: true, + data: ({ state: current }) => + current + } } } } }) const bridge = ClusterMachine.make("SnapshotEncodeFailureEntity", opaqueMachine, { version: "1" }) const storage = makeTestStorage() - yield* Effect.gen(function*() { yield* TestClock.adjust(1) const makeClient = yield* bridge.entity.client const result = yield* makeClient("opaque-1").send(new Fail({})) - assertRejected(result, "SnapshotEncodeFailure") assert.strictEqual(storage.entries.size, 0) assert.strictEqual(storage.commits, 0) }).pipe(Effect.provide(makeLayer(bridge, storage.service, () => Effect.void))) })) - it.effect("does not apply a redelivered persisted request twice", () => Effect.gen(function*() { const gate = yield* Latch.make() const state = { gate, initialEntries: 0, actions: 0, inFlight: 0, maxInFlight: 0 } const bridge = ClusterMachine.make("DeduplicatedCounter", makeCounter(state), { version: "1" }) const storage = makeTestStorage() - yield* Effect.gen(function*() { yield* TestClock.adjust(1) const sharding = yield* Sharding.Sharding @@ -442,14 +424,10 @@ describe("ClusterMachine", () => { ...storage.entries.get(key)!, version: "previous-deployment" }) - assert.isTrue(yield* sharding.reset(requestId)) yield* sharding.pollStorage yield* TestClock.adjust(1) - yield* Effect.yieldNow.pipe( - Effect.repeat({ until: () => storage.loads >= 2 }) - ) - + yield* Effect.yieldNow.pipe(Effect.repeat({ until: () => storage.loads >= 2 })) assert.isAtLeast(storage.loads, 2) assert.strictEqual(state.actions, 1) assert.strictEqual(storage.commits, 1) @@ -460,11 +438,12 @@ describe("ClusterMachine", () => { const reply = driver.requests.get(String(requestId))!.replies[0]! assert( reply._tag === "WithExit" && reply.exit._tag === "Success" && - (reply.exit.value as { readonly _tag: string })._tag === "Accepted" + (reply.exit.value as { + readonly _tag: string + })._tag === "Accepted" ) }).pipe(Effect.provide(makeLayer(bridge, storage.service, () => Effect.void))) })) - it.effect("rolls back a checkpoint and partial outbox when enqueue fails", () => Effect.gen(function*() { const gate = yield* Latch.make() @@ -475,23 +454,18 @@ describe("ClusterMachine", () => { const withTransaction: MessageStorage.MessageStorage["Service"]["withTransaction"] = (effect) => { const checkpoint = storage.snapshot() const emittedLength = emitted.length - return Effect.onExit( - Effect.provideService(effect, MessageStorage.MemoryTransaction, true), - (exit) => - Exit.isFailure(exit) - ? Effect.sync(() => { - storage.restore(checkpoint) - emitted.length = emittedLength - }) - : Effect.void - ) + return Effect.onExit(Effect.provideService(effect, MessageStorage.MemoryTransaction, true), (exit) => + Exit.isFailure(exit) + ? Effect.sync(() => { + storage.restore(checkpoint) + emitted.length = emittedLength + }) + : Effect.void) } - yield* Effect.gen(function*() { yield* TestClock.adjust(1) const makeClient = yield* bridge.entity.client const result = yield* makeClient("counter-1").send(new Increment({ by: 1, block: false })) - assertRejected(result, "EmissionFailure") assert.strictEqual(storage.entries.size, 0) assert.strictEqual(storage.commits, 0) @@ -499,12 +473,14 @@ describe("ClusterMachine", () => { }).pipe(Effect.provide(makeLayer( bridge, storage.service, - (event) => Effect.sync(() => emitted.push(event)).pipe(Effect.andThen(Effect.fail("outbox unavailable"))), + (event) => + Effect.sync(() => + emitted.push(event) + ).pipe(Effect.andThen(Effect.fail("outbox unavailable"))), undefined, withTransaction ))) })) - it.effect("keeps historical request ids in the in-memory storage", () => Effect.gen(function*() { const storage = yield* ClusterMachine.Storage @@ -534,7 +510,6 @@ describe("ClusterMachine", () => { active: [{ path: "" }, { path: "Count" as const, value: { _tag: "Count", value: "2" } }] } } - assert.deepStrictEqual(yield* storage.commit(address, first), ClusterMachine.CommitResult.Committed()) assert.deepStrictEqual(yield* storage.commit(address, second), ClusterMachine.CommitResult.Committed()) assert.deepStrictEqual(yield* storage.commit(address, first), ClusterMachine.CommitResult.Duplicate()) @@ -542,7 +517,6 @@ describe("ClusterMachine", () => { assert.isTrue(loaded.processed) assert.deepStrictEqual(Option.getOrThrow(loaded.checkpoint), second) }).pipe(Effect.provide(ClusterMachine.layerMemory))) - it.effect("rejects incompatible and undecodable checkpoints without resetting", () => Effect.gen(function*() { const cases: ReadonlyArray<{ @@ -586,7 +560,6 @@ describe("ClusterMachine", () => { reason: "InvalidCheckpoint" } ] - for (const testCase of cases) { const gate = yield* Latch.make() const state = { gate, initialEntries: 0, actions: 0, inFlight: 0, maxInFlight: 0 } @@ -599,28 +572,22 @@ describe("ClusterMachine", () => { snapshot: testCase.snapshot } storage.entries.set(storageKey(testCase.entityType, "counter-1"), checkpoint) - yield* Effect.gen(function*() { yield* TestClock.adjust(1) const makeClient = yield* bridge.entity.client - assertRejected( - yield* makeClient("counter-1").send(new Increment({ by: 1, block: false })), - testCase.reason - ) + assertRejected(yield* makeClient("counter-1").send(new Increment({ by: 1, block: false })), testCase.reason) assert.strictEqual(storage.entries.get(storageKey(testCase.entityType, "counter-1")), checkpoint) assert.strictEqual(storage.commits, 0) assert.strictEqual(state.initialEntries, 0) }).pipe(Effect.provide(makeLayer(bridge, storage.service, () => Effect.void))) } })) - it.effect("persists final state and treats later events as no-ops", () => Effect.gen(function*() { const gate = yield* Latch.make() const state = { gate, initialEntries: 0, actions: 0, inFlight: 0, maxInFlight: 0 } const bridge = ClusterMachine.make("FinalCounter", makeCounter(state), { version: "1" }) const storage = makeTestStorage() - yield* Effect.gen(function*() { yield* TestClock.adjust(1) const makeClient = yield* bridge.entity.client @@ -632,7 +599,6 @@ describe("ClusterMachine", () => { path: "Done" as const, value: { _tag: "Done", value: "0" } }]) - assertAccepted(yield* client.send(new Increment({ by: 10, block: false }))) assert.strictEqual(state.actions, 0) assert.strictEqual(storage.commits, 2) @@ -644,14 +610,12 @@ describe("ClusterMachine", () => { }]) }).pipe(Effect.provide(makeLayer(bridge, storage.service, () => Effect.void))) })) - it.effect("rejects process-local machine commands", () => Effect.gen(function*() { const gate = yield* Latch.make() const state = { gate, initialEntries: 0, actions: 0, inFlight: 0, maxInFlight: 0 } const bridge = ClusterMachine.make("UnsupportedCommand", makeCounter(state), { version: "1" }) const storage = makeTestStorage() - yield* Effect.gen(function*() { yield* TestClock.adjust(1) const makeClient = yield* bridge.entity.client @@ -659,19 +623,20 @@ describe("ClusterMachine", () => { assert.strictEqual(storage.commits, 0) }).pipe(Effect.provide(makeLayer(bridge, storage.service, () => Effect.void))) })) - it.effect("rejects machines with invoke configurations", () => Effect.gen(function*() { - const states = Machine.state({ initial: "Count", states: { Count } }) + const states = Machine.state({ states: { Count } }) const invoked = Machine.make({ effects: { source1: Effect.suspend(() => Effect.void) }, - id: "Invoked", root: states, - events: Machine.eventsFromSchemas(Increment), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) + events: Machine.eventsFromSchemas(Increment) }).handle({ + initial: { + target: Machine.targets(states).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { Count: { invoke: { src: "source1", id: "child", onDone: { none: true } } @@ -680,7 +645,6 @@ describe("ClusterMachine", () => { }) const bridge = ClusterMachine.make("InvokedCounter", invoked, { version: "1" }) const storage = makeTestStorage() - yield* Effect.gen(function*() { yield* TestClock.adjust(1) const makeClient = yield* bridge.entity.client diff --git a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts index a0b561db..9db652eb 100644 --- a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts +++ b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts @@ -3,58 +3,38 @@ import { Cause, Data, Deferred, Effect, Fiber, Layer, Option, Ref, Schema, Strea import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity" import { Machine } from "../../../src/index.js" import { AtomMachine } from "../../../src/unstable/reactivity/index.js" - class Count extends Schema.TaggedClass("Count")("Count", { value: Schema.Number }) {} - class Done extends Schema.TaggedClass("Done")("Done", { value: Schema.Number }) {} - class Finish extends Schema.TaggedClass("Finish")("Finish", { by: Schema.Number }) {} - class ReadValue extends Schema.TaggedClass("ReadValue")("ReadValue", {}) {} - class ValueRead extends Schema.TaggedClass("ValueRead")("ValueRead", { value: Schema.String }) {} - class Ready extends Schema.TaggedClass("Ready")("Ready", {}) {} - class Editor extends Schema.TaggedClass("Editor")("Editor", {}) {} - class Editing extends Schema.TaggedClass("Editing")("Editing", {}) {} - class Saving extends Schema.TaggedClass("Saving")("Saving", {}) {} - class Network extends Schema.TaggedClass("Network")("Network", {}) {} - class Online extends Schema.TaggedClass("Online")("Online", {}) {} - class Offline extends Schema.TaggedClass("Offline")("Offline", {}) {} - const CounterStates = Machine.state({ - initial: "Count", states: { Count, Done: { schema: Done, type: "final" } } }) - const makeRegistry = Effect.acquireRelease( Effect.sync(() => AtomRegistry.make()), (registry) => Effect.sync(() => registry.dispose()) ) - const mount = (registry: AtomRegistry.AtomRegistry, atom: Atom.Atom) => - Effect.acquireRelease( - Effect.sync(() => registry.mount(atom)), - (release) => Effect.sync(release) - ) - + Effect.acquireRelease(Effect.sync(() => registry.mount(atom)), (release) => Effect.sync(release)) const waitForResult = ( registry: AtomRegistry.AtomRegistry, atom: Atom.Atom>, @@ -66,21 +46,24 @@ const waitForResult = ( Stream.runCollect, Effect.map((values) => Array.from(values)[0]!) ) - const makeCounterMachine = () => { const targets1 = Machine.targets(CounterStates) return Machine.make({ root: CounterStates, - events: Machine.eventsFromSchemas(Finish), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) + events: Machine.eventsFromSchemas(Finish) }).handle({ + initial: { + target: Machine.targets(CounterStates).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { Count: { on: { Finish: { target: targets1.root.Count, - decoded: ({ state, event }) => (new Count({ value: state.value + event.by })) + decoded: true, + data: ({ state, event }) => (new Count({ value: state.value + event.by })) } } }, @@ -88,22 +71,30 @@ const makeCounterMachine = () => { } }) } - const makeInputCounterMachine = () => { - const targets2 = Machine.targets(CounterStates) + const InputCounterStates = Machine.state({ + fields: { input: Schema.Number }, + states: { Count, Done: { schema: Done, type: "final" } } + }) + const targets2 = Machine.targets(InputCounterStates) return Machine.make({ - root: CounterStates, + root: InputCounterStates, events: Machine.eventsFromSchemas(Finish), - input: Schema.Number, - initialConfiguration: (root) => - root.resolve(({ input, target }) => target.from((to) => to.Count.decoded(new Count({ value: input })))) + input: Schema.Number }).handle({ + initial: { + target: Machine.targets(InputCounterStates).root.Count, + decoded: true, + data: ({ root: { input: input } }) => new Count({ value: input }) + }, + root: ({ input }) => ({ input }), states: { Count: { on: { Finish: { target: targets2.root.Count, - decoded: ({ state, event }) => (new Count({ value: state.value + event.by })) + decoded: true, + data: ({ state, event }) => (new Count({ value: state.value + event.by })) } } }, @@ -111,14 +102,12 @@ const makeInputCounterMachine = () => { } }) } - const forceGc = async () => { for (let attempt = 0; attempt < 10; attempt++) { globalThis.gc?.() await new Promise((resolve) => setTimeout(resolve, 0)) } } - const waitForCollection = async (ref: WeakRef) => { for (let attempt = 0; attempt < 100; attempt++) { globalThis.gc?.() @@ -129,7 +118,6 @@ const waitForCollection = async (ref: WeakRef) => { } return false } - describe("AtomMachine", () => { it.effect("observes prepared live inspection before atom startup", () => Effect.scoped(Effect.gen(function*() { @@ -141,24 +129,28 @@ describe("AtomMachine", () => { Effect.provideService(AtomRegistry.AtomRegistry, registry), Effect.forkScoped({ startImmediately: true }) ) - const records = Array.from(yield* Fiber.join(observed)) assert.deepStrictEqual(records.map(({ _tag }) => _tag), ["Created", "Initialized"]) assert.ok(records.every(({ rootSessionId }) => rootSessionId === "machine:0")) }))) - it.effect("observes initial emissions when the emission stream starts the machine", () => Effect.scoped(Effect.gen(function*() { - class Idle extends Schema.TaggedClass("AtomPreparedIdle")("Idle", {}) {} - class ReadyEmission extends Schema.TaggedClass("AtomPreparedReady")("ReadyEmission", {}) {} - const states = Machine.state({ initial: "Idle", states: { Idle } }) + class Idle extends Schema.TaggedClass("AtomPreparedIdle")("Idle", {}) { + } + class ReadyEmission extends Schema.TaggedClass("AtomPreparedReady")("ReadyEmission", {}) { + } + const states = Machine.state({ states: { Idle } }) const Emissions = Machine.emittedEventsFromSchemas(ReadyEmission) const machine = Machine.make({ root: states, events: Machine.eventsFromSchemas(), - emittedEvents: Emissions, - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) + emittedEvents: Emissions }).handle({ + initial: { + target: Machine.targets(states).root.Idle, + decoded: true, + data: new Idle({}) + }, states: { Idle: { entry: (_, enqueue) => { @@ -185,7 +177,6 @@ describe("AtomMachine", () => { Effect.provideService(AtomRegistry.AtomRegistry, registry), Effect.forkScoped({ startImmediately: true }) ) - assert.deepStrictEqual(Array.from(yield* Fiber.join(observed)), [new ReadyEmission({})]) assert.deepStrictEqual(yield* AtomRegistry.getResult(registry, selected), Option.some(new Idle({}))) assert.deepStrictEqual( @@ -195,7 +186,6 @@ describe("AtomMachine", () => { assert.strictEqual(yield* AtomRegistry.getResult(registry, matched), true) assert.strictEqual((yield* AtomRegistry.getResult(registry, bridge.snapshot)).status, "active") }))) - it.effect("resumes lazily once per registry and disposes the resumed runtime", () => Effect.gen(function*() { let initialCalls = 0 @@ -209,22 +199,25 @@ describe("AtomMachine", () => { run: () => Effect.never.pipe(Effect.onInterrupt(() => Deferred.succeed(invokeStopped, void 0))) }) }, - root: CounterStates, - events: Machine.eventsFromSchemas(Finish), - initialConfiguration: (root) => - root.resolve(({ target }) => { - initialCalls += 1 - return target.from((to) => to.Count.decoded(new Count({ value: 0 }))) - }) + events: Machine.eventsFromSchemas(Finish) }).handle({ + initial: { + target: Machine.targets(CounterStates).root.Count, + decoded: true, + data: () => { + initialCalls += 1 + return new Count({ value: 0 }) + } + }, states: { Count: { invoke: { src: "source1", id: "active", address: Machine.childAddress("active") }, on: { Finish: { target: targets3.root.Count, - decoded: ({ event, state }) => (new Count({ value: state.value + event.by })) + decoded: true, + data: ({ event, state }) => (new Count({ value: state.value + event.by })) } } }, @@ -239,7 +232,6 @@ describe("AtomMachine", () => { const canFinish = AtomMachine.can(new Finish({ by: 1 }))(bridge) const firstRegistry = AtomRegistry.make() const secondRegistry = AtomRegistry.make() - assert.strictEqual(yield* AtomRegistry.getResult(firstRegistry, canFinish), true) const first = yield* AtomRegistry.getResult(firstRegistry, bridge.ref) const firstAgain = yield* AtomRegistry.getResult(firstRegistry, bridge.ref) @@ -253,7 +245,6 @@ describe("AtomMachine", () => { }) assert.strictEqual(initialCalls, 0) assert.strictEqual(yield* Ref.get(invokeStarts), 2) - const stopped = yield* first.changes.pipe( Stream.filter((snapshot) => snapshot.status === "stopped"), Stream.take(1), @@ -266,32 +257,37 @@ describe("AtomMachine", () => { assert.strictEqual((yield* first.snapshot).status, "stopped") secondRegistry.dispose() })) - it.effect("reactively exposes an invoked child machine", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry const childMachine = makeCounterMachine() const Child = Machine.child("counter", childMachine) - const root4 = Machine.state({ initial: "Count", states: { Count, ValueRead } }) + const root4 = Machine.state({ states: { Count, ValueRead } }) const targets4 = Machine.targets(root4) const parent = Machine.make({ children: { source1: Child }, - root: root4, - events: Machine.eventsFromSchemas(Finish, ReadValue), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) + events: Machine.eventsFromSchemas(Finish, ReadValue) }).handle({ + initial: { + target: Machine.targets(root4).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { Count: { on: { - Finish: { target: targets4.root.ValueRead, decoded: () => (new ValueRead({ value: "active" })) } + Finish: { + target: targets4.root.ValueRead, + decoded: true, + data: () => (new ValueRead({ value: "active" })) + } } }, ValueRead: { invoke: { src: "source1", onDone: { none: true } }, on: { - ReadValue: { target: targets4.root.Count, decoded: () => (new Count({ value: 0 })) } + ReadValue: { target: targets4.root.Count, decoded: true, data: () => (new Count({ value: 0 })) } } } } @@ -338,7 +334,6 @@ describe("AtomMachine", () => { assert(Option.isSome(yield* parentRef.child(Alias))) assert(Option.isNone(yield* parentRef.child(Impostor))) assert(Option.isNone(yield* AtomRegistry.getResult(registry, impostorAtoms.result))) - const initial = yield* waitForResult(registry, childAtoms.result, Option.isSome) assert(Option.isSome(initial)) assert.strictEqual(initial.value.state.value.value, 0) @@ -352,66 +347,61 @@ describe("AtomMachine", () => { value: new Count({ value: 0 }) }) assert.strictEqual(yield* AtomRegistry.getResult(registry, countMatches), true) - yield* Effect.sync(() => registry.set(childAtoms.send, new Finish({ by: 2 }))) - const updated = yield* waitForResult( - registry, - childAtoms.result, - (state) => Option.isSome(state) && state.value.state.value.value === 2 - ) + const updated = yield* waitForResult(registry, childAtoms.result, (state) => + Option.isSome(state) && state.value.state.value.value === 2) assert(Option.isSome(updated)) - const selectedUpdated = yield* waitForResult( - registry, - selectedCount, - (state) => Option.isSome(state) && state.value.value === 2 - ) + const selectedUpdated = yield* waitForResult(registry, selectedCount, (state) => + Option.isSome(state) && state.value.value === 2) assert(Option.isSome(selectedUpdated)) assert.strictEqual(selectedUpdated.value.value, 2) - const selectedUpdatedSnapshot = yield* waitForResult( - registry, - selectedCountSnapshot, - (state) => Option.isSome(state) && state.value.value.value === 2 - ) + const selectedUpdatedSnapshot = yield* waitForResult(registry, selectedCountSnapshot, (state) => + Option.isSome(state) && state.value.value.value === 2) assert(Option.isSome(selectedUpdatedSnapshot)) assert.strictEqual(selectedUpdatedSnapshot.value.value.value, 2) - - yield* Effect.sync(() => registry.set(parentAtoms.send, new ReadValue({}))) + yield* Effect.sync(() => + registry.set(parentAtoms.send, new ReadValue({})) + ) const inactive = yield* waitForResult(registry, childAtoms.ref, Option.isNone) assert(Option.isNone(inactive)) assert(Option.isNone(yield* waitForResult(registry, selectedCount, Option.isNone))) assert(Option.isNone(yield* waitForResult(registry, selectedCountSnapshot, Option.isNone))) assert.strictEqual(yield* AtomRegistry.getResult(registry, countMatches), false) }))) - it.effect("reactively exposes a dynamically spawned child by family and runtime id", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry const childMachine = makeCounterMachine() const Child = Machine.childFamily(childMachine) - const root5 = Machine.state({ initial: "Count", states: { Count } }) + const root5 = Machine.state({ states: { Count } }) const parent = Machine.make({ effects: { - source1: ( - { children }: Machine.Machine.InvokeContext< - { - readonly "": { readonly initial: "Count"; readonly states: { readonly Count: typeof Count } } & { - readonly "~effect/Machine/ExplicitInitial": true + source1: ({ children }: Machine.Machine.InvokeContext< + { + readonly "": { + readonly initial: "Count" + readonly states: { + readonly Count: typeof Count } - }, - readonly [], - readonly [], - "Count", - readonly [], - readonly [] - > - ) => children.spawn(Child("dynamic")) + } & { + readonly "~effect/Machine/ExplicitInitial": true + } + }, + readonly [], + readonly [], + "Count", + readonly [], + readonly [] + >) => children.spawn(Child("dynamic")) }, - root: root5, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(root5).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { Count: { invoke: { @@ -430,47 +420,49 @@ describe("AtomMachine", () => { assert.strictEqual(parentAtoms.child(Machine.child("dynamic", childMachine)), childAtoms) const selected = AtomMachine.selectChild(childAtoms, "Count") const matches = AtomMachine.matchesChild(childAtoms, "Count") - yield* mount(registry, childAtoms.result) const active = yield* waitForResult(registry, selected, Option.isSome) assert(Option.isSome(active)) assert.strictEqual(active.value.value, 0) assert.strictEqual(yield* AtomRegistry.getResult(registry, matches), true) - yield* Effect.sync(() => registry.set(childAtoms.stop, undefined)) assert(Option.isNone(yield* waitForResult(registry, childAtoms.ref, Option.isNone))) assert.strictEqual(yield* AtomRegistry.getResult(registry, matches), false) }))) - it.effect("creates retained atom families for dynamically spawned children", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry const childMachine = makeCounterMachine() const Child = Machine.childFamily(childMachine) - const root6 = Machine.state({ initial: "Count", states: { Count } }) + const root6 = Machine.state({ states: { Count } }) const parent = Machine.make({ effects: { - source1: ( - { children }: Machine.Machine.InvokeContext< - { - readonly "": { readonly initial: "Count"; readonly states: { readonly Count: typeof Count } } & { - readonly "~effect/Machine/ExplicitInitial": true + source1: ({ children }: Machine.Machine.InvokeContext< + { + readonly "": { + readonly initial: "Count" + readonly states: { + readonly Count: typeof Count } - }, - readonly [], - readonly [], - "Count", - readonly [], - readonly [] - > - ) => children.spawn(Child("dynamic")) + } & { + readonly "~effect/Machine/ExplicitInitial": true + } + }, + readonly [], + readonly [], + "Count", + readonly [], + readonly [] + >) => children.spawn(Child("dynamic")) }, - root: root6, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(root6).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { Count: { invoke: { @@ -495,7 +487,6 @@ describe("AtomMachine", () => { }) const count = children.count("dynamic") const send = children.send("dynamic") - assert.strictEqual(children.count("dynamic"), count) assert.notStrictEqual(children.count("missing"), count) assert.strictEqual(count.label?.[0], "counter:dynamic:count") @@ -503,21 +494,18 @@ describe("AtomMachine", () => { const initial = yield* waitForResult(registry, count, Option.isSome) assert(Option.isSome(initial)) assert.strictEqual(initial.value.value, 0) - yield* Effect.sync(() => registry.set(send, new Finish({ by: 3 }))) - const updated = yield* waitForResult( - registry, - count, - (value) => Option.isSome(value) && value.value.value === 3 - ) + const updated = yield* waitForResult(registry, count, (value) => Option.isSome(value) && value.value.value === 3) assert(Option.isSome(updated)) assert.strictEqual(updated.value.value, 3) assert.strictEqual(yield* AtomRegistry.getResult(registry, children.matches("dynamic")), true) assert.strictEqual(yield* AtomRegistry.getResult(registry, children.matches("missing")), false) }))) - it("uses Effect key equality without retaining family values permanently", async () => { - class FamilyKey extends Data.Class<{ readonly id: string }> {} + class FamilyKey extends Data.Class<{ + readonly id: string + }> { + } const machine = makeInputCounterMachine() const atoms = AtomMachine.family(machine, { atoms: { @@ -532,20 +520,26 @@ describe("AtomMachine", () => { const anyInputMachine = Machine.make({ root: CounterStates, events: Machine.eventsFromSchemas(), - input: Schema.Any, - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) - }).handle({ states: { Count: {}, Done: {} } }) + input: Schema.Any + }).handle({ + initial: { + target: Machine.targets(CounterStates).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, + states: { + Count: {}, + Done: {} + } + }) const anyInputAtoms = AtomMachine.family(anyInputMachine, { atoms: { state: (machine) => machine.result } }) - assert.strictEqual(anyInputAtoms.state(equalFirst), anyInputAtoms.state(equalSecond)) assert.strictEqual(anyInputAtoms.state(plainFirst), anyInputAtoms.state(plainSecond)) assert.notStrictEqual(anyInputAtoms.state(plainFirst), anyInputAtoms.state(plainDifferent)) assert.strictEqual(atoms.state(1), atoms.state(1)) assert.notStrictEqual(atoms.state(1), atoms.state(2)) - if (globalThis.gc !== undefined) { const weak = (() => { const atom = atoms.state(99) @@ -554,10 +548,10 @@ describe("AtomMachine", () => { assert.strictEqual(await waitForCollection(weak), true) } }) - it("does not retain abandoned bridges through projection caches", async () => { - if (globalThis.gc === undefined) return - + if (globalThis.gc === undefined) { + return + } const canFinish = AtomMachine.can(new Finish({ by: 1 })) const refs = (() => { const bridge = AtomMachine.make(makeCounterMachine()) @@ -569,12 +563,10 @@ describe("AtomMachine", () => { selector: new WeakRef(selector) } })() - assert.strictEqual(await waitForCollection(refs.acceptance), true) assert.strictEqual(await waitForCollection(refs.selector), true) assert.strictEqual(await waitForCollection(refs.bridge), true) }) - it.effect("retains one keyed machine owner through every public projection", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry @@ -589,7 +581,6 @@ describe("AtomMachine", () => { } }) const send = atoms.send(4) - yield* Effect.promise(forceGc) const count = atoms.count(4) const state = atoms.state(4) @@ -598,31 +589,23 @@ describe("AtomMachine", () => { assert.strictEqual(atoms.equal(4).equals(AsyncResult.initial(), AsyncResult.success({} as never)), true) yield* mount(registry, state) yield* Effect.sync(() => registry.set(send, new Finish({ by: 5 }))) - const updated = yield* waitForResult(registry, count, (value) => Option.isSome(value) && value.value.value === 9) assert(Option.isSome(updated)) assert.strictEqual(updated.value.value, 9) - const secondRegistry = AtomRegistry.make() assert.strictEqual((yield* AtomRegistry.getResult(secondRegistry, state)).state.value.value, 4) secondRegistry.dispose() - const ref = yield* AtomRegistry.getResult(registry, atoms.ref(4)) - const stopped = yield* Machine.watch(ref).pipe( - Stream.runCollect, - Effect.forkScoped - ) + const stopped = yield* Machine.watch(ref).pipe(Stream.runCollect, Effect.forkScoped) yield* Effect.sync(() => registry.dispose()) const events = Array.from(yield* Fiber.join(stopped)) assert.strictEqual(events.at(-1)?._tag, "Stopped") }))) - it.effect("exposes snapshots and sends events", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry const bridge = AtomMachine.make(makeCounterMachine()) yield* mount(registry, bridge.snapshot) - const initial = yield* AtomRegistry.getResult(registry, bridge.snapshot) assert.deepStrictEqual(initial, { status: "active", @@ -635,16 +618,13 @@ describe("AtomMachine", () => { } } }) - yield* Effect.sync(() => registry.set(bridge.send, new Finish({ by: 2 }))) - 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 }) }) }))) - it.effect("creates fresh inferred bridges from reusable constructors", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry @@ -652,12 +632,10 @@ describe("AtomMachine", () => { const first = makeCounter(1) const second = makeCounter(2) assert.notStrictEqual(first, second) - 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) @@ -668,7 +646,6 @@ describe("AtomMachine", () => { true ) }))) - it.effect("provides equality-aware typed state selectors", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry @@ -677,7 +654,6 @@ describe("AtomMachine", () => { const countMatches = AtomMachine.matches(bridge, "Count") const doneMatches = AtomMachine.matches(bridge, "Done") let doneMatchNotifications = 0 - yield* mount(registry, selected) yield* Effect.acquireRelease( Effect.sync(() => @@ -692,7 +668,6 @@ describe("AtomMachine", () => { assert.strictEqual(count.value.value, 0) assert.strictEqual(yield* AtomRegistry.getResult(registry, countMatches), true) assert.strictEqual(yield* AtomRegistry.getResult(registry, doneMatches), false) - yield* Effect.sync(() => registry.set(bridge.send, new Finish({ by: 2 }))) const updated = yield* waitForResult( registry, @@ -703,18 +678,20 @@ describe("AtomMachine", () => { assert.strictEqual(updated.value.value, 2) assert.strictEqual(doneMatchNotifications, 1) }))) - it.effect("projects static and reactive event acceptance across runtime lifecycles", () => Effect.scoped(Effect.gen(function*() { - class CanIdle extends Schema.TaggedClass("AtomCanIdle")("CanIdle", {}) {} - class CanDone extends Schema.TaggedClass("AtomCanDone")("CanDone", {}) {} + class CanIdle extends Schema.TaggedClass("AtomCanIdle")("CanIdle", {}) { + } + class CanDone extends Schema.TaggedClass("AtomCanDone")("CanDone", {}) { + } class Check extends Schema.TaggedClass("AtomCanCheck")("Check", { accept: Schema.Boolean - }) {} - class Complete extends Schema.TaggedClass("AtomCanComplete")("Complete", {}) {} + }) { + } + class Complete extends Schema.TaggedClass("AtomCanComplete")("Complete", {}) { + } const events = Machine.eventsFromSchemas(Check, Complete) const states = Machine.state({ - initial: "CanIdle", states: { CanIdle, CanDone: { schema: CanDone, type: "final" } @@ -725,12 +702,14 @@ describe("AtomMachine", () => { const targets7 = Machine.targets(states) const machine = Machine.make({ branches: { transition1: { destination: { target: targets7.root.CanDone } } }, - root: states, - events, - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.CanIdle.decoded(new CanIdle({})))) + events }).handle({ + initial: { + target: Machine.targets(states).root.CanIdle, + decoded: true, + data: new CanIdle({}) + }, states: { CanIdle: { on: { @@ -762,7 +741,6 @@ describe("AtomMachine", () => { const canCheck = checkAllowed(bridge) const canComplete = completeAllowed(bridge) let canCheckNotifications = 0 - assert.strictEqual(checkAllowed(bridge), canCheck) assert.strictEqual(completeAllowed(bridge), canComplete) assert.notStrictEqual(AtomMachine.can(events.Complete())(bridge), canComplete) @@ -779,7 +757,6 @@ describe("AtomMachine", () => { assert.strictEqual(declinableResolverCalls, 1) assert.strictEqual(yield* AtomRegistry.getResult(registry, canComplete), true) assert.strictEqual(requiredResolverCalls, 0) - yield* Effect.sync(() => registry.set(checkEvent, events.Check({ accept: false }))) assert.strictEqual(yield* AtomRegistry.getResult(registry, canCheck), false) assert.strictEqual(canCheckNotifications, 1) @@ -787,42 +764,39 @@ describe("AtomMachine", () => { assert.strictEqual(yield* waitForResult(registry, canCheck, (accepted) => accepted), true) assert.strictEqual(declinableResolverCalls, 3) assert.strictEqual(canCheckNotifications, 2) - const invalid = AtomMachine.can({ _tag: "Check", accept: "invalid" } as any)(bridge) const invalidError = yield* AtomRegistry.getResult(registry, invalid).pipe(Effect.flip) assert.instanceOf(invalidError, Machine.MachineSchemaDecodeError) - yield* Effect.sync(() => registry.set(bridge.send, events.Complete())) yield* waitForResult(registry, bridge.snapshot, (snapshot) => snapshot.status === "done") assert.strictEqual(yield* waitForResult(registry, canCheck, (accepted) => !accepted), false) assert.strictEqual(yield* AtomRegistry.getResult(registry, canComplete), false) assert.strictEqual(requiredResolverCalls, 1) - const stoppedBridge = AtomMachine.make(machine) const stoppedCanComplete = completeAllowed(stoppedBridge) yield* mount(registry, stoppedCanComplete) assert.strictEqual(yield* AtomRegistry.getResult(registry, stoppedCanComplete), true) yield* Effect.sync(() => registry.set(stoppedBridge.stop, undefined)) yield* waitForResult(registry, stoppedBridge.snapshot, (snapshot) => snapshot.status === "stopped") - assert.strictEqual( - yield* waitForResult(registry, stoppedCanComplete, (accepted) => !accepted), - false - ) + assert.strictEqual(yield* waitForResult(registry, stoppedCanComplete, (accepted) => !accepted), false) }))) - it.effect("propagates machine startup and runtime failures through acceptance projections", () => Effect.scoped(Effect.gen(function*() { class Published extends Schema.TaggedClass("AtomCanPublished")("Published", { value: Schema.Number - }) {} + }) { + } const emissions = Machine.emittedEventsFromSchemas(Published) const startupMachine = Machine.make({ root: CounterStates, events: Machine.eventsFromSchemas(Finish), - emittedEvents: emissions, - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) + emittedEvents: emissions }).handle({ + initial: { + target: Machine.targets(CounterStates).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { Count: { entry: (_, enqueue) => { @@ -837,25 +811,29 @@ describe("AtomMachine", () => { const startupCanFinish = AtomMachine.can(new Finish({ by: 1 }))(startupBridge) const startupError = yield* AtomRegistry.getResult(registry, startupCanFinish).pipe(Effect.flip) assert.instanceOf(startupError, Machine.MachineSchemaDecodeError) - - class FaultIdle extends Schema.TaggedClass("AtomCanFaultIdle")("FaultIdle", {}) {} - class FaultLoading extends Schema.TaggedClass("AtomCanFaultLoading")("FaultLoading", {}) {} - class Begin extends Schema.TaggedClass("AtomCanBegin")("Begin", {}) {} + class FaultIdle extends Schema.TaggedClass("AtomCanFaultIdle")("FaultIdle", {}) { + } + class FaultLoading extends Schema.TaggedClass("AtomCanFaultLoading")("FaultLoading", {}) { + } + class Begin extends Schema.TaggedClass("AtomCanBegin")("Begin", {}) { + } const failure = new Error("runtime failed") - const root8 = Machine.state({ initial: "FaultIdle", states: { FaultIdle, FaultLoading } }) + const root8 = Machine.state({ states: { FaultIdle, FaultLoading } }) const targets8 = Machine.targets(root8) const faultMachine = Machine.make({ effects: { source1: Effect.suspend(() => Effect.die(failure)) }, - root: root8, - events: Machine.eventsFromSchemas(Begin), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.FaultIdle.decoded(new FaultIdle({})))) + events: Machine.eventsFromSchemas(Begin) }).handle({ + initial: { + target: Machine.targets(root8).root.FaultIdle, + decoded: true, + data: new FaultIdle({}) + }, states: { FaultIdle: { on: { - Begin: { target: targets8.root.FaultLoading, decoded: () => (new FaultLoading({})) } + Begin: { target: targets8.root.FaultLoading, decoded: true, data: () => (new FaultLoading({})) } } }, FaultLoading: { @@ -869,18 +847,15 @@ describe("AtomMachine", () => { assert.strictEqual(yield* AtomRegistry.getResult(registry, canBegin), true) yield* Effect.sync(() => registry.set(faultBridge.send, new Begin({}))) yield* waitForResult(registry, faultBridge.snapshot, (snapshot) => snapshot.status === "error") - const result = registry.get(canBegin) assert(AsyncResult.isFailure(result)) assert.strictEqual(Cause.squash(result.cause), failure) assert(Option.isSome(result.previousSuccess)) assert.strictEqual(result.previousSuccess.value.value, false) }))) - it.effect("selects compound and parallel state paths from the bridge snapshot", () => Effect.scoped(Effect.gen(function*() { const states = Machine.state({ - initial: "Ready", states: { Ready: { schema: Ready, @@ -888,7 +863,6 @@ describe("AtomMachine", () => { states: { editor: { schema: Editor, - initial: "Editing", states: { Editing, Saving @@ -896,7 +870,6 @@ describe("AtomMachine", () => { }, network: { schema: Network, - initial: "Online", states: { Online, Offline @@ -908,27 +881,37 @@ describe("AtomMachine", () => { }) const machine = Machine.make({ 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({})))) - ) - ) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(states).root.Ready, + decoded: true, + data: new Ready({}) + }, states: { Ready: { + initial: { + editor: { decoded: true, data: new Editor({}) }, + network: { decoded: true, data: new Network({}) } + }, states: { editor: { + initial: { + decoded: true, + data: new Editing({}), + target: Machine.targets(states).root.Ready.editor.Editing + }, states: { Editing: {}, Saving: {} } }, network: { + initial: { + target: Machine.targets(states).root.Ready.network.Online, + decoded: true, + data: new Online({}) + }, states: { Online: {}, Offline: {} @@ -950,7 +933,6 @@ describe("AtomMachine", () => { const savingSnapshot = AtomMachine.selectSnapshot(bridge, "Ready.editor.Saving") const online = AtomMachine.matches(bridge, "Ready.network.Online") const offline = AtomMachine.matches(bridge, "Ready.network.Offline") - assert(Option.isSome(yield* AtomRegistry.getResult(registry, ready))) const selectedReadySnapshot = yield* AtomRegistry.getResult(registry, readySnapshot) assert(Option.isSome(selectedReadySnapshot)) @@ -969,34 +951,25 @@ describe("AtomMachine", () => { assert.strictEqual(yield* AtomRegistry.getResult(registry, online), true) assert.strictEqual(yield* AtomRegistry.getResult(registry, offline), false) }))) - it.effect("stops the machine when the registry is disposed", () => Effect.gen(function*() { const registry = AtomRegistry.make() const bridge = AtomMachine.make(makeCounterMachine()) const ref = yield* AtomRegistry.getResult(registry, bridge.ref) - const watcher = yield* Machine.watch(ref).pipe( - Stream.runCollect, - Effect.forkScoped - ) - + const watcher = yield* Machine.watch(ref).pipe(Stream.runCollect, Effect.forkScoped) yield* Effect.sync(() => registry.dispose()) - const events = Array.from(yield* Fiber.join(watcher)) assert.strictEqual(events.length, 1) assert.strictEqual(events[0]?._tag, "Stopped") })) - it.effect("stops the machine through the writable stop atom", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry const bridge = AtomMachine.make(makeCounterMachine()) yield* mount(registry, bridge.snapshot) yield* mount(registry, bridge.send) - yield* AtomRegistry.getResult(registry, bridge.snapshot) yield* Effect.sync(() => registry.set(bridge.stop, undefined)) - const snapshot = yield* waitForResult(registry, bridge.snapshot, (snapshot) => snapshot.status === "stopped") assert.deepStrictEqual(snapshot, { status: "stopped", @@ -1010,13 +983,11 @@ describe("AtomMachine", () => { } }) }))) - it.effect("exposes stopped send failures through the writable send atom", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry const bridge = AtomMachine.make(makeCounterMachine()) yield* mount(registry, bridge.snapshot) - yield* AtomRegistry.getResult(registry, bridge.snapshot) yield* Effect.sync(() => registry.set(bridge.stop, undefined)) yield* waitForResult(registry, bridge.snapshot, (snapshot) => snapshot.status === "stopped") @@ -1027,7 +998,6 @@ describe("AtomMachine", () => { Effect.forkChild({ startImmediately: true }) ) yield* Effect.sync(() => registry.set(bridge.send, new Finish({ by: 1 }))) - const result = Array.from(yield* Fiber.join(failureFiber))[0]! assert.strictEqual(AsyncResult.isFailure(result), true) if (AsyncResult.isFailure(result)) { @@ -1038,12 +1008,10 @@ describe("AtomMachine", () => { } } }))) - it.effect("runs a machine and exposes the final snapshot", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry const root9 = Machine.state({ - initial: "Count", states: { Count, Done: { @@ -1056,16 +1024,20 @@ describe("AtomMachine", () => { const targets9 = Machine.targets(root9) const machine = Machine.make({ root: root9, - events: Machine.eventsFromSchemas(Finish), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 1 })))) + events: Machine.eventsFromSchemas(Finish) }).handle({ + initial: { + target: Machine.targets(root9).root.Count, + decoded: true, + data: new Count({ value: 1 }) + }, states: { Count: { on: { Finish: { target: targets9.root.Done, - decoded: ({ state, event }) => (new Done({ value: state.value + event.by })) + decoded: true, + data: ({ state, event }) => (new Done({ value: state.value + event.by })) } } }, @@ -1076,9 +1048,7 @@ describe("AtomMachine", () => { }) const bridge = AtomMachine.make(machine) yield* mount(registry, bridge.snapshot) - yield* Effect.sync(() => registry.set(bridge.send, new Finish({ by: 3 }))) - const snapshot = yield* waitForResult(registry, bridge.snapshot, (snapshot) => snapshot.status === "done") assert.deepStrictEqual(snapshot, { status: "done", diff --git a/packages/effect-machine/typetest/machine/Activities.tst.ts b/packages/effect-machine/typetest/machine/Activities.tst.ts index 5f63238f..530b09f8 100644 --- a/packages/effect-machine/typetest/machine/Activities.tst.ts +++ b/packages/effect-machine/typetest/machine/Activities.tst.ts @@ -1,42 +1,33 @@ import { Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class Loading extends Schema.TaggedClass("Loading")("Loading", {}) {} class Dynamic extends Schema.TaggedClass("Dynamic")("Dynamic", {}) {} class TimedOut extends Schema.TaggedClass("TimedOut")("TimedOut", {}) {} - -const States = Machine.state({ initial: "Loading", states: { Loading, Dynamic } }) - +const States = Machine.state({ states: { Loading, Dynamic } }) const machine = Machine.make({ timers: { source1: "1 second", source2: (_input: undefined) => ("2 seconds" as const) }, - root: States, - events: Machine.eventsFromSchemas(TimedOut), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.Loading.decoded(new Loading({}))))) + events: Machine.eventsFromSchemas(TimedOut) }).handle({ + initial: { + target: Machine.targets(States).root.Loading, + decoded: true, + data: new Loading({}) + }, states: { - Loading: { - invoke: { src: "source1", id: "timeout", onDone: { none: true } } - }, - Dynamic: { - invoke: { src: "source2", id: "dynamic", input: () => undefined, onDone: { none: true } } - } + Loading: { invoke: { src: "source1", id: "timeout", onDone: { none: true } } }, + Dynamic: { invoke: { src: "source2", id: "dynamic", input: () => undefined, onDone: { none: true } } } } }) - 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.type).type.toBe<"process" | "effect" | "stream" | "timer" | "machine">() }) - it("narrows kind-specific descriptive metadata", () => { const definition = Machine.activityDefinitions(machine)[0]! - if (definition.type === "timer") { expect(definition.id).type.toBe() expect(definition.duration).type.toBe() diff --git a/packages/effect-machine/typetest/machine/Annotations.tst.ts b/packages/effect-machine/typetest/machine/Annotations.tst.ts index d4d729be..4ebd1693 100644 --- a/packages/effect-machine/typetest/machine/Annotations.tst.ts +++ b/packages/effect-machine/typetest/machine/Annotations.tst.ts @@ -1,23 +1,18 @@ import { Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class Workflow extends Schema.TaggedClass("Workflow")("Workflow", {}) {} class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - const AnnotatedWorkflow = Workflow.annotate({ title: "Document workflow", description: "Coordinates the document lifecycle", documentation: "https://example.test/docs/workflow", designOwner: "editor-platform" }) - const States = Machine.state({ - initial: "Workflow", states: { Workflow: { schema: AnnotatedWorkflow, - initial: "Idle", states: { Idle, Routing: { @@ -38,18 +33,26 @@ const States = Machine.state({ } } }) - const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(( - { target } - ) => (target.from((to) => - to.Workflow.decoded(new Workflow({}), (workflow) => workflow.Idle.decoded(new Idle({}))) - ))) + events: Machine.eventsFromSchemas() +}).handle({ + initial: { + target: Machine.targets(States).root.Workflow, + decoded: true, + data: new Workflow({}) + }, + states: { + Workflow: { + initial: { + target: Machine.targets(States).root.Workflow.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: {} } + } + } }) - describe("Machine state annotations", () => { it("exposes typed resolved annotations on every state node", () => { const node = Machine.stateNodes(machine)[0]! @@ -59,7 +62,6 @@ describe("Machine state annotations", () => { expect(node.annotations?.documentation).type.toBe() expect(node.annotations?.designOwner).type.toBe() }) - it("limits pseudo-state annotations to descriptive metadata", () => { expect(Machine.state).type.not.toBeCallableWith({ initial: "Workflow", @@ -88,7 +90,6 @@ describe("Machine state annotations", () => { } }) }) - it("keeps schema-backed APIs unavailable to annotated pseudo-states", () => { expect(Machine.state).type.not.toBeCallableWith({ initial: "Workflow", diff --git a/packages/effect-machine/typetest/machine/Choice.tst.ts b/packages/effect-machine/typetest/machine/Choice.tst.ts index 3cb21ad8..001fa469 100644 --- a/packages/effect-machine/typetest/machine/Choice.tst.ts +++ b/packages/effect-machine/typetest/machine/Choice.tst.ts @@ -1,17 +1,13 @@ import { Context, Data, Effect, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class Flow extends Schema.TaggedClass("Flow")("Flow", { score: Schema.Number }) {} class Approved extends Schema.TaggedClass("Approved")("Approved", {}) {} class Rejected extends Schema.TaggedClass("Rejected")("Rejected", {}) {} - const States = Machine.state({ - initial: "Flow", states: { Flow: { schema: Flow, - initial: "Routing", states: { Routing: { type: "choice" }, Approved, @@ -20,35 +16,44 @@ const States = Machine.state({ } } }) - 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.Routing">() - expect>().type.toBe< - "" | "Flow" | "Flow.Routing" | "Flow.Approved" | "Flow.Rejected" - >() + expect< + Machine.Machine.StateIdentifier<{ + readonly "": typeof States.node + }> + >().type.toBe<"" | "Flow" | "Flow.Approved" | "Flow.Rejected">() + expect< + Machine.Machine.ChoiceIdentifier<{ + readonly "": typeof States.node + }> + >().type.toBe<"Flow.Routing">() + expect< + Machine.Machine.StateNodeIdentifier<{ + readonly "": typeof States.node + }> + >().type.toBe<"" | "Flow" | "Flow.Routing" | "Flow.Approved" | "Flow.Rejected">() expect["path"]>().type.not.toBe<"Flow.Routing">() }) - it("exposes only choice context and requires implementation before planning", () => { const targets1 = Machine.targets(States) const incomplete = Machine.make({ branches: { transition1: { destination: { target: targets1.root.Flow.Approved } } }, - root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(( - { target } - ) => (target.from((to) => to.Flow.decoded(new Flow({ score: 80 }), (flow) => flow.Routing())))) + events: Machine.eventsFromSchemas() }) expect(Machine.planInitial).type.not.toBeCallableWith(incomplete) const complete = incomplete.handle({ + initial: { + target: Machine.targets(States).root.Flow, + decoded: true, + data: new Flow({ score: 80 }) + }, states: { Flow: { + initial: { + target: Machine.targets(States).root.Flow.Routing + }, states: { Routing: { choice: { @@ -62,29 +67,33 @@ describe("Machine choice pseudo-states", () => { return context.select.destination.decoded(new Approved({})) } } - } + }, + Approved: {}, + Rejected: {} } } } }) expect(Machine.planInitial).type.toBeCallableWith(complete) }) - it("rejects Effects returned by choice resolvers", () => { const targets2 = Machine.targets(States) const machine = Machine.make({ branches: { transition1: { destination: { target: targets2.root.Flow.Approved } } }, - root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(( - { target } - ) => (target.from((to) => to.Flow.decoded(new Flow({ score: 80 }), (flow) => flow.Routing())))) + events: Machine.eventsFromSchemas() }) machine.handle({ + initial: { + target: Machine.targets(States).root.Flow, + decoded: true, + data: new Flow({ score: 80 }) + }, states: { Flow: { + initial: { + target: Machine.targets(States).root.Flow.Routing + }, states: { Routing: { choice: { @@ -92,13 +101,14 @@ describe("Machine choice pseudo-states", () => { // @ts-expect-error! resolve: ({ select: { destination: target } }) => Effect.succeed(target.decoded(new Approved({}))) } - } + }, + Approved: {}, + Rejected: {} } } } }) }) - it("rejects every active definition and handler property", () => { const invalidDefinitions = [ { type: "choice", schema: Approved }, @@ -119,14 +129,9 @@ describe("Machine choice pseudo-states", () => { } }) } - const base = Machine.make({ root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(( - { target } - ) => (target.from((to) => to.Flow.decoded(new Flow({ score: 80 }), (flow) => flow.Routing())))) + events: Machine.eventsFromSchemas() }) const invalidHandlers = [ { entry: () => undefined }, @@ -141,25 +146,63 @@ describe("Machine choice pseudo-states", () => { { states: {} } ] as const for (const invalid of invalidHandlers) { - expect(base.handle).type.not.toBeCallableWith({ states: { Flow: { states: { Routing: invalid } } } }) + expect(base.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(States).root.Flow, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + Flow: { + initial: { + target: Machine.targets(States).root.Flow.Approved, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + }) + expect(base.handle).type.not.toBeCallableWith({ + states: { + Flow: { + states: { Routing: invalid }, + initial: { + target: Machine.targets(States).root.Flow.Approved, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + target: Machine.targets(States).root.Flow, + data: () => { + throw new Error("type-only constructor") + } + } + }) } }) - it("validates the selected choice result", () => { const targets3 = Machine.targets(States) const base = Machine.make({ branches: { transition1: { destination: { target: targets3.root.Flow.Rejected } } }, - root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(( - { target } - ) => (target.from((to) => to.Flow.decoded(new Flow({ score: 80 }), (flow) => flow.Routing())))) + events: Machine.eventsFromSchemas() }) base.handle({ + initial: { + target: Machine.targets(States).root.Flow, + decoded: true, + data: new Flow({ score: 80 }) + }, states: { Flow: { + initial: { + target: Machine.targets(States).root.Flow.Routing + }, states: { Routing: { choice: { @@ -169,6 +212,26 @@ describe("Machine choice pseudo-states", () => { return selectedTarget.decoded(new Rejected({})) } } + }, + Approved: {}, + Rejected: {} + } + } + } + }) + expect(base.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(States).root.Flow, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + Flow: { + initial: { + target: Machine.targets(States).root.Flow.Approved, + data: () => { + throw new Error("type-only constructor") } } } @@ -177,20 +240,47 @@ describe("Machine choice pseudo-states", () => { expect(base.handle).type.not.toBeCallableWith({ states: { Flow: { - states: { - Routing: { choice: () => undefined } + states: { Routing: { choice: () => undefined } }, + initial: { + target: Machine.targets(States).root.Flow.Approved, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(States).root.Flow, + data: () => { + throw new Error("type-only constructor") + } } }) - expect(base.handle).type.not.toBeCallableWith({ states: { Flow: { states: { - Routing: { choice: { branches: "transition1", declinable: true, resolve: () => undefined } } + Routing: { + choice: { + branches: "transition1", + declinable: true, + resolve: () => undefined + } + } + }, + initial: { + target: Machine.targets(States).root.Flow.Approved, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(States).root.Flow, + data: () => { + throw new Error("type-only constructor") + } } }) }) diff --git a/packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts b/packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts index bf7d0e3c..4986dc70 100644 --- a/packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts +++ b/packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts @@ -1,17 +1,13 @@ import { Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class InSession extends Schema.TaggedClass("ConsumerTypesInSession")("InSession", { offerId: Schema.String, role: Schema.Literals(["offerer", "proposer"]) }) {} - const States = Machine.state({ - initial: "root", states: { root: { - initial: "Idle", states: { Idle: {}, InSession @@ -19,69 +15,64 @@ const States = Machine.state({ } } }) - const StartupInput = Schema.Struct({ offerId: Schema.String, role: Schema.Literals(["offerer", "proposer"]) }) - const definition = Machine.make({ root: States, events: Machine.eventsFromSchemas(), - input: StartupInput, - initialConfiguration: (root) => - root.resolve(({ input, target }) => { - expect(input).type.toBe() - return target.from((to) => to.root.from((root) => root.Idle.from())) - }) + input: StartupInput }) - const machine = definition.handle({ + initial: { target: Machine.targets(States).root.root }, states: { - root: { - states: { - Idle: {}, - InSession: {} - } - } + root: { initial: { target: Machine.targets(States).root.root.Idle }, states: { Idle: {}, InSession: {} } } } }) - const voidMachine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.root.from((root) => root.Idle.from()))) + events: Machine.eventsFromSchemas() +}).handle({ + initial: { + target: Machine.targets(States).root.root + }, + states: { + root: { + initial: { + target: Machine.targets(States).root.root.Idle + }, + states: { Idle: {}, InSession: {} } + } + } }) - describe("consumer type extractors", () => { it("extracts complete snapshots from defined states and machines", () => { expect>().type.toBe>() expect>().type.toBe>() }) - it("extracts schema-backed values from defined states and machines", () => { expect>().type.toBe() expect>().type.toBe() - // @ts-expect-error! type MissingPath = Machine.Value // @ts-expect-error! type StructuralPath = Machine.Value }) - it("extracts path-rooted snapshots including structural states", () => { expect>().type.toBe< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "root"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "root"> >() expect>().type.toBe< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "root.Idle"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "root.Idle"> >() - // @ts-expect-error! type MissingPath = Machine.SnapshotAt }) - it("separates decoded startup input from its schema", () => { expect>().type.toBe() expect>().type.toBe() diff --git a/packages/effect-machine/typetest/machine/Declarative.tst.ts b/packages/effect-machine/typetest/machine/Declarative.tst.ts index 17b0ab37..bb4c884d 100644 --- a/packages/effect-machine/typetest/machine/Declarative.tst.ts +++ b/packages/effect-machine/typetest/machine/Declarative.tst.ts @@ -1,20 +1,23 @@ import { Context, Effect, Schema, Stream } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - -class Database extends Context.Service()("test/Database") {} -class Unused extends Context.Service()("test/Unused") {} +class Database extends Context.Service()("test/Database") { +} +class Unused extends Context.Service()("test/Unused") { +} class LoadError extends Schema.TaggedClass("LoadError")("LoadError", { message: Schema.String }) {} const root = Machine.state({ fields: { revision: Schema.Number }, - initial: "Idle", states: { Idle: {}, Ready: { fields: { count: Schema.Number } }, Failed: { fields: { message: Schema.String } }, Nested: { fields: { name: Schema.String }, - initial: "Child", states: { Child: { fields: { count: Schema.Number } } } } } @@ -23,7 +26,6 @@ const targets = Machine.targets(root) const definition = Machine.make({ root, events: Machine.events({ Load: { count: Schema.Number }, Reset: {} }), - initial: (root) => root.from(() => ({ revision: 0 })), effects: { direct: Effect.succeed(1), never: Effect.never, @@ -39,7 +41,6 @@ const definition = Machine.make({ nested: { child: { target: targets.root.Nested } } } }) - describe("declarative inference", () => { it("rejects ambiguous source inputs and non-topological branch fields", () => { expect(Machine.make).type.not.toBeCallableWith({ @@ -74,15 +75,18 @@ describe("declarative inference", () => { branches: { bad: { ready: { target: targets.root.Ready, title: "" } } } }) }) - it("infers events and root data for inline construction", () => { definition.handle({ + initial: { + target: Machine.targets(root).root.Idle + }, + root: () => ({ revision: 0 }), states: { Idle: { on: { Load: { target: targets.root.Ready, - from: ({ event, root, state }) => { + data: ({ event, root, state }) => { expect(event.count).type.toBe() expect(root.revision).type.toBe() expect(state).type.toBe() @@ -90,27 +94,145 @@ describe("declarative inference", () => { } } } + }, + Ready: {}, + Failed: {}, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Child: {} } + } + } + }) + expect(definition.handle).type.toBeCallableWith({ + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle }, + states: { + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } } } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { on: { Load: { target: targets.root.Ready } } } } + states: { + Idle: { on: { Load: { target: targets.root.Ready } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { on: { Load: { target: targets.root.Ready, from: () => ({ count: "bad" }) } } } } + states: { + Idle: { on: { Load: { target: targets.root.Ready, data: () => ({ count: "bad" }) } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { on: { Load: { target: targets.root.Ready, resolve: () => undefined } } } } + states: { + Idle: { on: { Load: { target: targets.root.Ready, resolve: () => undefined } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) - expect(definition.handle).type.not.toBeCallableWith({ on: { Reset: { target: targets.root } } }) - expect(definition.handle).type.not.toBeCallableWith({ on: { Reset: { update: targets.root, from: () => ({}) } } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { on: { Reset: { update: targets.root.Ready, from: () => ({ count: 1 }) } } } } + on: { Reset: { target: targets.root } }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle }, + states: { + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + }) + expect(definition.handle).type.not.toBeCallableWith({ + on: { Reset: { update: targets.root, data: () => ({}) } }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle }, + states: { + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + }) + expect(definition.handle).type.not.toBeCallableWith({ + states: { + Idle: { on: { Reset: { update: targets.root.Ready, data: () => ({ count: 1 }) } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) }) - it("infers named branch constructors without a second target declaration", () => { definition.handle({ + initial: { + target: Machine.targets(root).root.Idle + }, + root: () => ({ revision: 0 }), states: { Idle: { on: { @@ -130,16 +252,44 @@ describe("declarative inference", () => { resolve: ({ select }) => select.child.from({ name: "nested" }, (child) => child.Child.from({ count: 1 })) } } + }, + Ready: {}, + Failed: {}, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Child: {} } } } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { on: { Reset: { branches: "missing", resolve: () => undefined } } } } + states: { + Idle: { on: { Reset: { branches: "missing", resolve: () => undefined } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) }) - it("requires exactly the reachable invocation outcomes", () => { definition.handle({ + initial: { + target: Machine.targets(root).root.Idle + }, + root: () => ({ revision: 0 }), states: { Idle: { invoke: [ @@ -147,7 +297,7 @@ describe("declarative inference", () => { src: "direct", onDone: { target: targets.root.Ready, - from: ({ output }) => { + data: ({ output }) => { expect(output).type.toBe() return { count: output } } @@ -159,7 +309,7 @@ describe("declarative inference", () => { src: "failed", onFailure: { target: targets.root.Failed, - from: ({ error }) => { + data: ({ error }) => { expect(error).type.toBe() return { message: error.message } } @@ -177,46 +327,187 @@ describe("declarative inference", () => { }, { src: "timeout", onDone: { none: true } } ] + }, + Ready: {}, + Failed: {}, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Child: {} } } } }) for (const src of ["direct", "empty", "timeout"] as const) { - expect(definition.handle).type.not.toBeCallableWith({ states: { Idle: { invoke: { src } } } }) + expect(definition.handle).type.not.toBeCallableWith({ + states: { + Idle: { invoke: { src } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } + }) } - expect(definition.handle).type.not.toBeCallableWith({ states: { Idle: { invoke: { src: "failed" } } } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { invoke: { src: "direct", onDone: { none: true }, onFailure: { none: true } } } } + states: { + Idle: { invoke: { src: "failed" } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { invoke: { src: "never", onDone: { none: true } } } } + states: { + Idle: { invoke: { src: "direct", onDone: { none: true }, onFailure: { none: true } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } + }) + expect(definition.handle).type.not.toBeCallableWith({ + states: { + Idle: { invoke: { src: "never", onDone: { none: true } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { invoke: { src: "values", onDone: { none: true } } } } + states: { + Idle: { invoke: { src: "values", onDone: { none: true } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) }) - it("requires input only for input-taking programs and carries used dependencies", () => { const machine = definition.handle({ + initial: { + target: Machine.targets(root).root.Idle + }, + root: () => ({ revision: 0 }), states: { Idle: { invoke: { src: "load", input: ({ root }) => root.revision, - onDone: { target: targets.root.Ready, from: ({ output }) => ({ count: output }) } + onDone: { target: targets.root.Ready, data: ({ output }) => ({ count: output }) } } + }, + Ready: {}, + Failed: {}, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Child: {} } } } }) expect, Database>>().type.toBe() expect, Unused>>().type.toBe() expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { invoke: { src: "load", onDone: { none: true } } } } + states: { + Idle: { invoke: { src: "load", onDone: { none: true } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { invoke: { src: "load", input: () => "bad", onDone: { none: true } } } } + states: { + Idle: { invoke: { src: "load", input: () => "bad", onDone: { none: true } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { invoke: { src: "direct", input: () => 1, onDone: { none: true } } } } + states: { + Idle: { invoke: { src: "direct", input: () => 1, onDone: { none: true } } }, + Nested: { + initial: { + target: Machine.targets(root).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root).root.Idle } }) }) }) diff --git a/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts b/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts index 515b8353..1dab606c 100644 --- a/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts +++ b/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts @@ -1,7 +1,6 @@ import { Context, Data, Effect, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class Root extends Schema.TaggedClass("Root")("Root", {}) {} class Branch extends Schema.TaggedClass("Branch")("Branch", {}) {} class Hub extends Schema.TaggedClass("Hub")("Hub", {}) {} @@ -13,62 +12,52 @@ class RightRegion extends Schema.TaggedClass("RightRegion")("RightR class LeftDone extends Schema.TaggedClass("LeftDone")("LeftDone", { value: Schema.String }) {} class RightDone extends Schema.TaggedClass("RightDone")("RightDone", { value: Schema.Number }) {} class Advance extends Schema.TaggedClass("Advance")("Advance", { value: Schema.String }) {} - -class DeepService extends Context.Service()("types/deep/DeepService") {} -class DeepActionService extends Context.Service()("types/deep/DeepActionService") {} -class DeepFailure extends Data.TaggedError("DeepFailure")<{}> {} -class DeepActionFailure extends Data.TaggedError("DeepActionFailure")<{}> {} - +class DeepService extends Context.Service()("types/deep/DeepService") { +} +class DeepActionService extends Context.Service()("types/deep/DeepActionService") { +} +class DeepFailure extends Data.TaggedError("DeepFailure")<{}> { +} +class DeepActionFailure extends Data.TaggedError("DeepActionFailure")<{}> { +} 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, @@ -87,7 +76,6 @@ const DeepStates = Machine.state({ states: { left: { schema: LeftRegion, - initial: "LeftDone", states: { LeftDone: { schema: LeftDone, @@ -98,7 +86,6 @@ const DeepStates = Machine.state({ }, right: { schema: RightRegion, - initial: "RightDone", states: { RightDone: { schema: RightDone, @@ -136,12 +123,10 @@ const DeepStates = Machine.state({ } } }) - const deepHistoryFallback = ( - target: Machine.Machine.HistoryDefaultTargetBuilder< - { readonly "": typeof DeepStates.node }, - "Root.L1.L2.L3.L4.L5.L6.L7.L8.L9.L10.Hub" - > + target: Machine.Machine.HistoryDefaultTargetBuilder<{ + readonly "": typeof DeepStates.node + }, "Root.L1.L2.L3.L4.L5.L6.L7.L8.L9.L10.Hub"> ) => target.from((tree) => tree.Root.decoded( @@ -160,42 +145,117 @@ const deepHistoryFallback = ( l10.Hub.decoded(new Hub({}), (hub) => hub.Idle.decoded(new Idle({}))))))))))))) ) ) - const makeDeepMachine = () => Machine.make({ root: DeepStates, - events: Machine.eventsFromSchemas(Advance), - initialConfiguration: (to) => - to.resolve((): never => { - throw new Error("type-only") - }) + events: Machine.eventsFromSchemas(Advance) }) - -const atHub = (config: Config) => +const atHub = (config: Config) => ({ - Root: { - states: { - L1: { - states: { - L2: { - states: { - L3: { - states: { - L4: { - states: { - L5: { - states: { - L6: { - states: { - L7: { - states: { - L8: { - states: { - L9: { - states: { - L10: { - states: { - Hub: config + initial: { target: Machine.targets(DeepStates).root.Root }, + states: { + Root: { + initial: { target: Machine.targets(DeepStates).root.Root.L1 }, + states: { + L1: { + initial: { target: Machine.targets(DeepStates).root.Root.L1.L2 }, + states: { + L2: { + initial: { target: Machine.targets(DeepStates).root.Root.L1.L2.L3 }, + states: { + L3: { + initial: { target: Machine.targets(DeepStates).root.Root.L1.L2.L3.L4 }, + states: { + L4: { + initial: { target: Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5 }, + states: { + L5: { + initial: { target: Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6 }, + states: { + L6: { + initial: { target: Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6.L7 }, + states: { + L7: { + initial: { target: Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6.L7.L8 }, + states: { + L8: { + initial: { + target: Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6.L7.L8.L9 + }, + states: { + L9: { + initial: { + target: + Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6.L7.L8.L9.L10 + }, + states: { + L10: { + initial: { + target: + Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6.L7.L8.L9.L10 + .Hub + }, + states: { + Hub: { + initial: { + target: + Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6.L7.L8.L9 + .L10.Hub.Idle + }, + history: { + recent: { + default: ( + { target }: { + target: Parameters[0] + } + ) => deepHistoryFallback(target) + } + }, + states: { + Route: { + choice: { + target: + Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6.L7 + .L8.L9.L10.Hub.Idle + } + }, + Idle: {}, + Done: { output: ({ state }: { state: Done }) => state.value }, + Work: { + output: () => ({ left: "", right: 0 }), + states: { + left: { + initial: { + target: + Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6 + .L7.L8.L9.L10.Hub.Work.left.LeftDone, + data: { value: "" } + }, + states: { + LeftDone: { + output: ({ state }: { state: LeftDone }) => state.value + } + } + }, + right: { + initial: { + target: + Machine.targets(DeepStates).root.Root.L1.L2.L3.L4.L5.L6 + .L7.L8.L9.L10.Hub.Work.right.RightDone, + data: { value: 0 } + }, + states: { + RightDone: { + output: ({ state }: { state: RightDone }) => state.value + } + } + } + } + } + }, + ...config + } + } } } } @@ -223,7 +283,7 @@ const atHub = (config: Config) => describe("deep handler trees", () => { it("keeps branded validation effective at deep paths", () => { const machine = makeDeepMachine() - + machine.handle(atHub({})) expect(machine.handle).type.not.toBeCallableWith(atHub({ states: { Missing: {} @@ -249,111 +309,86 @@ describe("deep handler trees", () => { onDone: () => undefined })) }) - it("retains exact evidence through a narrow depth-24 tree", () => { const NarrowNode = Schema.TaggedStruct("NarrowNode", {}) - class NarrowService extends Context.Service()("types/deep/NarrowService") {} - class NarrowFailure extends Data.TaggedError("NarrowFailure")<{}> {} - + class NarrowService extends Context.Service()("types/deep/NarrowService") { + } + class NarrowFailure extends Data.TaggedError("NarrowFailure")<{}> { + } 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: @@ -414,60 +449,276 @@ describe("deep handler trees", () => { }) const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (to) => - to.resolve((): never => { - throw new Error("type-only") - }) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(States).root.n0, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n0: { + initial: { + target: Machine.targets(States).root.n0.n1, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n1: { + initial: { + target: Machine.targets(States).root.n0.n1.n2, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n2: { + initial: { + target: Machine.targets(States).root.n0.n1.n2.n3, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n3: { + initial: { + target: Machine.targets(States).root.n0.n1.n2.n3.n4, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n4: { + initial: { + target: Machine.targets(States).root.n0.n1.n2.n3.n4.n5, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n5: { + initial: { + target: Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n6: { + initial: { + target: Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6.n7, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n7: { + initial: { + target: Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6.n7.n8, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n8: { + initial: { + target: Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n9: { + initial: { + target: Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n10: { + initial: { + target: + Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10 + .n11, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n11: { + initial: { + target: + Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10 + .n11.n12, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n12: { + initial: { + target: + Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9 + .n10.n11.n12.n13, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n13: { + initial: { + target: + Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6.n7 + .n8.n9.n10.n11.n12.n13.n14, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n14: { + initial: { + target: + Machine.targets(States).root.n0.n1.n2.n3.n4.n5.n6 + .n7.n8.n9.n10.n11.n12.n13.n14.n15, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n15: { + initial: { + target: + Machine.targets(States).root.n0.n1.n2.n3.n4.n5 + .n6.n7.n8.n9.n10.n11.n12.n13.n14.n15.n16, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n16: { + initial: { + target: + Machine.targets(States).root.n0.n1.n2.n3 + .n4.n5.n6.n7.n8.n9.n10.n11.n12.n13.n14 + .n15.n16.n17, + data: () => { + throw new Error("type-only constructor") + } + }, states: { n17: { + initial: { + target: + Machine.targets(States).root.n0.n1.n2 + .n3.n4.n5.n6.n7.n8.n9.n10.n11.n12 + .n13.n14.n15.n16.n17.n18, + data: () => { + throw new Error( + "type-only constructor" + ) + } + }, states: { n18: { + initial: { + target: + Machine.targets(States).root.n0.n1 + .n2.n3.n4.n5.n6.n7.n8.n9.n10.n11 + .n12.n13.n14.n15.n16.n17.n18 + .n19, + data: () => { + throw new Error( + "type-only constructor" + ) + } + }, states: { n19: { + initial: { + target: + Machine.targets(States).root + .n0.n1.n2.n3.n4.n5.n6.n7.n8 + .n9.n10.n11.n12.n13.n14.n15 + .n16.n17.n18.n19.n20, + data: () => { + throw new Error( + "type-only constructor" + ) + } + }, states: { n20: { + initial: { + target: + Machine.targets(States) + .root.n0.n1.n2.n3.n4.n5 + .n6.n7.n8.n9.n10.n11.n12 + .n13.n14.n15.n16.n17.n18 + .n19.n20.n21, + data: () => { + throw new Error( + "type-only constructor" + ) + } + }, states: { n21: { + initial: { + target: Machine.targets( + States + ).root.n0.n1.n2.n3.n4 + .n5.n6.n7.n8.n9.n10 + .n11.n12.n13.n14.n15 + .n16.n17.n18.n19.n20 + .n21.n22, + data: () => { + throw new Error( + "type-only constructor" + ) + } + }, states: { n22: { + initial: { + target: + Machine.targets( + States + ).root.n0.n1.n2.n3 + .n4.n5.n6.n7.n8 + .n9.n10.n11.n12 + .n13.n14.n15.n16 + .n17.n18.n19.n20 + .n21.n22.n23, + data: () => { + throw new Error( + "type-only constructor" + ) + } + }, states: { n23: { + initial: { + target: Machine + .targets( + States + ).root.n0.n1 + .n2.n3.n4.n5 + .n6.n7.n8.n9 + .n10.n11.n12 + .n13.n14.n15 + .n16.n17.n18 + .n19.n20.n21 + .n22.n23 + .n24, + data: () => { + throw new Error( + "type-only constructor" + ) + } + }, states: { n24: { entry: @@ -525,7 +776,6 @@ describe("deep handler trees", () => { } } }) - expect>().type.toBe() expect>().type.toBe() expect>().type.toBe() diff --git a/packages/effect-machine/typetest/machine/DynamicChildren.tst.ts b/packages/effect-machine/typetest/machine/DynamicChildren.tst.ts index 45ecebaa..b5a0ed58 100644 --- a/packages/effect-machine/typetest/machine/DynamicChildren.tst.ts +++ b/packages/effect-machine/typetest/machine/DynamicChildren.tst.ts @@ -1,81 +1,98 @@ import { Effect, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - describe("dynamic child machines", () => { class ChildIdle extends Schema.TaggedClass("DynamicTypesChildIdle")("ChildIdle", { id: Schema.String - }) {} - class ChildEvent extends Schema.TaggedClass("DynamicTypesChildEvent")("ChildEvent", {}) {} - class ParentNotice extends Schema.TaggedClass("DynamicTypesParentNotice")("ParentNotice", {}) {} - class OtherEvent extends Schema.TaggedClass("DynamicTypesOtherEvent")("OtherEvent", {}) {} - class ParentIdle extends Schema.TaggedClass("DynamicTypesParentIdle")("ParentIdle", {}) {} - + }) { + } + class ChildEvent extends Schema.TaggedClass("DynamicTypesChildEvent")("ChildEvent", {}) { + } + class ParentNotice extends Schema.TaggedClass("DynamicTypesParentNotice")("ParentNotice", {}) { + } + class OtherEvent extends Schema.TaggedClass("DynamicTypesOtherEvent")("OtherEvent", {}) { + } + class ParentIdle extends Schema.TaggedClass("DynamicTypesParentIdle")("ParentIdle", {}) { + } const Input = Schema.Struct({ id: Schema.String }) const ParentEvents = Machine.eventsFromSchemas(ParentNotice) - const root1 = Machine.state({ initial: "ChildIdle", states: { ChildIdle } }) - + const root1 = Machine.state({ + fields: { + input: Schema.toType(Input) + }, + states: { ChildIdle } + }) const childMachine = Machine.make({ root: root1, events: Machine.eventsFromSchemas(ChildEvent), input: Input, - parent: Machine.parent(ParentEvents), - initialConfiguration: (root) => - root.resolve(({ input, target }) => target.from((to) => to.ChildIdle.decoded(new ChildIdle({ id: input.id })))) + parent: Machine.parent(ParentEvents) }).handle({ - states: { - ChildIdle: { - on: { ChildEvent: { none: true } } - } - } + initial: { + target: Machine.targets(root1).root.ChildIdle, + decoded: true, + data: ({ root: { input: input } }) => new ChildIdle({ id: input.id }) + }, + root: ({ input }) => ({ input }), + states: { ChildIdle: { on: { ChildEvent: { none: true } } } } }) const Child = Machine.childFamily(childMachine) + const InitialRoot1 = Machine.state({ states: { ChildIdle } }) const voidChildMachine = Machine.make({ - 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: {} } }) + root: InitialRoot1, + events: Machine.eventsFromSchemas() + }).handle({ + initial: { + target: Machine.targets(InitialRoot1).root.ChildIdle, + decoded: true, + data: new ChildIdle({ id: "void" }) + }, + states: { ChildIdle: {} } + }) const VoidChild = Machine.childFamily(voidChildMachine) - it("binds one machine type to runtime ids", () => { expect(Child("p-1")).type.toBe>() expect(Child("p-1")).type.toBeAssignableTo(Machine.child("p-1", childMachine)) expect(Child("p-1").id).type.toBe<"p-1">() expect(Child("p-1").machine).type.toBe() }) - it("types statechart-owned dynamic child operations", () => { - const root2 = Machine.state({ initial: "ParentIdle", states: { ParentIdle } }) - + const root2 = Machine.state({ states: { ParentIdle } }) Machine.make({ effects: { - source1: ({ - children - }: Machine.Machine.InvokeContext< + source1: ({ children }: Machine.Machine.InvokeContext< { - readonly "": - & { readonly initial: "ParentIdle"; readonly states: { readonly ParentIdle: typeof ParentIdle } } - & { readonly "~effect/Machine/ExplicitInitial": true } + readonly "": { + readonly initial: "ParentIdle" + readonly states: { + readonly ParentIdle: typeof ParentIdle + } + } & { + readonly "~effect/Machine/ExplicitInitial": true + } }, - readonly [typeof ParentNotice, typeof OtherEvent], + readonly [ + typeof ParentNotice, + typeof OtherEvent + ], readonly [], "ParentIdle", - readonly [typeof ParentNotice, typeof OtherEvent], + readonly [ + typeof ParentNotice, + typeof OtherEvent + ], readonly [] >) => { 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.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< @@ -84,12 +101,14 @@ describe("dynamic child machines", () => { return spawned } }, - root: root2, - events: Machine.eventsFromSchemas(ParentNotice, OtherEvent), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.ParentIdle.decoded(new ParentIdle({})))) + events: Machine.eventsFromSchemas(ParentNotice, OtherEvent) }).handle({ + initial: { + target: Machine.targets(root2).root.ParentIdle, + decoded: true, + data: new ParentIdle({}) + }, states: { ParentIdle: { invoke: { @@ -103,44 +122,55 @@ describe("dynamic child machines", () => { } }) }) - it("rejects a child whose parent protocol is not accepted", () => { - const root3 = Machine.state({ initial: "ParentIdle", states: { ParentIdle } }) - + const root3 = Machine.state({ states: { ParentIdle } }) Machine.make({ effects: { - source1: ({ - children - }: Machine.Machine.InvokeContext< + source1: ({ children }: Machine.Machine.InvokeContext< { - readonly "": - & { readonly initial: "ParentIdle"; readonly states: { readonly ParentIdle: typeof ParentIdle } } - & { readonly "~effect/Machine/ExplicitInitial": true } + readonly "": { + readonly initial: "ParentIdle" + readonly states: { + readonly ParentIdle: typeof ParentIdle + } + } & { + readonly "~effect/Machine/ExplicitInitial": true + } }, - readonly [typeof OtherEvent], + readonly [ + typeof OtherEvent + ], readonly [], "ParentIdle", - readonly [typeof OtherEvent], + readonly [ + typeof OtherEvent + ], readonly [] >) => { expect(children.spawn).type.not.toBeCallableWith(Child("p-1"), { input: { id: "p-1" } }) return Effect.void } }, - root: root3, - events: Machine.eventsFromSchemas(OtherEvent), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.ParentIdle.decoded(new ParentIdle({})))) + events: Machine.eventsFromSchemas(OtherEvent) }).handle({ + initial: { + target: Machine.targets(root3).root.ParentIdle, + decoded: true, + data: new ParentIdle({}) + }, states: { ParentIdle: { - invoke: { src: "source1", id: "incompatible", input: (context) => context, onDone: { none: true } } + invoke: { + src: "source1", + id: "incompatible", + input: (context) => context, + onDone: { none: true } + } } } }) }) - it("adds child descriptors to process spawn interfaces", () => { Machine.logic({ initial: ({ spawn }) => { @@ -149,7 +179,6 @@ describe("dynamic child machines", () => { }, run: () => Effect.never }) - Machine.logic({ initial: ({ spawn }) => { expect(spawn).type.not.toBeCallableWith(Child("p-1"), { input: { id: "p-1" } }) diff --git a/packages/effect-machine/typetest/machine/EventByTag.tst.ts b/packages/effect-machine/typetest/machine/EventByTag.tst.ts index 99badb05..236439e6 100644 --- a/packages/effect-machine/typetest/machine/EventByTag.tst.ts +++ b/packages/effect-machine/typetest/machine/EventByTag.tst.ts @@ -1,23 +1,31 @@ import { Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - describe("Machine.EventByTag", () => { - class Idle extends Schema.TaggedClass("EventByTagIdle")("EventByTagIdle", {}) {} + class Idle extends Schema.TaggedClass("EventByTagIdle")("EventByTagIdle", {}) { + } class Single extends Schema.TaggedClass("Single")("Single", { value: Schema.Number - }) {} - + }) { + } const FiniteUnion = Schema.Struct({ _tag: Schema.Union([Schema.Literal("Alpha"), Schema.Literal("Beta")]), payload: Schema.String, count: Schema.Number }) - it("narrows each member of a finite tag union and preserves its payload", () => { - type Alpha = Machine.Machine.EventByTag - type Beta = Machine.Machine.EventByTag - + type Alpha = Machine.Machine.EventByTag< + readonly [ + typeof FiniteUnion + ], + "Alpha" + > + type Beta = Machine.Machine.EventByTag< + readonly [ + typeof FiniteUnion + ], + "Beta" + > expect().type.toBe<{ readonly _tag: "Alpha" readonly payload: string @@ -29,15 +37,17 @@ describe("Machine.EventByTag", () => { readonly count: number }>() }) - it("narrows handler contexts for every finite tag", () => { - const states = Machine.state({ initial: "Idle", states: { Idle } }) - + const states = Machine.state({ states: { Idle } }) Machine.make({ root: states, - events: Machine.eventsFromSchemas(FiniteUnion), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas(FiniteUnion) }).handle({ + initial: { + target: Machine.targets(states).root.Idle, + decoded: true, + data: new Idle({}) + }, states: { Idle: { on: { @@ -68,8 +78,14 @@ describe("Machine.EventByTag", () => { } }) }) - it("preserves ordinary tagged-class event types", () => { - expect>().type.toBe() + expect< + Machine.Machine.EventByTag< + readonly [ + typeof Single + ], + "Single" + > + >().type.toBe() }) }) diff --git a/packages/effect-machine/typetest/machine/EventConstructors.tst.ts b/packages/effect-machine/typetest/machine/EventConstructors.tst.ts index 549330b9..421b4f36 100644 --- a/packages/effect-machine/typetest/machine/EventConstructors.tst.ts +++ b/packages/effect-machine/typetest/machine/EventConstructors.tst.ts @@ -1,45 +1,34 @@ import { Effect, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - describe("Machine event constructor collections", () => { const PublicEvent = Schema.TaggedUnion({ Increment: { by: Schema.Number }, Reset: {} }) - class SetLabel extends Schema.TaggedClass("SetLabel")("SetLabel", { id: Schema.String, - label: Schema.String.pipe( - Schema.optionalKey, - Schema.withConstructorDefault(Effect.succeed("default-label")) - ) - }) {} - + label: Schema.String.pipe(Schema.optionalKey, Schema.withConstructorDefault(Effect.succeed("default-label"))) + }) { + } const InternalEvent = Schema.TaggedUnion({ Loaded: { value: Schema.String }, Failed: {} }) - const FiniteEvent = Schema.Struct({ _tag: Schema.Union([Schema.Literal("Alpha"), Schema.Literal("Beta")]), value: Schema.String }) - - const states = Machine.state({ initial: "Idle", states: { Idle: {} } }) + const states = Machine.state({ states: { Idle: {} } }) const events = Machine.eventsFromSchemas(PublicEvent, SetLabel, FiniteEvent) const internalEvents = Machine.internalEventsFromSchemas(InternalEvent) - const machine = Machine.make({ effects: { source1: Effect.suspend(() => Effect.succeed("ready")) }, timers: { source2: "1 second" }, - root: states, events, - internalEvents, - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.from()))) + internalEvents }) - it("derives public constructors and their schema make inputs", () => { expect(events.Increment({ by: 1 })).type.toBe< Machine.Machine.EventConstruction @@ -47,20 +36,19 @@ describe("Machine event constructor collections", () => { expect(events.Reset()).type.toBe>() expect(events.SetLabel({ id: "label-1" })).type.toBe>() expect(events.Alpha({ value: "alpha" })).type.toBe< - Machine.Machine.EventConstruction<{ readonly _tag: "Alpha"; readonly value: string }> + Machine.Machine.EventConstruction<{ + readonly _tag: "Alpha" + readonly value: string + }> >() - expect(events.Increment()).type.toRaiseError() expect(events.Increment({ by: "1" })).type.toRaiseError() expect(events.SetLabel({})).type.toRaiseError() expect(events.Alpha({ _tag: "Beta", value: "alpha" })).type.toRaiseError() expect(events.Loaded).type.toRaiseError() - expect>().type.toBe< - typeof PublicEvent.Type | SetLabel | typeof FiniteEvent.Type - >() + expect>().type.toBe() expect(machine.events).type.toBe() }) - it("keeps internal constructors separate from public constructors", () => { expect(internalEvents.Loaded({ value: "ready" })).type.toBe< Machine.Machine.EventConstruction @@ -68,12 +56,10 @@ describe("Machine event constructor collections", () => { expect(internalEvents.Failed()).type.toBe< Machine.Machine.EventConstruction >() - expect(internalEvents.Increment).type.toRaiseError() expect(internalEvents.Loaded()).type.toRaiseError() expect(machine.internalEvents).type.toBe() }) - it("keeps public and internal protocol descriptors nominally separate", () => { expect(Machine.make).type.not.toBeCallableWith({ root: states, @@ -84,64 +70,71 @@ describe("Machine event constructor collections", () => { events, internalEvents: events }) - const Reset = Schema.TaggedStruct("Reset", {}) expect(Machine.events).type.not.toBeCallableWith(Reset, PublicEvent) }) - 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.eventsFromSchemas(OpenEvent) const openMachine = Machine.make({ root: states, - events: openEvents, - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.from()))) + events: openEvents + }).handle({ + initial: { + target: Machine.targets(states).root.Idle + }, + states: { Idle: {} } }) - expect(openEvents.Dynamic).type.toRaiseError() expect>().type.toBe() }) - it("accepts public constructions at machine delivery boundaries", () => { - expect( - Machine.plan(machine.handle({ states: { Idle: {} } }), { + expect(Machine.plan( + machine.handle({ + initial: { + target: Machine.targets(states).root.Idle + }, + states: { Idle: {} } + }), + { path: "", value: undefined, state: { path: "Idle", value: undefined } - }, events.Reset()) - ).type.not + }, + events.Reset() + )).type.not .toRaiseError() }) - it("accepts internal constructions raised from invocation handlers", () => { - expect( - machine.handle({ - states: { - Idle: { - invoke: [{ - src: "source1", - id: "load", - onDone: { - none: true, - resolve: ({ output }, enqueue) => { - enqueue.raise(internalEvents.Loaded({ value: output })) - return undefined - } + expect(machine.handle({ + initial: { + target: Machine.targets(states).root.Idle + }, + states: { + Idle: { + invoke: [{ + src: "source1", + id: "load", + onDone: { + none: true, + resolve: ({ output }, enqueue) => { + enqueue.raise(internalEvents.Loaded({ value: output })) + return undefined } - }, { - src: "source2", - id: "timeout", - onDone: { - none: true, - resolve: (_, enqueue) => { - enqueue.raise(internalEvents.Failed()) - return undefined - } + } + }, { + src: "source2", + id: "timeout", + onDone: { + none: true, + resolve: (_, enqueue) => { + enqueue.raise(internalEvents.Failed()) + return undefined } - }] - } + } + }] } - }) - ).type.not.toRaiseError() + } + })).type.not.toRaiseError() }) }) diff --git a/packages/effect-machine/typetest/machine/History.tst.ts b/packages/effect-machine/typetest/machine/History.tst.ts index 5d3a236a..0649900b 100644 --- a/packages/effect-machine/typetest/machine/History.tst.ts +++ b/packages/effect-machine/typetest/machine/History.tst.ts @@ -1,39 +1,31 @@ import { Context, Effect, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class Checkout extends Schema.TaggedClass("Checkout")("Checkout", { orderId: Schema.String }) {} - class Shipping extends Schema.TaggedClass("Shipping")("Shipping", { address: Schema.String }) {} - class Payment extends Schema.TaggedClass("Payment")("Payment", { attempt: Schema.Number }) {} - class CardEntry extends Schema.TaggedClass("CardEntry")("CardEntry", { cardNumber: Schema.String }) {} - class Verifying extends Schema.TaggedClass("Verifying")("Verifying", { challengeId: Schema.String }) {} - class Support extends Schema.TaggedClass("Support")("Support", {}) {} - class Resume extends Schema.TaggedClass("Resume")("Resume", {}) {} - class InitialRequirement extends Context.Service()("test/MachineHistory/InitialRequirement") {} - +}>()("test/MachineHistory/InitialRequirement") { +} class FallbackRequirement extends Context.Service()("test/MachineHistory/FallbackRequirement") {} - +}>()("test/MachineHistory/FallbackRequirement") { +} class App extends Schema.TaggedClass("App")("App", { session: Schema.String }) {} @@ -43,18 +35,14 @@ class Closed extends Schema.TaggedClass("Closed")("Closed", {}) {} class Editor extends Schema.TaggedClass("Editor")("Editor", {}) {} class Editing extends Schema.TaggedClass("Editing")("Editing", {}) {} class Sidebar extends Schema.TaggedClass("Sidebar")("Sidebar", {}) {} - 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 @@ -72,13 +60,10 @@ const States = Machine.state({ support: Support } }) - const NestedStates = Machine.state({ - initial: "Closed", states: { App: { schema: App, - initial: "Workspace", states: { Workspace: { schema: Workspace, @@ -86,7 +71,6 @@ const NestedStates = Machine.state({ states: { Editor: { schema: Editor, - initial: "Editing", states: { Editing } @@ -104,28 +88,24 @@ const NestedStates = Machine.state({ Closed } }) - const completeNestedFallback = ( - target: Machine.Machine.HistoryDefaultTargetBuilder<{ readonly "": typeof NestedStates.node }, "App.Workspace"> + target: Machine.Machine.HistoryDefaultTargetBuilder<{ + readonly "": typeof NestedStates.node + }, "App.Workspace"> ) => 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({})) - ) - } - ) + 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<{ readonly "": typeof NestedStates.node }, "App.Workspace">, + target: Machine.Machine.HistoryDefaultTargetBuilder<{ + readonly "": typeof NestedStates.node + }, "App.Workspace">, session: string ) => target.from((tree) => @@ -136,10 +116,13 @@ const constructedNestedFallback = ( .Sidebar.from() )) ) - describe("Machine history states", () => { it("separates active and history identifiers", () => { - expect>().type.toBe< + expect< + Machine.Machine.StateIdentifier<{ + readonly "": typeof States.node + }> + >().type.toBe< | "" | "checkout" | "checkout.shipping" @@ -148,47 +131,52 @@ describe("Machine history states", () => { | "checkout.payment.verifying" | "support" >() - expect>().type.toBe< - "checkout.recent" | "checkout.exact" - >() + expect< + Machine.Machine.HistoryIdentifier<{ + readonly "": typeof States.node + }> + >().type.toBe<"checkout.recent" | "checkout.exact">() }) - - it("excludes history pseudo-states from snapshots and initial selectors", () => { - type Snapshot = Machine.Snapshot - expect<"checkout.recent">().type.not.toBeAssignableTo() - Machine.make({ - root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (to) => { - expect(to).type.not.toHaveProperty("recent") - expect(to).type.not.toHaveProperty("exact") - return to.resolve(({ target }) => - target.from((to) => - to.checkout.decoded( - new Checkout({ orderId: "order-1" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "Main Street" })) - ) - ) - ) - } - }) - expect(States.get).type.not.toBeCallableWith( - { path: "support" as const, value: new Support({}) }, - "checkout.recent" - ) + it("excludes history pseudo-states from initial edges", () => { + const root = Machine.state({ states: { Idle: {}, recent: { type: "history" } } }) + const targets = Machine.targets(root) + const definition = Machine.make({ root, events: Machine.events({}) }) + expect(definition.handle).type.toBeCallableWith({ initial: { target: targets.root.Idle } }) + expect(definition.handle).type.not.toBeCallableWith({ initial: { target: targets.root.recent } }) }) - it("exposes history references without value overrides", () => { const targets1 = Machine.targets(States) - const definition = Machine.make({ root: States, - events: Machine.eventsFromSchemas(Resume), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) + events: Machine.eventsFromSchemas(Resume) }) const incomplete = definition.handle({ + initial: { + target: Machine.targets(States).root.support, + decoded: true, + data: new Support({}) + }, states: { + checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + shipping: {}, + payment: { + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { cardEntry: {}, verifying: {} } + } + } + }, support: { on: { Resume: { history: targets1.root.checkout.exact } @@ -196,31 +184,106 @@ describe("Machine history states", () => { } } }) - expect(incomplete).type.toBeAssignableTo() expect(targets1.root.checkout.exact).type.not.toBeAssignableTo<() => unknown>() expect(targets1.root.checkout.exact).type.not.toHaveProperty("from") + expect(definition.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(States).root.checkout, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + payment: { + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + }) expect(definition.handle).type.not.toBeCallableWith({ - states: { support: { on: { Resume: { history: targets1.root.checkout.exact, from: () => ({}) } } } } + states: { + support: { on: { Resume: { history: targets1.root.checkout.exact, from: () => ({}) } } }, + checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + payment: { + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(States).root.checkout, + data: () => { + throw new Error("type-only constructor") + } + } }) }) - it("requires typed defaults and only the shallow-dependent initializer", () => { - expect>().type.toBe< - "checkout.payment" - >() - + expect< + Machine.Machine.RequiredHistoryInitializers<{ + readonly "": typeof States.node + }> + >().type.toBe<"checkout.payment">() const targets2 = Machine.targets(States) const definition = Machine.make({ branches: { transition1: { destination: { history: targets2.root.checkout.recent } } }, - root: States, - events: Machine.eventsFromSchemas(Resume), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) + events: Machine.eventsFromSchemas(Resume) }) const incomplete = definition.handle({ + initial: { + target: Machine.targets(States).root.support, + decoded: true, + data: new Support({}) + }, states: { + checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + shipping: {}, + payment: { + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { cardEntry: {}, verifying: {} } + } + } + }, support: { on: { Resume: { branches: "transition1", resolve: ({ select: { destination: target } }) => target() } @@ -228,18 +291,29 @@ describe("Machine history states", () => { } } }) - expect(Machine.planInitial).type.not.toBeCallableWith(incomplete) - const complete = definition.handle({ + initial: { + target: Machine.targets(States).root.support, + decoded: true, + data: new Support({}) + }, states: { checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, history: { recent: { default: ({ owner, target }) => { expect(owner).type.toBe<"checkout">() expect(target).type.toBe< - Machine.Machine.HistoryDefaultTargetBuilder<{ readonly "": typeof States.node }, "checkout"> + Machine.Machine.HistoryDefaultTargetBuilder<{ + readonly "": typeof States.node + }, "checkout"> >() return target.from((tree) => tree.checkout.decoded( @@ -260,33 +334,42 @@ describe("Machine history states", () => { } }, states: { + shipping: {}, 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}` })) - } + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { cardEntry: {}, verifying: {} } } } - } + }, + support: {} } }) - expect(Machine.planInitial).type.toBeCallableWith(complete) }) - it("rejects defaults outside the history parent and wrong initial child values", () => { const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(Resume), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) + events: Machine.eventsFromSchemas(Resume) }) - machine.handle({ + initial: { + target: Machine.targets(States).root.support, + decoded: true, + data: new Support({}) + }, states: { checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, history: { recent: { default: ({ target }) => { @@ -308,54 +391,139 @@ describe("Machine history states", () => { ) ) } + }, + states: { + shipping: {}, + payment: { + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { cardEntry: {}, verifying: {} } + } } - } + }, + support: {} } }) - machine.handle({ + initial: { + target: Machine.targets(States).root.support, + decoded: true, + data: new Support({}) + }, states: { checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, states: { + shipping: {}, payment: { - initialize: ({ builder }) => { - expect(builder.decoded).type.not.toBeCallableWith(new Verifying({ challengeId: "wrong-child" })) - return builder.decoded(new CardEntry({ cardNumber: "" })) + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { cardEntry: {}, verifying: {} } + } + } + }, + support: {} + } + }) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(States).root.checkout, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + payment: { + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } } } } } } }) - expect(machine.handle).type.not.toBeCallableWith({ states: { checkout: { states: { - recent: {} + recent: {}, + payment: { + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(States).root.checkout, + data: () => { + throw new Error("type-only constructor") + } } }) }) - it("accepts only complete root configurations containing a nested history owner", () => { - expect>() + expect< + Machine.Machine.CompleteSnapshotContaining<{ + readonly "": typeof NestedStates.node + }, "App.Workspace"> + >() .type.toBe< ReturnType extends Machine.Machine.StateConstruction ? Snapshot : never >() - const machine = Machine.make({ root: NestedStates, - events: Machine.eventsFromSchemas(Resume), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.Closed.decoded(new Closed({}))))) + events: Machine.eventsFromSchemas(Resume) }) - const complete = machine.handle({ + initial: { + target: Machine.targets(NestedStates).root.Closed, + decoded: true, + data: new Closed({}) + }, states: { App: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace, + data: () => { + throw new Error("type-only constructor") + } + }, states: { Workspace: { history: { @@ -363,23 +531,83 @@ describe("Machine history states", () => { default: ({ owner, target }) => { expect(owner).type.toBe<"App.Workspace">() expect(target).type.toBe< - Machine.Machine.HistoryDefaultTargetBuilder< - { readonly "": typeof NestedStates.node }, - "App.Workspace" - > + Machine.Machine.HistoryDefaultTargetBuilder<{ + readonly "": typeof NestedStates.node + }, "App.Workspace"> >() expect(target).type.not.toHaveProperty("Closed") return completeNestedFallback(target) } } + }, + states: { + Editor: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace.Editor.Editing, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Editing: {} } + }, + Sidebar: {} + }, + initial: { + Editor: () => { + throw new Error("type-only constructor") + }, + Sidebar: () => { + throw new Error("type-only constructor") + } + } + }, + Settings: {} + } + }, + Closed: {} + } + }) + expect(Machine.planInitial).type.toBeCallableWith(complete) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(NestedStates).root.App, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + App: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + Workspace: { + initial: { + Editor: () => { + throw new Error("type-only constructor") + }, + Sidebar: () => { + throw new Error("type-only constructor") + } + }, + states: { + Editor: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace.Editor.Editing, + data: () => { + throw new Error("type-only constructor") + } + } + } } } } } } }) - expect(Machine.planInitial).type.toBeCallableWith(complete) - expect(machine.handle).type.not.toBeCallableWith({ states: { App: { @@ -392,13 +620,42 @@ describe("Machine history states", () => { value: new Closed({}) }) } + }, + initial: { + Editor: () => { + throw new Error("type-only constructor") + }, + Sidebar: () => { + throw new Error("type-only constructor") + } + }, + states: { + Editor: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace.Editor.Editing, + data: () => { + throw new Error("type-only constructor") + } + } + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App.Workspace, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App, + data: () => { + throw new Error("type-only constructor") + } } }) - expect(machine.handle).type.not.toBeCallableWith({ states: { App: { @@ -415,13 +672,42 @@ describe("Machine history states", () => { } }) } + }, + initial: { + Editor: () => { + throw new Error("type-only constructor") + }, + Sidebar: () => { + throw new Error("type-only constructor") + } + }, + states: { + Editor: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace.Editor.Editing, + data: () => { + throw new Error("type-only constructor") + } + } + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App.Workspace, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App, + data: () => { + throw new Error("type-only constructor") + } } }) - expect(machine.handle).type.not.toBeCallableWith({ states: { App: { @@ -448,13 +734,42 @@ describe("Machine history states", () => { } }) } + }, + initial: { + Editor: () => { + throw new Error("type-only constructor") + }, + Sidebar: () => { + throw new Error("type-only constructor") + } + }, + states: { + Editor: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace.Editor.Editing, + data: () => { + throw new Error("type-only constructor") + } + } + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App.Workspace, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App, + data: () => { + throw new Error("type-only constructor") + } } }) - expect(machine.handle).type.not.toBeCallableWith({ states: { App: { @@ -477,20 +792,87 @@ describe("Machine history states", () => { } }) } + }, + initial: { + Editor: () => { + throw new Error("type-only constructor") + }, + Sidebar: () => { + throw new Error("type-only constructor") + } + }, + states: { + Editor: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace.Editor.Editing, + data: () => { + throw new Error("type-only constructor") + } + } + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App.Workspace, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App, + data: () => { + throw new Error("type-only constructor") + } } }) }) - it("rejects Effects returned by nested history defaults", () => { const machine = Machine.make({ root: NestedStates, - events: Machine.eventsFromSchemas(Resume), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.Closed.decoded(new Closed({}))))) + events: Machine.eventsFromSchemas(Resume) + }) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(NestedStates).root.App, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + App: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + Workspace: { + initial: { + Editor: () => { + throw new Error("type-only constructor") + }, + Sidebar: () => { + throw new Error("type-only constructor") + } + }, + states: { + Editor: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace.Editor.Editing, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + } + } }) expect(machine.handle).type.not.toBeCallableWith({ states: { @@ -501,24 +883,62 @@ describe("Machine history states", () => { resume: { default: () => Effect.succeed(null) } + }, + initial: { + Editor: () => { + throw new Error("type-only constructor") + }, + Sidebar: () => { + throw new Error("type-only constructor") + } + }, + states: { + Editor: { + initial: { + target: Machine.targets(NestedStates).root.App.Workspace.Editor.Editing, + data: () => { + throw new Error("type-only constructor") + } + } + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App.Workspace, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(NestedStates).root.App, + data: () => { + throw new Error("type-only constructor") + } } }) }) - it("requires defaults and shallow initializers in one handler tree", () => { const definition = Machine.make({ root: States, - events: Machine.eventsFromSchemas(Resume), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) + events: Machine.eventsFromSchemas(Resume) }) const afterDefaults = definition.handle({ + initial: { + target: Machine.targets(States).root.support, + decoded: true, + data: new Support({}) + }, states: { checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, history: { recent: { default: ({ target }) => @@ -538,15 +958,38 @@ describe("Machine history states", () => { ) ) } + }, + states: { + shipping: {}, + payment: { + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { cardEntry: {}, verifying: {} } + } } - } + }, + support: {} } }) - expect(Machine.planInitial).type.not.toBeCallableWith(afterDefaults) - + expect(Machine.planInitial).type.toBeCallableWith(afterDefaults) const complete = definition.handle({ + initial: { + target: Machine.targets(States).root.support, + decoded: true, + data: new Support({}) + }, states: { checkout: { + initial: { + target: Machine.targets(States).root.checkout.shipping, + data: () => { + throw new Error("type-only constructor") + } + }, history: { recent: { default: ({ target }) => @@ -568,30 +1011,32 @@ describe("Machine history states", () => { } }, states: { + shipping: {}, payment: { - initialize: ({ state, builder }) => builder.decoded(new CardEntry({ cardNumber: String(state.attempt) })) + initial: { + target: Machine.targets(States).root.checkout.payment.cardEntry, + decoded: true, + data: ({ state }) => new CardEntry({ cardNumber: String(state.attempt) }) + }, + states: { cardEntry: {}, verifying: {} } } } - } + }, + support: {} } }) - expect(Machine.planInitial).type.toBeCallableWith(complete) expect>().type.toBe() expect>().type.toBe() }) - it("does not require nested initializers for deep-only history", () => { const DeepOnlyStates = Machine.state({ - initial: "support", states: { checkout: { schema: Checkout, - initial: "payment", states: { payment: { schema: Payment, - initial: "cardEntry", states: { cardEntry: CardEntry, verifying: Verifying @@ -606,18 +1051,28 @@ describe("Machine history states", () => { support: Support } }) - expect>().type.toBe< - never - >() - + expect< + Machine.Machine.RequiredHistoryInitializers<{ + readonly "": typeof DeepOnlyStates.node + }> + >().type.toBe() const machine = Machine.make({ root: DeepOnlyStates, - events: Machine.eventsFromSchemas(Resume), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) + events: Machine.eventsFromSchemas(Resume) }).handle({ + initial: { + target: Machine.targets(DeepOnlyStates).root.support, + decoded: true, + data: new Support({}) + }, states: { checkout: { + initial: { + target: Machine.targets(DeepOnlyStates).root.checkout.payment, + data: () => { + throw new Error("type-only constructor") + } + }, history: { exact: { default: () => ({ @@ -637,21 +1092,29 @@ describe("Machine history states", () => { } }) } + }, + states: { + payment: { + initial: { + target: Machine.targets(DeepOnlyStates).root.checkout.payment.cardEntry, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { cardEntry: {}, verifying: {} } + } } - } + }, + support: {} } }) - expect(Machine.planInitial).type.toBeCallableWith(machine) }) - it("requires an exact region-value map when shallow restoration descends through a parallel state", () => { const ParallelStates = Machine.state({ - initial: "support", states: { outer: { schema: Checkout, - initial: "all", states: { all: { schema: Payment, @@ -669,19 +1132,29 @@ describe("Machine history states", () => { support: Support } }) - expect>().type.toBe< - "outer.all" - >() - + expect< + Machine.Machine.RequiredHistoryInitializers<{ + readonly "": typeof ParallelStates.node + }> + >().type.toBe<"outer.all">() const machine = Machine.make({ root: ParallelStates, - events: Machine.eventsFromSchemas(Resume), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.support.decoded(new Support({}))))) + events: Machine.eventsFromSchemas(Resume) }) const complete = machine.handle({ + initial: { + target: Machine.targets(ParallelStates).root.support, + decoded: true, + data: new Support({}) + }, states: { outer: { + initial: { + target: Machine.targets(ParallelStates).root.outer.all, + data: () => { + throw new Error("type-only constructor") + } + }, history: { recent: { default: ({ target }) => @@ -689,50 +1162,83 @@ describe("Machine history states", () => { 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: "" })) - ) + 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: { shipping: {}, card: {} }, + initial: { + shipping: ({ state }) => { + expect(state).type.toBe() + return { address: String(state.attempt) } + }, + card: { cardNumber: "" } } } } - } + }, + support: {} } }) expect(Machine.planInitial).type.toBeCallableWith(complete) - - expect(machine.handle).type.not.toBeCallableWith({ + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(ParallelStates).root.outer, + data: () => { + throw new Error("type-only constructor") + } + }, states: { outer: { + initial: { + target: Machine.targets(ParallelStates).root.outer.all, + data: () => { + throw new Error("type-only constructor") + } + }, 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" })) + initial: { + shipping: () => { + throw new Error("type-only constructor") + }, + card: () => { + throw new Error("type-only constructor") + } + } } } } } }) + expect(machine.handle).type.not.toBeCallableWith({ + states: { + outer: { + states: { + all: { initial: { shipping: { address: "missing-card" } } } + }, + initial: { + target: Machine.targets(ParallelStates).root.outer.all, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + target: Machine.targets(ParallelStates).root.outer, + data: () => { + throw new Error("type-only constructor") + } + } + }) }) - it("rejects root history nodes and active-state properties on history nodes", () => { expect(Machine.state).type.not.toBeCallableWith({ initial: "rootHistory", diff --git a/packages/effect-machine/typetest/machine/InitialDeclaration.tst.ts b/packages/effect-machine/typetest/machine/InitialDeclaration.tst.ts new file mode 100644 index 00000000..ef42caa6 --- /dev/null +++ b/packages/effect-machine/typetest/machine/InitialDeclaration.tst.ts @@ -0,0 +1,158 @@ +import { Effect, Schema } from "effect" +import { describe, expect, test } from "tstyche" +import { Machine } from "../../src/index.js" + +describe("handler-owned initial declarations", () => { + test("keeps input at root construction and narrows parent data", () => { + const root = Machine.state({ + fields: { seed: Schema.Number }, + states: { + Session: { + fields: { count: Schema.Number }, + states: { Ready: { fields: { label: Schema.String } }, Empty: {} } + }, + Idle: {} + } + }) + const targets = Machine.targets(root) + const definition = Machine.make({ root, input: Schema.Number, events: Machine.events({ Reset: {} }) }) + const nested = { initial: { target: targets.root.Session.Empty } } + expect(definition.handle).type.not.toBeCallableWith({ root: { seed: 0 }, states: { Session: nested } }) + expect(definition.handle).type.not.toBeCallableWith({ + root: { seed: 0 }, + initial: { target: targets.root.Session.Ready, data: { label: "" } }, + states: { Session: nested } + }) + expect(definition.handle).type.not.toBeCallableWith({ + root: { seed: 0 }, + initial: { target: targets.root.Idle, data: {} }, + states: { Session: nested } + }) + const machine = definition.handle({ + root: ({ input }) => ({ seed: input }), + initial: { + target: targets.root.Session, + data: ({ root, state }) => { + expect(root.seed).type.toBe() + expect(state.seed).type.toBe() + return { count: root.seed } + } + }, + states: { + Session: { + initial: { + target: targets.root.Session.Ready, + data: (context) => { + expect(context.state.count).type.toBe() + expect(context.root.seed).type.toBe() + expect(context).type.not.toHaveProperty("input") + return { label: String(context.state.count) } + } + } + } + } + }) + expect(Machine.start).type.toBeCallableWith(machine, 1) + }) + + test("requires valued parallel regions without a regions wrapper", () => { + const root = Machine.state({ + type: "parallel", + states: { + data: { fields: { count: Schema.Number } }, + decoded: {}, + Empty: {} + } + }) + const definition = Machine.make({ root, events: Machine.events({}) }) + expect(definition.handle).type.not.toBeCallableWith({}) + expect(definition.handle).type.not.toBeCallableWith({ initial: { data: { count: "bad" } } }) + expect(definition.handle).type.not.toBeCallableWith({ initial: { data: { count: 0 }, Empty: {} } }) + expect(definition.handle).type.not.toBeCallableWith({ initial: { data: { count: 0 }, Unknown: {} } }) + expect(definition.handle).type.not.toBeCallableWith({ + initial: { data: { decoded: true, data: { _tag: "data", count: 0 }, extra: true } } + }) + const machine = definition.handle({ initial: { data: { count: 0 } } }) + expect(Machine.start).type.toBeCallableWith(machine) + }) + + test("requires construction throughout inactive and history descendants", () => { + const root = Machine.state({ + states: { + Idle: {}, + Session: { + states: { + recent: { type: "history", history: "shallow" }, + Work: { + type: "parallel", + states: { + Left: { fields: { count: Schema.Number }, states: { Ready: { fields: { label: Schema.String } } } }, + Right: {} + } + } + } + } + } + }) + const targets = Machine.targets(root) + const definition = Machine.make({ root, events: Machine.events({}) }) + const left = { initial: { target: targets.root.Session.Work.Left.Ready, data: { label: "ready" } } } + const work = { initial: { Left: { count: 0 } }, states: { Left: left } } + const session = { initial: { target: targets.root.Session.Work }, states: { Work: work } } + const baseline = { initial: { target: targets.root.Idle }, states: { Session: session } } + expect(definition.handle).type.toBeCallableWith(baseline) + expect(definition.handle).type.not.toBeCallableWith({ + ...baseline, + initial: { target: targets.root.Idle, resolve: () => ({}) } + }) + expect(definition.handle).type.not.toBeCallableWith({ initial: baseline.initial }) + expect(definition.handle).type.not.toBeCallableWith({ ...baseline, states: { Session: {} } }) + expect(definition.handle).type.not.toBeCallableWith({ + ...baseline, + states: { Session: { ...session, states: { Work: { states: { Left: left } } } } } + }) + expect(definition.handle).type.not.toBeCallableWith({ + ...baseline, + states: { + Session: { + ...session, + states: { Work: { ...work, states: { Left: { initial: { target: targets.root.Session.Work.Left.Ready } } } } } + } + } + }) + expect(Machine.planInitial).type.not.toBeCallableWith(definition) + }) + + test("constructors cannot perform asynchronous work", () => { + const root = Machine.state({ states: { Ready: { fields: { count: Schema.Number } } } }) + const targets = Machine.targets(root) + const definition = Machine.make({ root, events: Machine.events({}) }) + expect(definition.handle).type.toBeCallableWith({ initial: { target: targets.root.Ready, data: { count: 0 } } }) + expect(definition.handle).type.not.toBeCallableWith({ + initial: { target: targets.root.Ready, data: () => Effect.succeed({ count: 0 }) } + }) + expect(definition.handle).type.not.toBeCallableWith({ + initial: { target: targets.root.Ready, data: async () => ({ count: 0 }) } + }) + expect(definition.handle).type.not.toBeCallableWith({ + initial: { target: targets.root.Ready, data: { count: 0 } }, + initialize: () => ({}) + }) + }) + + test("reserves only the direct decoded descriptor shape", () => { + const root = Machine.state({ fields: { decoded: Schema.Boolean, data: Schema.String } }) + const definition = Machine.make({ root, events: Machine.events({}) }) + expect(definition.handle).type.not.toBeCallableWith({ root: { decoded: true, data: "value" } }) + expect(definition.handle).type.not.toBeCallableWith({ root: { decoded: true as boolean, data: "value" } }) + expect(definition.handle).type.not.toBeCallableWith({ + root: { decoded: true, data: { _tag: "", decoded: true, data: "value" }, extra: true } + }) + const ordinary = definition.handle({ root: { decoded: false, data: "value" } }) + const callback = definition.handle({ root: () => ({ decoded: true, data: "value" }) }) + const decoded = definition.handle({ root: { decoded: true, data: { _tag: "", decoded: true, data: "value" } } }) + expect(Machine.start).type.toBeCallableWith(ordinary) + expect(Machine.start).type.toBeCallableWith(callback) + expect(Machine.start).type.toBeCallableWith(decoded) + }) +}) diff --git a/packages/effect-machine/typetest/machine/InitialEntry.tst.ts b/packages/effect-machine/typetest/machine/InitialEntry.tst.ts index ee27d77c..bb012734 100644 --- a/packages/effect-machine/typetest/machine/InitialEntry.tst.ts +++ b/packages/effect-machine/typetest/machine/InitialEntry.tst.ts @@ -1,85 +1,119 @@ import { Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class Closed extends Schema.TaggedClass("InitialTypeClosed")("Closed", {}) {} class Opened extends Schema.TaggedClass("InitialTypeOpened")("Opened", { id: Schema.String }) {} class Idle extends Schema.TaggedClass("InitialTypeIdle")("Idle", { count: Schema.Number }) {} class Loading extends Schema.TaggedClass("InitialTypeLoading")("Loading", {}) {} class Open extends Schema.TaggedClass("InitialTypeOpen")("Open", {}) {} - const States = Machine.state({ - initial: "closed", states: { closed: Closed, opened: { schema: Opened, - initial: "idle", states: { idle: Idle, loading: Loading } } } }) - const targets1 = Machine.targets(States) const base = Machine.make({ branches: { transition3: { destination: { target: targets1.root.opened } }, transition4: { destination: { target: targets1.root.opened } } }, - root: States, - events: Machine.eventsFromSchemas(Open), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.closed.decoded(new Closed({}))))) + events: Machine.eventsFromSchemas(Open) }) - describe("declared initial entry types", () => { - it("requires initialize at the handle call that returns an initial target", () => { + it("requires data for non-defaultable initial children", () => { + expect(base.handle).type.not.toBeCallableWith({ + initial: { target: targets1.root.closed }, + states: { opened: { initial: { target: targets1.root.opened.idle } } } + }) base.handle({ + initial: { + target: Machine.targets(States).root.closed, + decoded: true, + data: new Closed({}) + }, states: { closed: { on: { - Open: { initial: targets1.root.opened, decoded: () => (new Opened({ id: "team-1" })) } + Open: { initial: targets1.root.opened, decoded: true, data: () => (new Opened({ id: "team-1" })) } } }, - // @ts-expect-error! - opened: {} + opened: { + initial: { + target: Machine.targets(States).root.opened.idle, + data: { count: 0 } + }, + states: { + idle: {}, + loading: {} + } + } } }) - base.handle({ + initial: { + target: Machine.targets(States).root.closed, + decoded: true, + data: new Closed({}) + }, states: { closed: { on: { - Open: { initial: targets1.root.opened, from: () => ({ id: "team-1" }) } + Open: { initial: targets1.root.opened, data: () => ({ id: "team-1" }) } } }, opened: { - initialize: ({ builder }) => builder.from({ count: 0 }) + initial: { + target: Machine.targets(States).root.opened.idle, + data: ({}) => ({ count: 0 }) + }, + states: { + idle: {}, + loading: {} + } } } }) - base.handle({ + initial: { + target: Machine.targets(States).root.closed, + decoded: true, + data: new Closed({}) + }, states: { closed: { on: { Open: { branches: "transition3", resolve: ({ select: { destination: target } }) => - target.decoded( - new Opened({ id: "team-1" }), - (opened) => opened.loading.decoded(new Loading({})) - ) + target.decoded(new Opened({ id: "team-1" }), (opened) => opened.loading.decoded(new Loading({}))) } } }, - opened: {} + opened: { + initial: { + target: Machine.targets(States).root.opened.idle, + data: { count: 0 } + }, + states: { + idle: {}, + loading: {} + } + } } }) }) - it("only exposes initial on compound and parallel state builders", () => { base.handle({ + initial: { + target: Machine.targets(States).root.closed, + decoded: true, + data: new Closed({}) + }, states: { closed: { on: { @@ -91,13 +125,20 @@ describe("declared initial entry types", () => { 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({})) - ) + return target.decoded(new Opened({ id: "team-1" }), (opened) => opened.loading.decoded(new Loading({}))) } } } + }, + opened: { + initial: { + target: Machine.targets(States).root.opened.idle, + data: { count: 0 } + }, + states: { + idle: {}, + loading: {} + } } } }) diff --git a/packages/effect-machine/typetest/machine/Inspection.tst.ts b/packages/effect-machine/typetest/machine/Inspection.tst.ts index d136f4d8..43a5bc18 100644 --- a/packages/effect-machine/typetest/machine/Inspection.tst.ts +++ b/packages/effect-machine/typetest/machine/Inspection.tst.ts @@ -1,19 +1,19 @@ import { Effect, Schema, Stream } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - describe("Machine inspection", () => { - class Root extends Schema.TaggedClass("Root")("Root", {}) {} - class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - class Running extends Schema.TaggedClass("Running")("Running", {}) {} - class Reset extends Schema.TaggedClass("Reset")("Reset", {}) {} - + class Root extends Schema.TaggedClass("Root")("Root", {}) { + } + class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + } + class Running extends Schema.TaggedClass("Running")("Running", {}) { + } + class Reset extends Schema.TaggedClass("Reset")("Reset", {}) { + } const States = Machine.state({ - initial: "root", states: { root: { schema: Root, - initial: "idle", states: { idle: Idle, recent: { type: "history" } @@ -30,32 +30,40 @@ describe("Machine inspection", () => { state: { path: "root.idle", value: new Idle({}) } } } - const machine = Machine.make({ 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({})))) - ) + events: Machine.eventsFromSchemas(Reset) }).handle({ + initial: { + target: Machine.targets(States).root.root, + decoded: true, + data: new Root({}) + }, states: { root: { + initial: { + target: Machine.targets(States).root.root.idle, + decoded: true, + data: new Idle({}) + }, on: { Reset: { none: true } - } + }, + states: { idle: {} } } } }) - it("exposes one closed operational protocol from prepared machines", () => { - const FlatStates = Machine.state({ initial: "Idle", states: { Idle } }) - + const FlatStates = Machine.state({ states: { Idle } }) const executable = Machine.make({ root: FlatStates, - events: Machine.eventsFromSchemas(Reset), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas(Reset) }).handle({ + initial: { + target: Machine.targets(FlatStates).root.Idle, + decoded: true, + data: new Idle({}) + }, states: { Idle: { on: { @@ -80,9 +88,12 @@ describe("Machine inspection", () => { } }) }) - it("preserves state paths for structural inspection", () => { - expect(Machine.inputEventSchemas(machine)).type.toBe() + expect(Machine.inputEventSchemas(machine)).type.toBe< + readonly [ + typeof Reset + ] + >() const nodes = Machine.stateNodes(machine) expect(nodes[0]!.path).type.toBe<"" | "root" | "root.idle" | "root.recent">() expect(nodes.find((node) => node.type === "history")!.path).type.toBe<"root.recent">() @@ -90,7 +101,6 @@ describe("Machine inspection", () => { 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", () => { const inspect = (node: Machine.Machine.StateNode<"state">) => { switch (node.type) { @@ -146,17 +156,13 @@ describe("Machine inspection", () => { expect(node).type.toBe() } } - expect(inspect).type.toBe<(node: Machine.Machine.StateNode<"state">) => void>() }) - it("keeps choice initial paths while configuration remains active-only", () => { const ChoiceStates = Machine.state({ - initial: "Flow", states: { Flow: { schema: Root, - initial: "Routing", states: { Routing: { type: "choice" }, Ready: Idle @@ -166,19 +172,29 @@ describe("Machine inspection", () => { }) const choiceMachine = Machine.make({ root: ChoiceStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.Flow.decoded(new Root({}), (flow) => flow.Routing())))) + events: Machine.eventsFromSchemas() + }).handle({ + initial: { + target: Machine.targets(ChoiceStates).root.Flow, + decoded: true, + data: new Root({}) + }, + states: { + Flow: { + initial: { + target: Machine.targets(ChoiceStates).root.Flow.Routing + }, + states: { Ready: {} } + } + } }) 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.Routing">().type.toBeAssignableTo() expect(routing.path).type.toBe<"Flow.Routing">() expect(routing.parent).type.toBe<"" | "Flow" | "Flow.Ready">() - const settled: Machine.Snapshot = { path: "", value: undefined, @@ -191,16 +207,18 @@ describe("Machine inspection", () => { const configuration = Machine.configuration(choiceMachine, settled) expect<(typeof configuration)[number]["path"]>().type.toBe<"" | "Flow" | "Flow.Ready">() expect<(typeof configuration)[number]["type"]>().type.toBe<"atomic" | "compound" | "parallel" | "final">() - expect>().type.toBe() + expect< + Extract<(typeof configuration)[number], { + readonly type: "history" | "choice" + }> + >().type.toBe() }) - it("preserves source paths and event tags for transition inspection", () => { const initialDefinition = Machine.initialDefinition(machine) 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.acceptance).type.toBe() @@ -212,29 +230,52 @@ describe("Machine inspection", () => { >() expect(definition.branches[0]!.selection.path).type.toBe<"" | "root" | "root.idle" | "root.recent" | undefined>() }) - it("requires statically selected transitions", () => { - const FlatStates = Machine.state({ initial: "idle", states: { idle: Idle, running: Running } }) + const FlatStates = Machine.state({ states: { idle: Idle, running: Running } }) const targets3 = Machine.targets(FlatStates) const flat = Machine.make({ root: FlatStates, - events: Machine.eventsFromSchemas(Reset), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas(Reset) }) flat.handle({ + initial: { + target: Machine.targets(FlatStates).root.idle, + decoded: true, + data: new Idle({}) + }, states: { idle: { on: { - Reset: { target: targets3.root.running, decoded: () => (new Running({})) } + Reset: { target: targets3.root.running, decoded: true, data: () => (new Running({})) } } + }, + running: {} + } + }) + expect(flat.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(FlatStates).root.idle, + data: () => { + throw new Error("type-only constructor") } } }) - - expect(flat.handle).type.not.toBeCallableWith({ states: { idle: { on: { Reset: () => undefined } } } }) expect(flat.handle).type.not.toBeCallableWith({ - states: { - idle: { on: { Reset: { target: () => undefined, resolve: () => undefined } } } + states: { idle: { on: { Reset: () => undefined } } }, + initial: { + target: Machine.targets(FlatStates).root.idle, + data: () => { + throw new Error("type-only constructor") + } + } + }) + expect(flat.handle).type.not.toBeCallableWith({ + states: { idle: { on: { Reset: { target: () => undefined, resolve: () => undefined } } } }, + initial: { + target: Machine.targets(FlatStates).root.idle, + data: () => { + throw new Error("type-only constructor") + } } }) }) diff --git a/packages/effect-machine/typetest/machine/Machine.tst.ts b/packages/effect-machine/typetest/machine/Machine.tst.ts index de6ea5d0..d0682252 100644 --- a/packages/effect-machine/typetest/machine/Machine.tst.ts +++ b/packages/effect-machine/typetest/machine/Machine.tst.ts @@ -1,77 +1,75 @@ import { Context, Effect, Option, Schema, Stream } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - describe("Machine", () => { class Up extends Schema.TaggedClass("Up")("Up", { id: Schema.String - }) {} - - class Down extends Schema.TaggedClass("Down")("Down", {}) {} - + }) { + } + class Down extends Schema.TaggedClass("Down")("Down", {}) { + } class RetaggedUp extends Schema.TaggedClass("RetaggedUp")("RetaggedUp", { id: Schema.String, attempt: Schema.Number - }) {} - + }) { + } class Auth extends Schema.TaggedClass("Auth")("Auth", { userId: Schema.String - }) {} - - class SignedOut extends Schema.TaggedClass("SignedOut")("SignedOut", {}) {} - + }) { + } + class SignedOut extends Schema.TaggedClass("SignedOut")("SignedOut", {}) { + } class SignedIn extends Schema.TaggedClass("SignedIn")("SignedIn", { userId: Schema.String - }) {} - + }) { + } class Sync extends Schema.TaggedClass("Sync")("Sync", { enabled: Schema.Boolean - }) {} - - class SyncIdle extends Schema.TaggedClass("SyncIdle")("SyncIdle", {}) {} - + }) { + } + class SyncIdle extends Schema.TaggedClass("SyncIdle")("SyncIdle", {}) { + } class Syncing extends Schema.TaggedClass("Syncing")("Syncing", { requestId: Schema.String - }) {} - - class Payment extends Schema.TaggedClass("Payment")("Payment", {}) {} - - class PendingPayment extends Schema.TaggedClass("PendingPayment")("PendingPayment", {}) {} - + }) { + } + class Payment extends Schema.TaggedClass("Payment")("Payment", {}) { + } + class PendingPayment extends Schema.TaggedClass("PendingPayment")("PendingPayment", {}) { + } class ApprovedPayment extends Schema.TaggedClass("ApprovedPayment")("ApprovedPayment", { authId: Schema.String - }) {} - + }) { + } class DeclinedPayment extends Schema.TaggedClass("DeclinedPayment")("DeclinedPayment", { reason: Schema.String - }) {} - + }) { + } class SignIn extends Schema.TaggedClass("SignIn")("SignIn", { userId: Schema.String - }) {} - + }) { + } class SignInCompleted extends Schema.TaggedClass("SignInCompleted")("SignInCompleted", { userId: Schema.String - }) {} - + }) { + } class InitialRequirement extends Context.Service()("test/Machine/InitialRequirement") {} - + }>()("test/Machine/InitialRequirement") { + } class EntryRequirement extends Context.Service()("test/Machine/EntryRequirement") {} - + }>()("test/Machine/EntryRequirement") { + } class DoneRequirement extends Context.Service()("test/Machine/DoneRequirement") {} - + }>()("test/Machine/DoneRequirement") { + } class DeferredRequirement extends Context.Service()("test/Machine/DeferredRequirement") {} - + }>()("test/Machine/DeferredRequirement") { + } const UpStates = Machine.state({ - initial: "up", states: { up: { schema: Up, @@ -79,7 +77,6 @@ describe("Machine", () => { states: { auth: { schema: Auth, - initial: "signedOut", states: { signedOut: SignedOut, signedIn: SignedIn @@ -87,7 +84,6 @@ describe("Machine", () => { }, sync: { schema: Sync, - initial: "idle", states: { idle: SyncIdle, syncing: Syncing @@ -99,11 +95,10 @@ describe("Machine", () => { } }) const NestedParallelStates = Machine.state({ - initial: "root", states: { root: { schema: Up, - initial: "idle", + states: { idle: Down, work: { @@ -112,7 +107,7 @@ describe("Machine", () => { states: { auth: { schema: Auth, - initial: "signedOut", + states: { signedOut: SignedOut, signedIn: SignedIn @@ -120,7 +115,7 @@ describe("Machine", () => { }, sync: { schema: Sync, - initial: "idle", + states: { idle: SyncIdle, syncing: Syncing @@ -132,70 +127,84 @@ describe("Machine", () => { } } }) - type ChildBuilder = Method extends { readonly decoded: (value: any, build: (builder: infer Builder) => any) => any - } ? Builder - : never + } ? Builder : + never type IsCallable = A extends (...args: ReadonlyArray) => any ? true : false - - 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 - + const UpInitial = null as unknown as Machine.Machine.FullTargetBuilder["up"] + const DownInitial = null as unknown as Machine.Machine.FullTargetBuilder["down"] type SignInContext = Machine.Machine.HandlerContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], [], "down", "SignIn", never, never > - type SignedOutContext = Machine.Machine.HandlerContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], [], "up.auth.signedOut", "SignIn", never, never > - type AuthContext = Machine.Machine.HandlerContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], [], "up.auth", "SignIn", never, never > - type NestedIdleContext = Machine.Machine.HandlerContext< - { readonly "": typeof NestedParallelStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof NestedParallelStates.node + }, + readonly [ + typeof SignIn + ], [], "root.idle", "SignIn", never, never > - type NestedActiveContext = Machine.Machine.HandlerContext< - { readonly "": typeof NestedParallelStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof NestedParallelStates.node + }, + readonly [ + typeof SignIn + ], [], "root.work.auth.signedOut", "SignIn", never, never > - it("states preserves literal state paths", () => { - expect>().type.toBe< + expect< + Machine.Machine.StateIdentifier<{ + readonly "": typeof UpStates.node + }> + >().type.toBe< | "" | "up" | "up.auth" @@ -207,7 +216,6 @@ describe("Machine", () => { | "down" >() }) - it("constructs exact public and internal event instructions from protocol descriptors", () => { const Event = Schema.TaggedUnion({ Tick: { amount: Schema.Number }, @@ -215,21 +223,26 @@ describe("Machine", () => { }) class Internal extends Schema.TaggedClass("ConstructedInternal")("ConstructedInternal", { id: Schema.String - }) {} + }) { + } const State = Schema.TaggedStruct("ConstructedEventState", {}) - const States = Machine.state({ initial: "State", states: { State } }) + const States = Machine.state({ states: { State } }) const Events = Machine.eventsFromSchemas(Event) const InternalEvents = Machine.internalEventsFromSchemas(Internal) Machine.make({ root: States, events: Events, - internalEvents: InternalEvents, - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.State.from()))) + internalEvents: InternalEvents + }).handle({ + initial: { + target: Machine.targets(States).root.State, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { State: {} } }) - - expect(Events.Tick({ amount: 1 })).type.toBe< - Machine.Machine.EventConstruction - >() + expect(Events.Tick({ amount: 1 })).type.toBe>() expect(Events.Stop()).type.toBe>() expect(InternalEvents.ConstructedInternal({ id: "internal-1" })).type.toBe< Machine.Machine.EventConstruction @@ -239,7 +252,6 @@ describe("Machine", () => { expect(Events.Tick).type.not.toBeCallableWith({ amount: "1" }) expect(InternalEvents.ConstructedInternal).type.not.toBeCallableWith({}) }) - it("machine contexts expose type-safe parent state values", () => { const nested = null as unknown as SignedOutContext expect(nested.containingState).type.toBe() @@ -251,95 +263,107 @@ describe("Machine", () => { expect(nested.ancestors["up.auth"]).type.toBe() expect(nested.ancestors).type.not.toHaveProperty("up.sync") expect(nested).type.not.toHaveProperty("action") - type NestedParents = { readonly up: Up readonly "up.auth": Auth } expect< Machine.Machine.StateActionContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], [], "up.auth.signedOut" >["ancestors"] >().type.toBe() expect< Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], [], "up.auth.signedOut" >["containingState"] >().type.toBe() expect< Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], [], "up.auth.signedOut" >["ancestors"] >().type.toBe() expect< Machine.Machine.AlwaysContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], [], "up.auth.signedOut" >["ancestors"] >().type.toBe() expect< Machine.Machine.DoneContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], [], "up.auth.signedOut" >["ancestors"] >().type.toBe() expect< Machine.Machine.FinalOutputContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], "up.auth.signedOut" >["ancestors"] >().type.toBe() expect< Machine.Machine.ParallelOutputContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], "up.auth.signedOut" >["ancestors"] >().type.toBe() - const root = null as unknown as SignInContext expect(root.containingState).type.toBe() expect(root.ancestors).type.toBe<{}>() - expect< - Machine.Machine.ParentStateValue< - { readonly "": typeof UpStates.node }, - "up.auth.signedOut" | "down" - > + Machine.Machine.ParentStateValue<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut" | "down"> >().type.toBe() }) - it("states selects state values and snapshots with type-safe paths", () => { - const childSnapshot = UpInitial.decoded( - new Up({ id: "up-1" }), - (up) => - up - .auth.decoded( - new Auth({ userId: "guest" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) - .sync.decoded( - new Sync({ enabled: true }), - (sync) => sync.idle.decoded(new SyncIdle({})) - ) - ) - + const childSnapshot = UpInitial.decoded(new Up({ id: "up-1" }), (up) => + up + .auth.decoded(new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) + .sync.decoded(new Sync({ enabled: true }), (sync) => sync.idle.decoded(new SyncIdle({})))) 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< @@ -360,31 +384,37 @@ describe("Machine", () => { const path = "down" as "up.auth.signedOut" | "down" expect(UpStates.getWithParents(snapshot, path)).type.toBe< Option.Option< - | { + { readonly value: SignedOut readonly parents: { readonly up: Up readonly "up.auth": Auth } - } - | { + } | { readonly value: Down readonly parents: {} } > >() expect(UpStates.getSnapshot(snapshot, "up.auth")).type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof UpStates.node + }, "up.auth"> + > >() expect(UpStates.matches(snapshot, "up.sync.idle")).type.toBe() expect(UpStates.get).type.not.toBeCallableWith(snapshot, "up.missing") expect(UpStates.getWithParents).type.not.toBeCallableWith(snapshot, "up.missing") - const upSnapshot = Option.getOrThrow(UpStates.getSnapshot(snapshot, "up")) 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< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof UpStates.node + }, "up.sync"> + > >() expect(UpStates.matches(authSnapshot, "up.auth.signedIn")).type.toBe() expect(UpStates.get(authSnapshot, "up.auth")).type.toBe>() @@ -392,27 +422,58 @@ describe("Machine", () => { expect(UpStates.getSnapshot).type.not.toBeCallableWith(authSnapshot, "up") expect(UpStates.matches).type.not.toBeCallableWith(authSnapshot, "signedOut") expect(UpStates.matches).type.not.toBeCallableWith(authSnapshot, "down") - const other = { path: "other" as const, value: new Down({}) } expect(UpStates.get).type.not.toBeCallableWith(other, "up") expect(UpStates.getWithParents).type.not.toBeCallableWith(other, "up") }) - it("states preserves declared compound initial keys", () => { - expect().type.toBe<"signedOut">() - expect().type.toBe<"idle">() + expect(UpStates.node.states.up.states.auth).type.not.toHaveProperty("initial") + expect(UpStates.node.states.up.states.sync).type.not.toHaveProperty("initial") }) - it("make accepts defined states", () => { const machine = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) + }).handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, + states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: {} + } }) - expect(machine.root).type.toBe() }) - it("make rejects raw decoded initial states", () => { expect(Machine.make).type.not.toBeCallableWith({ root: UpStates, @@ -420,14 +481,48 @@ describe("Machine", () => { initial: () => new Down({}) }) }) - it("encodes and decodes snapshots with typed effects", () => { const machine = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) + }).handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, + states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: {} + } }) - const encoded = Machine.encodeSnapshot(machine, { path: "" as const, value: undefined, @@ -436,7 +531,6 @@ describe("Machine", () => { expect>().type.toBe() expect>().type.toBe() expect>().type.toBe() - const decoded = Machine.decodeSnapshot(machine, { version: 2, _tag: "MachineSnapshot", @@ -446,54 +540,178 @@ describe("Machine", () => { expect>().type.toBe() expect>().type.toBe() }) - it("planInitial is synchronous at the transition boundary", () => { const definition = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) const machine = definition.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { - down: { - entry: () => {} - } + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: { entry: () => {} } } }) - const planned = Machine.planInitial(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 - .toBeCallableWith(( - { target }: { - readonly input: void - readonly target: Machine.Machine.SelectionBuilder> - } - ) => Effect.succeed(target.decoded(new Down({})))) + expect(definition.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + }) expect(definition.handle).type.not.toBeCallableWith({ states: { - down: { entry: () => Effect.void } + down: { entry: () => Effect.void }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } } }) expect(machine).type.not.toHaveProperty("handle") }) - it("types the closed enqueue protocol without exposing Effects", () => { const worker = Machine.childAddress("worker") const targets1 = Machine.targets(UpStates) const machine = Machine.make({ branches: { transition1: { destination: { target: targets1.root.down } } }, - root: UpStates, events: Machine.eventsFromSchemas(SignIn), - emittedEvents: Machine.emittedEventsFromSchemas(SignInCompleted), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + emittedEvents: Machine.emittedEventsFromSchemas(SignInCompleted) }).handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { SignIn: { @@ -514,28 +732,23 @@ describe("Machine", () => { } } }) - expect>().type.toBe() expect>().type.toBe() }) - it("spawn accepts reusable logic rather than one-shot Effects", () => { const logic = Machine.logic({ initial: undefined, run: () => Effect.as(DeferredRequirement, 1 as const) }) - expect(Machine.spawn).type.toBeCallableWith(logic) expect(Machine.spawn).type.not.toBeCallableWith(Effect.succeed(1)) }) - it("logic exposes public machine-scoped context types", () => { Machine.logic({ initial: (scope) => { const worker = Machine.childAddress("worker") const incompatibleWorker = Machine.childAddress("incompatible-worker") const child = Machine.logic({ initial: 0, run: () => Effect.never }) - expect(scope).type.toBe>() expect(scope.self).type.toBe>() expect(scope.spawn).type.toBeCallableWith(child, { id: worker }) @@ -553,114 +766,335 @@ describe("Machine", () => { } }) }) - it("invoke infers one-shot outputs from factories in the owning state", () => { expect(Machine).type.not.toHaveProperty("invoke") const targets2 = Machine.targets(UpStates) const machine = Machine.make({ effects: { source1: Effect.suspend(() => Effect.succeed(1)) }, - root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) - machine.handle({ - states: { - down: { - invoke: { src: "source1", id: "valid", onDone: { target: targets2.root.down, decoded: () => (new Down({})) } } - } - } - }) - expect(machine.handle).type.not.toBeCallableWith({ states: { down: { invoke: { src: "source1" } } } }) - // Lazy Effects are valid registered values; a value source forbids an input mapper. - expect(machine.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "source1", input: () => undefined, onDone: { none: true } } } } - }) - }) - - it("contextually types dynamic Effect sources through registered invocation sources", () => { - const targets3 = Machine.targets(UpStates) - const machine = Machine.make({ - effects: { - source1: ({ - state - }: Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], - readonly [], - "down", - readonly [typeof SignIn], - readonly [] - >) => { - expect(state).type.toBe() - return Effect.succeed(state._tag) - } + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) }, - - root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) - }) - - machine.handle({ states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { invoke: { src: "source1", - id: "dynamic", - input: (context) => context, - onDone: { target: targets3.root.down, decoded: () => (new Down({})) } + id: "valid", + onDone: { target: targets2.root.down, decoded: true, data: () => (new Down({})) } } } } }) - }) - - it("infers Stream elements, failures, and services through registered invocation sources", () => { - class StreamFailure { - readonly _tag = "StreamFailure" - } - const updates: Stream.Stream<1, StreamFailure, EntryRequirement> = Stream.fromEffect( - Effect.as(EntryRequirement, 1 as const) - ).pipe(Stream.concat(Stream.fail(new StreamFailure()))) - - const machine = Machine.make({ + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + }) + expect(machine.handle).type.not.toBeCallableWith({ + states: { + down: { invoke: { src: "source1" } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } + }) + // Lazy Effects are valid registered values; a value source forbids an input mapper. + expect(machine.handle).type.not.toBeCallableWith({ + states: { + down: { invoke: { src: "source1", input: () => undefined, onDone: { none: true } } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } + }) + }) + it("contextually types dynamic Effect sources through registered invocation sources", () => { + const targets3 = Machine.targets(UpStates) + const machine = Machine.make({ + effects: { + source1: ({ state }: Machine.Machine.InvokeContext< + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], + readonly [], + "down", + readonly [ + typeof SignIn + ], + readonly [] + >) => { + expect(state).type.toBe() + return Effect.succeed(state._tag) + } + }, + root: UpStates, + events: Machine.eventsFromSchemas(SignIn) + }) + machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, + states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: { + invoke: { + src: "source1", + id: "dynamic", + input: (context) => context, + onDone: { target: targets3.root.down, decoded: true, data: () => (new Down({})) } + } + } + } + }) + }) + it("infers Stream elements, failures, and services through registered invocation sources", () => { + class StreamFailure { + readonly _tag = "StreamFailure" + } + const updates: Stream.Stream<1, StreamFailure, EntryRequirement> = Stream.fromEffect( + Effect.as(EntryRequirement, 1 as const) + ).pipe(Stream.concat(Stream.fail(new StreamFailure()))) + const machine = Machine.make({ streams: { - source1: ({ - state - }: Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + source1: ({ state }: Machine.Machine.InvokeContext< + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], readonly [], "down", - readonly [typeof SignIn], + readonly [ + typeof SignIn + ], readonly [] >) => { expect(state).type.toBe() return updates }, - source2: ({ - state - }: Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + source2: ({ state }: Machine.Machine.InvokeContext< + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], readonly [], "down", - readonly [typeof SignIn], + readonly [ + typeof SignIn + ], readonly [] >) => { expect(state).type.toBe() return updates } }, - root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.down.decoded(new Down({})))) + events: Machine.eventsFromSchemas(SignIn) }) const handled = machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { invoke: { src: "source1", @@ -683,12 +1117,45 @@ describe("Machine", () => { } } }) - expect().type.toBeAssignableTo>() expect>().type.not.toBe() - const staticHandled = machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { invoke: { src: "source2", @@ -711,65 +1178,277 @@ describe("Machine", () => { } } }) - expect().type.toBeAssignableTo>() expect>().type.not.toBe() }) - it("requires only reachable Stream handlers", () => { const machine = Machine.make({ root: UpStates, events: Machine.eventsFromSchemas(SignIn), streams: { values: Stream.make(1), failure: Stream.fail("unavailable" as const) } }) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + }) expect(machine.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "values", onDone: { none: true } } } } + states: { + down: { invoke: { src: "values", onDone: { none: true } } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(machine.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "values", onElement: { none: true } } } } + states: { + down: { invoke: { src: "values", onElement: { none: true } } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(machine.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "failure", onDone: { none: true } } } } + states: { + down: { invoke: { src: "failure", onDone: { none: true } } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(machine.handle).type.not.toBeCallableWith({ states: { down: { invoke: { src: "failure", onDone: { none: true }, onFailure: { none: true }, onElement: { none: true } } + }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") } } }) }) - it("infers dynamic Effect invocation channels from the source return", () => { class LoadFailure { readonly _tag = "LoadFailure" } const load = (userId: string) => Effect.fail(new LoadFailure()).pipe(Effect.as({ userId })) - const machine = Machine.make({ effects: { - source1: ({ - state - }: Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + source1: ({ state }: Machine.Machine.InvokeContext< + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], readonly [], "down", - readonly [typeof SignIn], + readonly [ + typeof SignIn + ], readonly [] >) => { expect(state).type.toBe() return load(state._tag) } }, - root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { invoke: { src: "source1", @@ -782,73 +1461,123 @@ describe("Machine", () => { } }) }) - it("requires only reachable handlers for dynamic Effect invocations", () => { class LoadFailure { readonly _tag = "LoadFailure" } - const machine = Machine.make({ effects: { - source1: ({ - state - }: Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + source1: ({ state }: Machine.Machine.InvokeContext< + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], readonly [], "down", - readonly [typeof SignIn], + readonly [ + typeof SignIn + ], readonly [] >) => Effect.succeed(state._tag), - source2: ({ - state - }: Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + source2: ({ state }: Machine.Machine.InvokeContext< + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], readonly [], "down", - readonly [typeof SignIn], + readonly [ + typeof SignIn + ], readonly [] >) => Effect.fail(new LoadFailure()).pipe(Effect.annotateLogs("state", state._tag)), - source3: ({ - state - }: Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + source3: ({ state }: Machine.Machine.InvokeContext< + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], readonly [], "down", - readonly [typeof SignIn], + readonly [ + typeof SignIn + ], readonly [] >) => Effect.never.pipe(Effect.annotateLogs("state", state._tag)), - source4: ({ - state - }: Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + source4: ({ state }: Machine.Machine.InvokeContext< + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], readonly [], "down", - readonly [typeof SignIn], + readonly [ + typeof SignIn + ], readonly [] >) => Effect.as(EntryRequirement, state._tag), - source5: ({ - state - }: Machine.Machine.InvokeContext< - { readonly "": typeof UpStates.node }, - readonly [typeof SignIn], + source5: ({ state }: Machine.Machine.InvokeContext< + { + readonly "": typeof UpStates.node + }, + readonly [ + typeof SignIn + ], readonly [], "down", - readonly [typeof SignIn], + readonly [ + typeof SignIn + ], readonly [] >) => Effect.as(EntryRequirement, state._tag) }, - root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { invoke: [ { src: "source1", id: "success", input: (context) => context, onDone: { none: true } }, @@ -864,20 +1593,52 @@ describe("Machine", () => { } } }) - const requirementsHandled = machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { invoke: { src: "source5", id: "requirements-only", input: (context) => context, onDone: { none: true } } } } }) - expect>().type.not.toBe() expect().type.toBeAssignableTo>() expect().type.not.toBeAssignableTo>() }) - it("rejects unreachable and missing handlers for dynamic Effect invocations", () => { const machine = Machine.make({ root: UpStates, @@ -888,11 +1649,121 @@ describe("Machine", () => { pending: (_: string) => Effect.never } }) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + }) expect(machine.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "success", input: () => "id" } } } + states: { + down: { invoke: { src: "success", input: () => "id" } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(machine.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "failure", input: () => "id" } } } + states: { + down: { invoke: { src: "failure", input: () => "id" } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(machine.handle).type.not.toBeCallableWith({ states: { @@ -903,6 +1774,40 @@ describe("Machine", () => { onDone: { none: true }, onFailure: { none: true } } + }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") } } }) @@ -915,26 +1820,205 @@ describe("Machine", () => { onFailure: { none: true }, onDone: { none: true } } - } + }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } + }) + expect(machine.handle).type.not.toBeCallableWith({ + states: { + down: { invoke: { src: "pending", input: () => "id", onDone: { none: true } } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } + }) + expect(machine.handle).type.not.toBeCallableWith({ + states: { + down: { invoke: { src: "pending", input: () => "id", onFailure: { none: true } } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } + }) + machine.handle({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: { invoke: { src: "pending", input: () => "id" } } } }) - expect(machine.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "pending", input: () => "id", onDone: { none: true } } } } - }) - expect(machine.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "pending", input: () => "id", onFailure: { none: true } } } } - }) - machine.handle({ states: { down: { invoke: { src: "pending", input: () => "id" } } } }) }) - it("separates public input events from the complete internal protocol", () => { const machine = Machine.make({ root: UpStates, events: Machine.eventsFromSchemas(SignIn), - internalEvents: Machine.internalEventsFromSchemas(SignInCompleted), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + internalEvents: Machine.internalEventsFromSchemas(SignInCompleted) }).handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { SignIn: { @@ -957,84 +2041,225 @@ describe("Machine", () => { }) const started = Machine.start(machine) type Ref = Effect.Success - expect(machine).type.not.toHaveProperty("eventSchemas") const anyMachine: Machine.Machine.Any = machine expect(anyMachine).type.not.toHaveProperty("eventSchemas") expect>().type.toBe() expect>().type.toBe() expect[0]>().type.toBe>() - expect(Machine.plan).type.toBeCallableWith( - machine, - { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, - new SignIn({ userId: "user-1" }) - ) - expect(Machine.plan).type.not.toBeCallableWith( - machine, - { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, - new SignInCompleted({ userId: "user-1" }) - ) - expect(Machine.can).type.toBeCallableWith( - machine, - { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, - new SignInCompleted({ userId: "user-1" }) - ) - expect(Machine.can(machine)).type.toBeCallableWith( - { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, - new SignInCompleted({ userId: "user-1" }) - ) - expect(Machine.can(machine)).type.not.toBeCallableWith( - { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, - { _tag: "Undeclared" } - ) + expect(Machine.plan).type.toBeCallableWith(machine, { + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }, new SignIn({ userId: "user-1" })) + expect(Machine.plan).type.not.toBeCallableWith(machine, { + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }, new SignInCompleted({ userId: "user-1" })) + expect(Machine.can).type.toBeCallableWith(machine, { + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }, new SignInCompleted({ userId: "user-1" })) + expect(Machine.can(machine)).type.toBeCallableWith({ + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }, new SignInCompleted({ userId: "user-1" })) + expect(Machine.can(machine)).type.not.toBeCallableWith({ + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }, { _tag: "Undeclared" }) const publicEvents = Machine.eventsFromSchemas(SignIn) const overlappingInternalEvents = Machine.internalEventsFromSchemas(SignIn) expect(Machine.make).type.not.toBeCallableWith({ root: UpStates, events: publicEvents, - internalEvents: overlappingInternalEvents, - initialConfiguration: (root: Machine.Machine.RootConfigurationBuilderInput) => root + internalEvents: overlappingInternalEvents }) expect(Machine.events).type.not.toBeCallableWith(SignIn, SignIn) expect(Machine.internalEvents).type.not.toBeCallableWith(SignInCompleted, SignInCompleted) }) - it("invoke requires only the lifecycle handlers reachable from the source type", () => { const erasedFailure = () => Effect.fail("unavailable" as const) as Effect.Effect - const machine = Machine.make({ effects: { source1: Effect.suspend(erasedFailure) }, - root: UpStates, events: Machine.eventsFromSchemas(SignIn), - internalEvents: Machine.internalEventsFromSchemas(SignInCompleted), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + internalEvents: Machine.internalEventsFromSchemas(SignInCompleted) + }) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + }) + expect(machine.handle).type.not.toBeCallableWith({ + states: { + down: { invoke: { src: "source1" } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } }) - - expect(machine.handle).type.not.toBeCallableWith({ states: { down: { invoke: { src: "source1" } } } }) expect(machine.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "source1", onDone: { none: true } } } } + states: { + down: { invoke: { src: "source1", onDone: { none: true } } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } }) machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { - down: { - invoke: { src: "source1", id: "erased-failure", onFailure: { none: true } } - } + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: { invoke: { src: "source1", id: "erased-failure", onFailure: { none: true } } } } }) }) - it("constructs sibling targets from destructured source fields", () => { - const states = Machine.state({ initial: "source", states: { source: Up, target: RetaggedUp } }) + const states = Machine.state({ states: { source: Up, target: RetaggedUp } }) const targets9 = Machine.targets(states) Machine.make({ branches: { transition1: { destination: { target: targets9.root.target } } }, - root: states, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.source.decoded(new Up({ id: "up-1" }))))) + events: Machine.eventsFromSchemas(SignIn) }).handle({ + initial: { + target: Machine.targets(states).root.source, + decoded: true, + data: new Up({ id: "up-1" }) + }, states: { source: { on: { @@ -1049,15 +2274,14 @@ describe("Machine", () => { } } } - } + }, + target: {} } }) }) - it("child invocation composes complete machines with type-safe protocols", () => { const ChildInput = Schema.Struct({ userId: Schema.String }) const childStates = Machine.state({ - initial: "done", states: { done: { schema: Down, @@ -1070,28 +2294,60 @@ describe("Machine", () => { root: childStates, events: Machine.eventsFromSchemas(SignIn), emittedEvents: Machine.emittedEventsFromSchemas(SignIn), - input: ChildInput, - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.done.decoded(new Down({}))))) + input: ChildInput }).handle({ - states: { - done: { - output: () => new SignIn({ userId: "child" }) - } - } + initial: { + target: Machine.targets(childStates).root.done, + decoded: true, + data: new Down({}) + }, + 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({ children: { source1: Child }, - root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) parent.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { invoke: { src: "source1", @@ -1115,14 +2371,121 @@ describe("Machine", () => { } } }) + expect(parent.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + }) expect(parent.handle).type.not.toBeCallableWith({ states: { - down: { invoke: { child: Child, onDone: () => undefined } } + down: { invoke: { child: Child, onDone: () => undefined } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } } }) - expect(parent.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "source1", input: () => ({ userId: "child" }), onDone: () => new Down({}) } } } + states: { + down: { invoke: { src: "source1", input: () => ({ userId: "child" }), onDone: () => new Down({}) } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(parent.handle).type.not.toBeCallableWith({ states: { @@ -1133,43 +2496,98 @@ describe("Machine", () => { onDone: { none: true }, onSnapshot: () => new Down({}) } + }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") } } }) }) - it("types nested invocation output handlers against their owning state", () => { const machine = Machine.make({ effects: { source1: Effect.suspend(() => Effect.succeed(Option.some(1))) }, - root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { up: { states: { auth: { - states: { - signedOut: { - invoke: { src: "source1", id: "nested", onDone: { none: true } } + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") } + }, + states: { + signedOut: { invoke: { src: "source1", id: "nested", onDone: { none: true } } }, + signedIn: {} } }, - sync: {} + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } } - } + }, + down: {} } }) }) - it("typed child addresses enforce their event protocol", () => { const worker = Machine.childAddress("worker") const inert = Machine.childAddress("inert") const child = Machine.logic({ initial: 0, run: () => Effect.never }) - expect(Machine.child).type.not.toBeCallableWith("worker") expect>().type.toBe() expect(Machine.sendTo).type.toBeCallableWith(worker, new SignIn({ userId: "user-1" })) @@ -1184,71 +2602,124 @@ describe("Machine", () => { { id: worker } ) }) - it("keeps child startup failures out of successfully spawned refs", () => { const child = Machine.logic({ initial: () => Effect.fail("child-start" as const), run: () => Effect.fail("child-runtime" as const) }) const spawned = Machine.spawn(child) - expect>().type.toBe<"child-start">() - expect["join"]>>().type.toBe< - "child-runtime" | Machine.StoppedError - >() + expect["join"]>>().type.toBe<"child-runtime" | Machine.StoppedError>() }) - it("start exposes machine infrastructure failure channels", () => { const targets12 = Machine.targets(UpStates) const machine = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }).handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { - SignIn: { target: targets12.root.down, decoded: () => (new Down({})) } + SignIn: { target: targets12.root.down, decoded: true, data: () => (new Down({})) } } } } }) const started = Machine.start(machine) - expect>().type.toBe< - | Machine.MachineSchemaDecodeError - | Machine.StartupError - | Machine.InfiniteTransitionError - | Machine.StoppedError + Machine.MachineSchemaDecodeError | Machine.StartupError | Machine.InfiniteTransitionError | Machine.StoppedError >() expect["join"]>>().type.toBe< - | Machine.InfiniteTransitionError - | Machine.MachineSchemaDecodeError - | Machine.StoppedError + Machine.InfiniteTransitionError | Machine.MachineSchemaDecodeError | Machine.StoppedError >() - const Child = Machine.child("failure-child", machine) type ChildError = Effect.Error["join"]> - expect().type.toBe< - | Machine.InfiniteTransitionError - | Machine.MachineSchemaDecodeError - | Machine.StoppedError - >() - expect>().type.toBe< - | Machine.InfiniteTransitionError - | Machine.MachineSchemaDecodeError - | Machine.StoppedError + Machine.InfiniteTransitionError | Machine.MachineSchemaDecodeError | Machine.StoppedError >() + expect< + Machine.Machine.InvokeRuntimeError<{ + readonly child: typeof Child + }> + >().type.toBe() }) - it("plan and getters require snapshots", () => { const machine = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) + }).handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, + states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: {} + } }) - expect(Machine.plan).type.toBeCallableWith( machine, { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, @@ -1256,11 +2727,11 @@ describe("Machine", () => { userId: "user-1" }) ) - const planned = Machine.plan( - machine, - { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, - new SignIn({ userId: "user-1" }) - ) + const planned = Machine.plan(machine, { + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }, new SignIn({ userId: "user-1" })) expect>().type.toBe< Machine.InfiniteTransitionError | Machine.MachineSchemaDecodeError >() @@ -1274,7 +2745,6 @@ describe("Machine", () => { value: undefined, state: DownInitial.decoded(new Down({})) }) - const can = Machine.can( machine, { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, @@ -1283,32 +2753,101 @@ describe("Machine", () => { const canMachine = Machine.can(machine) expect>().type.toBe() expect>().type.toBe() - expect(canMachine).type.toBeCallableWith( - { path: "" as const, value: undefined, state: DownInitial.decoded(new Down({})) }, - new SignIn({ userId: "user-1" }) - ) - + expect(canMachine).type.toBeCallableWith({ + path: "" as const, + value: undefined, + state: DownInitial.decoded(new Down({})) + }, new SignIn({ userId: "user-1" })) expect(Machine.plan).type.not.toBeCallableWith(machine, new Down({}), new SignIn({ userId: "user-1" })) expect(Machine.can).type.not.toBeCallableWith(machine, new Down({}), new SignIn({ userId: "user-1" })) expect(canMachine).type.not.toBeCallableWith(new Down({}), new SignIn({ userId: "user-1" })) expect(Machine.enabled).type.not.toBeCallableWith(machine, new Down({})) expect(Machine.isFinal).type.not.toBeCallableWith(machine, new Down({})) }) - it("handlers reject raw decoded state returns", () => { const machine = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) - expect>().type.toBe() + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + }) expect(machine.handle).type.not.toBeCallableWith({ states: { down: { on: { SignIn: () => new Down({}) } + }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") } } }) @@ -1318,11 +2857,44 @@ describe("Machine", () => { on: { SignIn: () => Effect.succeed(new Down({})) } + }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") } } }) }) - it("branching transitions infer unbounded named targets", () => { const targets13 = Machine.targets(UpStates) const machine = Machine.make({ @@ -1336,14 +2908,46 @@ describe("Machine", () => { transition2: { accepted: { target: targets13.root.down }, consumed: { none: true } }, transition3: { ignored: { none: true } } }, - root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.down.decoded(new Down({})))) + events: Machine.eventsFromSchemas(SignIn) }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { SignIn: { @@ -1370,21 +2974,89 @@ describe("Machine", () => { } } }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { SignIn: { none: true, reenter: true } }, - // @ts-expect-error! + // @ts-expect-error! Unconditional eventless reentry cannot stabilize. always: { none: true, reenter: true } } } }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { // @ts-expect-error! @@ -1393,17 +3065,55 @@ describe("Machine", () => { } } }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { SignIn: { branches: "transition2", resolve: (context) => { expect(context.decline()).type.toBe() - if (context.event.userId === "decline") return context.decline() - if (context.event.userId === "consume") return context.select.consumed() + 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 @@ -1412,9 +3122,43 @@ describe("Machine", () => { } } }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { SignIn: { @@ -1427,9 +3171,43 @@ describe("Machine", () => { } } }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { // @ts-expect-error! @@ -1440,7 +3218,42 @@ describe("Machine", () => { }) const widenedDeclinable = true as boolean machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { // @ts-expect-error! @@ -1449,9 +3262,43 @@ describe("Machine", () => { } } }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { // @ts-expect-error! @@ -1482,22 +3329,29 @@ describe("Machine", () => { branches: { invalid: { valid: { none: true }, [symbolBranch]: { none: true } } } }) }) - it("handle accepts nested states through reserved states objects", () => { const targets14 = Machine.targets(UpStates) const machine = Machine.make({ branches: { transition1: { destination: { target: targets14.root.up.auth.signedIn } } }, - root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { up: { states: { auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, states: { signedOut: { on: { @@ -1510,24 +3364,45 @@ describe("Machine", () => { } } } - } + }, + signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") } } - } + }, + down: {} } }) }) - it("handle accepts parent config and child config in the same object", () => { const targets15 = Machine.targets(UpStates) const machine = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) - machine.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { up: { entry: ({ event, state }) => { @@ -1549,58 +3424,78 @@ describe("Machine", () => { }, states: { auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, states: { signedOut: { on: { SignIn: { target: targets15.root.up.auth.signedIn, - decoded: ({ event }) => (new SignedIn({ userId: event.userId })) + decoded: true, + data: ({ event }) => (new SignedIn({ userId: event.userId })) } } - } + }, + signedIn: {} } }, sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, states: { idle: { entry: ({ state }) => { const tag: "SyncIdle" = state._tag void tag } - } + }, + syncing: {} } } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } } - } + }, + down: {} } }) }) - it("onDone handlers receive typed state context without Effect requirements", () => { const targets16 = Machine.targets(UpStates) const machine = Machine.make({ branches: { transition1: { destination: { target: targets16.root.down } } }, - 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" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) - .sync.decoded( - new Sync({ enabled: true }), - (sync) => sync.idle.decoded(new SyncIdle({})) - )) - ))) + events: Machine.eventsFromSchemas(SignIn) }).handle({ + initial: { + target: Machine.targets(UpStates).root.up, + decoded: true, + data: new Up({ id: "up-1" }) + }, states: { up: { states: { auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, onDone: { branches: "transition1", resolve: ({ event, output, state, select: { destination: target } }) => { @@ -1610,84 +3505,158 @@ describe("Machine", () => { return target.decoded(new Down({})) } }, - states: { - signedIn: {} - } + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + decoded: true, + data: new SyncIdle({}) + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") } } - } + }, + down: {} } }) - - const planned = Machine.plan( - machine, - { - 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" }) - ) - + const planned = Machine.plan(machine, { + 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" })) expect>().type.toBe() }) - it("handle rejects old property and callback APIs", () => { const machine = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }) - expect(machine.handle).type.not.toHaveProperty("up") expect(machine.handle).type.not.toBeCallableWith("up.auth.signedOut", { on: {} }) expect(machine.handle).type.not.toBeCallableWith((up: unknown) => up) }) - it("allows independent implementations but removes handle from each result", () => { const definition = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.down.decoded(new Down({})))) + events: Machine.eventsFromSchemas(SignIn) + }) + const first = definition.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, + states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: {} + } + }) + const second = definition.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, + states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: {} + } }) - - const first = definition.handle({ states: { down: {} } }) - const second = definition.handle({ states: { down: {} } }) - expect(definition).type.toHaveProperty("handle") expect(first).type.not.toHaveProperty("handle") expect(second).type.not.toHaveProperty("handle") }) - it("final output callbacks receive lifecycle events", () => { - const machine = Machine.make({ - root: Machine.state({ - initial: "down", - states: { - down: { - schema: Down, - type: "final", - output: Schema.Void - } + const InitialRoot1 = Machine.state({ + states: { + down: { + schema: Down, + type: "final", + output: Schema.Void } - }), - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + } + }) + const machine = Machine.make({ + root: InitialRoot1, + events: Machine.eventsFromSchemas(SignIn) }).handle({ + initial: { + target: Machine.targets(InitialRoot1).root.down, + decoded: true, + data: new Down({}) + }, states: { down: { output: ({ event }) => { @@ -1696,13 +3665,10 @@ describe("Machine", () => { } } }) - expect(machine).type.toBeAssignableTo() }) - it("final output callbacks conform to declared output schemas", () => { const States = Machine.state({ - initial: "signedIn", states: { signedIn: { schema: SignedIn, @@ -1711,25 +3677,21 @@ describe("Machine", () => { } } }) - const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.signedIn.decoded(new SignedIn({ userId: "user-1" }))))) + events: Machine.eventsFromSchemas(SignIn) }).handle({ - states: { - signedIn: { - output: ({ state }) => state.userId - } - } + initial: { + target: Machine.targets(States).root.signedIn, + decoded: true, + data: new SignedIn({ userId: "user-1" }) + }, + states: { signedIn: { output: ({ state }) => state.userId } } }) - const planned = Machine.planInitial(machine) const started = Machine.start(machine) expect["output"]>().type.toBe() expect["join"]>>().type.toBe() - const result = null as unknown as Effect.Success if (result.done) { expect(result.output).type.toBe() @@ -1737,10 +3699,8 @@ describe("Machine", () => { expect(result.output).type.toBe() } }) - it("requires one definition-led final output contract before execution", () => { const States = Machine.state({ - initial: "signedIn", states: { signedIn: { schema: SignedIn, @@ -1751,13 +3711,15 @@ describe("Machine", () => { }) const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.signedIn.decoded(new SignedIn({ userId: "user-1" }))))) + events: Machine.eventsFromSchemas(SignIn) }) type ForgedCompleteMachine = Machine.Machine< - { readonly "": typeof States.node }, - readonly [typeof SignIn], + { + readonly "": typeof States.node + }, + readonly [ + typeof SignIn + ], typeof Schema.Void, "signedIn", never, @@ -1768,21 +3730,33 @@ describe("Machine", () => { string, readonly [], "signedIn", - readonly [typeof SignIn] + readonly [ + typeof SignIn + ] > - + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(States).root.signedIn, + data: () => { + throw new Error("type-only constructor") + } + } + }) expect(machine.handle).type.not.toBeCallableWith({ - states: { - signedIn: { - output: () => 1 + states: { signedIn: { output: () => 1 } }, + initial: { + target: Machine.targets(States).root.signedIn, + data: () => { + throw new Error("type-only constructor") } } }) expect(machine.handle).type.not.toBeCallableWith({ - states: { - signedIn: { - type: "final", - output: () => "user-1" + states: { signedIn: { type: "final", output: () => "user-1" } }, + initial: { + target: Machine.targets(States).root.signedIn, + data: () => { + throw new Error("type-only constructor") } } }) @@ -1791,27 +3765,104 @@ describe("Machine", () => { const parent = Machine.make({ root: UpStates, events: Machine.eventsFromSchemas(SignIn), - children: { incomplete: Machine.child("incomplete", machine) } + children: { + incomplete: Machine.child( + "incomplete", + machine.handle({ initial: { target: Machine.targets(States).root.signedIn, data: { userId: "user-1" } } }) + ) + } }) - expect(parent.handle).type.not.toBeCallableWith({ - states: { down: { invoke: { src: "incomplete", onDone: { none: true } } } } + expect(parent.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } }) - expect(machine).type.not.toBeAssignableTo() - - const complete = machine.handle({ + expect(parent.handle).type.not.toBeCallableWith({ states: { - signedIn: { - output: ({ state }) => state.userId + down: { invoke: { src: "incomplete", onDone: { none: true } } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") } } }) + expect(machine).type.not.toBeAssignableTo() + const complete = machine.handle({ + initial: { + target: Machine.targets(States).root.signedIn, + decoded: true, + data: new SignedIn({ userId: "user-1" }) + }, + states: { signedIn: { output: ({ state }) => state.userId } } + }) expect(Machine.planInitial).type.toBeCallableWith(complete) expect(Machine.start).type.toBeCallableWith(complete) }) - it("keeps only legitimate undefined values in terminal output", () => { const States = Machine.state({ - initial: "active", states: { active: Down, succeeded: { @@ -1827,36 +3878,37 @@ describe("Machine", () => { }) const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.active.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) }).handle({ - states: { - succeeded: { - output: ({ state }) => state.userId - } - } + initial: { + target: Machine.targets(States).root.active, + decoded: true, + data: new Down({}) + }, + states: { active: {}, succeeded: { output: ({ state }) => state.userId }, cancelled: {} } }) const started = Machine.start(machine) expect["join"]>>().type.toBe() - + const InitialRoot2 = Machine.state({ states: { active: Down } }) const activeOnly = Machine.make({ - 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({}))))) + root: InitialRoot2, + events: Machine.eventsFromSchemas(SignIn) + }).handle({ + initial: { + target: Machine.targets(InitialRoot2).root.active, + decoded: true, + data: new Down({}) + }, + states: { active: {} } }) const activeRef = Machine.start(activeOnly) expect["join"]>>().type.toBe() }) - it("compound onDone receives the declared child final output type", () => { const States = Machine.state({ - initial: "auth", states: { auth: { schema: Auth, - initial: "signedOut", states: { signedOut: SignedOut, signedIn: { @@ -1869,24 +3921,25 @@ describe("Machine", () => { down: Down } }) - const targets17 = Machine.targets(States) const machine = Machine.make({ branches: { transition1: { destination: { target: targets17.root.down } } }, - root: States, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => - root.resolve(( - { target } - ) => (target.from((to) => - to.auth.decoded(new Auth({ userId: "user-1" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) - ))) + events: Machine.eventsFromSchemas(SignIn) }) - machine.handle({ + initial: { + target: Machine.targets(States).root.auth, + decoded: true, + data: new Auth({ userId: "user-1" }) + }, states: { auth: { + initial: { + target: Machine.targets(States).root.auth.signedOut, + decoded: true, + data: new SignedOut({}) + }, onDone: { branches: "transition1", resolve: ({ output, select: { destination: target } }) => { @@ -1894,23 +3947,17 @@ describe("Machine", () => { return target.decoded(new Down({})) } }, - states: { - signedIn: { - output: ({ state }) => state.userId - } - } - } + states: { signedOut: {}, signedIn: { output: ({ state }) => state.userId } } + }, + down: {} } }) }) - it("rejects compound onDone when declared child output is not implemented", () => { const States = Machine.state({ - initial: "auth", states: { auth: { schema: Auth, - initial: "signedOut", states: { signedOut: SignedOut, signedIn: { @@ -1924,31 +3971,51 @@ describe("Machine", () => { }) const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => - root.resolve(( - { target } - ) => (target.from((to) => - to.auth.decoded(new Auth({ userId: "user-1" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) - ))) + events: Machine.eventsFromSchemas(SignIn) + }) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(States).root.auth, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(States).root.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + } + } }) - expect(machine.handle).type.not.toBeCallableWith({ states: { auth: { - onDone: () => undefined + onDone: () => undefined, + initial: { + target: Machine.targets(States).root.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + target: Machine.targets(States).root.auth, + data: () => { + throw new Error("type-only constructor") } } }) }) - it("multiple final children produce a discriminated completion output union", () => { const States = Machine.state({ - initial: "payment", states: { payment: { schema: Payment, - initial: "pending", states: { pending: PendingPayment, approved: { @@ -1971,21 +4038,23 @@ describe("Machine", () => { } } }) - const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => - root.resolve(( - { target } - ) => (target.from((to) => - to.payment.decoded(new Payment({}), (payment) => payment.pending.decoded(new PendingPayment({}))) - ))) + events: Machine.eventsFromSchemas(SignIn) }) - machine.handle({ + initial: { + target: Machine.targets(States).root.payment, + decoded: true, + data: new Payment({}) + }, states: { payment: { + initial: { + target: Machine.targets(States).root.payment.pending, + decoded: true, + data: new PendingPayment({}) + }, onDone: { none: true, resolve: ({ output }) => { @@ -1999,6 +4068,7 @@ describe("Machine", () => { } }, states: { + pending: {}, approved: { output: ({ state }) => ({ status: "approved" as const, @@ -2016,10 +4086,8 @@ describe("Machine", () => { } }) }) - it("parallel output callbacks receive typed region outputs and conform to declared output schemas", () => { const States = Machine.state({ - initial: "up", states: { up: { schema: Up, @@ -2031,7 +4099,6 @@ describe("Machine", () => { states: { auth: { schema: Auth, - initial: "signedOut", states: { signedOut: SignedOut, signedIn: { @@ -2043,7 +4110,6 @@ describe("Machine", () => { }, sync: { schema: Sync, - initial: "idle", states: { idle: SyncIdle, syncing: { @@ -2057,20 +4123,16 @@ describe("Machine", () => { } } }) - const machine = Machine.make({ 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({})))) - ))) + events: Machine.eventsFromSchemas(SignIn) }) - const complete = machine.handle({ + initial: { + target: Machine.targets(States).root.up, + decoded: true, + data: new Up({ id: "up-1" }) + }, states: { up: { output: ({ outputs }) => { @@ -2091,18 +4153,29 @@ describe("Machine", () => { }, states: { auth: { - states: { - signedIn: { - output: ({ state }) => ({ userId: state.userId }) + initial: { + target: Machine.targets(States).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") } - } + }, + states: { signedOut: {}, signedIn: { output: ({ state }) => ({ userId: state.userId }) } } }, sync: { - states: { - syncing: { - output: ({ state }) => ({ requestId: state.requestId }) - } - } + initial: { + target: Machine.targets(States).root.up.sync.idle, + decoded: true, + data: new SyncIdle({}) + }, + states: { idle: {}, syncing: { output: ({ state }) => ({ requestId: state.requestId }) } } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") } } } @@ -2110,13 +4183,13 @@ describe("Machine", () => { }) const started = Machine.start(complete) expect["join"]>>().type.toBe< - Machine.Machine.OutputByIdentifier<{ readonly "": typeof States.node }, "up"> + Machine.Machine.OutputByIdentifier<{ + readonly "": typeof States.node + }, "up"> >() }) - it("rejects parallel output callbacks that do not match declared output schemas", () => { const States = Machine.state({ - initial: "up", states: { up: { schema: Up, @@ -2127,7 +4200,6 @@ describe("Machine", () => { states: { auth: { schema: Auth, - initial: "signedOut", states: { signedOut: SignedOut, signedIn: { @@ -2143,18 +4215,37 @@ describe("Machine", () => { }) const machine = Machine.make({ 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({}))) - ) - ))) + events: Machine.eventsFromSchemas(SignIn) + }) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(States).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + output: () => ({ userId: "user-1" }), + initial: { + auth: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + states: { signedIn: { output: () => ({ userId: "user-1" }) } }, + initial: { + target: Machine.targets(States).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } }) - expect(machine.handle).type.not.toBeCallableWith({ states: { up: { @@ -2165,78 +4256,74 @@ describe("Machine", () => { auth: { states: { signedIn: { - output: ({ state }: { readonly state: SignedIn }) => ({ userId: state.userId }) + output: ({ state }: { + readonly state: SignedIn + }) => ({ userId: state.userId }) + } + }, + initial: { + target: Machine.targets(States).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") } } } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(States).root.up, + data: () => { + throw new Error("type-only constructor") + } } }) }) - it("initial builder constructs typed initial snapshots", () => { - const snapshot = UpInitial.decoded( - new Up({ id: "up-1" }), - (up) => - up - .auth.decoded( - new Auth({ userId: "guest" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) - .sync.decoded( - new Sync({ enabled: true }), - (sync) => sync.idle.decoded(new SyncIdle({})) - ) - ) - + const snapshot = UpInitial.decoded(new Up({ id: "up-1" }), (up) => + up + .auth.decoded(new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) + .sync.decoded(new Sync({ enabled: true }), (sync) => sync.idle.decoded(new SyncIdle({})))) expect(snapshot).type.toBeAssignableTo< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof UpStates.node }, "up"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof UpStates.node + }, "up"> >() expect(snapshot.path).type.toBe<"up">() expect(snapshot.value).type.toBe() expect(snapshot.states.auth.value).type.toBe() - expect(snapshot.states.auth.state.path).type.toBe<"up.auth.signedOut">() + expect(snapshot.states.auth.state.path).type.toBe<"up.auth.signedOut" | "up.auth.signedIn">() expect(snapshot.states.sync.value).type.toBe() - expect(snapshot.states.sync.state.path).type.toBe<"up.sync.idle">() + expect(snapshot.states.sync.state.path).type.toBe<"up.sync.idle" | "up.sync.syncing">() }) - it("initial builder rejects incomplete parallel callbacks", () => { expect(UpInitial.decoded).type.not.toBeCallableWith( new Up({ id: "up-1" }), (up: ChildBuilder) => - up.auth.decoded( - new Auth({ userId: "guest" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) + up.auth.decoded(new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) ) }) - it("initial builder exposes only the declared compound initial child", () => { const up = null as unknown as ChildBuilder const auth = null as unknown as ChildBuilder - expect(auth.signedOut.decoded).type.toBeCallableWith(new SignedOut({})) - expect(auth).type.not.toHaveProperty("signedIn") + expect(auth).type.toHaveProperty("signedIn") }) - it("initial builder checks values at parent and leaf nodes", () => { const up = null as unknown as ChildBuilder const sync = null as unknown as ChildBuilder - expect(DownInitial.decoded).type.not.toBeCallableWith(new Up({ id: "up-1" })) expect(UpInitial.decoded).type.not.toBeCallableWith( new Auth({ userId: "guest" }), (up: ChildBuilder) => up - .auth.decoded( - new Auth({ userId: "guest" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) - .sync.decoded( - new Sync({ enabled: true }), - (sync) => sync.idle.decoded(new SyncIdle({})) - ) + .auth.decoded(new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) + .sync.decoded(new Sync({ enabled: true }), (sync) => sync.idle.decoded(new SyncIdle({}))) ) expect(up.auth.decoded).type.not.toBeCallableWith( new Up({ id: "up-1" }), @@ -2244,67 +4331,53 @@ describe("Machine", () => { ) expect(sync.idle.decoded).type.not.toBeCallableWith(new Syncing({ requestId: "sync-1" })) }) - it("initial builder removes parallel region methods after they are called", () => { const up = null as unknown as ChildBuilder const afterAuth = up.auth.decoded( new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({})) ) - const complete = afterAuth.sync.decoded( - new Sync({ enabled: true }), - (sync) => sync.idle.decoded(new SyncIdle({})) - ) - + const complete = afterAuth.sync.decoded(new Sync({ enabled: true }), (sync) => sync.idle.decoded(new SyncIdle({}))) expect(afterAuth).type.not.toHaveProperty("auth") expect(afterAuth).type.toHaveProperty("sync") expect(complete).type.not.toHaveProperty("sync") expect(complete).type.not.toHaveProperty("done") }) - it("internal target constructors: target.full constructs typed full snapshots", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "down"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "down"> } const full = null as unknown as Machine.Machine.FullTargetBuilder - const snapshot = full.up.decoded( - new Up({ id: "up-1" }), - (up) => - up - .auth.decoded( - new Auth({ userId: "guest" }), - (auth) => auth.signedIn.decoded(new SignedIn({ userId: "user-1" })) - ) - .sync.decoded( - new Sync({ enabled: true }), - (sync) => sync.syncing.decoded(new Syncing({ requestId: "sync-1" })) - ) - ) - + const snapshot = full.up.decoded(new Up({ id: "up-1" }), (up) => + up + .auth.decoded( + new Auth({ userId: "guest" }), + (auth) => auth.signedIn.decoded(new SignedIn({ userId: "user-1" })) + ) + .sync.decoded( + new Sync({ enabled: true }), + (sync) => sync.syncing.decoded(new Syncing({ requestId: "sync-1" })) + )) expect(snapshot).type.toBeAssignableTo< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof UpStates.node }, "up"> + 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">() 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 = UpInitial.from( - { id: "up-1" }, - (up) => - up - .auth.from( - { userId: "guest" }, - (auth) => auth.signedOut.from({}) - ) - .sync.from( - { enabled: true }, - (sync) => sync.idle.from({}) - ) - ) + const initial = UpInitial.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 { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth.signedOut"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut"> } const local = context.target.local.signedIn.from({ userId: "user-1" }) const full = context.target.branch[""].down.from({}) @@ -2314,21 +4387,20 @@ describe("Machine", () => { ) const branch = context.target.branch[""].up.from( { id: "up-2" }, - (up) => - up.auth.from( - { userId: "user-1" }, - (auth) => auth.signedIn.from({ userId: "user-1" }) - ) + (up) => up.auth.from({ userId: "user-1" }, (auth) => auth.signedIn.from({ userId: "user-1" })) ) - expect(initial).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof UpStates.node }, "up"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof UpStates.node + }, "up"> > >() expect(local).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.Target<{ readonly "": typeof UpStates.node }, "up.auth.signedIn"> + Machine.Machine.Target<{ + readonly "": typeof UpStates.node + }, "up.auth.signedIn"> > >() expect(local).type.not.toHaveProperty("path") @@ -2337,18 +4409,16 @@ describe("Machine", () => { expect(branch).type.not.toHaveProperty("path") expect(full).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof UpStates.node }, "down"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof UpStates.node + }, "down"> > >() expect(full).type.not.toHaveProperty("value") - - expect(UpInitial.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(UpInitial.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( @@ -2356,7 +4426,6 @@ describe("Machine", () => { (auth: ChildBuilder) => auth.signedOut.from({}) ) }) - it("state builders omit empty constructor inputs across every target surface", () => { const State = Schema.TaggedUnion({ Flow: {}, @@ -2373,23 +4442,20 @@ describe("Machine", () => { RightIdle: {} }) class DefaultOnly extends Schema.TaggedClass("DefaultOnly")("DefaultOnly", { - label: Schema.String.pipe( - Schema.optionalKey, - Schema.withConstructorDefault(Effect.succeed("default")) - ) - }) {} + label: Schema.String.pipe(Schema.optionalKey, Schema.withConstructorDefault(Effect.succeed("default"))) + }) { + } 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 } @@ -2405,7 +4471,6 @@ describe("Machine", () => { } }) const ParallelStates = Machine.state({ - initial: "Parallel", states: { Parallel: { schema: State.cases.Parallel, @@ -2413,14 +4478,14 @@ describe("Machine", () => { 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 } @@ -2429,28 +4494,26 @@ describe("Machine", () => { } } }) - type Context = { readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof States.node }, "Flow.Idle"> } + type Context = { + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof States.node + }, "Flow.Idle"> + } type ParallelContext = { - readonly target: Machine.Machine.TargetBuilder< - { readonly "": typeof ParallelStates.node }, - "Parallel.left.LeftIdle" - > + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof ParallelStates.node + }, "Parallel.left.LeftIdle"> } 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"] - > - const RequiredInitial = null as unknown as Machine.Machine.SelectionBuilder< - ReturnType["Required"]> - > - const DefaultOnlyInitial = null as unknown as Machine.Machine.SelectionBuilder< - ReturnType["DefaultOnly"]> - > - const ParallelInitial = null as unknown as Machine.Machine.SelectionBuilder< - Machine.Machine.InitialSelector["Parallel"]["initial"] - > - + const FlowInitial = null as unknown as Machine.Machine.FullTargetBuilder["Flow"] + const RequiredInitial = null as unknown as Machine.Machine.FullTargetBuilder["Required"] + const DefaultOnlyInitial = null as unknown as Machine.Machine.FullTargetBuilder< + typeof States.node.states + >["DefaultOnly"] + const ParallelInitial = null as unknown as Machine.Machine.FullTargetBuilder< + typeof ParallelStates.node.states + >["Parallel"] const initial = FlowInitial.from((flow) => flow.Idle.from()) const defaulted = DefaultOnlyInitial.from() const local = context.target.local.Running.from() @@ -2470,91 +4533,109 @@ describe("Machine", () => { .left.from((left) => left.LeftIdle.from()) .right.from((right) => right.RightIdle.from()) ) - expect(initial).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "Flow"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "Flow"> > >() expect(defaulted).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "DefaultOnly"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "DefaultOnly"> > >() expect(local).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction< + Machine.Machine.Target<{ + readonly "": typeof States.node + }, "Flow.Running"> + > >() expect(localWith).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction< + Machine.Machine.Target<{ + readonly "": typeof States.node + }, "Flow.Running"> + > >() expect(branch).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.Target<{ readonly "": typeof States.node }, "Flow.Nested.NestedIdle"> + Machine.Machine.Target<{ + readonly "": typeof States.node + }, "Flow.Nested.NestedIdle"> > >() expect(full).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof States.node }, "Flow"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "Flow"> > >() expect(final).type.toBeAssignableTo< - Machine.Machine.StateConstruction> + Machine.Machine.StateConstruction< + Machine.Machine.Target<{ + readonly "": typeof States.node + }, "Flow.Done"> + > >() expect(parallel).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof ParallelStates.node }, "Parallel"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof ParallelStates.node + }, "Parallel"> > >() expect(fullParallel).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof ParallelStates.node }, "Parallel"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof ParallelStates.node + }, "Parallel"> > >() - expect(RequiredInitial.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() - const requiredContext = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth.signedOut"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut"> } - expect(UpInitial.from).type.not.toBeCallableWith( - (up: ChildBuilder) => - up - .auth.from({ userId: "guest" }, (auth) => auth.signedOut.from()) - .sync.from({ enabled: true }, (sync) => sync.idle.from()) + expect(UpInitial.from).type.not.toBeCallableWith((up: ChildBuilder) => + up + .auth.from({ userId: "guest" }, (auth) => auth.signedOut.from()) + .sync.from({ enabled: true }, (sync) => sync.idle.from()) ) 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()) - ) - 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(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()) ) + 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()) }) - it("from preserves parallel-region exhaustiveness", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof NestedParallelStates.node }, "root.idle"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof NestedParallelStates.node + }, "root.idle"> } const activeContext = null as unknown as { - readonly target: Machine.Machine.TargetBuilder< - { readonly "": typeof NestedParallelStates.node }, - "root.work.auth.signedOut" - > + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof NestedParallelStates.node + }, "root.work.auth.signedOut"> } const work = null as unknown as ChildBuilder - const afterAuth = work.auth.from( - { userId: "guest" }, - (auth) => auth.signedOut.from({}) - ) + 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({})) @@ -2569,23 +4650,28 @@ describe("Machine", () => { { 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<{ readonly "": typeof NestedParallelStates.node }, "root.work"> + 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<{ readonly "": typeof NestedParallelStates.node }, "root.work"> + Machine.Machine.Target<{ + readonly "": typeof NestedParallelStates.node + }, "root.work"> > >() expect(partial).type.toBeAssignableTo< Machine.Machine.StateConstruction< - Machine.Machine.Target<{ readonly "": typeof NestedParallelStates.node }, "root.work.sync.syncing"> + Machine.Machine.Target<{ + readonly "": typeof NestedParallelStates.node + }, "root.work.sync.syncing"> > >() expect(context.target.local.work.from).type.not.toBeCallableWith( @@ -2594,43 +4680,34 @@ describe("Machine", () => { 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.state({ - initial: "Idle", states: { Idle: State.cases.Idle, Active: State.cases.Active } }) - const IdleInitial = null as unknown as Machine.Machine.SelectionBuilder< - ReturnType["Idle"]> - > - const ActiveInitial = null as unknown as Machine.Machine.SelectionBuilder< - ReturnType["Active"]> - > + const IdleInitial = null as unknown as Machine.Machine.FullTargetBuilder["Idle"] + const ActiveInitial = null as unknown as Machine.Machine.FullTargetBuilder["Active"] const initial = IdleInitial.from({}) - expect(initial).type.toBeAssignableTo< - Machine.Machine.StateConstruction< - Machine.Machine.AtomicSnapshot<"Idle", typeof State.cases.Idle.Type> - > + Machine.Machine.StateConstruction> >() expect(initial).type.not.toHaveProperty("value") expect(ActiveInitial.from).type.toBeCallableWith({ requestId: "request-1" }) expect(ActiveInitial.from).type.not.toBeCallableWith({}) expect(ActiveInitial.from).type.not.toBeCallableWith({ requestId: 1 }) }) - it("internal target constructors: target.full requires every parallel region", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "down"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "down"> } - expect(context.target.branch[""].up.decoded).type.not.toBeCallableWith( new Up({ id: "up-1" }), (up: ChildBuilder) => @@ -2640,90 +4717,74 @@ describe("Machine", () => { ) ) }) - it("internal target constructors: target.full exposes every compound child", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "down"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "down"> } const up = null as unknown as ChildBuilder const auth = null as unknown as ChildBuilder - expect(auth.signedOut.decoded).type.toBeCallableWith(new SignedOut({})) expect(auth.signedIn.decoded).type.toBeCallableWith(new SignedIn({ userId: "user-1" })) }) - it("internal target constructors: target.local requires every region when entering an inactive nested parallel state", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof NestedParallelStates.node }, "root.idle"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof NestedParallelStates.node + }, "root.idle"> } - const target = context.target.local.work.decoded( - new Payment({}), - (work) => - work - .auth.decoded( - new Auth({ userId: "guest" }), - (auth) => auth.signedIn.decoded(new SignedIn({ userId: "user-1" })) - ) - .sync.decoded( - new Sync({ enabled: true }), - (sync) => sync.syncing.decoded(new Syncing({ requestId: "sync-1" })) - ) - ) - + const target = context.target.local.work.decoded(new Payment({}), (work) => + work + .auth.decoded( + new Auth({ userId: "guest" }), + (auth) => auth.signedIn.decoded(new SignedIn({ userId: "user-1" })) + ) + .sync.decoded( + new Sync({ enabled: true }), + (sync) => sync.syncing.decoded(new Syncing({ requestId: "sync-1" })) + )) expect(target).type.toBeAssignableTo< - Machine.Machine.Target<{ readonly "": typeof NestedParallelStates.node }, "root.work"> + 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( new Payment({}), (work: ChildBuilder) => - work.auth.decoded( - new Auth({ userId: "guest" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) + work.auth.decoded(new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) ) }) - it("internal target constructors: target.branch requires every region when entering an inactive nested parallel state", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof NestedParallelStates.node }, "root.idle"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof NestedParallelStates.node + }, "root.idle"> } - const target = context.target.branch[""].root.work.decoded( - new Payment({}), - (work) => - work - .auth.decoded( - new Auth({ userId: "guest" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) - .sync.decoded( - new Sync({ enabled: true }), - (sync) => sync.idle.decoded(new SyncIdle({})) - ) - ) - + const target = context.target.branch[""].root.work.decoded(new Payment({}), (work) => + work + .auth.decoded(new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) + .sync.decoded(new Sync({ enabled: true }), (sync) => sync.idle.decoded(new SyncIdle({})))) 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( new Payment({}), (work: ChildBuilder) => - work.auth.decoded( - new Auth({ userId: "guest" }), - (auth) => auth.signedOut.decoded(new SignedOut({})) - ) + work.auth.decoded(new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({}))) ) }) - it("nested parallel target builders remove regions and validate payloads", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof NestedParallelStates.node }, "root.idle"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof NestedParallelStates.node + }, "root.idle"> } const work = null as unknown as ChildBuilder const afterAuth = work.auth.decoded( new Auth({ userId: "guest" }), (auth) => auth.signedOut.decoded(new SignedOut({})) ) - expect(afterAuth).type.not.toHaveProperty("auth") expect(afterAuth).type.toHaveProperty("sync") expect(work.auth.decoded).type.not.toBeCallableWith( @@ -2731,55 +4792,54 @@ describe("Machine", () => { (auth: ChildBuilder) => auth.signedOut.decoded(new SignedOut({})) ) }) - it("internal target constructors: target.branch keeps partial navigation for an already-active parallel state", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder< - { readonly "": typeof NestedParallelStates.node }, - "root.work.auth.signedOut" - > + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof NestedParallelStates.node + }, "root.work.auth.signedOut"> } 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(target.path).type.toBe<"root.work.sync.syncing">() }) - it("internal target constructors: target.local constructs typed local leaf targets", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth.signedOut"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut"> } const target = context.target.local.signedIn.decoded(new SignedIn({ userId: "user-1" })) - expect(target).type.toBeAssignableTo< - Machine.Machine.Target<{ readonly "": typeof UpStates.node }, "up.auth.signedIn"> + Machine.Machine.Target<{ + readonly "": typeof UpStates.node + }, "up.auth.signedIn"> >() expect(target.path).type.toBe<"up.auth.signedIn">() expect(target.value).type.toBe() }) - it("internal target constructors: target.local exposes the source compound children when the source is compound", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth"> } - expect(context.target.local.signedOut.decoded).type.toBeCallableWith(new SignedOut({})) expect(context.target.local.signedIn.decoded).type.toBeCallableWith(new SignedIn({ userId: "user-1" })) }) - it("internal target constructors: target.local.with checks the local compound value", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth.signedOut"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut"> } const target = context.target.local.with.decoded( new Auth({ userId: "user-1" }), (auth) => auth.signedIn.decoded(new SignedIn({ userId: "user-1" })) ) - expect(target.path).type.toBe<"up.auth.signedIn">() expect(context.target.local.with.decoded).type.not.toBeCallableWith( new Up({ id: "up-1" }), @@ -2787,61 +4847,66 @@ describe("Machine", () => { auth.signedIn.decoded(new SignedIn({ userId: "user-1" })) ) }) - it("internal target constructors: target.local rejects unrelated children and wrong values", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth.signedOut"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut"> } - expect(context.target.local).type.not.toHaveProperty("sync") expect(context.target.local).type.not.toHaveProperty("down") expect(context.target.local).type.not.toHaveProperty("idle") expect(context.target.local.signedIn.decoded).type.not.toBeCallableWith(new SignedOut({})) }) - it("internal target constructors: target.local exposes the root compound children", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "down"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "down"> } - expect(context.target.local).type.toHaveProperty("up") expect(context.target.local).type.toHaveProperty("down") expect(context.target.local).type.not.toHaveProperty("with") }) - it("internal target constructors: target.branch starts at the shared machine root", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth.signedOut"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut"> } const downContext = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "down"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "down"> } - 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("internal target constructors: target.branch constructs typed partial branch targets", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth.signedOut"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut"> } 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<{ readonly "": typeof UpStates.node }, "up.sync.syncing"> + Machine.Machine.Target<{ + readonly "": typeof UpStates.node + }, "up.sync.syncing"> >() expect(target.path).type.toBe<"up.sync.syncing">() expect(target.value).type.toBe() }) - it("internal target constructors: target.branch can replace ancestors before selecting a leaf", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth.signedOut"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut"> } const target = context.target.branch[""].up.decoded( new Up({ id: "up-2" }), @@ -2851,16 +4916,15 @@ describe("Machine", () => { (auth) => auth.signedIn.decoded(new SignedIn({ userId: "user-1" })) ) ) - expect(target.path).type.toBe<"up.auth.signedIn">() expect(target.value).type.toBe() }) - it("internal target constructors: target.branch rejects non-leaf targets and wrong values", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "up.auth.signedOut"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "up.auth.signedOut"> } - 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( @@ -2870,49 +4934,185 @@ describe("Machine", () => { ) expect(context.target.branch[""].up.auth.signedIn.decoded).type.not.toBeCallableWith(new SignedOut({})) }) - it("internal target constructor containers are not callable", () => { const context = null as unknown as { - readonly target: Machine.Machine.TargetBuilder<{ readonly "": typeof UpStates.node }, "down"> + readonly target: Machine.Machine.TargetBuilder<{ + readonly "": typeof UpStates.node + }, "down"> } - expect>().type.toBe() expect(context.target.none()).type.toBe() }) - it("requires explicit targetless transitions", () => { expect(Machine).type.not.toHaveProperty("targetless") - const definition = Machine.make({ root: UpStates, - events: Machine.eventsFromSchemas(SignIn), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.down.decoded(new Down({}))))) + events: Machine.eventsFromSchemas(SignIn) + }) + expect(definition.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } }) - expect(definition.handle).type.not.toBeCallableWith({ states: { - down: { on: { SignIn: () => undefined } } + down: { on: { SignIn: () => undefined } }, + up: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(UpStates).root.up, + data: () => { + throw new Error("type-only constructor") + } } }) - expect( - definition.handle({ - states: { - down: { - on: { - SignIn: { - none: true, - resolve: ({ event }, enqueue) => { - expect(event).type.toBe() - expect(enqueue.raise).type.toBeCallableWith(event) + expect(definition.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, + states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, + down: { + on: { + SignIn: { + none: true, + resolve: ({ event }, enqueue) => { + expect(event).type.toBe() + expect(enqueue.raise).type.toBeCallableWith(event) } } } } - }) - ).type.not.toRaiseError() + } + })).type.not.toRaiseError() definition.handle({ + initial: { + target: Machine.targets(UpStates).root.down, + decoded: true, + data: new Down({}) + }, states: { + up: { + states: { + auth: { + initial: { + target: Machine.targets(UpStates).root.up.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { signedOut: {}, signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(UpStates).root.up.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {}, syncing: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + }, down: { on: { // @ts-expect-error! Targetless resolvers cannot return unrelated values. @@ -2922,7 +5122,6 @@ describe("Machine", () => { } }) }) - it("rejects invalid compound initial keys", () => { expect(Machine.state).type.not.toBeCallableWith({ initial: "up", @@ -2937,7 +5136,6 @@ describe("Machine", () => { } }) }) - it("rejects initial keys on parallel states", () => { expect(Machine.state).type.not.toBeCallableWith({ initial: "up", @@ -2953,7 +5151,6 @@ describe("Machine", () => { } }) }) - it("rejects invalid nested state definitions", () => { expect(Machine.state).type.not.toBeCallableWith({ initial: "up", @@ -2974,7 +5171,6 @@ describe("Machine", () => { } }) }) - it("make validates raw state trees", () => { expect(Machine.make).type.not.toBeCallableWith({ states: { @@ -2992,7 +5188,6 @@ describe("Machine", () => { } }) }) - it("rejects child states on final states", () => { expect(Machine.state).type.not.toBeCallableWith({ initial: "down", diff --git a/packages/effect-machine/typetest/machine/MachineReferences.tst.ts b/packages/effect-machine/typetest/machine/MachineReferences.tst.ts index e4fe111c..1a63e174 100644 --- a/packages/effect-machine/typetest/machine/MachineReferences.tst.ts +++ b/packages/effect-machine/typetest/machine/MachineReferences.tst.ts @@ -5,38 +5,46 @@ import { Machine } from "../../src/index.js" import { MachineTest } from "../../src/testing/index.js" import { ClusterMachine } from "../../src/unstable/cluster/index.js" import { AtomMachine } from "../../src/unstable/reactivity/index.js" - describe("machine reference event channels", () => { - class Idle extends Schema.TaggedClass("MachineReferencesIdle")("Idle", {}) {} - class Ping extends Schema.TaggedClass("MachineReferencesPing")("Ping", {}) {} - class Local extends Schema.TaggedClass("MachineReferencesLocal")("Local", {}) {} + class Idle extends Schema.TaggedClass("MachineReferencesIdle")("Idle", {}) { + } + class Ping extends Schema.TaggedClass("MachineReferencesPing")("Ping", {}) { + } + class Local extends Schema.TaggedClass("MachineReferencesLocal")("Local", {}) { + } class ParentNotice extends Schema.TaggedClass("MachineReferencesParentNotice")("ParentNotice", { value: Schema.Number - }) {} - class OtherParentEvent extends Schema.TaggedClass("MachineReferencesOtherParent")( - "OtherParentEvent", - {} - ) {} - class Published extends Schema.TaggedClass("MachineReferencesPublished")("Published", {}) {} - class ValuedPublished extends Schema.TaggedClass("MachineReferencesValuedPublished")( - "ValuedPublished", - { value: Schema.Number } - ) {} - + }) { + } + class OtherParentEvent + extends Schema.TaggedClass("MachineReferencesOtherParent")("OtherParentEvent", {}) + { + } + class Published extends Schema.TaggedClass("MachineReferencesPublished")("Published", {}) { + } + class ValuedPublished + extends Schema.TaggedClass("MachineReferencesValuedPublished")("ValuedPublished", { + value: Schema.Number + }) + { + } 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 states = Machine.state({ states: { Idle } }) const childMachine = Machine.make({ root: states, events: Events, internalEvents: InternalEvents, parent: Machine.optionalParent(ParentEvents), - emittedEvents: Emissions, - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + emittedEvents: Emissions }).handle({ + initial: { + target: Machine.targets(states).root.Idle, + decoded: true, + data: new Idle({}) + }, states: { Idle: { on: { @@ -46,24 +54,63 @@ describe("machine reference event channels", () => { } }) const Child = Machine.child("child", childMachine) - it("types self, parent, raised events, and emissions as separate channels", () => { expect["send"]>().type.toBeCallableWith(new Ping({})) - expect["self"]>().type.toBe< - Machine.MachineTarget> + expect< + Machine.MachineReferences< + readonly [ + typeof Ping + ], + readonly [ + typeof ParentNotice + ] + >["self"] + >().type.toBe< + Machine.MachineTarget< + Machine.Machine.EventInputOf< + readonly [ + typeof Ping + ] + > + > >() expect< - Machine.MachineReferences["parent"] - >().type.toBe> | undefined>() - expect>().type.toBe<"self">() + Machine.MachineReferences< + readonly [ + typeof Ping + ], + readonly [ + typeof ParentNotice + ] + >["parent"] + >().type.toBe< + Machine.MachineTarget< + Machine.Machine.EventInputOf< + readonly [ + typeof ParentNotice + ] + > + > | undefined + >() + expect< + keyof Machine.MachineReferences< + readonly [ + typeof Ping + ], + readonly [] + > + >().type.toBe<"self">() expect(Machine.parent).type.not.toBeCallableWith(InternalEvents) expect(Machine.optionalParent).type.not.toBeCallableWith(InternalEvents) - const independentMachine = Machine.make({ root: states, - events: Events, - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) + events: Events }).handle({ + initial: { + target: Machine.targets(states).root.Idle, + decoded: true, + data: new Idle({}) + }, states: { Idle: { on: { @@ -79,15 +126,18 @@ describe("machine reference event channels", () => { } }) expect(independentMachine.parent).type.toBe() - Machine.make({ root: states, events: Events, internalEvents: InternalEvents, parent: Machine.optionalParent(ParentEvents), - emittedEvents: Emissions, - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + emittedEvents: Emissions }).handle({ + initial: { + target: Machine.targets(states).root.Idle, + decoded: true, + data: new Idle({}) + }, states: { Idle: { on: { @@ -98,12 +148,10 @@ describe("machine reference event channels", () => { 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()) } - expect(enqueue.raise).type.toBeCallableWith(InternalEvents.Local()) expect(enqueue.emit).type.toBeCallableWith(Emissions.Published()) expect(enqueue.emit).type.toBeCallableWith(Emissions.ValuedPublished({ value: 1 })) @@ -116,36 +164,43 @@ describe("machine reference event channels", () => { } }) }) - it("composes builder protocols and checks required parent inputs", () => { const compatible = Machine.make({ children: { source1: Child }, - root: states, - events: Machine.eventsFromSchemas(Ping, ParentEvents), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas(Ping, ParentEvents) }) compatible.handle({ - states: { - Idle: { invoke: { src: "source1" } } - } + initial: { + target: Machine.targets(states).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: { invoke: { src: "source1" } } } }) - const incompatible = Machine.make({ children: { worker: Child }, root: states, - events: Machine.eventsFromSchemas(Ping, OtherParentEvent), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas(Ping, OtherParentEvent) + }) + expect(incompatible.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(states).root.Idle, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(incompatible.handle).type.not.toBeCallableWith({ - states: { - Idle: { - invoke: { src: "worker" } + states: { Idle: { invoke: { src: "worker" } } }, + initial: { + target: Machine.targets(states).root.Idle, + data: () => { + throw new Error("type-only constructor") } } }) }) - it("infers emitted streams through MachineRef and AtomMachine", () => { const preparedEffect = Machine.prepare(childMachine) type Prepared = Effect.Success @@ -156,40 +211,49 @@ describe("machine reference event channels", () => { Machine.Snapshot, Machine.InfiniteTransitionError | Machine.MachineSchemaDecodeError | Machine.StoppedError >, - | Machine.InfiniteTransitionError - | Machine.MachineSchemaDecodeError - | Machine.StartupError - | Machine.StoppedError + Machine.InfiniteTransitionError | Machine.MachineSchemaDecodeError | Machine.StartupError | Machine.StoppedError > >() - const started = Machine.start(childMachine) type Ref = Effect.Success expect().type.toBe>() - const atom = AtomMachine.make(childMachine) const atomEmissions = AtomMachine.emissions(atom) expect>().type.toBe() expect>().type.toBe() }) - it("contextually binds invocation self and parent references to the owning machine protocols", () => { Machine.make({ effects: { - source1: ({ - parent, - self - }: Machine.Machine.InvokeContext< + source1: ({ parent, self }: Machine.Machine.InvokeContext< { - readonly "": { readonly initial: "Idle"; readonly states: { readonly Idle: typeof Idle } } & { + readonly "": { + readonly initial: "Idle" + readonly states: { + readonly Idle: typeof Idle + } + } & { readonly "~effect/Machine/ExplicitInitial": true } }, - readonly [typeof Ping, typeof Local], - readonly [typeof Published, typeof ValuedPublished], + readonly [ + typeof Ping, + typeof Local + ], + readonly [ + typeof Published, + typeof ValuedPublished + ], "Idle", - readonly [typeof Ping], - Machine.Machine.ParentEventSchemas<"required", readonly [typeof ParentNotice]> + readonly [ + typeof Ping + ], + Machine.Machine.ParentEventSchemas< + "required", + readonly [ + typeof ParentNotice + ] + > >) => { expect(self.send).type.toBeCallableWith(Events.Ping()) expect(self.send).type.not.toBeCallableWith(InternalEvents.Local()) @@ -199,14 +263,17 @@ describe("machine reference event channels", () => { return Effect.void } }, - root: states, events: Events, internalEvents: InternalEvents, parent: Machine.parent(ParentEvents), - emittedEvents: Emissions, - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + emittedEvents: Emissions }).handle({ + initial: { + target: Machine.targets(states).root.Idle, + decoded: true, + data: new Idle({}) + }, states: { Idle: { invoke: { @@ -226,15 +293,18 @@ describe("machine reference event channels", () => { } } }) - const requiredParentMachine = Machine.make({ effects: { source1: Effect.suspend(() => Effect.fail("failed" as const)) }, - root: states, events: Events, - parent: Machine.parent(ParentEvents), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.from())) + parent: Machine.parent(ParentEvents) }).handle({ + initial: { + target: Machine.targets(states).root.Idle, + data: () => { + throw new Error("type-only constructor") + } + }, states: { Idle: { invoke: { @@ -253,7 +323,6 @@ describe("machine reference event channels", () => { } } }) - expect(Machine.start).type.not.toBeCallableWith(requiredParentMachine) expect(Machine.prepare).type.not.toBeCallableWith(requiredParentMachine) expect(Machine.planInitial).type.not.toBeCallableWith(requiredParentMachine) diff --git a/packages/effect-machine/typetest/machine/Readiness.tst.ts b/packages/effect-machine/typetest/machine/Readiness.tst.ts index 15697b7f..3cddedd8 100644 --- a/packages/effect-machine/typetest/machine/Readiness.tst.ts +++ b/packages/effect-machine/typetest/machine/Readiness.tst.ts @@ -4,20 +4,16 @@ import { Machine } from "../../src/index.js" import { MachineTest } from "../../src/testing/index.js" import { ClusterMachine } from "../../src/unstable/cluster/index.js" import { AtomMachine } from "../../src/unstable/reactivity/index.js" - class Ready extends Schema.TaggedClass("Ready")("Ready", {}) {} class Flow extends Schema.TaggedClass("Flow")("Flow", {}) {} 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.state({ - initial: "Ready", states: { Ready, Flow: { schema: Flow, - initial: "Route", states: { Route: { type: "choice" }, Idle @@ -25,21 +21,31 @@ const choiceStates = Machine.state({ } } }) - const choiceIncomplete = Machine.make({ root: choiceStates, - events: Machine.eventsFromSchemas(Tick), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Ready.decoded(new Ready({}))))) + events: Machine.eventsFromSchemas(Tick) +}).handle({ + initial: { + target: Machine.targets(choiceStates).root.Ready, + decoded: true, + data: new Ready({}) + }, + states: { + Ready: {}, + Flow: { + initial: { + target: Machine.targets(choiceStates).root.Flow.Route + }, + states: { Idle: {} } + } + } }) const choiceSnapshot = { path: "" as const, value: undefined, state: { path: "Ready" as const, value: new Ready({}) } } - const historyStates = Machine.state({ - initial: "Ready", states: { Ready, Flow: { schema: Flow, - initial: "Idle", states: { Idle, recent: { type: "history", history: "deep" } @@ -47,16 +53,30 @@ const historyStates = Machine.state({ } } }) - const historyIncomplete = Machine.make({ root: historyStates, - events: Machine.eventsFromSchemas(Tick), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Ready.decoded(new Ready({}))))) + events: Machine.eventsFromSchemas(Tick) +}).handle({ + initial: { + target: Machine.targets(historyStates).root.Ready, + decoded: true, + data: new Ready({}) + }, + states: { + Ready: {}, + Flow: { + initial: { + target: Machine.targets(historyStates).root.Flow.Idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Idle: {} } + } + } }) const historySnapshot = { path: "" as const, value: undefined, state: { path: "Ready" as const, value: new Ready({}) } } - const outputStates = Machine.state({ - initial: "Ready", states: { Ready, Done: { @@ -66,11 +86,16 @@ const outputStates = Machine.state({ } } }) - const outputIncomplete = Machine.make({ root: outputStates, - events: Machine.eventsFromSchemas(Tick), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Ready.decoded(new Ready({}))))) + events: Machine.eventsFromSchemas(Tick) +}).handle({ + initial: { + target: Machine.targets(outputStates).root.Ready, + decoded: true, + data: new Ready({}) + }, + states: { Ready: {} } }) const outputSnapshot = { path: "Ready" as const, value: new Ready({}) } const parent = Machine.make({ @@ -83,7 +108,6 @@ const parent = Machine.make({ } }) const bound = null as unknown as AtomMachine.Bound - describe("executable machine readiness", () => { it("rejects an unimplemented choice at every planning and execution boundary", () => { expect(Machine.planInitial).type.not.toBeCallableWith(choiceIncomplete) @@ -92,8 +116,22 @@ describe("executable machine readiness", () => { expect(Machine.can).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot, new Tick({})) expect(Machine.start).type.not.toBeCallableWith(choiceIncomplete) expect(Machine.resume).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot) + expect(parent.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(outputStates).root.Ready, + data: () => { + throw new Error("type-only constructor") + } + } + }) expect(parent.handle).type.not.toBeCallableWith({ - states: { Ready: { invoke: { src: "choice", onDone: { none: true } } } } + states: { Ready: { invoke: { src: "choice", onDone: { none: true } } } }, + initial: { + target: Machine.targets(outputStates).root.Ready, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(MachineTest.run).type.not.toBeCallableWith(choiceIncomplete, { events: [] }) expect(AtomMachine.make).type.not.toBeCallableWith(choiceIncomplete) @@ -104,7 +142,6 @@ describe("executable machine readiness", () => { expect(bound.resume).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot) expect(ClusterMachine.make).type.not.toBeCallableWith("Choice", choiceIncomplete, { version: "1" }) }) - it("rejects an unimplemented history default at every planning and execution boundary", () => { expect(Machine.planInitial).type.not.toBeCallableWith(historyIncomplete) expect(Machine.plan).type.not.toBeCallableWith(historyIncomplete, historySnapshot, new Tick({})) @@ -113,7 +150,13 @@ describe("executable machine readiness", () => { expect(Machine.start).type.not.toBeCallableWith(historyIncomplete) expect(Machine.resume).type.not.toBeCallableWith(historyIncomplete, historySnapshot) expect(parent.handle).type.not.toBeCallableWith({ - states: { Ready: { invoke: { src: "history", onDone: { none: true } } } } + states: { Ready: { invoke: { src: "history", onDone: { none: true } } } }, + initial: { + target: Machine.targets(outputStates).root.Ready, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(MachineTest.run).type.not.toBeCallableWith(historyIncomplete, { events: [] }) expect(AtomMachine.make).type.not.toBeCallableWith(historyIncomplete) @@ -124,7 +167,6 @@ describe("executable machine readiness", () => { expect(bound.resume).type.not.toBeCallableWith(historyIncomplete, historySnapshot) expect(ClusterMachine.make).type.not.toBeCallableWith("History", historyIncomplete, { version: "1" }) }) - it("rejects an unimplemented output at every planning and execution boundary", () => { expect(Machine.planInitial).type.not.toBeCallableWith(outputIncomplete) expect(Machine.plan).type.not.toBeCallableWith(outputIncomplete, outputSnapshot, new Tick({})) @@ -133,7 +175,13 @@ describe("executable machine readiness", () => { expect(Machine.start).type.not.toBeCallableWith(outputIncomplete) expect(Machine.resume).type.not.toBeCallableWith(outputIncomplete, outputSnapshot) expect(parent.handle).type.not.toBeCallableWith({ - states: { Ready: { invoke: { src: "output", onDone: { none: true } } } } + states: { Ready: { invoke: { src: "output", onDone: { none: true } } } }, + initial: { + target: Machine.targets(outputStates).root.Ready, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(MachineTest.run).type.not.toBeCallableWith(outputIncomplete, { events: [] }) expect(AtomMachine.make).type.not.toBeCallableWith(outputIncomplete) @@ -144,15 +192,12 @@ describe("executable machine readiness", () => { expect(bound.resume).type.not.toBeCallableWith(outputIncomplete, outputSnapshot) expect(ClusterMachine.make).type.not.toBeCallableWith("Output", outputIncomplete, { version: "1" }) }) - it("accepts a complete machine and preserves its exact channels through every adapter", () => { const completeStates = Machine.state({ - initial: "Ready", states: { Ready, Flow: { schema: Flow, - initial: "Route", states: { Route: { type: "choice" }, Idle, @@ -169,30 +214,29 @@ describe("executable machine readiness", () => { const targets1 = Machine.targets(completeStates) const complete = Machine.make({ root: completeStates, - events: Machine.eventsFromSchemas(Tick), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.Ready.decoded(new Ready({}))))) + events: Machine.eventsFromSchemas(Tick) }).handle({ + initial: { + target: Machine.targets(completeStates).root.Ready, + decoded: true, + data: new Ready({}) + }, states: { Flow: { + initial: { + target: Machine.targets(completeStates).root.Flow.Route + }, history: { recent: { default: ({ target }) => - target.from((tree) => - tree.Flow.decoded( - new Flow({}), - (flow) => flow.Idle.decoded(new Idle({})) - ) - ) + target.from((tree) => tree.Flow.decoded(new Flow({}), (flow) => flow.Idle.decoded(new Idle({})))) } }, states: { Route: { - choice: { target: targets1.root.Flow.Idle, decoded: () => (new Idle({})) } + choice: { target: targets1.root.Flow.Idle, decoded: true, data: () => (new Idle({})) } }, - Done: { - output: ({ state }) => state.value - } + Done: { output: ({ state }) => state.value } } } } @@ -202,7 +246,6 @@ describe("executable machine readiness", () => { value: undefined, state: { path: "Ready" as const, value: new Ready({}) } } - const plannedInitial = Machine.planInitial(complete) const planned = Machine.plan(complete, completeSnapshot, new Tick({})) const can = Machine.can(complete, completeSnapshot, new Tick({})) @@ -217,24 +260,22 @@ describe("executable machine readiness", () => { const makeBoundAtom = bound.factory(complete) const boundResumedAtom = bound.resume(complete, completeSnapshot) const cluster = ClusterMachine.make("Complete", complete, { version: "1" }) - type AtomChannels = A extends AtomMachine.MachineAtom ? - readonly [State, Event, Output] - : never - + readonly [ + State, + Event, + Output + ] : + never expect>().type.toBe<"Ready" | "Flow.Idle">() expect>().type.toBe() expect>().type.toBe() - expect["state"]>().type.toBe< - Machine.Snapshot - >() + expect["state"]>().type.toBe>() expect>().type.toBe() expect(canComplete).type.toBeCallableWith(completeSnapshot, new Tick({})) expect(makeAtom).type.toBeCallableWith() expect(makeBoundAtom).type.toBeCallableWith() - expect["next"]>().type.toBe< - Machine.Snapshot - >() + expect["next"]>().type.toBe>() expect["send"]>().type.toBe< (event: Machine.Machine.EventInput) => Effect.Effect >() diff --git a/packages/effect-machine/typetest/machine/Resume.tst.ts b/packages/effect-machine/typetest/machine/Resume.tst.ts index 985eef2c..4a079412 100644 --- a/packages/effect-machine/typetest/machine/Resume.tst.ts +++ b/packages/effect-machine/typetest/machine/Resume.tst.ts @@ -3,47 +3,48 @@ import { Atom } from "effect/unstable/reactivity" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" 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.state({ initial: "Idle", states: { Idle } }) +const States = Machine.state({ + fields: { + input: Schema.toType(Schema.Struct({ seed: Schema.Number })) + }, + states: { Idle } +}) type Snapshot = Machine.Snapshot - const targets1 = Machine.targets(States) const machine = Machine.make({ root: States, events: Machine.eventsFromSchemas(Tick), - input: Schema.Struct({ seed: Schema.Number }), - initialConfiguration: (root) => - root.resolve(({ input: input, target }) => (target.from((to) => to.Idle.decoded(new Idle({ value: input.seed }))))) + input: Schema.Struct({ seed: Schema.Number }) }).handle({ + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: ({ root: { input: input } }) => new Idle({ value: input.seed }) + }, + root: ({ input }) => ({ input }), states: { Idle: { on: { - Tick: { target: targets1.root.Idle, decoded: ({ state }) => (new Idle({ value: state.value + 1 })) } + Tick: { target: targets1.root.Idle, decoded: true, data: ({ state }) => (new Idle({ value: state.value + 1 })) } } } } }) - const snapshot: Snapshot = { path: "" as const, - value: undefined, + value: { _tag: "", input: { seed: 0 } }, state: { path: "Idle", value: new Idle({ value: 3 }) } } - describe("Machine logical resumption", () => { it("excludes input while preserving synchronous runtime inference", () => { const resumed = Machine.resume(machine, snapshot) type Ref = Effect.Success - expect>().type.toBe() expect>().type.toBe() expect>().type.toBe< - | Machine.InfiniteTransitionError - | Machine.MachineSchemaDecodeError - | Machine.StoppedError + Machine.InfiniteTransitionError | Machine.MachineSchemaDecodeError | Machine.StoppedError >() expect>().type.toBe() expect(Machine.resume).type.not.toBeCallableWith(machine, snapshot, { seed: 1 }) @@ -53,12 +54,10 @@ describe("Machine logical resumption", () => { active: [{ path: "" }, { path: "Idle", value: { _tag: "Idle", value: 3 } }] }) }) - it("mirrors inference through the atom bridge", () => { const bridge = AtomMachine.resume(machine, snapshot) type RefFailure = Atom.Failure type ResultFailure = Atom.Failure - expect(AtomMachine.resume).type.toBeCallableWith(machine, snapshot) 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 index cadd7ca1..4ae63df7 100644 --- a/packages/effect-machine/typetest/machine/Root.tst.ts +++ b/packages/effect-machine/typetest/machine/Root.tst.ts @@ -1,72 +1,77 @@ 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>().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("constructs root data and initial children at the handler boundary", () => { + const definition = Machine.make({ root, events, input: Schema.Number }) + expect(Machine.start).type.not.toBeCallableWith(definition, 1) + expect(definition).type.not.toHaveProperty("initial") + expect(definition.handle).type.not.toBeCallableWith({ initial: { target: Machine.targets(root).root.Saving } }) + expect(definition.handle).type.not.toBeCallableWith({ + root: { count: 0 }, + initial: { target: Machine.targets(root).root.Editing } + }) + const machine = definition.handle({ + root: ({ input }) => { + expect(input).type.toBe() + return { count: input } + }, + initial: { + target: Machine.targets(root).root.Editing, + data: ({ root, state }) => { + expect(root.count).type.toBe() + expect(state.count).type.toBe() + return { draft: String(root.count) } + } + } + }) + expect(Machine.start).type.toBeCallableWith(machine, 1) }) - - 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("keeps a reusable root independent from machine initialization", () => { + const definition = Machine.make({ root, events }) + const editing = definition.handle({ + root: { count: 0 }, + initial: { target: Machine.targets(root).root.Editing, data: { draft: "" } } + }) + const saving = definition.handle({ root: { count: 1 }, initial: { target: Machine.targets(root).root.Saving } }) + expect(Machine.start).type.toBeCallableWith(editing) + expect(Machine.start).type.toBeCallableWith(saving) + expect(root.node).type.not.toHaveProperty("initial") }) - - test("does not accept raw constructor shorthand or incompatible fields", () => { - // fields and schema express different construction contracts. + test("rejects conflicting schemas and invalid root values", () => { // @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 })) - } + const definition = Machine.make({ root, events }) + expect(definition.handle).type.not.toBeCallableWith({ + root: { count: "zero" }, + initial: { target: Machine.targets(root).root.Saving } + }) + expect(definition.handle).type.not.toBeCallableWith({ + root: { decoded: true, data: { count: 0 } }, + initial: { target: Machine.targets(root).root.Saving } }) }) }) diff --git a/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts b/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts index 785c94b9..82113844 100644 --- a/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts +++ b/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class Root extends Schema.TaggedClass("Root")("Root", {}) {} class Left extends Schema.TaggedClass("Left")("Left", {}) {} class LeftIdle extends Schema.TaggedClass("LeftIdle")("LeftIdle", {}) {} @@ -9,9 +8,7 @@ class LeftDone extends Schema.TaggedClass("LeftDone")("LeftDone", {}) class Right extends Schema.TaggedClass("Right")("Right", {}) {} class RightIdle extends Schema.TaggedClass("RightIdle")("RightIdle", {}) {} class Advance extends Schema.TaggedClass("Advance")("Advance", {}) {} - const States = Machine.state({ - initial: "Root", states: { Root: { schema: Root, @@ -19,7 +16,6 @@ const States = Machine.state({ states: { Left: { schema: Left, - initial: "LeftIdle", states: { LeftIdle, LeftDone: { schema: LeftDone, type: "final" } @@ -27,14 +23,12 @@ const States = Machine.state({ }, Right: { schema: Right, - initial: "RightIdle", states: { RightIdle } } } } } }) - describe("Machine transition snapshot context", () => { it("infers the complete machine snapshot for event, always, and onDone handlers", () => { const targets1 = Machine.targets(States) @@ -43,21 +37,24 @@ describe("Machine transition snapshot context", () => { transition1: { destination: { target: targets1.root.Root.Left.LeftIdle } }, transition2: { destination: { target: targets1.root.Root.Left.LeftDone } } }, - 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({})))) - ))) + events: Machine.eventsFromSchemas(Advance) }).handle({ + initial: { + target: Machine.targets(States).root.Root, + decoded: true, + data: new Root({}) + }, states: { Root: { + initial: { Left: { decoded: true, data: new Left({}) }, Right: { decoded: true, data: new Right({}) } }, states: { Left: { + initial: { + decoded: true, + data: new LeftIdle({}), + target: Machine.targets(States).root.Root.Left.LeftIdle + }, onDone: { branches: "transition1", resolve: ({ snapshot, select: { destination: target } }) => { @@ -87,24 +84,32 @@ describe("Machine transition snapshot context", () => { } } } - } + }, + LeftDone: {} } + }, + Right: { + initial: { + target: Machine.targets(States).root.Root.Right.RightIdle, + decoded: true, + data: new RightIdle({}) + }, + states: { RightIdle: {} } } } } } }) }) - 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", {}) {} + class Flow extends Schema.TaggedClass("Flow")("Flow", {}) { + } + class Active extends Schema.TaggedClass("Active")("Active", {}) { + } const choiceStates = Machine.state({ - initial: "Flow", states: { Flow: { schema: Flow, - initial: "Routing", states: { Routing: { type: "choice" }, Active @@ -115,14 +120,19 @@ describe("Machine transition snapshot context", () => { const targets2 = Machine.targets(choiceStates) Machine.make({ branches: { transition1: { destination: { target: targets2.root.Flow.Active } } }, - root: choiceStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.Flow.decoded(new Flow({}), (flow) => flow.Routing())))) + events: Machine.eventsFromSchemas() }).handle({ + initial: { + target: Machine.targets(choiceStates).root.Flow, + decoded: true, + data: new Flow({}) + }, states: { Flow: { + initial: { + target: Machine.targets(choiceStates).root.Flow.Routing + }, entry: (context) => { expect(context).type.not.toHaveProperty("snapshot") }, @@ -135,7 +145,8 @@ describe("Machine transition snapshot context", () => { return context.select.destination.decoded(new Active({})) } } - } + }, + Active: {} } } } diff --git a/packages/effect-machine/typetest/machine/StateDefinition.tst.ts b/packages/effect-machine/typetest/machine/StateDefinition.tst.ts index b93a6408..59039626 100644 --- a/packages/effect-machine/typetest/machine/StateDefinition.tst.ts +++ b/packages/effect-machine/typetest/machine/StateDefinition.tst.ts @@ -15,14 +15,12 @@ describe("exact state definitions", () => { arbitrarySchemaAnnotation: { owner: "machine-team" } }) 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" } }, @@ -54,35 +52,29 @@ describe("exact state definitions", () => { it("rejects unknown properties for every state kind, including nested nodes", () => { 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 } + Root: { schema: Root, 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 }, @@ -91,17 +83,12 @@ describe("exact state definitions", () => { } } }) - expect(Machine.make).type.not.toBeCallableWith({ + expect(Machine.state).type.not.toBeCallableWith({ states: { Root: { schema: Root, - initial: "Idle", states: { Idle: { schema: Idle, nestedUnknown: true } } } - }, - events: Machine.eventsFromSchemas(), - initial: (): never => { - throw new Error("unreachable") } }) }) @@ -109,25 +96,23 @@ describe("exact state definitions", () => { it("rejects empty, dotted, numeric-form, symbol, and __proto__ keys recursively", () => { const symbolKey = Symbol("state") - 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({ states: { "": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { "bad.path": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { 0: Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { "01": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { "-1": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { "1e3": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { "0x10": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { " 1": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { "1 ": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { " ": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { "\t": Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { [symbolKey]: Idle } }) + expect(Machine.state).type.not.toBeCallableWith({ states: { __proto__: Idle } }) expect(Machine.state).type.not.toBeCallableWith({ - initial: "Root", states: { Root: { schema: Root, - initial: "bad.path", states: { "bad.path": Idle } } } @@ -136,7 +121,6 @@ describe("exact state definitions", () => { type InvalidKey = Machine.Machine.ValidateStateSchemas<{ readonly Root: { readonly schema: typeof Root - readonly initial: "bad.path" readonly states: { readonly "bad.path": typeof Idle } } }> @@ -146,7 +130,6 @@ describe("exact state definitions", () => { type InvalidProperty = Machine.Machine.ValidateStateSchemas<{ readonly Root: { readonly schema: typeof Root - readonly initial: "Idle" readonly states: { readonly Idle: { readonly schema: typeof Idle; readonly nestedUnknown: true } } @@ -158,20 +141,16 @@ describe("exact state definitions", () => { it("rejects child keys reserved by definition-time target selectors", () => { expect(Machine.state).type.not.toBeCallableWith({ - initial: "Root", states: { Root: { - initial: "initial", states: { initial: Idle } } } }) expect(Machine.state).type.not.toBeCallableWith({ - initial: "Root", states: { Root: { schema: Root, - initial: "with", states: { with: Idle } } } @@ -180,7 +159,6 @@ describe("exact state definitions", () => { it("checks reusable state definitions and finite path families against the full tree", () => { const TradingSlot = Machine.state({ - initial: "Idle", states: { Idle: {}, InSession: Idle, @@ -188,7 +166,6 @@ describe("exact state definitions", () => { } }) const States = Machine.state({ - initial: "root", states: { root: { type: "parallel", @@ -205,10 +182,8 @@ describe("exact state definitions", () => { } }, teamStatus: { - initial: "TeamLoaded", states: { TeamLoaded: { - initial: "SlotSelected", states: { SlotSelected: Idle } } } @@ -236,15 +211,13 @@ describe("exact state definitions", () => { expect(States.path).type.not.toBeCallableWith(invalidFamily) expect(States.path).type.not.toBeCallableWith(partlyInvalidFamily) - expect(Machine.state).type.not.toBeCallableWith({ - initial: "Missing", + expect(Machine.state).type.toBeCallableWith({ states: { Idle: {}, InSession: Idle } }) - expect(Machine.state).type.not.toBeCallableWith({ + expect(Machine.state).type.toBeCallableWith({ states: { Idle: {}, InSession: Idle } }) expect(Machine.state).type.not.toBeCallableWith({ - initial: "Idle", states: { Idle: {}, InSession: Idle, Misspelled: { unknown: true } } }) }) diff --git a/packages/effect-machine/typetest/machine/StateUpdate.tst.ts b/packages/effect-machine/typetest/machine/StateUpdate.tst.ts index f669cae5..bc4f615a 100644 --- a/packages/effect-machine/typetest/machine/StateUpdate.tst.ts +++ b/packages/effect-machine/typetest/machine/StateUpdate.tst.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class Root extends Schema.TaggedClass("Root")("Root", { revision: Schema.Number }) {} class Work extends Schema.TaggedClass("Work")("Work", { revision: Schema.Number }) {} class Auth extends Schema.TaggedClass("Auth")("Auth", { user: Schema.String }) {} @@ -10,13 +9,10 @@ class SignedOut extends Schema.TaggedClass("SignedOut")("SignedOut", class SignedIn extends Schema.TaggedClass("SignedIn")("SignedIn", {}) {} class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} class Tick extends Schema.TaggedClass("Tick")("Tick", {}) {} - const States = Machine.state({ - initial: "structural", states: { root: { schema: Root, - initial: "work", states: { work: { schema: Work, @@ -24,12 +20,10 @@ const States = Machine.state({ states: { auth: { schema: Auth, - initial: "signedOut", states: { signedOut: SignedOut, signedIn: { schema: SignedIn, type: "final" } } }, sync: { schema: Sync, - initial: "idle", states: { idle: Idle } } } @@ -38,43 +32,41 @@ const States = Machine.state({ } }, structural: { - initial: "idle", states: { idle: Idle } } } }) - describe("Machine state-value updates", () => { it("exposes updates only for the valued active ancestor chain", () => { const targets1 = Machine.targets(States) const machine = Machine.make({ branches: { auth: { owner: { update: targets1.root.root.work.auth } } }, 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({}))) - )) - ) - ) + events: Machine.eventsFromSchemas(Tick) }) - machine.handle({ + initial: { + target: Machine.targets(States).root.root, + decoded: true, + data: new Root({ revision: 0 }) + }, states: { root: { + initial: { + target: Machine.targets(States).root.root.work, + decoded: true, + data: new Work({ revision: 0 }) + }, states: { work: { states: { auth: { + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, states: { signedOut: { on: { @@ -90,17 +82,42 @@ describe("Machine state-value updates", () => { } } } - } + }, + signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + decoded: true, + data: new Idle({}) + }, + states: { idle: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") } } } } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {} } } } }) }) - it("requires the declared retained owner replacement", () => { const targets = Machine.targets(States) const machine = Machine.make({ @@ -113,12 +130,27 @@ describe("Machine state-value updates", () => { } }) machine.handle({ + initial: { + target: Machine.targets(States).root.structural + }, states: { root: { + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } + }, states: { work: { states: { auth: { + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, states: { signedOut: { on: { @@ -135,22 +167,64 @@ describe("Machine state-value updates", () => { } } } - } + }, + signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") } } } } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {} } } } }) machine.handle({ + initial: { + target: Machine.targets(States).root.structural + }, states: { root: { + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } + }, states: { work: { states: { auth: { + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, states: { signedOut: { on: { @@ -160,12 +234,95 @@ describe("Machine state-value updates", () => { resolve: ({ select }) => select.signedIn.decoded(new SignedIn({})) } } + }, + signedIn: {} + } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + } + } + } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {} } + } + } + }) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(States).root.root, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + root: { + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + work: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") } } } } } } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + } } } }) @@ -185,21 +342,78 @@ describe("Machine state-value updates", () => { } } } + }, + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + target: Machine.targets(States).root.root, + data: () => { + throw new Error("type-only constructor") } } }) machine.handle({ + initial: { + target: Machine.targets(States).root.structural + }, states: { root: { + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } + }, states: { work: { states: { auth: { + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + }, states: { signedOut: { on: { @@ -210,12 +424,39 @@ describe("Machine state-value updates", () => { ancestors.root.revision === 0 ? select.changed.from({ revision: 1 }) : decline() } } - } + }, + signedIn: {} } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {} } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") } } } } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { idle: {} } } } }) @@ -235,25 +476,228 @@ describe("Machine state-value updates", () => { } } } + }, + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") } } } + }, + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } + } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(States).root.root, + data: () => { + throw new Error("type-only constructor") + } } }) }) - it("rejects updates to structural states, choice updates, and final-state transitions", () => { const targets = Machine.targets(States) const machine = Machine.make({ root: States, events: Machine.eventsFromSchemas(Tick) }) + expect(machine.handle).type.toBeCallableWith({ + initial: { + target: Machine.targets(States).root.root, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + root: { + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + work: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + }) expect(machine.handle).type.not.toBeCallableWith({ - states: { structural: { on: { Tick: { update: targets.root.structural, from: () => ({}) } } } } + states: { + structural: { + on: { Tick: { update: targets.root.structural, data: () => ({}) } }, + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + } + }, + root: { + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { + work: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + } + } + }, + initial: { + target: Machine.targets(States).root.root, + data: () => { + throw new Error("type-only constructor") + } + } }) expect(machine.handle).type.not.toBeCallableWith({ states: { - root: { states: { routing: { choice: { update: targets.root.root, from: () => ({ revision: 1 }) } } } } + root: { + states: { + routing: { choice: { update: targets.root.root, data: () => ({ revision: 1 }) } }, + work: { + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } + }, + states: { + auth: { + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + } + }, + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } + } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + target: Machine.targets(States).root.root, + data: () => { + throw new Error("type-only constructor") + } } }) expect(machine.handle).type.not.toBeCallableWith({ @@ -264,12 +708,56 @@ describe("Machine state-value updates", () => { states: { auth: { states: { - signedIn: { on: { Tick: { update: targets.root.root.work.auth, from: () => ({ user: "next" }) } } } + signedIn: { + on: { Tick: { update: targets.root.root.work.auth, data: () => ({ user: "next" }) } } + } + }, + initial: { + target: Machine.targets(States).root.root.work.auth.signedOut, + data: () => { + throw new Error("type-only constructor") + } + } + }, + sync: { + initial: { + target: Machine.targets(States).root.root.work.sync.idle, + data: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + auth: () => { + throw new Error("type-only constructor") + }, + sync: () => { + throw new Error("type-only constructor") + } } } + }, + initial: { + target: Machine.targets(States).root.root.work, + data: () => { + throw new Error("type-only constructor") + } } + }, + structural: { + initial: { + target: Machine.targets(States).root.structural.idle, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + initial: { + target: Machine.targets(States).root.root, + data: () => { + throw new Error("type-only constructor") } } }) diff --git a/packages/effect-machine/typetest/machine/StructuralStates.tst.ts b/packages/effect-machine/typetest/machine/StructuralStates.tst.ts index 874c7263..d851095e 100644 --- a/packages/effect-machine/typetest/machine/StructuralStates.tst.ts +++ b/packages/effect-machine/typetest/machine/StructuralStates.tst.ts @@ -1,7 +1,6 @@ import { type Option, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" - class Loading extends Schema.TaggedClass("StructuralTypeLoading")("Loading", { url: Schema.String }) {} @@ -24,21 +23,17 @@ class Loaded extends Schema.TaggedClass("StructuralTypeLoaded")("Loaded" duration: Schema.Number }) {} class Play extends Schema.TaggedClass("StructuralTypePlay")("Play", {}) {} - 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 @@ -47,24 +42,30 @@ const States = Machine.state({ } }, settings: { - initial: "Audible", states: { Audible, Muted } } } } } }) - describe("structural active state types", () => { it("separates active, valued, and structural identifiers", () => { - expect>().type.toBe< + expect< + Machine.Machine.ValuedStateIdentifier<{ + readonly "": typeof States.node + }> + >().type.toBe< | "player.transport.Loading" | "player.transport.Ready" | "player.transport.Ready.Playing" | "player.settings.Audible" | "player.settings.Muted" >() - expect>().type.toBe< + expect< + Machine.Machine.StructuralStateIdentifier<{ + readonly "": typeof States.node + }> + >().type.toBe< | "" | "player" | "player.transport" @@ -73,66 +74,58 @@ describe("structural active state types", () => { | "player.settings" >() }) - it("requires values only for schema-backed snapshot builders", () => { - Machine.make({ - root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => - root.resolve(({ target }) => { - expect(target.from).type.not.toBeCallableWith({}, () => undefined) - expect) => any ? true : false>().type.toBe() - - 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) - - type TransportBuilder = Parameters[0] extends - (builder: infer Builder) => unknown ? Builder - : never - const transport = null as unknown as TransportBuilder - expect(transport.Empty.from).type.toBeCallableWith() - expect(transport.Empty.from).type.not.toBeCallableWith({}) - type SettingsBuilder = Parameters[0] extends - (builder: infer Builder) => unknown ? Builder - : never - const settings = null as unknown as SettingsBuilder - expect(settings.Audible.from).type.toBeCallableWith({ volume: 1 }) - expect(settings.Audible.from).type.not.toBeCallableWith() - - return target.from((to) => - to.player.from((player) => - player - .transport.from((transport) => transport.Empty.from()) - .settings.from((settings) => settings.Audible.from({ volume: 1 })) - ) - ) - }) - }) + const target = null as unknown as Machine.Machine.HistoryDefaultTargetBuilder< + { readonly "": typeof States.node }, + "" + > + expect(target.from).type.not.toBeCallableWith({}, () => undefined) + expect) => any ? true : false>().type.toBe() + 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) + type TransportBuilder = Parameters[0] extends (builder: infer Builder) => unknown ? + Builder : + never + const transport = null as unknown as TransportBuilder + expect(transport.Empty.from).type.toBeCallableWith() + expect(transport.Empty.from).type.not.toBeCallableWith({}) + type SettingsBuilder = Parameters[0] extends (builder: infer Builder) => unknown + ? Builder + : never + const settings = null as unknown as SettingsBuilder + expect(settings.Audible.from).type.toBeCallableWith({ volume: 1 }) + expect(settings.Audible.from).type.not.toBeCallableWith() + 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.Snapshot const snapshot = null as unknown as Snapshot - expect(States.get(snapshot, "player.transport.Loading")).type.toBe>() expect(States.get).type.not.toBeCallableWith(snapshot, "player") expect(States.get).type.not.toBeCallableWith(snapshot, "player.transport.Empty") expect(States.getWithParents).type.not.toBeCallableWith(snapshot, "player.transport") - expect(States.matches).type.toBeCallableWith(snapshot, "player") 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< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "player.transport"> + > >() }) - it("types structural handler contexts and targets without fake values", () => { const targets1 = Machine.targets(States) Machine.make({ @@ -140,22 +133,19 @@ describe("structural active state types", () => { transition1: { destination: { target: targets1.root.player.transport.Ready } }, transition2: { destination: { target: targets1.root.player.transport.Ready } } }, - 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 })) - ) - ))) + events: Machine.eventsFromSchemas(Select, Loaded, Play) }).handle({ + initial: { + target: Machine.targets(States).root.player + }, states: { player: { states: { transport: { + initial: { + target: Machine.targets(States).root.player.transport.Empty + }, states: { Empty: { entry: ({ state }) => { @@ -164,7 +154,7 @@ describe("structural active state types", () => { on: { Select: { target: targets1.root.player.transport.Loading, - from: ({ containingState, ancestors, state }) => { + data: ({ containingState, ancestors, state }) => { expect(state).type.toBe() expect(containingState).type.toBe() expect(ancestors).type.toBe<{}>() @@ -178,14 +168,14 @@ describe("structural active state types", () => { Loaded: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.from( - { duration: event.duration }, - (ready) => ready.Paused.from() - ) + target.from({ duration: event.duration }, (ready) => ready.Paused.from()) } } }, Ready: { + initial: { + target: Machine.targets(States).root.player.transport.Ready.Paused + }, states: { Paused: { on: { @@ -195,7 +185,9 @@ describe("structural active state types", () => { expect(target).type.not.toBeAssignableTo<() => unknown>() expect(state).type.toBe() expect(containingState).type.toBe() - expect(ancestors).type.toBe<{ readonly "player.transport.Ready": Ready }>() + expect(ancestors).type.toBe<{ + readonly "player.transport.Ready": Ready + }>() return target.from( { duration: containingState.duration }, (ready) => ready.Playing.from({ position: 0 }) @@ -203,17 +195,24 @@ describe("structural active state types", () => { } } } - } + }, + Playing: {} } } } + }, + settings: { + initial: { + target: Machine.targets(States).root.player.settings.Audible, + data: { volume: 1 } + }, + states: { Audible: {}, Muted: {} } } } } } }) }) - it("rejects malformed structural declarations", () => { expect(Machine.state).type.not.toBeCallableWith({ initial: "invalid", diff --git a/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts b/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts index 72e29978..85a60184 100644 --- a/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts +++ b/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts @@ -1,38 +1,31 @@ import { Schema } from "effect" import { describe, expect, test } from "tstyche" import { Machine } from "../../src/index.js" - class Root extends Schema.TaggedClass("Root")("Root", { count: Schema.Number }) {} class Saved extends Schema.TaggedClass("Saved")("Saved", { text: Schema.String }) {} const root1 = Machine.state({ schema: Root, - initial: "Idle", states: { Idle: {}, Saved: { schema: Saved }, Nested: { fields: { label: Schema.String }, - initial: "Child", states: { Child: { fields: { required: Schema.String } } } } } }) - const targets1 = Machine.targets(root1) const definition = Machine.make({ branches: { saved: { ready: { target: targets1.root.Saved } } }, root: root1, - events: Machine.events({ Save: { text: Schema.String } }), - initial: (root) => { - expect(root).type.not.toHaveProperty("guard") - expect(root).type.not.toHaveProperty("reenter") - return root.from(() => ({ count: 0 })) - } + events: Machine.events({ Save: { text: Schema.String } }) }) - describe("transition construction", () => { test("infers both values and preserves explicit construction requirements", () => { definition.handle({ + initial: { + target: Machine.targets(root1).root.Idle + }, states: { Idle: { on: { @@ -45,7 +38,36 @@ describe("transition construction", () => { expect(event.text).type.toBe() return event.text.length > 0 }, - from: ({ root, event }) => ({ target: { text: event.text }, update: { count: root.count + 1 } }) + data: ({ root, event }) => ({ target: { text: event.text }, update: { count: root.count + 1 } }) + } + } + }, + Saved: {}, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Child: {} } + } + }, + root: () => { + throw new Error("type-only constructor") + } + }) + expect(definition.handle).type.toBeCallableWith({ + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle }, + states: { + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") } } } @@ -58,11 +80,23 @@ describe("transition construction", () => { Save: { target: targets1.root.Saved, update: targets1.root, - from: () => ({ target: {}, update: { count: 1 } }) + data: () => ({ target: {}, update: { count: 1 } }) + } + } + }, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") } } } - } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ states: { @@ -71,18 +105,42 @@ describe("transition construction", () => { Save: { target: targets1.root.Saved, update: targets1.root, - from: () => ({ target: { text: "" }, update: {} }) + data: () => ({ target: { text: "" }, update: {} }) + } + } + }, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") } } } - } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ states: { Idle: { - on: { Save: { target: targets1.root.Saved, update: targets1.root, from: () => ({ target: { text: "" } }) } } + on: { Save: { target: targets1.root.Saved, update: targets1.root, data: () => ({ target: { text: "" } }) } } + }, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } } - } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ states: { @@ -91,11 +149,24 @@ describe("transition construction", () => { Save: { target: targets1.root.Saved, update: targets1.root, - decoded: () => ({ target: { text: "" }, update: new Root({ count: 1 }) }) + decoded: true, + data: () => ({ target: { text: "" }, update: new Root({ count: 1 }) }) + } + } + }, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") } } } - } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ states: { @@ -104,23 +175,53 @@ describe("transition construction", () => { Save: { target: targets1.root.Saved, update: targets1.root, - decoded: () => ({ target: new Saved({ text: "" }), update: { count: 1 } }) + decoded: true, + data: () => ({ target: new Saved({ text: "" }), update: { count: 1 } }) + } + } + }, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") } } } - } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle } }) definition.handle({ + initial: { + target: Machine.targets(root1).root.Idle + }, states: { Idle: { on: { Save: { target: targets1.root.Saved, update: targets1.root, - decoded: () => ({ target: new Saved({ text: "" }), update: new Root({ count: 1 }) }) + decoded: true, + data: () => ({ target: new Saved({ text: "" }), update: new Root({ count: 1 }) }) } } + }, + Saved: {}, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Child: {} } } + }, + root: () => { + throw new Error("type-only constructor") } }) expect(definition.handle).type.not.toBeCallableWith({ @@ -130,36 +231,115 @@ describe("transition construction", () => { Save: { target: targets1.root.Nested, update: targets1.root, - from: () => ({ target: { label: "" }, update: { count: 1 } }) + data: () => ({ target: { label: "" }, update: { count: 1 } }) + } + } + }, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") } } } - } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { on: { Save: { update: targets1.root.Idle, from: () => undefined } } } } + states: { + Idle: { on: { Save: { update: targets1.root.Idle, data: () => undefined } } }, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle } }) }) test("guards updates without allowing incomplete replacements or implicit reentry", () => { definition.handle({ + initial: { + target: Machine.targets(root1).root.Idle + }, on: { Save: { update: targets1.root, guard: ({ root, event }) => root.count > event.text.length, - decoded: ({ root }) => { + decoded: true, + data: ({ root }) => { expect(root).type.toBe() return root } } + }, + states: { + Idle: {}, + Saved: {}, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Child: {} } + } + }, + root: () => { + throw new Error("type-only constructor") } }) - expect(definition.handle).type.not.toBeCallableWith({ on: { Save: { update: targets1.root, from: () => ({}) } } }) expect(definition.handle).type.not.toBeCallableWith({ - on: { Save: { update: targets1.root, reenter: true, from: () => ({ count: 1 }) } } + on: { Save: { update: targets1.root, data: () => ({}) } }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle }, + states: { + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + } + }) + expect(definition.handle).type.not.toBeCallableWith({ + on: { Save: { update: targets1.root, reenter: true, data: () => ({ count: 1 }) } }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle }, + states: { + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + } }) }) test("reentry composes with value construction and branch resolution", () => { definition.handle({ + initial: { + target: Machine.targets(root1).root.Idle + }, states: { Idle: { on: { @@ -172,11 +352,38 @@ describe("transition construction", () => { } } } + }, + Saved: {}, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + }, + states: { Child: {} } } + }, + root: () => { + throw new Error("type-only constructor") } }) expect(definition.handle).type.not.toBeCallableWith({ - states: { Idle: { on: { Save: { target: targets1.root.Saved, resolve: () => undefined } } } } + states: { + Idle: { on: { Save: { target: targets1.root.Saved, resolve: () => undefined } } }, + Nested: { + initial: { + target: Machine.targets(root1).root.Nested.Child, + data: () => { + throw new Error("type-only constructor") + } + } + } + }, + root: () => { + throw new Error("type-only constructor") + }, + initial: { target: Machine.targets(root1).root.Idle } }) }) }) diff --git a/packages/effect-machine/typetest/testing/Coverage.tst.ts b/packages/effect-machine/typetest/testing/Coverage.tst.ts index 2c925abb..4986410c 100644 --- a/packages/effect-machine/typetest/testing/Coverage.tst.ts +++ b/packages/effect-machine/typetest/testing/Coverage.tst.ts @@ -4,35 +4,40 @@ import * as Schema from "effect/Schema" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" import { MachineTest } from "../../src/testing/index.js" - describe("MachineTest coverage and observed graph", () => { - class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - class Done extends Schema.TaggedClass("Done")("Done", {}) {} - class Start extends Schema.TaggedClass("Start")("Start", {}) {} - - const States = Machine.state({ initial: "idle", states: { idle: Idle, done: Done } }) + class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + } + class Done extends Schema.TaggedClass("Done")("Done", {}) { + } + class Start extends Schema.TaggedClass("Start")("Start", {}) { + } + const States = Machine.state({ states: { idle: Idle, done: Done } }) const targets1 = Machine.targets(States) const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(Start), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas(Start) }).handle({ + initial: { + target: Machine.targets(States).root.idle, + decoded: true, + data: new Idle({}) + }, states: { idle: { on: { - Start: { target: targets1.root.done, decoded: () => (new Done({})) } + Start: { target: targets1.root.done, decoded: true, data: () => (new Done({})) } } }, done: {} } }) const trace = {} as MachineTest.Trace - it("preserves machine-specific paths and public event tags in coverage", () => { const result = MachineTest.coverage(machine, trace) - expect(result).type.toBe>() - if (result.events.available) expect(result.events.hits[0]!.tag).type.toBe<"Start">() + 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.transitions.definitions.hits[0]!.trigger).type.toBe>() @@ -44,24 +49,22 @@ describe("MachineTest coverage and observed graph", () => { Machine.Machine.TransitionBranch<"" | "idle" | "done"> >() }) - it("returns an Effect graph with typed nodes and concrete edge evidence", () => { const result = MachineTest.observedGraph(machine, trace) - expect>().type.toBe>() expect["graph"]>().type.toBe< - Graph.DirectedGraph< - MachineTest.ObservedGraphNode, - MachineTest.ObservedGraphEdge - > + Graph.DirectedGraph, MachineTest.ObservedGraphEdge> >() expect>().type.toBe() expect>().type.toBe() - type Node = MachineTest.ObservedGraphNode type Edge = MachineTest.ObservedGraphEdge expect().type.toBe<"" | "idle" | "done">() expect().type.toBe() - expect["event"]>().type.toBe() + expect< + Extract["event"] + >().type.toBe() }) }) diff --git a/packages/effect-machine/typetest/testing/Exploration.tst.ts b/packages/effect-machine/typetest/testing/Exploration.tst.ts index b0b0131e..52ea4bc2 100644 --- a/packages/effect-machine/typetest/testing/Exploration.tst.ts +++ b/packages/effect-machine/typetest/testing/Exploration.tst.ts @@ -2,23 +2,33 @@ import { Effect, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" import { MachineTest } from "../../src/testing/index.js" - describe("MachineTest exploration", () => { - class Input extends Schema.Class("Input")({ seed: Schema.Int }) {} - class Counter extends Schema.TaggedClass("Counter")("Counter", { count: Schema.Int }) {} - class Increment extends Schema.TaggedClass("Increment")("Increment", {}) {} - class Internal extends Schema.TaggedClass("Internal")("Internal", {}) {} - - const States = Machine.state({ initial: "counter", states: { counter: Counter } }) - + class Input extends Schema.Class("Input")({ seed: Schema.Int }) { + } + class Counter extends Schema.TaggedClass("Counter")("Counter", { count: Schema.Int }) { + } + class Increment extends Schema.TaggedClass("Increment")("Increment", {}) { + } + class Internal extends Schema.TaggedClass("Internal")("Internal", {}) { + } + const States = Machine.state({ + fields: { + input: Schema.toType(Input) + }, + states: { counter: Counter } + }) const machine = Machine.make({ root: States, events: Machine.eventsFromSchemas(Increment), internalEvents: Machine.internalEventsFromSchemas(Internal), - input: Input, - initialConfiguration: (root) => - root.resolve(({ input, target }) => target.from((to) => to.counter.decoded(new Counter({ count: input.seed })))) + input: Input }).handle({ + initial: { + target: Machine.targets(States).root.counter, + decoded: true, + data: ({ root: { input: input } }) => new Counter({ count: input.seed }) + }, + root: ({ input }) => ({ input }), states: { counter: { on: { @@ -28,7 +38,6 @@ describe("MachineTest exploration", () => { } } }) - it("infers input, state, public events, and state keys", () => { const explored = MachineTest.explore(machine, { input: new Input({ seed: 0 }), @@ -42,14 +51,12 @@ describe("MachineTest exploration", () => { return context.snapshot.state.value.count } }) - expect>().type.toBe>() expect>().type.toBe< | MachineTest.RunFailure, typeof machine> | MachineTest.InvariantError >() expect>().type.toBe() - type Exploration = Effect.Success expect().type.toBe<"" | "counter">() expect().type.toBe< @@ -59,7 +66,6 @@ describe("MachineTest exploration", () => { Machine.Machine.TransitionBranch<"" | "counter"> >() }) - it("keeps reachability witnesses and errors machine-specific", () => { const exploration = {} as MachineTest.Exploration const reachable = MachineTest.assertReachable(exploration, "one", ({ key, snapshot }) => { @@ -68,20 +74,24 @@ describe("MachineTest exploration", () => { return key === 1 }) const unreachable = MachineTest.assertUnreachable(exploration, "two", ({ key }) => key === 2) - expect>().type.toBe>() expect>().type.toBe>() expect>().type.toBe() expect>().type.toBe>() }) - it("forbids input for machines without an input schema", () => { + const NoInputStates = Machine.state({ states: { counter: Counter } }) const noInput = Machine.make({ - root: States, - events: Machine.eventsFromSchemas(Increment), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.counter.decoded(new Counter({ count: 0 }))))) - }).handle({ states: { counter: {} } }) + root: NoInputStates, + events: Machine.eventsFromSchemas(Increment) + }).handle({ + initial: { + target: Machine.targets(NoInputStates).root.counter, + decoded: true, + data: new Counter({ count: 0 }) + }, + 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 a38d4815..8ecc7ffb 100644 --- a/packages/effect-machine/typetest/testing/Invariant.tst.ts +++ b/packages/effect-machine/typetest/testing/Invariant.tst.ts @@ -2,21 +2,24 @@ import { Effect, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" import { MachineTest } from "../../src/testing/index.js" - describe("MachineTest invariants", () => { - class Idle extends Schema.TaggedClass("Idle")("Idle", { count: Schema.Int }) {} - class Tick extends Schema.TaggedClass("Tick")("Tick", { amount: Schema.Int }) {} - class Internal extends Schema.TaggedClass("Internal")("Internal", {}) {} - - const States = Machine.state({ initial: "idle", states: { idle: Idle } }) - + class Idle extends Schema.TaggedClass("Idle")("Idle", { count: Schema.Int }) { + } + class Tick extends Schema.TaggedClass("Tick")("Tick", { amount: Schema.Int }) { + } + class Internal extends Schema.TaggedClass("Internal")("Internal", {}) { + } + const States = Machine.state({ states: { idle: Idle } }) const machine = Machine.make({ 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 }))))) + internalEvents: Machine.internalEventsFromSchemas(Internal) }).handle({ + initial: { + target: Machine.targets(States).root.idle, + decoded: true, + data: new Idle({ count: 0 }) + }, states: { idle: { on: { @@ -26,9 +29,7 @@ describe("MachineTest invariants", () => { } } }) - const define = MachineTest.invariants(machine) - it("infers exact state, event, and trace evidence from a machine-bound builder", () => { const state = define.state("state", (context) => { expect(context.machine).type.toBe() @@ -47,12 +48,10 @@ describe("MachineTest invariants", () => { expect(context.trace).type.toBe>() return true }) - expect(state).type.toBe>() expect(step).type.toBe>() expect(trace).type.toBe>() }) - it("preserves the machine type through reports and structured errors", () => { const trace = {} as MachineTest.Trace const checked = MachineTest.checkInvariants(machine, trace, [ @@ -60,7 +59,6 @@ describe("MachineTest invariants", () => { define.step("step", () => true), define.trace("trace", () => true) ]) - expect>().type.toBe() expect>().type.toBe>() expect>().type.toBe() @@ -68,12 +66,10 @@ describe("MachineTest invariants", () => { expect["violations"][number]["configuration"]>().type.toBe< ReadonlyArray<"" | "idle"> | undefined >() - const asserted = MachineTest.assertInvariants(machine, trace, [define.state("state", () => true)]) expect>().type.toBe() expect>().type.toBe>() }) - it("supports direct constructors with an explicit machine type", () => { const direct = MachineTest.Invariant.step("direct", ({ event }) => event.amount >= 0) expect(direct).type.toBe>() diff --git a/packages/effect-machine/typetest/testing/MachineTest.tst.ts b/packages/effect-machine/typetest/testing/MachineTest.tst.ts index ffb379f1..71013479 100644 --- a/packages/effect-machine/typetest/testing/MachineTest.tst.ts +++ b/packages/effect-machine/typetest/testing/MachineTest.tst.ts @@ -3,24 +3,29 @@ import { FastCheck } from "effect/testing" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" import { MachineTest } from "../../src/testing/index.js" - describe("MachineTest", () => { - class Input extends Schema.Class("Input")({ id: Schema.String }) {} - class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} + class Input extends Schema.Class("Input")({ id: Schema.String }) { + } + class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + } class PublicEvent extends Schema.TaggedClass("PublicEvent")("PublicEvent", { value: Schema.Number - }) {} - class InternalEvent extends Schema.TaggedClass("InternalEvent")("InternalEvent", {}) {} - - const States = Machine.state({ initial: "idle", states: { idle: Idle } }) - + }) { + } + class InternalEvent extends Schema.TaggedClass("InternalEvent")("InternalEvent", {}) { + } + const States = Machine.state({ states: { idle: Idle } }) const machine = Machine.make({ root: States, events: Machine.eventsFromSchemas(PublicEvent), internalEvents: Machine.internalEventsFromSchemas(InternalEvent), - input: Input, - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) + input: Input }).handle({ + initial: { + target: Machine.targets(States).root.idle, + decoded: true, + data: new Idle({}) + }, states: { idle: { on: { @@ -30,17 +35,14 @@ describe("MachineTest", () => { } } }) - it("preserves input and public event types in generated scenarios", () => { const generated = MachineTest.scenarios(machine) expect(generated.arbitrary).type.toBe>>() - type Scenario = MachineTest.Scenario expect().type.toBe() expect().type.toBe>() expect().type.not.toBeAssignableTo() }) - it("types whole-value arbitrary overrides", () => { const options: MachineTest.ScenarioOptions = { inputArbitrary: FastCheck.constant(new Input({ id: "test" })), @@ -49,35 +51,35 @@ describe("MachineTest", () => { expect(options.inputArbitrary).type.toBe | undefined>() expect(options.eventsArbitrary).type.toBe> | undefined>() }) - it("omits input for machines without an input schema", () => { const noInput = Machine.make({ root: States, - events: Machine.eventsFromSchemas(PublicEvent), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) - }).handle({ states: { idle: {} } }) + events: Machine.eventsFromSchemas(PublicEvent) + }).handle({ + initial: { + target: Machine.targets(States).root.idle, + decoded: true, + data: new Idle({}) + }, + states: { idle: {} } + }) type Scenario = MachineTest.Scenario type Options = MachineTest.ScenarioOptions - expect().type.toBe<"events">() expect().type.toBe() }) - it("retains typed trace plans and transition metadata", () => { const scenario: MachineTest.Scenario = { input: new Input({ id: "test" }), events: [new PublicEvent({ value: 1 })] } const executed = MachineTest.run(machine, scenario) - expect>().type.toBe>() expect>().type.toBe< MachineTest.RunFailure, typeof machine> >() - 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">() @@ -86,7 +88,6 @@ describe("MachineTest", () => { >() 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">() @@ -94,10 +95,9 @@ describe("MachineTest", () => { Machine.Machine.TransitionTrigger<"PublicEvent" | "InternalEvent"> >() }) - it("does not require invoke services while planning scenarios", () => { - class InvokeRequirement extends Context.Service()("InvokeRequirement") {} - + class InvokeRequirement extends Context.Service()("InvokeRequirement") { + } const invokedMachine = Machine.make({ effects: { source1: Effect.suspend(() => @@ -106,32 +106,24 @@ describe("MachineTest", () => { }) ) }, - root: States, - events: Machine.eventsFromSchemas(PublicEvent), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas(PublicEvent) }).handle({ - states: { - idle: { - invoke: { src: "source1", id: "service-backed-invoke", onDone: { none: true } } - } - } + initial: { + target: Machine.targets(States).root.idle, + decoded: true, + data: new Idle({}) + }, + states: { idle: { invoke: { src: "source1", id: "service-backed-invoke", onDone: { none: true } } } } }) - const executed = MachineTest.run(invokedMachine, { events: [] }) - expect>().type.toBe() expect>().type.toBe() }) - it("keeps runtime commands on the public event protocol", () => { const generated = MachineTest.runtimeCommands(machine) - expect(generated.arbitrary).type.toBe< - FastCheck.Arbitrary>> - >() - expect(MachineTest.sendCommand(new PublicEvent({ value: 1 }))).type.toBe< - MachineTest.RuntimeCommand - >() + expect(generated.arbitrary).type.toBe>>>() + expect(MachineTest.sendCommand(new PublicEvent({ value: 1 }))).type.toBe>() expect(MachineTest.sendCommand(new InternalEvent({}))).type.not.toBeAssignableTo< MachineTest.RuntimeCommand >() @@ -141,14 +133,19 @@ describe("MachineTest", () => { MachineTest.RuntimeCommand >() }) - it("preserves model and assertion errors and services in runtime command checks", () => { - class ModelFailure extends Data.TaggedError("ModelFailure")<{}> {} - class InspectionFailure extends Data.TaggedError("InspectionFailure")<{}> {} - class AssertionFailure extends Data.TaggedError("AssertionFailure")<{}> {} - class ModelRequirement extends Context.Service()("ModelRequirement") {} - class InspectionRequirement extends Context.Service()("InspectionRequirement") {} - class AssertionRequirement extends Context.Service()("AssertionRequirement") {} + class ModelFailure extends Data.TaggedError("ModelFailure")<{}> { + } + class InspectionFailure extends Data.TaggedError("InspectionFailure")<{}> { + } + class AssertionFailure extends Data.TaggedError("AssertionFailure")<{}> { + } + class ModelRequirement extends Context.Service()("ModelRequirement") { + } + class InspectionRequirement extends Context.Service()("InspectionRequirement") { + } + class AssertionRequirement extends Context.Service()("AssertionRequirement") { + } const started = Machine.start(machine, new Input({ id: "test" })) type Ref = Effect.Success const ref = null as unknown as Ref @@ -159,7 +156,9 @@ describe("MachineTest", () => { transition: (model) => Effect.gen(function*() { yield* ModelRequirement - if (model < 0) return yield* Effect.fail(new ModelFailure()) + if (model < 0) { + return yield* Effect.fail(new ModelFailure()) + } return { model: model + 1, expected: model + 1, @@ -169,7 +168,9 @@ describe("MachineTest", () => { inspect: () => Effect.gen(function*() { const inspected = yield* InspectionRequirement - if (inspected.length === 0) return yield* Effect.fail(new InspectionFailure()) + if (inspected.length === 0) { + return yield* Effect.fail(new InspectionFailure()) + } return inspected }), assert: () => @@ -178,11 +179,8 @@ describe("MachineTest", () => { return yield* Effect.fail(new AssertionFailure()) }) }) - expect["finalModel"]>().type.toBe() - expect["records"][number]["actual"]["inspected"]>().type.toBe< - string | undefined - >() + expect["records"][number]["actual"]["inspected"]>().type.toBe() expect>().type.toBe< ModelRequirement | InspectionRequirement | AssertionRequirement >() diff --git a/packages/effect-machine/typetest/testing/Probe.tst.ts b/packages/effect-machine/typetest/testing/Probe.tst.ts index 47776946..a64a4493 100644 --- a/packages/effect-machine/typetest/testing/Probe.tst.ts +++ b/packages/effect-machine/typetest/testing/Probe.tst.ts @@ -2,23 +2,26 @@ import { Effect, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" import { MachineTest } from "../../src/testing/index.js" - describe("MachineTest probe", () => { class State extends Schema.TaggedClass("ProbeTypeState")("State", { count: Schema.Number - }) {} - class PublicEvent extends Schema.TaggedClass("ProbeTypePublicEvent")("PublicEvent", {}) {} - class InternalEvent extends Schema.TaggedClass("ProbeTypeInternalEvent")("InternalEvent", {}) {} - - const states = Machine.state({ initial: "State", states: { State } }) - + }) { + } + class PublicEvent extends Schema.TaggedClass("ProbeTypePublicEvent")("PublicEvent", {}) { + } + class InternalEvent extends Schema.TaggedClass("ProbeTypeInternalEvent")("InternalEvent", {}) { + } + const states = Machine.state({ states: { State } }) const machine = Machine.make({ 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 }))))) + internalEvents: Machine.internalEventsFromSchemas(InternalEvent) }).handle({ + initial: { + target: Machine.targets(states).root.State, + decoded: true, + data: new State({ count: 0 }) + }, states: { State: { on: { @@ -28,12 +31,10 @@ describe("MachineTest probe", () => { } } }) - it("retains the machine state and public event protocol", () => { const started = Machine.start(machine) const attached = Effect.flatMap(started, (ref) => MachineTest.probe(machine, ref)) const sent = Effect.flatMap(attached, (probe) => probe.sendAndAwait(new PublicEvent({}))) - expect["machine"]>().type.toBe() expect>().type.toBe>() expect["before"]["state"]["value"]>().type.toBe() @@ -45,7 +46,6 @@ describe("MachineTest probe", () => { expect().type.toBeAssignableTo>() expect().type.toBeAssignableTo>() }) - it("infers causal command evidence and probe-bound asynchronous observations", () => { const started = Machine.start(machine) const executed = Effect.flatMap( @@ -75,18 +75,15 @@ describe("MachineTest probe", () => { } })) ) - expect["finalModel"]>().type.toBe() expect["records"][number]["actual"]["result"]>().type.toBe< MachineTest.CausalRuntimeCommandResult >() - type CausalFailure = Extract< - Effect.Error, - { readonly _tag: "MachineTestCausalRuntimeCommandFailure" } - > + type CausalFailure = Extract, { + readonly _tag: "MachineTestCausalRuntimeCommandFailure" + }> expect().type.not.toBe() }) - it("infers reusable runtime laws and law-oriented causal verification", () => { const invariant = MachineTest.runtimeInvariants(machine) const laws = [ @@ -113,7 +110,6 @@ describe("MachineTest probe", () => { MachineTest.sendCommand(new PublicEvent({})) ], { invariants: laws })) ) - expect>().type.toBe< MachineTest.CausalVerificationTranscript< typeof machine, @@ -126,7 +122,6 @@ describe("MachineTest probe", () => { >() type InvariantFailure = Extract, MachineTest.RuntimeInvariantError> expect().type.not.toBe() - const agreement = Effect.flatMap( verified, (transcript) => MachineTest.assertPlannerRuntimeAgreement(machine, transcript) diff --git a/packages/effect-machine/typetest/testing/Verification.tst.ts b/packages/effect-machine/typetest/testing/Verification.tst.ts index e611c550..9bafb393 100644 --- a/packages/effect-machine/typetest/testing/Verification.tst.ts +++ b/packages/effect-machine/typetest/testing/Verification.tst.ts @@ -2,40 +2,41 @@ import { Effect, Schema } from "effect" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" import { MachineTest } from "../../src/testing/index.js" - describe("MachineTest.verify", () => { - class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - class Tick extends Schema.TaggedClass("Tick")("Tick", {}) {} - - const States = Machine.state({ initial: "idle", states: { idle: Idle } }) + class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { + } + class Tick extends Schema.TaggedClass("Tick")("Tick", {}) { + } + const States = Machine.state({ states: { idle: Idle } }) const machine = Machine.make({ root: States, - events: Machine.eventsFromSchemas(Tick), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.idle.decoded(new Idle({}))))) - }).handle({ states: { idle: {} } }) - + events: Machine.eventsFromSchemas(Tick) + }).handle({ + initial: { + target: Machine.targets(States).root.idle, + decoded: true, + data: new Idle({}) + }, + states: { idle: {} } + }) const trace = {} as MachineTest.Trace const verified = MachineTest.verify(machine, trace) - it("has a void success and structured verification error", () => { expect>().type.toBe() expect>().type.toBe() expect>().type.toBe() - const error = {} as Effect.Error expect(error.violations[0]!.law).type.toBe() expect(error.violations[0]!.eventIndex).type.toBe() expect(error.violations[0]!.microstepIndex).type.toBe() expect(error.violations[0]!.path).type.toBe() }) - it("accepts only canonical law groups", () => { const options: MachineTest.VerifyOptions = { laws: ["configuration", "microsteps", "completion", "history", "definitions"] } expect(options.laws).type.toBe | undefined>() }) - it("exposes exact transition resolution violations", () => { expect<"definitions.resolution">().type.toBeAssignableTo() }) diff --git a/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts b/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts index 9a5e1aa0..24884575 100644 --- a/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts +++ b/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts @@ -4,7 +4,6 @@ import type { Rpc, RpcGroup } from "effect/unstable/rpc" import { describe, expect, it } from "tstyche" import { Machine } from "../../../src/index.js" import { ClusterMachine } from "../../../src/unstable/cluster/index.js" - describe("ClusterMachine", () => { it("preserves the public checkpoint and wire-schema contracts", () => { expect(ClusterMachine.CommitResult.Committed()).type.toBe() @@ -23,101 +22,101 @@ describe("ClusterMachine", () => { | "EmissionFailure" >() }) - class Count extends Schema.TaggedClass("Count")("Count", { value: Schema.Number - }) {} - + }) { + } class Increment extends Schema.TaggedClass("Increment")("Increment", { by: Schema.Number - }) {} - - class Reset extends Schema.TaggedClass("Reset")("Reset", {}) {} - + }) { + } + class Reset extends Schema.TaggedClass("Reset")("Reset", {}) { + } class Done extends Schema.TaggedClass("Done")("Done", { value: Schema.String - }) {} - + }) { + } class Input extends Schema.Class("Input")({ value: Schema.Number - }) {} - + }) { + } interface LocalResource { readonly close: () => void } - const LocalResource = Schema.declare((value): value is LocalResource => typeof value === "object" && value !== null && "close" in value && typeof value.close === "function" ) - class ResourceState extends Schema.TaggedClass("ResourceState")("ResourceState", { resource: LocalResource - }) {} - + }) { + } class ResourceEvent extends Schema.TaggedClass("ResourceEvent")("ResourceEvent", { resource: LocalResource - }) {} - + }) { + } class UnknownEvent extends Schema.TaggedClass("UnknownEvent")("UnknownEvent", { payload: Schema.Unknown - }) {} - + }) { + } class Scheduled extends Schema.TaggedClass("Scheduled")("Scheduled", { at: Schema.Date - }) {} - + }) { + } class PlanningService extends Context.Service()("test/ClusterMachine/PlanningService") {} - + }>()("test/ClusterMachine/PlanningService") { + } class ActionService extends Context.Service - }>()("test/ClusterMachine/ActionService") {} - - class SnapshotDecoding extends Context.Service()( - "test/ClusterMachine/SnapshotDecoding" - ) {} - - class SnapshotEncoding extends Context.Service()( - "test/ClusterMachine/SnapshotEncoding" - ) {} - - const states = Machine.state({ initial: "Count", states: { Count } }) - + }>()("test/ClusterMachine/ActionService") { + } + class SnapshotDecoding extends Context.Service()("test/ClusterMachine/SnapshotDecoding") { + } + class SnapshotEncoding extends Context.Service()("test/ClusterMachine/SnapshotEncoding") { + } + const states = Machine.state({ + states: { Count } + }) const targets1 = Machine.targets(states) const machine = Machine.make({ id: "Counter", root: states, - events: Machine.eventsFromSchemas(Increment, Reset), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.Count.decoded(new Count({ value: 0 }))))) + events: Machine.eventsFromSchemas(Increment, Reset) }).handle({ + initial: { + target: Machine.targets(states).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, states: { Count: { on: { Increment: { target: targets1.root.Count, - decoded: ({ event, state }) => (new Count({ value: state.value + event.by })) + decoded: true, + data: ({ event, state }) => (new Count({ value: state.value + event.by })) }, - Reset: { target: targets1.root.Count, decoded: () => (new Count({ value: 0 })) } + Reset: { target: targets1.root.Count, decoded: true, data: () => (new Count({ value: 0 })) } } } } }) - const bridge = ClusterMachine.make("CounterEntity", machine, { version: "1" }) - it("preserves the machine event union in the send RPC", () => { type Rpcs = RpcGroup.Rpcs expect>().type.toBe() expect>().type.toBe() - const internalMachine = Machine.make({ 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 }))))) + internalEvents: Machine.internalEventsFromSchemas(Reset) + }).handle({ + initial: { + target: Machine.targets(states).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, + states: { Count: {} } }) const internalBridge = ClusterMachine.make("InternalCounterEntity", internalMachine, { version: "1" @@ -125,31 +124,29 @@ describe("ClusterMachine", () => { type InternalRpcs = RpcGroup.Rpcs expect>().type.toBe() }) - it("retains Cluster service requirements", () => { const layer = bridge.toLayer() expect>().type.toBe< ClusterMachine.Storage | MessageStorage.MessageStorage | Sharding.Sharding >() }) - it("captures non-void machine input", () => { + const inputStates = Machine.state({ fields: { input: Input }, states: { Count } }) const inputMachine = Machine.make({ id: "InputCounter", - root: states, + root: inputStates, events: Machine.eventsFromSchemas(Reset), - input: Input, - initialConfiguration: (root) => - root.resolve(( - { input: input, target } - ) => (target.from((to) => to.Count.decoded(new Count({ value: input.value }))))) + input: Input + }).handle({ + initial: { + target: Machine.targets(inputStates).root.Count, + decoded: true, + data: ({ root: { input: input } }) => new Count({ value: input.value }) + }, + root: ({ input }) => ({ input }), + states: { Count: {} } }) - - expect(ClusterMachine.make).type.not.toBeCallableWith( - "InputCounterEntity", - inputMachine, - { version: "1" } - ) + expect(ClusterMachine.make).type.not.toBeCallableWith("InputCounterEntity", inputMachine, { version: "1" }) expect(ClusterMachine.make).type.toBeCallableWith( "InputCounterEntity", inputMachine, @@ -157,81 +154,69 @@ describe("ClusterMachine", () => { new Input({ value: 1 }) ) }) - it("uses encoded Machine snapshots in checkpoints", () => { expect().type.toBe() expect().type.toBe() expect().type.toBe() - expect().type.toBe< - Readonly> - >() + expect().type.toBe>>() }) - it("requires JSON-encoded state, output, and public input schemas", () => { - const resourceStates = Machine.state({ initial: "ResourceState", states: { ResourceState } }) + const resourceStates = Machine.state({ states: { ResourceState } }) const resourceMachine = Machine.make({ root: resourceStates, - events: Machine.eventsFromSchemas(ResourceEvent), - initialConfiguration: (root) => - root.resolve(({ target }) => - target.from((to) => to.ResourceState.decoded(new ResourceState({ resource: { close() {} } }))) - ) + events: Machine.eventsFromSchemas(ResourceEvent) + }).handle({ + initial: { + target: Machine.targets(resourceStates).root.ResourceState, + decoded: true, + data: new ResourceState({ resource: { close() {} } }) + }, + states: { ResourceState: {} } }) - - expect(ClusterMachine.make).type.not.toBeCallableWith( - "ResourceEntity", - resourceMachine, - { version: "1" } - ) - + expect(ClusterMachine.make).type.not.toBeCallableWith("ResourceEntity", resourceMachine, { version: "1" }) const unknownEventMachine = Machine.make({ root: states, - events: Machine.eventsFromSchemas(UnknownEvent), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Count.decoded(new Count({ value: 0 })))) + events: Machine.eventsFromSchemas(UnknownEvent) + }).handle({ + initial: { + target: Machine.targets(states).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, + states: { Count: {} } }) - - expect(ClusterMachine.make).type.not.toBeCallableWith( - "UnknownEventEntity", - unknownEventMachine, - { version: "1" } - ) - + expect(ClusterMachine.make).type.not.toBeCallableWith("UnknownEventEntity", unknownEventMachine, { version: "1" }) const anyOutputStates = Machine.state({ - initial: "Done", states: { Done: { schema: Done, type: "final", output: Schema.Any } } }) const anyOutputMachine = Machine.make({ root: anyOutputStates, - events: Machine.eventsFromSchemas(Reset), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Done.decoded(new Done({ value: "done" })))) + events: Machine.eventsFromSchemas(Reset) }).handle({ - states: { - Done: { output: () => null } - } + initial: { + target: Machine.targets(anyOutputStates).root.Done, + decoded: true, + data: new Done({ value: "done" }) + }, + states: { Done: { output: () => null } } }) - - expect(ClusterMachine.make).type.not.toBeCallableWith( - "AnyOutputEntity", - anyOutputMachine, - { version: "1" } - ) - + expect(ClusterMachine.make).type.not.toBeCallableWith("AnyOutputEntity", anyOutputMachine, { version: "1" }) const neverOutputStates = Machine.state({ - initial: "Done", states: { Done: { schema: Done, type: "final", output: Schema.Never } } }) const neverOutputMachine = Machine.make({ root: neverOutputStates, - events: Machine.eventsFromSchemas(Reset), - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Done.decoded(new Done({ value: "done" })))) + events: Machine.eventsFromSchemas(Reset) }).handle({ + initial: { + target: Machine.targets(neverOutputStates).root.Done, + decoded: true, + data: new Done({ value: "done" }) + }, states: { Done: { output: () => { @@ -240,50 +225,40 @@ describe("ClusterMachine", () => { } } }) - - expect(ClusterMachine.make).type.not.toBeCallableWith( - "NeverOutputEntity", - neverOutputMachine, - { version: "1" } - ) - + expect(ClusterMachine.make).type.not.toBeCallableWith("NeverOutputEntity", neverOutputMachine, { version: "1" }) const scheduledStates = Machine.state({ - initial: "Scheduled", states: { Scheduled: Schema.toCodecJson(Scheduled) } }) const scheduledMachine = Machine.make({ 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") }))) - ) + events: Machine.eventsFromSchemas(Reset) + }).handle({ + initial: { + target: Machine.targets(scheduledStates).root.Scheduled, + decoded: true, + data: new Scheduled({ at: new Date("2026-08-19") }) + }, + states: { Scheduled: {} } }) - - expect(ClusterMachine.make).type.toBeCallableWith( - "ScheduledEntity", - scheduledMachine, - { version: "1" } - ) - + expect(ClusterMachine.make).type.toBeCallableWith("ScheduledEntity", scheduledMachine, { version: "1" }) const internalResourceMachine = Machine.make({ 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 })))) + internalEvents: Machine.internalEventsFromSchemas(ResourceEvent) + }).handle({ + initial: { + target: Machine.targets(states).root.Count, + decoded: true, + data: new Count({ value: 0 }) + }, + states: { Count: {} } + }) + expect(ClusterMachine.make).type.toBeCallableWith("InternalResourceEntity", internalResourceMachine, { + version: "1" }) - - expect(ClusterMachine.make).type.toBeCallableWith( - "InternalResourceEntity", - internalResourceMachine, - { version: "1" } - ) }) - it("requires declared output implementations", () => { const outputStates = Machine.state({ - initial: "Done", states: { Done: { schema: Done, @@ -294,31 +269,19 @@ describe("ClusterMachine", () => { }) const incomplete = Machine.make({ root: outputStates, - events: Machine.eventsFromSchemas(Reset), - initialConfiguration: (root) => - root.resolve(({ target }) => (target.from((to) => to.Done.decoded(new Done({ value: "done" }))))) + events: Machine.eventsFromSchemas(Reset) }) - - expect(ClusterMachine.make).type.not.toBeCallableWith( - "Incomplete", - incomplete, - { version: "1" } - ) - + expect(ClusterMachine.make).type.not.toBeCallableWith("Incomplete", incomplete, { version: "1" }) const complete = incomplete.handle({ - states: { - Done: { - output: ({ state }) => state.value - } - } + initial: { + target: Machine.targets(outputStates).root.Done, + decoded: true, + data: new Done({ value: "done" }) + }, + states: { Done: { output: ({ state }) => state.value } } }) - expect(ClusterMachine.make).type.toBeCallableWith( - "Complete", - complete, - { version: "1" } - ) + expect(ClusterMachine.make).type.toBeCallableWith("Complete", complete, { version: "1" }) }) - it("retains snapshot codec service requirements", () => { const ContextualNumber = Schema.Number.pipe( Schema.decode({ @@ -332,24 +295,23 @@ describe("ClusterMachine", () => { ) class ContextualCount extends Schema.TaggedClass("ContextualCount")("ContextualCount", { value: ContextualNumber - }) {} - const contextualStates = Machine.state({ initial: "ContextualCount", states: { ContextualCount } }) + }) { + } + const contextualStates = Machine.state({ states: { ContextualCount } }) const contextualMachine = Machine.make({ root: contextualStates, - events: Machine.eventsFromSchemas(Reset), - initialConfiguration: (root) => - root.resolve(( - { target } - ) => (target.from((to) => to.ContextualCount.decoded(new ContextualCount({ value: 0 }))))) + events: Machine.eventsFromSchemas(Reset) + }).handle({ + initial: { + target: Machine.targets(contextualStates).root.ContextualCount, + decoded: true, + data: new ContextualCount({ value: 0 }) + }, + states: { ContextualCount: {} } }) const layer = ClusterMachine.make("ContextualCounter", contextualMachine, { version: "1" }).toLayer() - expect>().type.toBe< - | ClusterMachine.Storage - | MessageStorage.MessageStorage - | Sharding.Sharding - | SnapshotDecoding - | SnapshotEncoding + ClusterMachine.Storage | MessageStorage.MessageStorage | Sharding.Sharding | SnapshotDecoding | SnapshotEncoding >() }) }) diff --git a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts index 42f27411..ca07edc3 100644 --- a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts +++ b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts @@ -3,72 +3,48 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity" import { describe, expect, it } from "tstyche" import { Machine } from "../../../src/index.js" import { AtomMachine } from "../../../src/unstable/reactivity/index.js" - class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} - class Tick extends Schema.TaggedClass("Tick")("Tick", {}) {} - class InternalTick extends Schema.TaggedClass("InternalTick")("InternalTick", {}) {} - class Published extends Schema.TaggedClass("Published")("Published", {}) {} - class Done extends Schema.TaggedClass("Done")("Done", { value: Schema.String }) {} - class Dormant extends Schema.TaggedClass("Dormant")("Dormant", {}) {} - class Ready extends Schema.TaggedClass("Ready")("Ready", {}) {} - class Editor extends Schema.TaggedClass("Editor")("Editor", {}) {} - class Editing extends Schema.TaggedClass("Editing")("Editing", {}) {} - class Saving extends Schema.TaggedClass("Saving")("Saving", {}) {} - class Network extends Schema.TaggedClass("Network")("Network", {}) {} - class Online extends Schema.TaggedClass("Online")("Online", {}) {} - class Offline extends Schema.TaggedClass("Offline")("Offline", {}) {} - -class Multiplier extends Context.Service()("test/AtomMachine/Multiplier") {} - -class DeepService extends Context.Service()("test/AtomMachine/DeepService") {} - -class DeepInitialService extends Context.Service()( - "test/AtomMachine/DeepInitialService" -) {} - +class Multiplier extends Context.Service()("test/AtomMachine/Multiplier") { +} +class DeepService extends Context.Service()("test/AtomMachine/DeepService") { +} +class DeepInitialService extends Context.Service()("test/AtomMachine/DeepInitialService") { +} class DeepInitialFailure { readonly _tag = "DeepInitialFailure" } - class DeepTransitionFailure { readonly _tag = "DeepTransitionFailure" } - class DeepActionFailure { readonly _tag = "DeepActionFailure" } - class DeepRuntimeFailure { readonly _tag = "DeepRuntimeFailure" } - interface StartFailure { readonly _tag: "StartFailure" } - interface RuntimeFailure { readonly _tag: "RuntimeFailure" } - -const States = Machine.state({ initial: "Idle", states: { Idle } }) +const States = Machine.state({ states: { Idle } }) const Emissions = Machine.emittedEventsFromSchemas(Published) - const NestedStates = Machine.state({ - initial: "Dormant", states: { Dormant, Ready: { @@ -77,7 +53,6 @@ const NestedStates = Machine.state({ states: { editor: { schema: Editor, - initial: "Editing", states: { Editing, Saving @@ -85,7 +60,6 @@ const NestedStates = Machine.state({ }, network: { schema: Network, - initial: "Online", states: { Online, Offline @@ -95,15 +69,12 @@ const NestedStates = Machine.state({ } } }) - const StructuralNestedStates = Machine.state({ - initial: "Ready", states: { Ready: { type: "parallel", states: { editor: { - initial: "Idle", states: { Idle: {}, Saving @@ -114,56 +85,53 @@ const StructuralNestedStates = Machine.state({ } } }) - const makeMachine = () => Machine.make({ root: States, - events: Machine.eventsFromSchemas(Tick), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas(Tick) }).handle({ - states: { - Idle: {} - } + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: {} } }) - const makeEmittingMachine = () => Machine.make({ root: States, events: Machine.eventsFromSchemas(Tick), - emittedEvents: Emissions, - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) + emittedEvents: Emissions }).handle({ - states: { - Idle: {} - } + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: {} } }) - describe("AtomMachine", () => { it("derives invoked child protocols from the child descriptor", () => { const childMachine = makeMachine() const Child = Machine.child("child", childMachine) - const parentMachine = Machine.make({ children: { source1: Child }, - root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas() }).handle({ - states: { - Idle: { - invoke: { src: "source1" } - } - } + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: { invoke: { src: "source1" } } } }) const child = AtomMachine.make(parentMachine).child(Child) - expect>().type.toBe>>() expect ? Event : never>().type.toBe< Machine.Machine.EventInput >() }) - it("preserves emitted protocols across root and child selectors", () => { const machine = makeEmittingMachine() const direct = AtomMachine.make(machine) @@ -174,36 +142,40 @@ describe("AtomMachine", () => { const boundSelected = AtomMachine.select(bound, "Idle") const boundSnapshot = AtomMachine.selectSnapshot(bound, "Idle") const boundMatched = AtomMachine.matches(bound, "Idle") - expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "Idle"> + > >() expect>().type.toBe() expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "Idle"> + > >() expect>().type.toBe() expect(AtomMachine.select).type.not.toBeCallableWith(direct, "Missing") expect(AtomMachine.selectSnapshot).type.not.toBeCallableWith(direct, "Missing") expect(AtomMachine.matches).type.not.toBeCallableWith(direct, "Missing") - const Child = Machine.child("emitting-child", machine) - const parentMachine = Machine.make({ children: { source1: Child }, - root: States, events: Machine.eventsFromSchemas(), - emittedEvents: Emissions, - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) + emittedEvents: Emissions }).handle({ - states: { - Idle: { - invoke: { src: "source1" } - } - } + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: { invoke: { src: "source1" } } } }) const parent = AtomMachine.make(parentMachine) const child = parent.child(Child) @@ -212,11 +184,14 @@ describe("AtomMachine", () => { const childSnapshot = AtomMachine.selectSnapshotChild(child, "Idle") const childMatched = AtomMachine.matchesChild(child, "Idle") const childEmissions = AtomMachine.childEmissions(child) - expect().type.toBe() expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "Idle"> + > >() expect>().type.toBe() expect>().type.toBe() @@ -224,7 +199,6 @@ describe("AtomMachine", () => { expect(AtomMachine.selectSnapshotChild).type.not.toBeCallableWith(child, "Missing") expect(AtomMachine.matchesChild).type.not.toBeCallableWith(child, "Missing") }) - it("derives fail-aware results, selectors, and child bridge types", () => { const childMachine = makeMachine() const Child = Machine.child("child", childMachine) @@ -238,7 +212,6 @@ describe("AtomMachine", () => { const childSelected = AtomMachine.selectChild(child, "Idle") const childSelectedSnapshot = AtomMachine.selectSnapshotChild(child, "Idle") const childMatched = AtomMachine.matchesChild(child, "Idle") - expect().type.toBe< Atom.Atom> >() @@ -246,14 +219,20 @@ describe("AtomMachine", () => { Atom.Atom, StartFailure | RuntimeFailure>> >() expect>().type.toBe< - Option.Option> - >() - expect().type.toBe< - Atom.Atom> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "Idle"> + > >() + expect().type.toBe>>() expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "Idle"> + > >() expect>().type.toBe() expect>().type.toBe>() @@ -274,7 +253,6 @@ describe("AtomMachine", () => { expect(AtomMachine.selectChild).type.not.toBeCallableWith(child, States, "Idle") expect(AtomMachine.matchesChild).type.not.toBeCallableWith(child, States, "Idle") }) - it("infers exact compound and parallel paths from bridge snapshots", () => { type Snapshot = Machine.Snapshot type Parent = AtomMachine.MachineAtom @@ -290,16 +268,21 @@ describe("AtomMachine", () => { const path = null as unknown as "Dormant" | "Ready.editor.Editing" const selectedUnion = AtomMachine.select(parent, path) const widenedPath: string = "Ready" - expect>().type.toBe>() expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof NestedStates.node + }, "Ready.editor"> + > >() expect>().type.toBe>() expect>().type.toBe< Option.Option< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof NestedStates.node }, "Ready.editor.Editing"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof NestedStates.node + }, "Ready.editor.Editing"> > >() expect>().type.toBe() @@ -309,18 +292,25 @@ describe("AtomMachine", () => { expect(AtomMachine.select).type.not.toBeCallableWith(parent, "Ready.editor.Missing") expect(AtomMachine.matches).type.not.toBeCallableWith(parent, "Ready.network.Missing") expect(AtomMachine.select).type.not.toBeCallableWith(parent, widenedPath) - - type NestedMachine = Machine.Machine<{ readonly "": typeof NestedStates.node }, readonly [typeof Tick]> + 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") const childSelectedSnapshot = AtomMachine.selectSnapshotChild(child, "Ready.editor.Saving") const childMatched = AtomMachine.matchesChild(child, "Ready.network.Offline") - expect>().type.toBe>() expect>().type.toBe< Option.Option< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof NestedStates.node }, "Ready.editor.Saving"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof NestedStates.node + }, "Ready.editor.Saving"> > >() expect>().type.toBe() @@ -329,23 +319,26 @@ describe("AtomMachine", () => { expect(AtomMachine.selectChild).type.not.toBeCallableWith(child, "Idle") expect(AtomMachine.matchesChild).type.not.toBeCallableWith(child, "Ready.editor.Missing") }) - it("selects values only from schema-backed paths while structural paths remain queryable", () => { type Snapshot = Machine.Snapshot type Parent = AtomMachine.MachineAtom const parent = null as unknown as Parent - const readySnapshot = AtomMachine.selectSnapshot(parent, "Ready") const editorSnapshot = AtomMachine.selectSnapshot(parent, "Ready.editor") const matched = AtomMachine.matches(parent, "Ready.editor.Idle") const saving = AtomMachine.select(parent, "Ready.editor.Saving") - expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof StructuralNestedStates.node + }, "Ready"> + > >() expect>().type.toBe< Option.Option< - Machine.Machine.SnapshotByIdentifier<{ readonly "": typeof StructuralNestedStates.node }, "Ready.editor"> + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof StructuralNestedStates.node + }, "Ready.editor"> > >() expect>().type.toBe() @@ -355,7 +348,6 @@ describe("AtomMachine", () => { expect(AtomMachine.select).type.not.toBeCallableWith(parent, "Ready.editor.Idle") expect(AtomMachine.select).type.not.toBeCallableWith(parent, "Ready.network") }) - it("supports data-last root and child selectors", () => { type Snapshot = Machine.Snapshot type Parent = AtomMachine.MachineAtom @@ -364,31 +356,43 @@ describe("AtomMachine", () => { const selectedSnapshot = AtomMachine.selectSnapshot("Ready.editor")(parent) const matched = AtomMachine.matches("Ready.network.Online")(parent) const invalid = AtomMachine.select("Ready.editor.Missing") - const Child = Machine.child( "nested", - null as unknown as Machine.Machine<{ readonly "": typeof NestedStates.node }, readonly [typeof Tick]> + 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) const childSelectedSnapshot = AtomMachine.selectSnapshotChild("Ready.editor")(child) const childMatched = AtomMachine.matchesChild("Ready.network.Offline")(child) const invalidChild = AtomMachine.matchesChild("Ready.network.Missing") - expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof NestedStates.node + }, "Ready.editor"> + > >() expect>().type.toBe() expect(invalid).type.not.toBeCallableWith(parent) expect>().type.toBe>() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof NestedStates.node + }, "Ready.editor"> + > >() expect>().type.toBe() expect(invalidChild).type.not.toBeCallableWith(child) }) - it("infers static and reactive event acceptance projections", () => { type Snapshot = Machine.Snapshot type Parent = AtomMachine.MachineAtom< @@ -405,7 +409,6 @@ describe("AtomMachine", () => { const staticCan = staticProjection(parent) const reactiveCan = reactiveProjection(parent) const invalidProjection = AtomMachine.can(new InternalTick({})) - expect>().type.toBe() expect>().type.toBe() expect>().type.toBe< @@ -413,18 +416,19 @@ describe("AtomMachine", () => { >() expect(invalidProjection).type.not.toBeCallableWith(parent) }) - it("infers keyed root family inputs and exact projected atoms", () => { const Events = Machine.eventsFromSchemas(Tick) const machine = Machine.make({ root: States, events: Events, - input: Schema.String, - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) + input: Schema.String }).handle({ - states: { - Idle: {} - } + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: {} } }) const atoms = AtomMachine.family(machine, { atoms: { @@ -436,18 +440,20 @@ describe("AtomMachine", () => { send: (machine) => machine.send } }) - const selected = atoms.selected("one") const canTick = atoms.canTick("one") const snapshot = atoms.snapshot("one") const matched = atoms.matched("one") const state = atoms.state("one") const send = atoms.send("one") - expect>().type.toBe>() expect>().type.toBe() expect>().type.toBe< - Option.Option> + Option.Option< + Machine.Machine.SnapshotByIdentifier<{ + readonly "": typeof States.node + }, "Idle"> + > >() expect>().type.toBe() expect>().type.toBe>() @@ -461,21 +467,19 @@ describe("AtomMachine", () => { } }) }) - it("preserves bound runtime errors and child protocols through families", () => { const machine = Machine.make({ effects: { source1: Effect.suspend(() => Effect.as(Multiplier, undefined)) }, - root: States, events: Machine.eventsFromSchemas(Tick), - input: Schema.String, - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) + input: Schema.String }).handle({ - states: { - Idle: { - invoke: { src: "source1", id: "read-multiplier", onDone: { none: true } } - } - } + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: { invoke: { src: "source1", id: "read-multiplier", onDone: { none: true } } } } }) const runtime = Atom.runtime( Layer.merge( @@ -493,15 +497,20 @@ describe("AtomMachine", () => { const boundMachine = makeBoundMachine("one") type FamilyFailure = Atom.Failure> type FactoryFailure = Atom.Failure - const childMachine = makeMachine() const Child = Machine.childFamily(childMachine) const parent = AtomMachine.make( Machine.make({ root: States, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) - }).handle({ states: { Idle: {} } }) + events: Machine.eventsFromSchemas() + }).handle({ + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: {} } + }) ) const children = AtomMachine.familyChild(parent, { child: (id: string) => Child(id), @@ -511,7 +520,6 @@ describe("AtomMachine", () => { send: (child) => child.send } }) - expect>().type.toBe() expect>().type.toBe() expect(AtomMachine.factory).type.not.toBeCallableWith(machine) @@ -526,82 +534,80 @@ describe("AtomMachine", () => { >() expect(children.selected).type.not.toBeCallableWith(1) }) - it("accepts machines without external requirements", () => { expect(AtomMachine.make).type.toBeCallableWith(makeMachine()) }) - it("specializes machine definitions into inferred bridge constructors", () => { const makeBridge = AtomMachine.factory(makeMachine()) const bridge = makeBridge() type Bridge = ReturnType - expect(makeBridge).type.toBeCallableWith() expect(makeBridge).type.not.toBeCallableWith("input") expect().type.toBe() expect>().type.toBe>() - const inputMachine = Machine.make({ root: States, events: Machine.eventsFromSchemas(Tick), - input: Schema.String, - initialConfiguration: (root) => root.resolve(({ target }) => target.from((to) => to.Idle.decoded(new Idle({})))) - }).handle({ states: { Idle: {} } }) + input: Schema.String + }).handle({ + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: {} } + }) const makeInputBridge = AtomMachine.factory(inputMachine) expect(makeInputBridge).type.toBeCallableWith("input") expect(makeInputBridge).type.not.toBeCallableWith() expect(makeInputBridge).type.not.toBeCallableWith(1) - const runtime = Atom.runtime( Layer.effectDiscard(Effect.fail({ _tag: "StartFailure" } as const satisfies StartFailure)) ) const makeBoundBridge = AtomMachine.bind(runtime).factory(makeMachine()) const boundBridge = makeBoundBridge() type BoundFailure = Atom.Failure["result"]> - expect>().type.toBe() expect().type.toBe() expect().type.toBe>() }) - it("preserves bound runtime errors in the result failure channel", () => { const runtime = Atom.runtime( Layer.effectDiscard(Effect.fail({ _tag: "StartFailure" } as const satisfies StartFailure)) ) const bridge = AtomMachine.bind(runtime).make(makeMachine()) type Failure = Atom.Failure - expect>().type.toBe() expect().type.toBe() }) - it("only exposes public input events through atom send boundaries", () => { const targets4 = Machine.targets(States) const machine = Machine.make({ root: States, events: Machine.eventsFromSchemas(Tick), - internalEvents: Machine.internalEventsFromSchemas(InternalTick), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + internalEvents: Machine.internalEventsFromSchemas(InternalTick) }).handle({ + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: new Idle({}) + }, states: { Idle: { on: { - Tick: { target: targets4.root.Idle, decoded: () => (new Idle({})) }, - InternalTick: { target: targets4.root.Idle, decoded: () => (new Idle({})) } + Tick: { target: targets4.root.Idle, decoded: true, data: () => (new Idle({})) }, + InternalTick: { target: targets4.root.Idle, decoded: true, data: () => (new Idle({})) } } } } }) const bridge = AtomMachine.make(machine) - expect ? Event : never>().type.toBe< Machine.Machine.EventInput >() }) - it("requires output implementations and preserves exact terminal output", () => { const OutputStates = Machine.state({ - initial: "Idle", states: { Idle, Done: { @@ -613,28 +619,25 @@ describe("AtomMachine", () => { }) const incomplete = Machine.make({ root: OutputStates, - events: Machine.eventsFromSchemas(Tick), - initialConfiguration: (root) => root.resolve(({ target }) => (target.from((to) => to.Idle.decoded(new Idle({}))))) + events: Machine.eventsFromSchemas(Tick) }) const runtime = Atom.runtime(Layer.empty) const bound = AtomMachine.bind(runtime) - expect(AtomMachine.make).type.not.toBeCallableWith(incomplete) expect(AtomMachine.factory).type.not.toBeCallableWith(incomplete) expect(AtomMachine.make).type.not.toBeCallableWith(runtime, incomplete) expect(bound.make).type.not.toBeCallableWith(incomplete) expect(bound.factory).type.not.toBeCallableWith(incomplete) - const complete = incomplete.handle({ - states: { - Done: { - output: ({ state }) => state.value - } - } + initial: { + target: Machine.targets(OutputStates).root.Idle, + decoded: true, + data: new Idle({}) + }, + states: { Idle: {}, Done: { output: ({ state }) => state.value } } }) const bridge = AtomMachine.make(complete) type Output = typeof bridge extends AtomMachine.MachineAtom ? Output : never - expect().type.toBe() expect(AtomMachine.make).type.toBeCallableWith(complete) expect(AtomMachine.factory).type.toBeCallableWith(complete) diff --git a/packages/oxlint-plugin/README.md b/packages/oxlint-plugin/README.md index aa5a7d58..ccef92ac 100644 --- a/packages/oxlint-plugin/README.md +++ b/packages/oxlint-plugin/README.md @@ -77,7 +77,7 @@ transitions with default construction use `{ target: targets.root.Ready }`. ### `effect-machine/no-async-planning-callback` Rejects asynchronous work in transition construction, guards, resolvers, -lifecycle handlers, initializers, and invocation input mappers. Register lazy +lifecycle handlers, root and initial data constructors, and invocation input mappers. Register lazy Effects and Streams in `make`, then let a state invoke them: ```ts @@ -86,8 +86,8 @@ Submitting: { invoke: { src: "submitOrder", input: ({ state }) => state.order, - onDone: { target: targets.root.Complete, from: ({ output }) => ({ order: output }) }, - onFailure: { target: targets.root.Failed, from: ({ error }) => ({ message: String(error) }) } + onDone: { target: targets.root.Complete, data: ({ output }) => ({ order: output }) }, + onFailure: { target: targets.root.Failed, data: ({ error }) => ({ message: String(error) }) } } } ``` diff --git a/packages/oxlint-plugin/src/internal/planning.ts b/packages/oxlint-plugin/src/internal/planning.ts index a30ff726..19211622 100644 --- a/packages/oxlint-plugin/src/internal/planning.ts +++ b/packages/oxlint-plugin/src/internal/planning.ts @@ -1,7 +1,7 @@ import type { ESTree } from "@oxlint/plugins" import { resolvedVariable, staticMemberName } from "./ast.js" import { unwrapExpression } from "./ast.js" -import { isMachineHandleCall, isMachineMakeCall, type MachineBindings } from "./imports.js" +import { isMachineHandleCall, type MachineBindings } from "./imports.js" export type PlanningFunction = ESTree.ArrowFunctionExpression | ESTree.Function @@ -21,7 +21,7 @@ const statePlanningProperties = new Set([ "choice", "entry", "exit", - "initialize", + "root", "invoke", "onDone", "output" @@ -42,11 +42,6 @@ const isCallArgument = ( node.parent.arguments.includes(node) && predicate(node.parent) -const isMachineMakeConfig = ( - node: ESTree.ObjectExpression, - bindings: MachineBindings -): boolean => isCallArgument(node, (call) => isMachineMakeCall(call, bindings)) - const isMachineHandleConfig = ( node: ESTree.ObjectExpression, bindings: MachineBindings @@ -134,15 +129,25 @@ const isPropertyPlanningCallback = ( const name = propertyName(property) if ( - name !== undefined && ["resolve", "from", "decoded", "guard"].includes(name) && + name !== undefined && ["resolve", "data", "guard"].includes(name) && isTransitionConfig(property.parent, bindings) ) return true if (name === "input" && isInvocationConfig(property.parent, bindings)) return true - return name === "initial" || name === "initialConfiguration" - ? isMachineMakeConfig(property.parent, bindings) - : (name !== undefined && statePlanningProperties.has(name) && isStateConfig(property.parent, bindings)) || - isEventHandlerProperty(property, bindings) || - isHistoryDefaultProperty(property, bindings) + const owner = property.parent.parent + if ( + owner.type === "Property" && propertyName(owner) === "initial" && owner.parent.type === "ObjectExpression" && + isStateConfig(owner.parent, bindings) + ) return true + if (name === "data" && owner.type === "Property" && owner.parent.type === "ObjectExpression") { + if (propertyName(owner) === "root" && isMachineHandleConfig(owner.parent, bindings)) return true + const initial = owner.parent.parent + if ( + initial.type === "Property" && propertyName(initial) === "initial" && + initial.parent.type === "ObjectExpression" && isStateConfig(initial.parent, bindings) + ) return true + } + return (name !== undefined && statePlanningProperties.has(name) && isStateConfig(property.parent, bindings)) || + isEventHandlerProperty(property, bindings) || isHistoryDefaultProperty(property, bindings) } export const enclosingFunction = (node: ESTree.Node): PlanningFunction | undefined => { diff --git a/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts b/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts index 0ebef556..10e20406 100644 --- a/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts +++ b/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts @@ -16,33 +16,33 @@ tester.run("no-async-planning-callback", rule, { `import { Machine } from "@typeonce/effect-machine" Machine.make({ effects: { work: input => Effect.sync(() => fetch("/api")) }, streams: { workStream: input => Stream.fromEffect(Effect.sync(() => Date.now())) } }).handle({})`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("load", async () => undefined) } } })`, +Machine.make({}).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)`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready(), metadata: { initial: async () => undefined } })`, +Machine.make({}).handle({ root: (to) => to.Ready(), metadata: { initial: async () => undefined } })`, `import { Machine } from "@typeonce/effect-machine" const other = { handle: (_config: unknown) => 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({ states: { Ready: { invoke: (from) => [ +Machine.make({}).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({ states: { Ready: { entry: ({ fetch, setTimeout }) => { +Machine.make({}).handle({ states: { Ready: { entry: ({ fetch, setTimeout }) => { fetch() setTimeout() } } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: () => { +Machine.make({}).handle({ states: { Ready: { entry: () => { const later = () => Promise.resolve() return later } } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: () => +Machine.make({}).handle({ states: { Ready: { entry: () => other.resolve(async () => fetch("/helper")) } } })` ], @@ -54,7 +54,7 @@ Machine.make({}).handle({ states: { Loading: { invoke: { src: "load", input: asy }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({}).handle({ on: { Save: { target: destination, from: async () => ({}) } } })`, +Machine.make({}).handle({ on: { Save: { target: destination, data: async () => ({}) } } })`, errors: [{ messageId: "asyncPlanning" }] }, { @@ -69,38 +69,38 @@ Machine.make({ root: Machine.state({}) }).handle({ on: { Update: (to) => to.none }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ root: Machine.state({}), initialConfiguration: async (root) => root.resolve(() => undefined) })`, +Machine.make({ root: Machine.state({}) }).handle({ root: async () => ({}) })`, errors: [{ messageId: "asyncPlanning" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: async (to) => to.Ready() })`, +Machine.make({}).handle({ root: async (to) => to.Ready() })`, errors: [{ messageId: "asyncPlanning" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { on: { Start: async (to) => to.branch.Running() } } } })`, +Machine.make({}).handle({ states: { Ready: { on: { Start: async (to) => to.branch.Running() } } } })`, errors: [{ messageId: "asyncPlanning" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready().resolve(async ({ target }) => target.from()) })`, +Machine.make({}).handle({ root: (to) => to.Ready().resolve(async ({ target }) => target.from()) })`, errors: [{ messageId: "asyncPlanning" }] }, { code: `import * as EM from "@typeonce/effect-machine" -EM.Machine.make({ initial: (to) => to.Loading() }).handle({ states: { Loading: { invoke: (from) => from.effect("load", () => Promise.resolve()).onDone(async (to) => to.branch.Done()) } } })`, +EM.Machine.make({}).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() }) +const definition = Machine.make({}) 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({ states: { Ready: { +Machine.make({}).handle({ states: { Ready: { entry: async () => undefined, exit: async () => undefined, always: async (to) => to.none, @@ -111,7 +111,7 @@ Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => { +Machine.make({}).handle({ root: (to) => { fetch("/initial") new Promise(() => undefined) Promise.all([]) @@ -124,11 +124,11 @@ Machine.make({ initial: (to) => { }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { - initialize: ({ builder }) => { +Machine.make({}).handle({ states: { Ready: { + initial: { target: child, data: () => { globalThis["fetch"]("/initialize") - return builder.from() - }, + return ({}) + } }, output: ({ state }) => { window.setInterval(() => undefined, 100) return state @@ -146,7 +146,7 @@ Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: async (to) => { +Machine.make({}).handle({ root: async (to) => { await fetch("/initial") return to.Ready() } })`, diff --git a/packages/oxlint-plugin/test/noBrowserApiInPlanning.test.ts b/packages/oxlint-plugin/test/noBrowserApiInPlanning.test.ts index 2e6bd890..0775dddf 100644 --- a/packages/oxlint-plugin/test/noBrowserApiInPlanning.test.ts +++ b/packages/oxlint-plugin/test/noBrowserApiInPlanning.test.ts @@ -16,17 +16,17 @@ tester.run("no-browser-api-in-planning", rule, { `import { Machine } from "@typeonce/effect-machine" Machine.make({ effects: { work: input => Effect.sync(() => fetch("/api")) }, streams: { workStream: input => Stream.fromEffect(Effect.sync(() => Date.now())) } }).handle({})`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("storage", () => localStorage.getItem("key")) } } })`, +Machine.make({}).handle({ states: { Ready: { invoke: (from) => from.effect("storage", () => localStorage.getItem("key")) } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: ({ document, navigator }) => document.read(navigator) } } })`, +Machine.make({}).handle({ states: { Ready: { entry: ({ document, navigator }) => document.read(navigator) } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: () => { +Machine.make({}).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) => { +Machine.make({}).handle({ root: (to) => { window.fetch("/api") window.crypto.randomUUID() return to.Ready() @@ -35,7 +35,7 @@ Machine.make({ initial: (to) => { invalid: [ { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => { +Machine.make({}).handle({ root: (to) => { document.querySelector("main") localStorage.getItem("key") globalThis.navigator.onLine @@ -49,7 +49,7 @@ Machine.make({ initial: (to) => { }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { +Machine.make({}).handle({ states: { Ready: { output: () => sessionStorage.length, history: { recent: { default: (to) => location.pathname ? to.none : to.none } } } } })`, diff --git a/packages/oxlint-plugin/test/noConflictingInvocationIdentity.test.ts b/packages/oxlint-plugin/test/noConflictingInvocationIdentity.test.ts index efb68126..646946db 100644 --- a/packages/oxlint-plugin/test/noConflictingInvocationIdentity.test.ts +++ b/packages/oxlint-plugin/test/noConflictingInvocationIdentity.test.ts @@ -14,22 +14,22 @@ 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({ states: { Ready: { invoke: (from) => [ +Machine.make({}).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({ states: { +Machine.make({}).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({ states: { Ready: { invoke: (from) => [ +Machine.make({}).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({ states: { Ready: { invoke: (from) => { +Machine.make({}).handle({ states: { Ready: { invoke: (from) => { let id = "first" const first = from.effect(id, load) id = "second" @@ -37,7 +37,7 @@ Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke return [first, second] } } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("outer", () => [ +Machine.make({}).handle({ states: { Ready: { invoke: (from) => from.effect("outer", () => [ from.effect("nested", load), from.effect("nested", load) ]) } } })` @@ -62,7 +62,7 @@ Machine.make({ logic: { first: logic, second: logic } }).handle({ invoke: [{ src }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ +Machine.make({}).handle({ states: { Ready: { invoke: (from) => [ from.effect("load", load), from.timer("load", 1000) ] } } })`, @@ -71,7 +71,7 @@ Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke { code: `import { Machine } from "@typeonce/effect-machine" const address = Machine.childAddress("worker") -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ +Machine.make({}).handle({ states: { Ready: { invoke: (from) => [ from.logic("first", { address, logic }), from.logic("second", { address, logic }) ] } } })`, @@ -80,7 +80,7 @@ Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke { code: `import { Machine } from "@typeonce/effect-machine" const Child = Machine.child("worker", childMachine) -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ +Machine.make({}).handle({ states: { Ready: { invoke: (from) => [ from.child(Child), from.child(Child) ] } } })`, @@ -89,7 +89,7 @@ Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke { code: `import { Machine } from "@typeonce/effect-machine" const id = "same" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => { +Machine.make({}).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] @@ -100,7 +100,7 @@ Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke { code: `import { Machine } from "@typeonce/effect-machine" const Child = Machine.child("shared", childMachine) -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => [ +Machine.make({}).handle({ states: { Ready: { invoke: (from) => [ from.effect("shared", load), from.child(Child) ] } } })`, diff --git a/packages/oxlint-plugin/test/noNondeterministicPlanning.test.ts b/packages/oxlint-plugin/test/noNondeterministicPlanning.test.ts index 81f9d326..b6ba58da 100644 --- a/packages/oxlint-plugin/test/noNondeterministicPlanning.test.ts +++ b/packages/oxlint-plugin/test/noNondeterministicPlanning.test.ts @@ -16,18 +16,18 @@ tester.run("no-nondeterministic-planning", rule, { `import { Machine } from "@typeonce/effect-machine" Machine.make({ effects: { work: input => Effect.sync(() => fetch("/api")) }, streams: { workStream: input => Stream.fromEffect(Effect.sync(() => Date.now())) } }).handle({})`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: ({ event }) => new Date(event.timestamp) } } })`, +Machine.make({}).handle({ states: { Ready: { entry: ({ event }) => new Date(event.timestamp) } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { entry: ({ Date, Math }) => [Date.now(), Math.random()] } } })`, +Machine.make({}).handle({ states: { Ready: { entry: ({ Date, Math }) => [Date.now(), Math.random()] } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("random", () => crypto.randomUUID()) } } })`, +Machine.make({}).handle({ states: { Ready: { invoke: (from) => from.effect("random", () => crypto.randomUUID()) } } })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { output: ({ state }) => Date.parse(state.createdAt) } } })` +Machine.make({}).handle({ states: { Ready: { output: ({ state }) => Date.parse(state.createdAt) } } })` ], invalid: [ { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => { +Machine.make({}).handle({ root: (to) => { Date() new Date() Date.now() @@ -46,8 +46,8 @@ Machine.make({ initial: (to) => { }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { - initialize: ({ builder }) => globalThis.Date.now() ? builder.from() : builder.from(), +Machine.make({}).handle({ states: { Ready: { + initial: { target: child, data: () => globalThis.Date.now() ? ({}) : ({}) }, onDone: (to) => window.Math.random() ? to.none : to.none, history: { recent: { default: (to) => self.crypto.randomUUID() ? to.none : to.none } } } } })`, diff --git a/packages/oxlint-plugin/test/noRedundantResolve.test.ts b/packages/oxlint-plugin/test/noRedundantResolve.test.ts index c017394d..88e975f9 100644 --- a/packages/oxlint-plugin/test/noRedundantResolve.test.ts +++ b/packages/oxlint-plugin/test/noRedundantResolve.test.ts @@ -14,24 +14,24 @@ const rule = plugin.rules["no-redundant-resolve"] tester.run("no-redundant-resolve", rule, { valid: [ `import { Machine } from "@typeonce/effect-machine" -function unrelated(Machine: any) { Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from()) }) }`, +function unrelated(Machine: any) { Machine.make({}).handle({ root: (to) => to.Ready().resolve(({ target }) => target.from()) }) }`, `import { Machine as M } from "@typeonce/effect-machine" -function unrelated(M: any) { M.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from()) }) }`, +function unrelated(M: any) { M.make({}).handle({ root: (to) => to.Ready().resolve(({ target }) => target.from()) }) }`, `import * as EM from "@typeonce/effect-machine" -function unrelated(EM: any) { EM.Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from()) }) }`, +function unrelated(EM: any) { EM.Machine.make({}).handle({ root: (to) => to.Ready().resolve(({ target }) => target.from()) }) }`, `import { Machine } from "@typeonce/effect-machine" -const definition = Machine.make({ initial: (to) => to.Ready() }) +const definition = Machine.make({}) 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" })) })`, +Machine.make({}).handle({ root: (to) => to.Ready().resolve(({ target }) => target.from({ id: "ready" })) })`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from(), { declinable: true }) })`, +Machine.make({}).handle({ root: (to) => to.Ready().resolve(({ target }) => target.from(), { declinable: true }) })`, `import { Machine } from "@typeonce/effect-machine" const other = { resolve: (_callback: unknown) => undefined } other.resolve(({ target }) => target.from())`, `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { invoke: (from) => from.effect("work", () => other.resolve(({ target }) => target.from())) } } })`, +Machine.make({}).handle({ states: { Ready: { invoke: (from) => from.effect("work", () => other.resolve(({ target }) => target.from())) } } })`, `const other = { resolve: (_callback: unknown) => undefined } other.resolve(({ target }) => target.from())` ], @@ -45,56 +45,56 @@ Machine.make({}).handle({ on: { Reset: { none: true, reenter: true } } })`, }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from()) })`, +Machine.make({}).handle({ root: (to) => to.Ready().resolve(({ target }) => target.from()) })`, output: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() })`, +Machine.make({}).handle({ root: (to) => to.Ready() })`, errors: [{ messageId: "redundantResolver" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -const definition = Machine.make({ initial: (to) => to.Ready() }) +const definition = Machine.make({}) 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() }) +const definition = Machine.make({}) definition.handle({ states: { Ready: { on: { Reset: (to) => to.branch.Ready() } } } })`, errors: [{ messageId: "redundantResolver" }] }, { code: `import { Machine as StateMachine } from "@typeonce/effect-machine" -StateMachine.make({ initial: (to) => to.Ready().resolve(({ target: next }) => { return next.from() }) })`, +StateMachine.make({}).handle({ root: (to) => to.Ready().resolve(({ target: next }) => { return next.from() }) })`, output: `import { Machine as StateMachine } from "@typeonce/effect-machine" -StateMachine.make({ initial: (to) => to.Ready() })`, +StateMachine.make({}).handle({ root: (to) => to.Ready() })`, errors: [{ messageId: "redundantResolver" }] }, { code: `import * as EM from "@typeonce/effect-machine" -EM.Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => /* preserve */ target.from()) })`, +EM.Machine.make({}).handle({ root: (to) => to.Ready().resolve(({ target }) => /* preserve */ target.from()) })`, output: null, errors: [{ messageId: "redundantResolver" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { on: { +Machine.make({}).handle({ states: { Ready: { on: { Reset: (to) => to.branch.Ready().reenter().resolve(({ target }) => target.from()) } } } })`, output: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { on: { +Machine.make({}).handle({ states: { Ready: { on: { Reset: (to) => to.branch.Ready().reenter() } } } })`, errors: [{ messageId: "redundantResolver" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none.resolve(() => {}) } } })`, +Machine.make({}).handle({ states: { Ready: { always: (to) => to.none.resolve(() => {}) } } })`, output: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none } } })`, +Machine.make({}).handle({ states: { Ready: { always: (to) => to.none } } })`, errors: [{ messageId: "redundantTargetlessResolver" }] }, { code: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none.reenter().resolve(() => {}) } } })`, +Machine.make({}).handle({ states: { Ready: { always: (to) => to.none.reenter().resolve(() => {}) } } })`, output: `import { Machine } from "@typeonce/effect-machine" -Machine.make({ initial: (to) => to.Ready() }).handle({ states: { Ready: { always: (to) => to.none.reenter() } } })`, +Machine.make({}).handle({ states: { Ready: { always: (to) => to.none.reenter() } } })`, errors: [{ messageId: "redundantTargetlessResolver" }] } ] diff --git a/scripts/devtools-pack-check.mjs b/scripts/devtools-pack-check.mjs index ddf274eb..c72d3df6 100644 --- a/scripts/devtools-pack-check.mjs +++ b/scripts/devtools-pack-check.mjs @@ -82,13 +82,13 @@ import { Schema } from "effect" class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} -const root = Machine.state({ initial: "Idle", states: { Idle } }) +const root = Machine.state({ states: { Idle } }) export const machine = Machine.make({ id: "packed-fixture", root, events: Machine.events({}) -}).handle({}) +}).handle({ initial: { target: Machine.targets(root).root.Idle },}) ` try { diff --git a/scripts/fixtures/consumer/consumer.ts b/scripts/fixtures/consumer/consumer.ts index 8a2d9994..f4c6590d 100644 --- a/scripts/fixtures/consumer/consumer.ts +++ b/scripts/fixtures/consumer/consumer.ts @@ -3,25 +3,20 @@ import { ClusterMachine } from "@typeonce/effect-machine/cluster" import { AtomMachine } from "@typeonce/effect-machine/reactivity" import { MachineTest } from "@typeonce/effect-machine/testing" import { Effect, Schema } from "effect" - const State = Schema.TaggedUnion({ Idle: {}, Loading: {}, Done: { value: Schema.String } }) - const PublicEvent = Schema.TaggedUnion({ Start: {} }) - const InternalEvent = Schema.TaggedUnion({ Loaded: { value: Schema.String } }) - -const States = Machine.state({ initial: "Idle", states: State.cases }) +const States = Machine.state({ states: State.cases }) const PublicEvents = Machine.eventsFromSchemas(PublicEvent) const InternalEvents = Machine.internalEventsFromSchemas(InternalEvent) - const targets = Machine.targets(States) const machine = Machine.make({ effects: { load: Effect.succeed("ready") }, @@ -37,10 +32,9 @@ const machine = Machine.make({ id: "Consumer", root: States, events: PublicEvents, - internalEvents: InternalEvents, - initialConfiguration: (root) => - root.resolve(({ target }) => target.from((to) => to.Idle.decoded(State.cases.Idle.make({})))) + internalEvents: InternalEvents }).handle({ + initial: { target: targets.root.Idle }, states: { Idle: { on: { @@ -54,13 +48,16 @@ const machine = Machine.make({ onDone: { none: true } }], on: { - Loaded: { target: targets.root.Done, decoded: ({ event }) => State.cases.Done.make({ value: event.value }) } + Loaded: { + target: targets.root.Done, + decoded: true, + data: ({ event }) => State.cases.Done.make({ value: event.value }) + } } }, Done: {} } }) - const atoms = AtomMachine.make(machine) const idleAtom = AtomMachine.select(atoms, "Idle") const loadingAtom = AtomMachine.matches(atoms, "Loading") @@ -70,18 +67,14 @@ const cluster = ClusterMachine.make("ConsumerEntity", machine, { version: "1" }) const generated = MachineTest.scenarios(machine, { minEvents: 1, maxEvents: 2 }) - type InputEvent = Machine.Machine.InputEvent type HandledEvent = Machine.Machine.Event - const constructedStart = PublicEvents.Start() const constructedLoaded = InternalEvents.Loaded({ value: "ready" }) const start: InputEvent = { _tag: "Start" } const loaded: HandledEvent = { _tag: "Loaded", value: "ready" } - // @ts-expect-error Internal events cannot cross the public input boundary. const invalidInput: InputEvent = loaded - void [ atoms, idleAtom, diff --git a/scripts/fixtures/consumer/deep-bound.ts b/scripts/fixtures/consumer/deep-bound.ts index e9710e8a..6440cbbd 100644 --- a/scripts/fixtures/consumer/deep-bound.ts +++ b/scripts/fixtures/consumer/deep-bound.ts @@ -2,16 +2,14 @@ import { Machine } from "@typeonce/effect-machine" import { AtomMachine } from "@typeonce/effect-machine/reactivity" import { Context, Effect, Layer, Schema } from "effect" import { Atom } from "effect/unstable/reactivity" - type Equal = (() => Type extends Left ? 1 : 2) extends () => Type extends Right ? 1 : 2 ? true : false type Expect = Type - -class ExternalService extends Context.Service()("consumer/ExternalService") {} +class ExternalService extends Context.Service()("consumer/ExternalService") { +} class RuntimeFailure { readonly _tag = "RuntimeFailure" } - const State = Schema.TaggedUnion({ Idle: {}, Ready: {}, @@ -35,9 +33,10 @@ const Emitted = Schema.TaggedUnion({ const ChildState = Schema.TaggedUnion({ Done: { value: Schema.String } }) - const ChildStates = Machine.state({ - initial: "Done", + fields: { + input: Schema.toType(Schema.Struct({ value: Schema.String })) + }, states: { Done: { schema: ChildState.cases.Done, @@ -51,12 +50,14 @@ const childMachine = Machine.make({ root: ChildStates, events: Machine.eventsFromSchemas(), parent: Machine.parent(ChildParentEvents), - input: Schema.Struct({ value: Schema.String }), - initialConfiguration: (root) => - root.resolve(({ input, target }) => - target.from((to) => to.Done.decoded(ChildState.cases.Done.make({ value: input.value }))) - ) + input: Schema.Struct({ value: Schema.String }) }).handle({ + initial: { + target: Machine.targets(ChildStates).root.Done, + decoded: true, + data: ({ root: { input: input } }) => ChildState.cases.Done.make({ value: input.value }) + }, + root: ({ input }) => ({ input }), states: { Done: { entry: ({ parent, state }, enqueue) => { @@ -67,18 +68,14 @@ const childMachine = Machine.make({ } }) const Child = Machine.child("child", childMachine) - 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 @@ -93,9 +90,7 @@ const States = Machine.state({ } } }) - const Emissions = Machine.emittedEventsFromSchemas(Emitted.cases.Notice) - const targets = Machine.targets(States) const definition = Machine.make({ effects: { load: Effect.asVoid(ExternalService) }, @@ -108,13 +103,14 @@ const definition = Machine.make({ 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 }), - initialConfiguration: (root) => - root.resolve(({ input: { seed: _seed }, target }) => - target.from((to) => to.Idle.decoded(State.cases.Idle.make({}))) - ) + input: Schema.Struct({ seed: Schema.String }) }) const machine = definition.handle({ + initial: { + target: Machine.targets(States).root.Idle, + decoded: true, + data: State.cases.Idle.make({}) + }, states: { Idle: { invoke: { src: "load", id: "deep-inline-invoke", onDone: { none: true } }, @@ -125,23 +121,29 @@ const machine = definition.handle({ 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.Editor.decoded(State.cases.Editor.make({}), (editor) => + editor.Editing.decoded(State.cases.Editing.make({ value: "ready" }))) ) } } }, Ready: { + initial: { + target: Machine.targets(States).root.Ready.Editor + }, states: { Editor: { + initial: { + target: Machine.targets(States).root.Ready.Editor.Editing, + data: { value: "ready" } + }, states: { Editing: { on: { Save: { target: targets.root.Ready.Editor.Saving, - decoded: ({ event }) => State.cases.Saving.make({ value: event.value }) + decoded: true, + data: ({ event }) => State.cases.Saving.make({ value: event.value }) }, Loaded: { none: true } } @@ -158,7 +160,8 @@ const machine = definition.handle({ }, ChildCompleted: { target: targets.root.Done, - decoded: ({ event }) => State.cases.Done.make({ value: event.value }) + decoded: true, + data: ({ event }) => State.cases.Done.make({ value: event.value }) } } } @@ -171,58 +174,44 @@ const machine = definition.handle({ } } }) - const PackagedDeepState = Schema.TaggedStruct("PackagedDeepState", {}) 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, @@ -257,36 +246,61 @@ const PackagedDeepStates = Machine.state({ }) const packagedDeepMachine = Machine.make({ root: PackagedDeepStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (to) => - to.resolve((): never => { - throw new Error("type-only packaged consumer fixture") - }) + events: Machine.eventsFromSchemas() }).handle({ + initial: { target: Machine.targets(PackagedDeepStates).root.n0 }, states: { n0: { + initial: { target: Machine.targets(PackagedDeepStates).root.n0.n1 }, states: { n1: { + initial: { target: Machine.targets(PackagedDeepStates).root.n0.n1.n2 }, states: { n2: { + initial: { target: Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3 }, states: { n3: { + initial: { target: Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3.n4 }, states: { n4: { + initial: { target: Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3.n4.n5 }, states: { n5: { + initial: { target: Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3.n4.n5.n6 }, states: { n6: { + initial: { target: Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3.n4.n5.n6.n7 }, states: { n7: { + initial: { + target: Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3.n4.n5.n6.n7.n8 + }, states: { n8: { + initial: { + target: Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9 + }, states: { n9: { + initial: { + target: + Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9.n10 + }, states: { n10: { + initial: { + target: + Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3.n4.n5.n6.n7.n8.n9 + .n10.n11 + }, states: { n11: { + initial: { + target: + Machine.targets(PackagedDeepStates).root.n0.n1.n2.n3.n4.n5.n6.n7 + .n8.n9.n10.n11 + .n12 + }, states: { n12: { entry: () => {}, @@ -320,26 +334,18 @@ const packagedDeepMachine = Machine.make({ }) type PackagedDeepErrorIsExact = Expect, never>> type PackagedDeepServicesAreExact = Expect, never>> -type PackagedDeepUnhandledIsExact = Expect< - Equal, never> -> +type PackagedDeepUnhandledIsExact = Expect, never>> void Machine.planInitial(packagedDeepMachine) - const runtime = Atom.runtime( - Layer.mergeAll( - Layer.succeed(ExternalService, "provided"), - Layer.effectDiscard(Effect.fail(new RuntimeFailure())) - ) + Layer.mergeAll(Layer.succeed(ExternalService, "provided"), Layer.effectDiscard(Effect.fail(new RuntimeFailure()))) ) const Bound = AtomMachine.bind(runtime) const machineAtom = Bound.make(machine, { seed: "initial" }) - 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 - type StateIsExact = Expect> type EventsArePublicOnly = Expect< Equal>> @@ -350,7 +356,6 @@ type FailureIsNotUnknown = Expect ? true : false, false> > - // @ts-expect-error Input is required. Bound.make(machine) // @ts-expect-error Input retains its exact decoded type. @@ -360,7 +365,6 @@ AtomMachine.bind(Atom.runtime(Layer.empty)).make(machine, { seed: "initial" }) const erased: Machine.Machine.Any = machine // @ts-expect-error Machine.Any erasure cannot manufacture concrete protocol or output proof. Bound.make(erased, { seed: "initial" }) - void machineAtom export type { EventsArePublicOnly, diff --git a/scripts/invoke-autocomplete.test.mjs b/scripts/invoke-autocomplete.test.mjs index 659ad69c..6c894f19 100644 --- a/scripts/invoke-autocomplete.test.mjs +++ b/scripts/invoke-autocomplete.test.mjs @@ -11,18 +11,17 @@ 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: {} } }) +const rootCompletion = Machine.state({ /*root-properties*/ states: { Idle: {} } }) void rootCompletion class AtomIdle extends Schema.TaggedClass("AtomIdle")("AtomIdle", {}) {} class AtomReady extends Schema.TaggedClass("AtomReady")("AtomReady", {}) {} -const AtomStates = Machine.state({ initial: "AtomIdle", states: { AtomIdle, AtomReady } }) +const AtomStates = Machine.state({ states: { AtomIdle, AtomReady } }) const atomDefinition = Machine.make({ root: AtomStates, events: Machine.eventsFromSchemas(), - input: Schema.String, - initialConfiguration: (root) => root.resolve(({ target }) => target.from(to => to.AtomIdle.decoded(new AtomIdle({})))) -}).handle({ states: { AtomIdle: {}, AtomReady: {} } }) + input: Schema.String +}).handle({ initial: { target: Machine.targets(AtomStates).root.AtomIdle }, states: { AtomIdle: {}, AtomReady: {} } }) AtomMachine.family(atomDefinition, { atoms: { @@ -34,15 +33,13 @@ AtomMachine.family(atomDefinition, { const atomChildDefinition = Machine.make({ root: AtomStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => root.resolve(({ target }) => target.from(to => to.AtomIdle.decoded(new AtomIdle({})))) -}).handle({ states: { AtomIdle: {}, AtomReady: {} } }) + events: Machine.eventsFromSchemas() +}).handle({ initial: { target: Machine.targets(AtomStates).root.AtomIdle }, states: { AtomIdle: {}, AtomReady: {} } }) const AtomChild = Machine.childFamily(atomChildDefinition) const atomParent = AtomMachine.make(Machine.make({ root: AtomStates, - events: Machine.eventsFromSchemas(), - initialConfiguration: (root) => root.resolve(({ target }) => target.from(to => to.AtomIdle.decoded(new AtomIdle({})))) -}).handle({ states: { AtomIdle: {}, AtomReady: {} } })) + events: Machine.eventsFromSchemas() +}).handle({ initial: { target: Machine.targets(AtomStates).root.AtomIdle }, states: { AtomIdle: {}, AtomReady: {} } })) AtomMachine.familyChild(atomParent, { child: (id: string) => AtomChild(id), atoms: { @@ -60,47 +57,50 @@ AtomMachine.familyChild(atomParent, { } }) -const States = Machine.state({ initial: "Loading", states: { Loading: {}, Done: {}, Failed: {} } }) +const States = Machine.state({ fields: { count: Schema.Number }, states: { Loading: { fields: {} }, Done: { fields: {} }, Failed: { fields: {} } } }) const targets = Machine.targets(States) Machine.make({ /*invoke-sources*/root: States, events: Machine.eventsFromSchemas() }) const definition = Machine.make({ effects: { load: (tag: symbol) => Effect.fail("offline").pipe(Effect.as(tag)) }, streams: { updates: Stream.make(1) }, branches: { complete: { ready: { target: targets./*branch-target-scopes*/root.Done }, unchanged: { none: true } } }, - 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())) + root: States, events: Machine.eventsFromSchemas(), input: Schema.String +}) +definition.handle({ + root: ({ /*root-data-context*/ ...context }) => ({ count: context.input.length }), + initial: { /*initial-operations*/ target: targets.root./*initial-selector*/Loading, data: ({ /*initial-context*/ ...context }) => ({}) } }) -definition.handle({ states: { Loading: { invoke: { +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { invoke: { src: "load", input: ({ /*invoke-source-context*/ ...context }) => context.event._tag, onDone: { branches: "complete", resolve: ({ /*done-context*/ ...context }) => context.select.ready./*done-exact-target*/from() }, - onFailure: { target: targets.root./*done-target*/Failed, from: ({ /*failure-context*/ ...context }) => undefined } + onFailure: { target: targets.root./*done-target*/Failed, data: ({ /*failure-context*/ ...context }) => ({}) } } } } }) -definition.handle({ states: { Loading: { +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { // @ts-expect-error Incomplete invocation exposes required properties in completion. invoke: { src: "load", input: () => Machine.InitialEventTypeId, /*invoke-properties*/ } } } }) -definition.handle({ states: { Loading: { invoke: { +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { invoke: { src: "updates", onElement: { none: true, resolve: ({ /*element-context*/ ...context }) => undefined }, onDone: { none: true } } } } }) const requiredParentDefinition = Machine.make({ root: States, events: Machine.eventsFromSchemas(), parent: Machine.parent(Machine.eventsFromSchemas()), effects: { wait: (_input: undefined) => Effect.never } }) -requiredParentDefinition.handle({ states: { Loading: { invoke: { src: "wait", input: ({ /*required-parent-context*/ ...context }) => undefined } } } }) +requiredParentDefinition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { invoke: { src: "wait", input: ({ /*required-parent-context*/ ...context }) => undefined } } } }) const optionalParentDefinition = Machine.make({ root: States, events: Machine.eventsFromSchemas(), parent: Machine.optionalParent(Machine.eventsFromSchemas()), effects: { wait: (_input: undefined) => Effect.never } }) -optionalParentDefinition.handle({ states: { Loading: { invoke: { src: "wait", input: ({ /*optional-parent-context*/ ...context }) => undefined } } } }) -definition.handle({ states: { Loading: { +optionalParentDefinition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { invoke: { src: "wait", input: ({ /*optional-parent-context*/ ...context }) => undefined } } } }) +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { // @ts-expect-error Incomplete transition exposes its operation fields in completion. always: { /*transition-selector*/ } } } }) -definition.handle({ states: { Loading: { on: {}, always: { target: targets.root.Done, /*selected-operations*/ } } } }) -definition.handle({ states: { Loading: { always: { none: true, resolve: ({ /*targetless-context*/ ...context }) => undefined } } } }) -definition.handle({ states: { Loading: { always: { target: targets./*target-scopes*/root.Done, from: ({ /*transition-context*/ ...context }) => undefined } } } }) -definition.handle({ states: { Loading: { always: { branches: "complete", resolve: ({ /*branch-resolve-context*/ ...context }) => context.select./*branch-select-keys*/ready./*transition-exact-target*/from() } } } }) -definition.handle({ states: { Loading: { always: { none: true, resolve: ({ /*required-context*/ ...context }) => undefined } } } }) -definition.handle({ states: { Loading: { always: { none: true, declinable: true, resolve: ({ /*declinable-context*/ ...context }) => context.decline() } } } }) +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { on: {}, always: { target: targets.root.Done, /*selected-operations*/ } } } }) +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { always: { none: true, resolve: ({ /*targetless-context*/ ...context }) => undefined } } } }) +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { always: { target: targets./*target-scopes*/root.Done, data: ({ /*transition-context*/ ...context }) => ({}) } } } }) +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { always: { branches: "complete", resolve: ({ /*branch-resolve-context*/ ...context }) => context.select./*branch-select-keys*/ready./*transition-exact-target*/from() } } } }) +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { always: { none: true, resolve: ({ /*required-context*/ ...context }) => undefined } } } }) +definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { always: { none: true, declinable: true, resolve: ({ /*declinable-context*/ ...context }) => context.decline() } } } }) const eventDefinition = Machine.make({ root: States, events: Machine.events({ Retry: {} }) }) -eventDefinition.handle({ states: { Loading: { on: { /*event-handler-on*/ } } } }) -eventDefinition.handle({ /*event-handler-root*/ states: { Loading: { /*event-handler-node*/ on: { Retry: { none: true } } } } }) +eventDefinition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { on: { /*event-handler-on*/ } } } }) +eventDefinition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, /*event-handler-root*/ states: { Loading: { /*event-handler-node*/ on: { Retry: { none: true } } } } }) ` @@ -233,16 +233,16 @@ test("contextually completes transition definitions while authoring", () => { assert.equal(initialSelector.has("none"), false) const initialOperations = completions("initial-operations") - assert.equal(initialOperations.has("resolve"), true) + assert.equal(initialOperations.has("decoded"), true) + assert.equal(initialOperations.has("resolve"), false) assert.equal(initialOperations.has("reenter"), false) const initialContext = completions("initial-context") - assert.equal(initialContext.has("input"), true) - assert.equal(initialContext.has("target"), true) + assert.equal(initialContext.has("input"), false) + assert.equal(initialContext.has("root"), true) + assert.equal(initialContext.has("state"), true) + assert.equal(completions("root-data-context").has("input"), true) - const initialTarget = completions("initial-exact-target") - assert.equal(initialTarget.has("from"), true) - assert.equal(initialTarget.has("Done"), false) const selector = completions("transition-selector") assert.equal(selector.has("none"), true) @@ -289,7 +289,7 @@ test("contextually completes transition definitions while authoring", () => { assert.equal(declinable.has("decline"), true) const selected = completions("selected-operations") - assert.equal(selected.has("from"), true) + assert.equal(selected.has("data"), true) assert.equal(selected.has("decoded"), true) }) diff --git a/scripts/oxlint-plugin-pack-check.mjs b/scripts/oxlint-plugin-pack-check.mjs index 3653fd5e..9c193baf 100644 --- a/scripts/oxlint-plugin-pack-check.mjs +++ b/scripts/oxlint-plugin-pack-check.mjs @@ -104,7 +104,7 @@ try { const fixture = join(consumer, "machine.ts") await writeFile( fixture, - `import { Machine } from "@typeonce/effect-machine"\nMachine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from()) })\n` + `import { Machine } from "@typeonce/effect-machine"\nMachine.make({ root: Machine.state({}), events: Machine.events({ Ignore: {} }) }).handle({ on: { Ignore: { none: true, resolve: () => {} } } })\n` ) run(process.execPath, [ @@ -137,7 +137,7 @@ try { run(oxlint, ["-c", ".oxlintrc.json", "--fix", "machine.ts"], { cwd: consumer }) const fixed = await readFile(fixture, "utf8") - if (!fixed.includes("initial: (to) => to.Ready()") || fixed.includes(".resolve(")) { + if (!fixed.includes("Ignore: { none: true }") || fixed.includes("resolve:")) { throw new Error(`packed plugin did not apply its fixer\n${fixed}`) } diff --git a/scripts/runtime-performance-compatibility.test.mjs b/scripts/runtime-performance-compatibility.test.mjs index bb11a702..8b37ccca 100644 --- a/scripts/runtime-performance-compatibility.test.mjs +++ b/scripts/runtime-performance-compatibility.test.mjs @@ -277,3 +277,32 @@ 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) }) + +test("adapts handler-owned initial edges without changing benchmark values", () => { + let captured + const count = {} + const active = { Count: count } + const rootApi = { + state: (node) => ({ node }), + eventsFromSchemas: (...schemas) => schemas, + targets: () => ({ root: { Active: active } }), + make: (config) => { + captured = config + return { handle: (handlers) => ({ config, handlers }) } + } + } + const api = makeEffectMachineBenchmarkApi(rootApi) + const schema = Symbol("schema") + const states = api.states({ Active: { schema, initial: "Count", states: { Count: schema } } }) + const from = ({ state }) => ({ value: state.value + 1 }) + const machine = api.make({ + states: states.states, + events: [], + initial: api.initial({ target: (to) => to.Active.initial, values: { "Active.Count": { value: 0 } } }) + }).handle({ Active: { states: { Count: { on: { Increment: api.transition({ target: (to) => to.local.Count(), from }) } } } } }) + assert.equal("initialConfiguration" in captured, false) + assert.equal("initial" in captured.root.node.states.Active, false) + assert.equal(machine.handlers.initial.target, active) + assert.deepEqual(machine.handlers.states.Active.initial, { target: count, data: { value: 0 } }) + assert.equal(machine.handlers.states.Active.states.Count.on.Increment.data, from) +}) From 5336a4b8d368cb5a7ad8dd671531e07b20cfdeec Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 9 Sep 2026 18:42:37 +0200 Subject: [PATCH 2/2] Avoid rebuilding validated startup configuration --- .../src/internal/machine/executionPlan.ts | 17 ++++------- .../src/internal/machine/planner.ts | 23 ++++++++++++--- .../internal/machine/rootStrategies.test.ts | 29 +++++++++++++++++++ 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/effect-machine/src/internal/machine/executionPlan.ts b/packages/effect-machine/src/internal/machine/executionPlan.ts index a0aa539e..36f47285 100644 --- a/packages/effect-machine/src/internal/machine/executionPlan.ts +++ b/packages/effect-machine/src/internal/machine/executionPlan.ts @@ -39,6 +39,7 @@ import { normalizeTransition, planConfiguration, removeConflictingTransitions, + resolveInitialConfiguration, resolveInitialTarget, type SelectedTransition, sortEntryPaths, @@ -1145,8 +1146,8 @@ const makeIndexedExecutionPlan = ( ? (decodeInputSync(machine, machine.input, undefined), args) : [decodeInputSync(machine, machine.input, args[0])] const initial = machine.initial(...inputArgs as any) - const resolved = isInitialTarget(initial) - ? resolveInitialTarget( + const normalized = isInitialTarget(initial) + ? resolveInitialConfiguration( machine, { active: new Set(), @@ -1157,16 +1158,8 @@ const makeIndexedExecutionPlan = ( }, 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) + ).configuration + : normalizeConfigurationSync(machine, initial as Machine.Snapshot) const active = machineReferences === undefined ? normalized : withMachineReferences(normalized, machineReferences) diff --git a/packages/effect-machine/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts index 00e81ac7..8c69fcf6 100644 --- a/packages/effect-machine/src/internal/machine/planner.ts +++ b/packages/effect-machine/src/internal/machine/planner.ts @@ -39,6 +39,7 @@ import { } from "./configuration.js" import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError, StoppedError } from "./errors.js" import { type CapturedStateConfig, toImpl } from "./implementation.js" +import { isDataInitializer } from "./initialDeclaration.js" import { getStateInitializeValues, makeStateInitializeBuilder } from "./initialization.js" import * as InvocationDefinition from "./invocationDefinition.js" import * as InvocationEvent from "./invocationEvent.js" @@ -344,7 +345,7 @@ const completeHistoryConfiguration = ( containingState: getParentValue(machine, current, path), ancestors: getParentValues(machine, current, path), event, - builder: makeStateInitializeBuilder(machine, path) + ...(isDataInitializer(initializer) ? {} : { builder: makeStateInitializeBuilder(machine, path) }) }) const initializedValues = getStateInitializeValues(path, initialized.value) values.set(child.path, decodeStateValueSync(machine, child, initializedValues[child.key])) @@ -372,7 +373,7 @@ const completeHistoryConfiguration = ( containingState: getParentValue(machine, current, path), ancestors: getParentValues(machine, current, path), event, - builder: makeStateInitializeBuilder(machine, path) + ...(isDataInitializer(initializer) ? {} : { builder: makeStateInitializeBuilder(machine, path) }) }) const initializedValues = initialized === undefined ? undefined @@ -388,6 +389,10 @@ const completeHistoryConfiguration = ( if ( initializedValues === undefined || !Object.prototype.hasOwnProperty.call(initializedValues, child.key) ) { + if (initializer !== undefined && isDataInitializer(initializer)) { + values.set(child.path, decodeStateValueSync(machine, child, makeStateInput({}))) + continue + } throw new Error(`Machine parallel state initializer for "${path}" must return region "${child.key}"`) } values.set( @@ -420,14 +425,24 @@ const completeHistoryConfiguration = ( } } -export function resolveInitialTarget( +/** Resolves initial data into owned configuration without a snapshot round trip. */ +export function resolveInitialConfiguration( machine: Machine.Any, configuration: ActiveConfiguration, target: InitialTargetInstruction, event: unknown ) { const partial = configurationFromInitialTargetSync(machine, configuration, target) - const completed = completeHistoryConfiguration(machine, partial, event) + return completeHistoryConfiguration(machine, partial, event) +} + +export function resolveInitialTarget( + machine: Machine.Any, + configuration: ActiveConfiguration, + target: InitialTargetInstruction, + event: unknown +) { + const completed = resolveInitialConfiguration(machine, configuration, target, event) const snapshot = snapshotFromConfigurationAtPath(machine, completed.configuration, target.path) return { target: makeTarget(target.path as any, snapshot.value as any, { diff --git a/packages/effect-machine/test/internal/machine/rootStrategies.test.ts b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts index 653fe955..b13ef20b 100644 --- a/packages/effect-machine/test/internal/machine/rootStrategies.test.ts +++ b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts @@ -2,6 +2,35 @@ import { assert, it } from "@effect/vitest" import { Effect, Schema } from "effect" import { Machine } from "../../../src/index.js" import { verifyPlannerStrategies } from "./support/strategyDifferential.js" + +it.effect("matches generic startup with constructed and defaulted parallel region data", () => { + const root = Machine.state({ + type: "parallel", + states: { + Required: { fields: { count: Schema.Number } }, + Defaulted: { + fields: { + label: Schema.String.pipe(Schema.optionalKey, Schema.withConstructorDefault(Effect.succeed("default"))) + } + }, + Empty: {} + } + }) + const machine = Machine.make({ root, events: Machine.events({ Noop: {} }) }).handle({ + initial: { Required: { count: 1 } } + }) + return Effect.gen(function*() { + const initial = yield* Machine.planInitial(machine) + assert.deepStrictEqual(initial.state.states.Defaulted.value, { _tag: "Defaulted", label: "default" }) + yield* verifyPlannerStrategies({ + machine, + expected: "indexed-hierarchical", + label: "mixed explicit and defaulted region construction", + events: [{ _tag: "Noop" }] + }) + }) +}) + it.effect("retains independent callback contexts across root, inline, and named transitions", () => Effect.gen(function*() { const root = Machine.state({ fields: { count: Schema.Number }, states: { Idle: {} } })