diff --git a/.changeset/fresh-root-object-construction.md b/.changeset/fresh-root-object-construction.md new file mode 100644 index 00000000..d85250eb --- /dev/null +++ b/.changeset/fresh-root-object-construction.md @@ -0,0 +1,9 @@ +--- +"@typeonce/effect-machine": minor +--- + +Pass machine input directly to root initial constructors, including parallel regions, without retaining startup-only values in root data. A root transition uses `{ target: targets.root, input }` to reconstruct root and its initial children with fresh input; `reenter: true` restarts the root lifecycle when the handler is at root. Root updates continue to use `{ update: targets.root, data }` and retain active children. + +Use `target` instead of `initial` on event transitions. Named branch selectors now accept one construction object: `select.checkout({ data: cart, states: { Review: { data: review } } })`. Replace `.from(value)` with `({ data: value })`, `.decoded(value)` with `({ decoded: true, data: value })`, and chained owner updates with `update: { data: owner }` inside the same call. History fallbacks use `target({ states: ... })` with a complete tree containing their owner. + +Input remains limited to root construction and the root's initial callbacks. Nested initializers use state data and ancestors. Explicit subtree construction preserves source-local parallel retention, schema validation, and declared branch inspection. diff --git a/packages/devtools/src/internal/browser/example-machine.ts b/packages/devtools/src/internal/browser/example-machine.ts index 05fec15f..b9dae35e 100644 --- a/packages/devtools/src/internal/browser/example-machine.ts +++ b/packages/devtools/src/internal/browser/example-machine.ts @@ -141,8 +141,12 @@ 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({ + data: new Running({}), + decoded: true, + states: { editing: { data: new Editing({}), decoded: true } }, + update: { data: new Workflow({ document: "Machine.ts", unsavedChanges: 3 }), decoded: true } + }) }, Refresh: { update: targets1.root.application.workflow, diff --git a/packages/devtools/src/internal/browser/hierarchy-routing-example.ts b/packages/devtools/src/internal/browser/hierarchy-routing-example.ts index a720005a..db8d3427 100644 --- a/packages/devtools/src/internal/browser/hierarchy-routing-example.ts +++ b/packages/devtools/src/internal/browser/hierarchy-routing-example.ts @@ -60,8 +60,8 @@ export const hierarchyRoutingMachine = Machine.make({ branches: "transition1", resolve: ({ event, select }) => event.route === "save" - ? select.save.from() - : select.invalid.from({ message: "Add a title before continuing." }) + ? select.save({}) + : select.invalid({ data: { message: "Add a title before continuing." } }) } }, states: { diff --git a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts index 2380ea2b..57e64fb4 100644 --- a/packages/devtools/src/internal/browser/invoke-outcomes-example.ts +++ b/packages/devtools/src/internal/browser/invoke-outcomes-example.ts @@ -260,7 +260,7 @@ export const invokeOutcomesMachine = Machine.make({ branches: "transition13", resolve: ({ select, snapshot }) => snapshot.state === "ready" - ? select.ready.decoded(new Completed({ source: "process", result: "ready" })) + ? select.ready({ data: new Completed({ source: "process", result: "ready" }), decoded: true }) : select.waiting() } } @@ -279,12 +279,12 @@ export const invokeOutcomesMachine = Machine.make({ }, Completed: { on: { - Reset: { initial: targets2.root.Gallery, decoded: true, data: () => (new Gallery({ selectedDemo: null })) } + Reset: { target: targets2.root.Gallery, decoded: true, data: () => (new Gallery({ selectedDemo: null })) } } }, Failed: { on: { - Reset: { initial: targets2.root.Gallery, decoded: true, data: () => (new Gallery({ selectedDemo: null })) } + Reset: { target: 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 72044c12..98cc3643 100644 --- a/packages/devtools/src/internal/browser/layout-resilience-example.ts +++ b/packages/devtools/src/internal/browser/layout-resilience-example.ts @@ -117,12 +117,12 @@ export const layoutResilienceMachine = Machine.make({ branches: "transition4", resolve: ({ event, select }) => { if (event.route === "login") { - return select.login.from() + return select.login({}) } if (event.route === "verification") { - return select.requestVerification.from() + return select.requestVerification({}) } - return select.invalid.from({ message: "Enter valid authentication details." }) + return select.invalid({ data: { message: "Enter valid authentication details." } }) } } }, @@ -149,7 +149,7 @@ export const layoutResilienceMachine = Machine.make({ id: "request-verification", input: (context) => context, onDone: { - initial: targets1.root.Verification, + target: targets1.root.Verification, data: ({ containingState }) => ({ mode: containingState.mode, email: containingState.email, diff --git a/packages/devtools/src/internal/browser/parallel-completion-example.ts b/packages/devtools/src/internal/browser/parallel-completion-example.ts index 42abb3bd..c7978736 100644 --- a/packages/devtools/src/internal/browser/parallel-completion-example.ts +++ b/packages/devtools/src/internal/browser/parallel-completion-example.ts @@ -130,7 +130,7 @@ export const parallelCompletionMachine = Machine.make({ Cart: { on: { Checkout: { - initial: targets1.root.Order, + target: targets1.root.Order, decoded: true, data: ({ event }) => (new Order({ orderId: event.orderId, total: event.total })) } @@ -242,7 +242,7 @@ export const parallelCompletionMachine = Machine.make({ Cancelled: { on: { RetryOrder: { - initial: targets1.root.Order, + target: targets1.root.Order, decoded: true, data: () => (new Order({ orderId: "retry", total: 0 })) } diff --git a/packages/devtools/src/internal/browser/planner-example.ts b/packages/devtools/src/internal/browser/planner-example.ts index 34dc415f..a029a769 100644 --- a/packages/devtools/src/internal/browser/planner-example.ts +++ b/packages/devtools/src/internal/browser/planner-example.ts @@ -173,8 +173,8 @@ export const plannerMachine = Machine.make({ } const working = new Working({ owner: state.owner, job: event.job }) return event.priority === "urgent" - ? select.urgent.decoded(working) - : select.normal.decoded(working) + ? select.urgent({ data: working, decoded: true }) + : select.normal({ data: working, decoded: true }) } } } diff --git a/packages/devtools/src/internal/browser/protocol-events-example.ts b/packages/devtools/src/internal/browser/protocol-events-example.ts index 7d79dddc..9edef22c 100644 --- a/packages/devtools/src/internal/browser/protocol-events-example.ts +++ b/packages/devtools/src/internal/browser/protocol-events-example.ts @@ -89,7 +89,7 @@ export const requiredParentChildMachine = Machine.make({ resolve: ({ event, select: { destination: target } }, enqueue) => { enqueue.raise(ChildInternalEvents.Heartbeat({ percent: 25 })) enqueue.emit(ChildEmissions.ChildTrace({ message: `started ${event.job}` })) - return target.decoded(new ChildWorking({ job: event.job, progress: 0 })) + return target({ data: new ChildWorking({ job: event.job, progress: 0 }), decoded: true }) } } } @@ -111,7 +111,7 @@ export const requiredParentChildMachine = Machine.make({ branches: "transition3", resolve: ({ event, state, select: { destination: target } }, enqueue) => { enqueue.raise(ChildInternalEvents.CommitChildWork()) - return target.decoded(new ChildWorking({ job: state.job, progress: event.percent })) + return target({ data: new ChildWorking({ job: state.job, progress: event.percent }), decoded: true }) } }, CommitChildWork: { @@ -263,7 +263,7 @@ export const optionalParentMachine = Machine.make({ if (parent !== undefined) { enqueue.sendTo(parent, ParentEvents.ChildFinished({ result: event.result })) } - return target.decoded(new Published({ deliveredToParent: parent !== undefined })) + return target({ data: new Published({ deliveredToParent: parent !== undefined }), decoded: true }) } } } diff --git a/packages/devtools/src/internal/browser/transition-semantics-example.ts b/packages/devtools/src/internal/browser/transition-semantics-example.ts index 5e02476d..f08c94e2 100644 --- a/packages/devtools/src/internal/browser/transition-semantics-example.ts +++ b/packages/devtools/src/internal/browser/transition-semantics-example.ts @@ -133,10 +133,10 @@ export const transitionSemanticsMachine = Machine.make({ branches: { transition4: { draft: { target: targets1.root.Workspace.Draft, title: "Preferred route is draft" }, - review: { initial: targets1.root.Workspace.Review, title: "Preferred route is review" } + review: { target: targets1.root.Workspace.Review, title: "Preferred route is review" } }, transition7: { - review: { initial: targets1.root.Workspace.Review, title: "Enter the review flow" }, + review: { target: targets1.root.Workspace.Review, title: "Enter the review flow" }, publish: { target: targets1.root.Workspace.Finished, title: "Publish without review" } }, transition14: { destination: { history: targets1.root.Workspace.recent } }, @@ -203,8 +203,8 @@ export const transitionSemanticsMachine = Machine.make({ branches: "transition4", resolve: ({ containingState, select }) => containingState.preferredRoute === "review" - ? select.review.decoded(new Review({ requestedBy: "initial route" })) - : select.draft.decoded(new Draft({ text: "", autosaves: 0 })) + ? select.review({ data: new Review({ requestedBy: "initial route" }), decoded: true }) + : select.draft({ data: new Draft({ text: "", autosaves: 0 }), decoded: true }) } }, Draft: { @@ -219,8 +219,8 @@ export const transitionSemanticsMachine = Machine.make({ branches: "transition7", resolve: ({ event, select }) => event.mode === "publish" - ? select.publish.decoded(new WorkspaceFinished({ result: "published directly" })) - : select.review.decoded(new Review({ requestedBy: event.requestedBy })) + ? select.publish({ data: new WorkspaceFinished({ result: "published directly" }), decoded: true }) + : select.review({ data: new Review({ requestedBy: event.requestedBy }), decoded: true }) }, Refresh: { none: true, reenter: true }, Ignore: { none: true }, @@ -282,14 +282,14 @@ export const transitionSemanticsMachine = Machine.make({ Paused: { on: { Create: { - initial: targets1.root.Workspace, + target: targets1.root.Workspace, 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, + target: targets1.root.Workspace, decoded: true, data: () => (new Workspace({ revision: 0, preferredRoute: "draft" })) } diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 3f0ad31a..58294c03 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -209,8 +209,8 @@ const machine = Machine.make({ branches: "search", resolve: ({ event, select }) => event.query.length > 0 - ? select.loading.from({ query: event.query }) - : select.idle.from() + ? select.loading({ data: { query: event.query } }) + : select.idle() } } } @@ -221,9 +221,9 @@ const machine = Machine.make({ Each selector is bound to its declared destination. Its constructor rejects a 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))` +`select.checkout({ data: parentValues, states: { Review: { data: childValues } } })` constructs the explicitly selected subtree. Parallel constructors require every -entered region. Every compound has an initial edge, including inactive branches. Final outputs, +entered region; source-local construction can retain active sibling regions. 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. @@ -239,9 +239,10 @@ All references come from the same root descriptor supplied to `make`: | Declaration | Meaning | | ----------------------------------------------------- | ------------------------------------------------------------------------- | | `{ 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. | +| `{ target: 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, data: ... }` | Replace a retained active owner's value. | +| `{ target: targets.root, input: ... }` | Reconstruct root and its initial children using fresh machine input. | | `{ update: targets.root, data: ... }` | Replace root data and retain active descendants. | | `{ none: true }` | Accept an event without changing the configuration. | @@ -260,12 +261,12 @@ Save: { The complete replacement values are validated before either change is applied. Advanced construction declares both references in a branch and uses -`select.saved.from(destinationValues).update.from(ownerValues)`. +`select.saved({ data: destinationValues, update: { data: ownerValues } })`. 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. Startup follows the initial declarations in `.handle`; history +configurations. Root targets accept fresh input and follow the initial declarations in `.handle`; history defaults retain complete subtree construction for restoration. ### Protocols and ownership diff --git a/packages/effect-machine/docs/agent-guide.md b/packages/effect-machine/docs/agent-guide.md index 4898687f..d3ed1d91 100644 --- a/packages/effect-machine/docs/agent-guide.md +++ b/packages/effect-machine/docs/agent-guide.md @@ -22,7 +22,7 @@ const CounterState = Schema.TaggedUnion({ Running: { count: Schema.Number } }) -export const CounterStates = Machine.state({ initial: "Idle", states: { +export const CounterStates = Machine.state({ states: { Idle: {}, Running: CounterState.cases.Running } }) @@ -41,16 +41,15 @@ export const CounterMachine = Machine.make({ id: "Counter", root: CounterStates, events: CounterEvents, - initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) -}).handle({ states: { +}).handle({ initial: { target: targets.root.Idle }, states: { Idle: { on: { - Start: { target: targets.root.Running, from: () => ({ count: 0 }) } + Start: { target: targets.root.Running, data: () => ({ count: 0 }) } } }, Running: { on: { - Increment: { update: targets.root.Running, from: ({ state }) => ({ count: state.count + 1 }) }, + Increment: { update: targets.root.Running, data: ({ state }) => ({ count: state.count + 1 }) }, Stop: { target: targets.root.Idle } } } @@ -62,19 +61,18 @@ Each step has one job: - `Machine.state` declares the root, its child topology, and state-owned data. - `Machine.events` declares the public messages the machine accepts and returns typed event constructors. -- `Machine.make` joins the state tree, event protocol, input, and initial state. -- `.handle` implements the behavior of every active state and returns the +- `Machine.make` joins the state tree, event protocol, input, and reusable sources. +- `.handle` declares initial children and implements the behavior of every active state and returns the machine to export. Chain `.handle` from `Machine.make`. Do not store the intermediate definition when the module exports one machine implementation. -State builders construct the next snapshot. Use `.from(...)` for schema make -input; defaults, transformations, and refinements run while the machine plans -the transition. Use `.decoded(...)` only for an existing `Schema.Type`. It is -validated against the type side without rerunning encoded transformations. -Valued state builders are not callable, so the construction mode is always -visible. Schema-less state construction uses `.from()`. +Use `data` for schema make input. Constructor defaults and validation run while +planning. Use `{ decoded: true, data }` for an existing schema Type; validation +still applies. Named selectors take the same object format, with nested children +under `states`. Structural states omit data. See [Root API](./root-api.md) for +startup input, root targets, subtree construction, and history fallbacks. The examples below show one modeling decision at a time. They omit unchanged state and event declarations already shown above. @@ -108,7 +106,7 @@ const RequestState = Schema.TaggedUnion({ Failed: { message: Schema.String } }) -const RequestStates = Machine.state({ initial: "Idle", states: { +const RequestStates = Machine.state({ states: { Idle: {}, Loading: {}, Ready: RequestState.cases.Ready, @@ -141,12 +139,11 @@ const DocumentState = Schema.TaggedUnion({ } }) -const DocumentStates = Machine.state({ initial: "Closed", states: { +const DocumentStates = Machine.state({ states: { Closed: {}, Open: { // Editing, Saving, and SaveFailed all need the document and draft. schema: DocumentState.cases.Open, - initial: "Editing", states: { Editing: {}, Saving: {}, @@ -177,9 +174,10 @@ const documentTargets = Machine.targets(DocumentStates) const DocumentMachine = Machine.make({ root: DocumentStates, events: DocumentEvents -}).handle({ states: { +}).handle({ initial: { target: documentTargets.root.Closed }, states: { Closed: {}, Open: { + initial: { target: documentTargets.root.Open.Editing }, on: { // All Open children close the document in the same way. Close: { target: documentTargets.root.Closed } @@ -245,19 +243,17 @@ child in every region. A parallel model therefore accepts the full product of those regions. ```ts -const ScreenStates = Machine.state({ initial: "Screen", states: { +const ScreenStates = Machine.state({ states: { Screen: { type: "parallel", states: { connection: { - initial: "Online", states: { Online: {}, Offline: {} } }, panel: { - initial: "Closed", states: { Closed: {}, Open: {} @@ -266,6 +262,15 @@ const ScreenStates = Machine.state({ initial: "Screen", states: { } } } }) +const screenTargets = Machine.targets(ScreenStates) +const ScreenMachine = Machine.make({ root: ScreenStates, events: Machine.events({}) }).handle({ + initial: { target: screenTargets.root.Screen }, + states: { Screen: { states: { + connection: { initial: { target: screenTargets.root.Screen.connection.Online } }, + panel: { initial: { target: screenTargets.root.Screen.panel.Closed } } + } } } +}) + ``` This model permits all four combinations: online with a closed panel, online @@ -288,7 +293,7 @@ const LoadState = Schema.TaggedUnion({ Failed: { message: Schema.String } }) -const LoadStates = Machine.state({ initial: "Idle", states: { +const LoadStates = Machine.state({ states: { Idle: {}, Loading: LoadState.cases.Loading, Ready: LoadState.cases.Ready, @@ -300,15 +305,14 @@ const LoadMachine = Machine.make({ root: LoadStates, effects: { loadDocument }, events: Machine.eventsFromSchemas(), - initialConfiguration: root => root.resolve(({ target }) => target.from(to => to.Idle.from())) -}).handle({ states: { +}).handle({ initial: { target: loadTargets.root.Idle }, states: { Idle: {}, Loading: { invoke: { src: "loadDocument", input: ({ state }) => state.documentId, - onDone: { target: loadTargets.root.Ready, from: ({ output }) => ({ content: output }) }, - onFailure: { target: loadTargets.root.Failed, from: ({ error }) => ({ message: String(error) }) } + onDone: { target: loadTargets.root.Ready, data: ({ output }) => ({ content: output }) }, + onFailure: { target: loadTargets.root.Failed, data: ({ error }) => ({ message: String(error) }) } } }, Ready: {}, @@ -334,8 +338,8 @@ Loading: { { src: "loadDocument", input: ({ state }) => state.documentId, - onDone: { target: loadTargets.root.Ready, from: ({ output }) => ({ content: output }) }, - onFailure: { target: loadTargets.root.Failed, from: ({ error }) => ({ message: String(error) }) } + onDone: { target: loadTargets.root.Ready, data: ({ output }) => ({ content: output }) }, + onFailure: { target: loadTargets.root.Failed, data: ({ error }) => ({ message: String(error) }) } }, { src: "loadTimeout", onDone: { target: loadTargets.root.Idle } } ] @@ -387,14 +391,14 @@ const ReviewMachine = Machine.make({ rejected: { target: reviewTargets.root.Rejected } } } -}).handle({ states: { +}).handle({ initial: { target: reviewTargets.root.Pending }, states: { Pending: { on: { Evaluate: { branches: "evaluate", resolve: ({ event, select }) => event.score >= 80 - ? select.accepted.from() - : select.rejected.from() + ? select.accepted() + : select.rejected() } } }, @@ -414,7 +418,7 @@ descendants and running work: ```ts Changed: { update: targets.root.Document, - from: ({ ancestors }) => ({ + data: ({ ancestors }) => ({ ...ancestors.Document, revision: ancestors.Document.revision + 1 }) @@ -423,12 +427,11 @@ Changed: { Choose a valued source or retained ancestor explicitly. To change a parallel sibling, send an event handled by that sibling. Combine a destination and one -retained owner update with `{ target, update, from }`; the callback returns +retained owner update with `{ target, update, data }`; the callback returns `{ target: destinationInput, update: completeOwnerInput }`. Both values are validated atomically, and destination entry sees the new owner value. A named branch can declare the same pair when a resolver needs commands or nested -construction. Its constructor requires `.update.from(...)` or -`.update.decoded(...)` before the selection can be returned. +construction. Its selector requires `update: { data: ownerValue }` in the construction object. ## Test paths and invariants diff --git a/packages/effect-machine/docs/root-api.md b/packages/effect-machine/docs/root-api.md index 73c5f5a3..e03d6c22 100644 --- a/packages/effect-machine/docs/root-api.md +++ b/packages/effect-machine/docs/root-api.md @@ -1,259 +1,212 @@ # Root machine API -A machine is one explicit root state. The root can own data, child topology, -both, or neither. There is no separate global context store. +A machine has one root state. It can own data, child topology, both, or neither. +`Machine.state` declares that structure; `Machine.make` declares protocols and +reusable sources; `.handle` supplies initialization and behavior. -## Events and transitions only +## Startup input and state data ```ts import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" -const Events = Machine.events({ Open: {}, Close: {} }) const Root = Machine.state({ - initial: "Closed", - states: { Closed: {}, Open: {} } -}) -const targets = Machine.targets(Root) -const Door = Machine.make({ root: Root, events: Events }).handle({ - states: { - Closed: { on: { Open: { target: targets.root.Open } } }, - Open: { on: { Close: { target: targets.root.Closed } } } - } -}) -``` - -The root declares the default child once. No startup target, empty schema, or -resolver is needed. Structural nodes have `value: undefined` in snapshots. - -## Root data and child data - -```ts -const Root = Machine.state({ - fields: { title: Schema.String }, - initial: "Editing", + fields: { locale: Schema.String }, states: { - Editing: { fields: { draft: Schema.String } }, - Saving: {} + Loading: { fields: { documentId: Schema.String } }, + Ready: {} } }) -const Events = Machine.events({ Rename: { title: Schema.String }, Save: {} }) const targets = Machine.targets(Root) -const Editor = Machine.make({ +const Events = Machine.events({ + Loaded: {}, + Reload: { locale: Schema.String, documentId: Schema.String } +}) +const Document = Machine.make({ root: Root, events: Events, - initial: (root) => root.from(() => ({ title: "Untitled" })) + input: Schema.Struct({ locale: Schema.String, documentId: Schema.String }) }).handle({ - initialize: ({ builder, state }) => builder.from({ draft: state.title }), + root: ({ input }) => ({ locale: input.locale }), + initial: { + target: targets.root.Loading, + data: ({ input }) => ({ documentId: input.documentId }) + }, on: { - Rename: { update: targets.root, from: ({ event }) => ({ title: event.title }) } + Reload: { + target: targets.root, + input: ({ event }) => ({ locale: event.locale, documentId: event.documentId }), + reenter: true + } }, states: { - Editing: { on: { Save: { target: targets.root.Saving } } } + Loading: { on: { Loaded: { target: targets.root.Ready } } } } }) ``` -`initialize` constructs the declared child configuration. Required values must -be supplied before the machine becomes executable. Values whose schema make -input is optional can use their schema defaults. A parallel owner's initializer -constructs every required region through the typed builder. - -The complete snapshot always includes the root: - -```ts -// While Editing is active: -{ - path: "", - value: { _tag: "", title: "Untitled" }, - state: { - path: "Editing", - value: { _tag: "Editing", draft: "Untitled" } - } -} -``` - -Root handlers remain active across child transitions. Child handlers receive -their own state, typed ancestors, `root`, and the complete snapshot. -`update: targets.root` addresses root data from a child. An explicit descendant -reference addresses a valued handler owner or retained ancestor. -Both replace the entire value; omitted fields are not silently retained. -Updates preserve the active topology and do not restart scoped work. - -Simultaneous transitions retain conflict checking. Two parallel handlers cannot -silently overwrite the same owner. Use `{ target, update }` to combine a selected destination and a retained -owner's value in one atomic transition. +Start with `Machine.start(Document, { locale: "en", documentId: "intro" })`. +Root construction runs before its initial child construction. Both receive the +same validated input. `documentId` belongs only to Loading; it need not be stored +in root data. Input is not retained as a reset value on the machine or snapshot. -## Schemas and reusable descriptors +Only the root's initial callbacks receive machine input. Nested initial callbacks +receive their owning state, ancestors, root, and lifecycle event. Pass any needed +values through the nested state's data. Input is not an entry/exit capability. -Use `fields` for local data or `schema` for an existing tagged schema. They are -mutually exclusive. A schema preserves class identity, refinements, defaults, -and transformations; its tag is independent of its mount path. +For a parallel root, every required region constructor receives input: ```ts -class Ready extends Schema.TaggedClass("Ready")("Ready", { - name: Schema.String -}) {} - -const Form = Machine.state({ - initial: "Idle", - states: { Idle: {}, Ready: { schema: Ready } } -}) -const Root = Machine.state({ - type: "parallel", - states: { First: Form, Second: Form } -}) +initial: { + Documents: ({ input }) => ({ folder: input.folder }), + Connection: ({ input }) => ({ endpoint: input.endpoint }) +} ``` -Each mount has its own path and lifecycle. Descriptors capture their definition -without retaining caller-owned mutable topology containers. `Machine.make` -accepts the complete descriptor as `root`; callers do not extract its internals. - -`Machine.events({ Save: { title: Schema.String } })` creates a deferred -`Events.Save({ title })` constructor. The machine validates it when processing -the event. To reuse tagged classes or a tagged union, use -`Machine.eventsFromSchemas(...)`. Internal and emitted protocols have distinct -constructors with the same fields/import distinction. Public sends cannot send -internal events, and emissions do not become machine input events implicitly. +Structural regions require no data. Schema constructor defaults can supply +optional region data. Compound handlers declare one direct initial child with +`initial: { target, data }`; each compound has its own initial declaration. +There is no `initialConfiguration` or initializer in `make`. -## Default initialization and explicit configuration +## Root targets and updates -`initial` supplies root values while following the topology's declared child -defaults. It does not override those defaults. A structural root usually needs -no initializer. +`{ target: targets.root, input: ... }` reconstructs root data and follows its +initial declarations using fresh input. Input is required exactly when it is +required by `Machine.start`. Machines without input omit it. Root targets reject +`data`, `decoded`, and explicit child construction. -Use the separately named `initialConfiguration` to select a complete startup -configuration, for example to start the door open: - -```ts -const DoorOpen = Machine.make({ - root: Root, - events: Events, - initialConfiguration: (root) => root.resolve(({ target }) => - target.from((states) => states.Open.from()) - ) -}).handle({}) -``` +Targeting root retains its lifecycle unless it is reentered. Put a machine-wide +reset handler at root and use `reenter: true` to exit and enter root as well as +its descendants. Reentry restarts work owned by the exited states. Without +reentry, retained root work continues; it does not automatically acquire new +construction values. Ordinary transition conflict and lifecycle rules still apply. +This does not create a new machine reference or discard history records. -This example uses the door root and events from the first section. Every active -value and region in an explicit configuration must be provided. `initial` and -`initialConfiguration` cannot be combined. Use `Machine.resume` when restoring -a validated snapshot, including its completion and history metadata. +`{ update: targets.root, data: ... }` instead replaces root's complete value, +retaining its active children and running work. It takes no input and does not +run initial constructors. Other source/ancestor updates follow the same rules. -## Construction, guards, and branches +## Inline and named construction -Ordinary transitions declare their destination inline. `from` constructs schema -make input; `decoded` returns an already decoded value. Omit construction only -when the selected state can be constructed without arguments. +Ordinary transitions stay inline: ```ts -Save: { - target: targets.root.Saving, - guard: ({ state }) => state.draft.length > 0, - from: ({ state }) => ({ requestId: state.draft }) +Load: { + target: targets.root.Loading, + data: ({ event }) => ({ documentId: event.documentId }) } ``` -A false guard declines the handler and allows ancestor fallback. `{ none: true }` -accepts an event without changing topology. Initial entry and total choices -cannot decline. `reenter: true` explicitly restarts the handler source; a value -update alone retains its lifecycle and cannot request reentry. +Targeting a compound or parallel state follows its initial declarations. Event +transitions use `target`, never `initial`. `initial` names only handler startup +edges. `history` restores a declared history reference; `none: true` accepts an +event without changing its target configuration. -Use a branch group in `make` for conditional selection, commands, or explicit -nested construction. The resolver receives constructors derived from that -exact declaration, so its result cannot introduce another destination. +Declare branch topology in `make` when a resolver chooses an outcome, builds an +explicit subtree, or enqueues commands: ```ts -// In make: branches: { - choose: { - saving: { target: targets.root.Saving, title: "Save changes" }, - idle: { target: targets.root.Idle } + open: { + checkout: { target: targets.root.Checkout }, + unchanged: { none: true } } } +``` + +The resolver calls a constructor bound to that declaration: -// In handle: -Choose: { - branches: "choose", - resolve: ({ event, select }) => event.save - ? select.saving.from({ requestId: event.requestId }) - : select.idle.from() +```ts +Open: { + branches: "open", + resolve: ({ event, select }) => event.open + ? select.checkout({ + data: { cartId: event.cartId }, + states: { Review: { data: { total: event.total } } } + }) + : select.unchanged() } ``` -Declare `declinable: true` if a resolver may return `decline()`. Guards and -resolvers must remain synchronous and deterministic. +Objects contain computed values, not nested constructor callbacks. `data` is +schema make input; `{ decoded: true, data: value }` supplies the decoded type. +Both paths validate and report typed schema failures before committing changes. +The decoded flag applies only to its own node. Structural nodes reject data. -An owner update replaces its entire value. A combined transition constructs -both values atomically: +Omitting `states` follows declared initial children. Explicit compound +construction selects exactly one child. Entering an inactive parallel subtree +requires every region. Source-local parallel construction can select a single +region while retaining its active siblings, preserving the existing scope rules. +Names under `states` cannot collide with construction metadata. + +Declared branch targets remain inspectable without executing a resolver. +Dynamically selected descendants are visible after resolution; this API does not +add static analysis of resolver bodies. + +## Retained-owner updates + +An inline combined transition keeps `target` and `update` references separate: ```ts Save: { - target: targets.root.Saving, - update: targets.root, - from: ({ root, event }) => ({ + target: targets.root.Checkout.Saving, + update: targets.root.Checkout, + data: ({ event }) => ({ target: { requestId: event.requestId }, - update: { ...root, attempts: root.attempts + 1 } + update: { cartId: event.cartId } }) } ``` -Destination entry sees the updated owner. Use a named branch with the same -`{ target, update }` declaration for nested construction or mixed methods: -`select.saved.from(payload).update.decoded(ownerValue)`. +For the same pair declared as a named branch, construct both in one call: -All destination references are paths derived by `Machine.targets(Root)`. -`target` selects a descendant; `initial` enters a compound or parallel subtree -using its declared initialization; `history` restores a history reference. -`update: targets.root` changes root data while preserving active children. -There is no transition operation that replaces an arbitrary full root -configuration. Use explicit branches to keep every possible destination visible. - -## Completion and history +```ts +select.saved({ + data: { requestId: event.requestId }, + update: { data: { cartId: event.cartId } } +}) +``` -A compound root returns its completed direct workflow's output when that child -has no `onDone` handler. A child with `onDone` first handles its completion. -Nested compound states retain statechart completion rules: an arbitrary -completed descendant does not make every ancestor final. Parallel roots -complete when all required regions complete. +`update` is required only for a branch declaring an owner update. It is not an +arbitrary nested-state operation. The owner must be active and retained across +the transition. Both complete values validate before either is committed. -Restoring history beneath the root preserves current root data. Restoring the -root's own history can restore root-owned data. History fallback builders must -construct a complete configuration containing the history owner. +## History and lifecycle -## Observation and testing +History fallback uses a bound function with the same node object format: -- `MachineRef.state` reads the complete logical root snapshot. -- `MachineRef.snapshot` includes runtime status and completion information. -- `MachineAtom.result` retains asynchronous startup, failure, and the successful - logical snapshot. `MachineAtom.snapshot` exposes the runtime snapshot. -- `AtomMachine.select` selects an optional value; `selectSnapshot` selects an - optional subtree. Child inactivity remains `Option.none()`. -- `MachineState` renders a typed path in React. `createMachineContext(factory)` - owns a separate machine per Provider without subscribing the Provider to it. +```ts +history: { + recent: { + default: ({ target }) => target({ + states: { + Checkout: { + data: { cartId: "new" }, + states: { Review: { data: { total: 0 } } } + } + } + }) + } +} +``` -The Atom bridge and child bridge no longer expose `.state`; use `.result` or a -path selector. The core `MachineRef.state` contract remains available. +A fallback must explicitly construct a complete tree containing its owner, +including required ancestors and parallel siblings. Its root data is state data, +not startup input. `Machine.resume` restores a validated snapshot without rerunning +startup constructors. -`MachineTest.run` and `MachineTest.probe` accept the same public event inputs as -production sends, including deferred constructors. Traces and acknowledgements -record the decoded event that was processed, including ignored events. +`guard` declines before construction. A resolver may return `decline()` only with +`declinable: true`. Effect, Stream, timer, logic, child invocation, completion, +entry/exit, and required outcome channels retain their existing contracts. ## Migration -Replace `Machine.states(tree)` and `Machine.make({ states: States.states })` -with one `Machine.state({ initial, states: tree })` descriptor and -`Machine.make({ root })`. Move the old root-level handler map under -`handle({ states: ... })`; place machine-wide behavior directly in `handle`. -Use root `fields` to replace a wrapper state introduced only to carry shared data. -Use `Machine.Snapshot` for the complete logical snapshot. `Machine.StateAccessors` replaces the former `Machine.DefinedStates` -helper interface; it exposes paths and projections without a public state map. - -Replace schema arguments to `Machine.events`, `internalEvents`, and -`emittedEvents` with field records, or use their explicit `FromSchemas` -constructors. Replace old Atom `.state` reads with `.result`. Use -`EmittedEvent`, `EmittedEvents`, `EmittedEventOf`, and `SchemaLessStateAnnotations` in place -of the removed deprecated type aliases. - -Encoded snapshots now use version 2 and include the root at path `""`. -Version 1 payloads must be explicitly migrated; the decoder rejects them. +- Root initial constructors can read `input`; remove root fields used only to + forward that input to the initial child. +- Replace event `{ initial: reference, data }` with `{ target: reference, data }`. +- Replace `select.branch.from(value)` with `select.branch({ data: value })`. +- Replace `.decoded(value)` with `({ decoded: true, data: value })`. +- Replace child callbacks with `states: { Child: { data, states } }`. +- Replace `.update.from(value)` chaining with `update: { data: value }` inside + the same selector call. +- Replace history fallback chains with `target({ states: ... })`. diff --git a/packages/effect-machine/perf/types/adapter-readiness-control.ts b/packages/effect-machine/perf/types/adapter-readiness-control.ts index 2dc52736..475f30ea 100644 --- a/packages/effect-machine/perf/types/adapter-readiness-control.ts +++ b/packages/effect-machine/perf/types/adapter-readiness-control.ts @@ -44,7 +44,7 @@ export const machine = Machine.make({ history: { recent: { default: ({ target }) => - target.from((tree) => tree.Flow.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({})))) + target({ states: { Flow: { data: Flow.make({}), states: { Idle: { data: Idle.make({}) } } } } }) } }, states: { diff --git a/packages/effect-machine/perf/types/composition.ts b/packages/effect-machine/perf/types/composition.ts index 50ac29c6..f77cf752 100644 --- a/packages/effect-machine/perf/types/composition.ts +++ b/packages/effect-machine/perf/types/composition.ts @@ -33,13 +33,22 @@ const handled = machine.handle({ 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({}))))) - ) + target({ + states: { + App: { + data: App.make({}), + states: { + Workspace: { + data: Workspace.make({}), + states: { + Editor: { data: Editor.make({}), states: { Editing: { data: Editing.make({}) } } }, + Sync: { data: Sync.make({}), states: { Idle: { data: SyncIdle.make({}) } } } + } + } + } + } + } + }) } }, output: ({ outputs }) => ({ @@ -79,11 +88,18 @@ const handled = machine.handle({ choice: { 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({}))))) + select.app({ + data: App.make({}), + states: { + Workspace: { + data: Workspace.make({}), + states: { + Editor: { data: Editor.make({}), states: { Editing: { data: Editing.make({}) } } }, + Sync: { data: Sync.make({}), states: { Idle: { data: SyncIdle.make({}) } } } + } + } + } + }) } } } diff --git a/packages/effect-machine/perf/types/definition-variants.ts b/packages/effect-machine/perf/types/definition-variants.ts index b5dfbc9a..c2521a5b 100644 --- a/packages/effect-machine/perf/types/definition-variants.ts +++ b/packages/effect-machine/perf/types/definition-variants.ts @@ -14,7 +14,7 @@ const complete = machine.handle({ history: { recent: { default: ({ target }) => - target.from((tree) => tree.Flow.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({})))) + target({ states: { Flow: { data: Flow.make({}), states: { Idle: { data: Idle.make({}) } } } } }) } }, states: { diff --git a/packages/effect-machine/perf/types/exact-channels.ts b/packages/effect-machine/perf/types/exact-channels.ts index bbb45a02..6ef0c89f 100644 --- a/packages/effect-machine/perf/types/exact-channels.ts +++ b/packages/effect-machine/perf/types/exact-channels.ts @@ -26,7 +26,7 @@ const complete = machine.handle({ branches: "finish", resolve: ({ event, select }, enqueue) => { enqueue.emit(Notice.make({ value: event.value })) - return select.done.from(Done.make({ value: event.value })) + return select.done({ data: Done.make({ value: event.value }) }) } }, Loaded: { target: targets.root.Done, data: ({ event }) => ({ value: event.value }) } diff --git a/packages/effect-machine/perf/types/named-branches.ts b/packages/effect-machine/perf/types/named-branches.ts index 0c01db9f..e1b9615d 100644 --- a/packages/effect-machine/perf/types/named-branches.ts +++ b/packages/effect-machine/perf/types/named-branches.ts @@ -31,25 +31,25 @@ const handled = machine.handle({ const value = event.value switch (value.length) { case 1: - return select.length1.from(State.cases.Text.make({ value })) + return select.length1({ data: State.cases.Text.make({ value }) }) case 2: - return select.length2.from(State.cases.Count.make({ value: value.length })) + return select.length2({ data: State.cases.Count.make({ value: value.length }) }) case 3: - return select.length3.from(State.cases.Text.make({ value })) + return select.length3({ data: State.cases.Text.make({ value }) }) case 4: - return select.length4.from(State.cases.Count.make({ value: value.length })) + return select.length4({ data: State.cases.Count.make({ value: value.length }) }) case 5: - return select.length5.from(State.cases.Text.make({ value })) + return select.length5({ data: State.cases.Text.make({ value }) }) case 6: return select.length6() case 7: - return select.length7.from(State.cases.Count.make({ value: value.length })) + return select.length7({ data: State.cases.Count.make({ value: value.length }) }) case 8: - return select.length8.from(State.cases.Text.make({ value: value.toUpperCase() })) + return select.length8({ data: State.cases.Text.make({ value: value.toUpperCase() }) }) case 9: - return select.length9.from(State.cases.Count.make({ value: value.length })) + return select.length9({ data: State.cases.Count.make({ value: value.length }) }) case 10: - return select.length10.from(State.cases.Idle.make({})) + return select.length10({ data: State.cases.Idle.make({}) }) default: return select.unchanged() } diff --git a/packages/effect-machine/perf/types/transition-construction.ts b/packages/effect-machine/perf/types/transition-construction.ts index 888c816f..d9c11c6e 100644 --- a/packages/effect-machine/perf/types/transition-construction.ts +++ b/packages/effect-machine/perf/types/transition-construction.ts @@ -40,7 +40,7 @@ const handled = machine.handle({ Reset: { branches: "reset", reenter: true, - resolve: ({ state, select }) => state.text.length === 0 ? select.idle.from() : select.same() + resolve: ({ state, select }) => state.text.length === 0 ? select.idle({}) : select.same() } } } diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index e2b10ca8..8fd276ae 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -242,11 +242,6 @@ export interface Machine< /** @internal */ readonly stateNodes: Machine.StateNodes - /** @internal */ - readonly makeTargetBuilder: >( - source: Source - ) => Machine.TargetBuilder - /** @internal */ readonly handlers: Readonly> @@ -300,7 +295,7 @@ export interface Definition< InputEvents, ParentEvents >, - TypeId | "stateNodes" | "makeTargetBuilder" | "handlers" | "initial" | "initialDefinition" + TypeId | "stateNodes" | "handlers" | "initial" | "initialDefinition" > { /** @@ -355,10 +350,7 @@ export declare namespace Definition { * @category models * @since 0.15.0 */ - export interface Any - extends - Omit - { + export interface Any extends Omit { readonly handle: any } } @@ -2621,7 +2613,6 @@ export declare namespace Machine { /** @internal */ readonly stateNodes: StateNodes /** @internal */ - readonly makeTargetBuilder: any /** @internal */ readonly handlers: any /** @internal */ @@ -4534,12 +4525,9 @@ export declare namespace Machine { export type HistoryDefaultTargetBuilder< States extends StateSchemas, Owner extends StateIdentifier - > = HistorySnapshotMethod< - { readonly "": Extract, StateNodeConfig> }, - "" & ActiveStateKey<{ readonly "": Extract, StateNodeConfig> }>, - "", - Owner - > + > = ( + construction: HistoryConstruction + ) => StateConstruction> /** * Builder for source-local transition targets. @@ -5708,6 +5696,32 @@ export declare namespace Machine { readonly output?: never } + type HistoryConstruction = + & ConstructionData + & (Node extends { + readonly states: infer Children extends StateSchemas + } ? { + readonly states: Node extends { readonly type: "parallel" } ? { + readonly [K in (ActiveStateKey | ChoiceStateKey)]: HistoryConstruction< + Children[K], + Owner extends `${K}.${infer Rest}` ? Rest : "" + > + } + : { + readonly [K in (ActiveStateKey | ChoiceStateKey)]: Owner extends + "" | K | `${K}.${string}` ? + & { + readonly [P in K]: HistoryConstruction< + Children[P], + Owner extends `${K}.${infer Rest}` ? Rest : "" + > + } + & { readonly [P in Exclude<(ActiveStateKey | ChoiceStateKey), K>]?: never } : + never + }[(ActiveStateKey | ChoiceStateKey)] + } + : { readonly states?: never }) + /** Context used only when a history node has no previously captured record. */ export interface HistoryDefaultContext< in out States extends StateSchemas, @@ -6393,7 +6407,7 @@ export declare namespace Machine { & ( | { /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target: ReferenceUnion> + readonly target: ReferenceUnion> | TargetReference /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update?: ReferenceUnion> /** Enters a compound or parallel subtree through its declared initialization. */ @@ -6415,18 +6429,6 @@ export declare namespace Machine { /** Accepts the event without selecting a new destination. */ readonly none?: never } - | { - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial: 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 - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: never - /** Accepts the event without selecting a new destination. */ - readonly none?: never - } | { /** Restores the declared history reference, using its fallback on first entry. */ readonly history: ReferenceUnion> @@ -6470,56 +6472,111 @@ export declare namespace Machine { /** Named groups of inspectable destinations used by transition resolvers. */ readonly branches?: Readonly>>> } + type RegisteredInput = R extends { readonly "~input": infer I extends Schema.Top } ? I : typeof Schema.Void type Registered = Kind extends keyof R ? NonNullable : {} - type AtBuilder = P extends "" ? T - : P extends `${infer H}.${infer Rest}` ? H extends keyof T ? AtBuilder : never - : P extends keyof T ? T[P] - : never - type BranchBuilderAt, P extends string> = AtBuilder< - RootBuilder>, - P - > type RefPath = R extends TargetReference ? P : never - type SelectionOf, D> = D extends { - /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ + type ConstructionData = [NodeSchema] extends [never] ? { readonly data?: never; readonly decoded?: never } + : + | ( + & { readonly decoded?: false } + & ({} extends NodeMakeInput ? { readonly data?: NodeMakeInput } : + { readonly data: NodeMakeInput }) + ) + | { readonly decoded: true; readonly data: NodeValue } + type ConstructionChildren = + Parallel extends true ? { + readonly [K in ActiveStateKey]: ConstructionObject< + Children[K], + Source extends `${K}.${infer Rest}` ? Rest : Source extends K ? "" : never + > + } + : { + readonly [K in ActiveStateKey | ChoiceStateKey]: + & { + readonly [P in K]: ConstructionObject< + Children[P], + Source extends `${P}.${infer Rest}` ? Rest : Source extends P ? "" : never + > + } + & { readonly [P in Exclude | ChoiceStateKey, K>]?: never } + }[ActiveStateKey | ChoiceStateKey] + /** Values for one state and, optionally, its explicitly selected descendants. */ + export type ConstructionObject = + & { readonly input?: never } + & ConstructionData + & (Node extends { + readonly states: infer Children extends StateSchemas + } ? { + readonly states?: ConstructionChildren< + Children, + Node extends { readonly type: "parallel" } ? [Source] extends [never] ? true : false + : false, + Source + > + } + : { readonly states?: never }) + type ConstructionCall = {} extends A ? (construction?: A) => Result : (construction: A) => Result + type RootInput = + & { readonly data?: never; readonly decoded?: never; readonly states?: never; readonly update?: never } + & (RegisteredInput extends typeof Schema.Void ? { readonly input?: never } + : { + /** Fresh machine input used by root construction and root initial callbacks. */ readonly input: + RegisteredInput["Type"] + }) + type ObjectSelection< + S extends StateSchemas, + Src extends StateNodeIdentifier, + P extends StateNodeIdentifier, + R + > = P extends "" ? ConstructionCall, InitialTarget

> + : P extends ChoiceIdentifier ? () => ChoiceTarget + : P extends StateIdentifier ? ConstructionCall< + ConstructionObject< + NodeByIdentifier, + Src extends `${P}.${infer Rest}` ? Rest : Src extends P ? "" : never + > & { readonly update?: never }, + Target + > + : never + type SelectionOf, D, R = {}> = D extends { 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. */ - readonly update: infer Update - } ? - RefPath extends + } ? + RefPath extends infer P extends StateNodeIdentifier ? + D extends { readonly update: infer Update } + ? RefPath extends infer Owner extends Extract, ParentStateIdentifier

& ValuedStateIdentifier> - ? TargetSelection, S, Owner>, P, "state", "branch"> : - never - : TargetSelection, P, P extends ChoiceIdentifier ? "choice" : "state", "branch"> + ? P extends StateIdentifier ? TargetSelection< + ConstructionCall< + ConstructionObject< + NodeByIdentifier, + Src extends `${P}.${infer Rest}` ? Rest : Src extends P ? "" : never + > & { + readonly update: ConstructionData> + }, + CombinedTarget, S, Owner> + >, + P, + "state", + "branch" + > : + never + : never + : TargetSelection, P, P extends ChoiceIdentifier ? "choice" : "state", "branch"> : never - : D extends { - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial: infer Ref - } ? RefPath extends infer P extends string ? BranchBuilderAt extends { - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial: infer B - } ? TargetSelection - : never : - never - : D extends { - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history: infer Ref - } ? - RefPath extends infer P extends string - ? TargetSelection>, P>, P, "history", "full"> : + : D extends { readonly history: infer Ref } + ? RefPath extends infer P extends HistoryIdentifier + ? TargetSelection<() => HistoryTarget, P, "history", "full"> : never - : D extends { - /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ - readonly update: infer Ref - } ? - RefPath extends infer P extends Extract, ValuedStateIdentifier> - ? TargetSelection, P, "update", "branch"> : + : D extends { readonly update: infer Ref } + ? RefPath extends infer P extends Extract, ValuedStateIdentifier> + ? TargetSelection< + ConstructionCall>, StateUpdate>, + P, + "update", + "branch" + > : never - : D extends { - /** Accepts the event without selecting a new destination. */ - readonly none: true - } ? TargetSelection<() => NoTarget, never, "none", "local"> + : D extends { readonly none: true } ? TargetSelection<() => NoTarget, never, "none", "local"> : never type BuilderArgs = K extends keyof B ? B[K] extends (...args: infer A) => unknown ? A : never @@ -6530,15 +6587,6 @@ export declare namespace Machine { & { readonly [K in Exclude<"from" | "decoded", M>]?: never } : never }[Extract] - type ObjectDefault = B extends { - /** Constructs schema make input from the typed source context. */ - readonly from: () => unknown - } | (() => unknown) ? { - /** 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 - } - : never type ObjectPolicy = { /** Set to true to exit and reenter the handler source. */ readonly reenter?: Reenter extends true ? boolean : never @@ -6560,13 +6608,24 @@ export declare namespace Machine { P extends DestinationPath, C > = P extends ChoiceIdentifier ? { readonly from?: never; readonly decoded?: never } - : P extends StateIdentifier ? - NodeByIdentifier extends { readonly states: StateSchemas } - ? [NodeSchema>] extends [never] - ? ObjectConstruction> | ObjectDefault> - : never - : InlineNodeConstruction> + : P extends StateIdentifier ? InlineNodeConstruction> : never + type InlineRoot = + & { + readonly target: ReferenceAt>> + readonly update?: never + readonly branches?: never + readonly resolve?: never + readonly history?: never + readonly none?: never + } + & (RegisteredInput extends typeof Schema.Void ? { readonly input?: never } + : { + /** Fresh machine input used to reconstruct root and its initial children. */ readonly input: DataValue< + C, + RegisteredInput["Type"] + > + }) type InlineDestination, C> = { readonly [P in DestinationPath]: & { @@ -6595,24 +6654,7 @@ export declare namespace Machine { P extends string, Owner extends ValuedStateIdentifier, C - > = P extends StateIdentifier ? NodeByIdentifier extends { readonly states: StateSchemas } ? { - readonly [ - M in Extract & keyof StateUpdateBuilder, "from" | "decoded"> - ]: BuilderArgs, M> extends readonly [unknown?] ? - & { - readonly [K in M]: ( - context: C - ) => { - /** 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] - } - } - & { readonly [K in Exclude<"from" | "decoded", M>]?: never } - : never - }[Extract & keyof StateUpdateBuilder, "from" | "decoded">] - : + > = P extends StateIdentifier ? | { readonly from: ( context: C @@ -6684,35 +6726,10 @@ export declare namespace Machine { } & ObjectConstruction> }[Extract, ValuedStateIdentifier>] - type InlineInitial, C> = - [Exclude, "">] extends [never] ? never : { - readonly [P in Exclude, "">]: InitialTargetFactory, P> extends - infer B ? - & { - /** Enters a compound or parallel subtree through its declared initialization. */ - readonly initial: ReferenceAt - /** 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 - /** Restores the declared history reference, using its fallback on first entry. */ - readonly history?: 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 - /** Set to true when the resolver can explicitly return decline(). */ - readonly declinable?: never - } - & (ObjectConstruction | ObjectDefault) : - never - }[Exclude, "">] - type BranchSelections, Group> = { + type BranchSelections, Group, R> = { readonly [K in Extract]: { /** Selects a declared descendant; its transition or bound branch constructor supplies required values. */ - readonly target: SelectionOf + readonly target: SelectionOf } } type ObjectBranchResolver< @@ -6725,7 +6742,7 @@ export declare namespace Machine { context: C & { readonly select: BranchSelectors } & DeclineCapability, enqueue: Enqueue, EmittedEventOf> ) => BranchSelectionResult | (Declinable extends true ? Declined : never) - type InvalidBranchSelection, Group> = { + type InvalidBranchSelection, Group, R> = { readonly [K in keyof Group]: Src extends ChoiceIdentifier ? Group[K] extends { /** Accepts the event without selecting a new destination. */ readonly none: true @@ -6733,9 +6750,9 @@ export declare namespace Machine { /** Replaces the complete value of a retained source or ancestor without replacing its active children. */ readonly update: unknown } ? K - : [SelectionBuilder>] extends [never] ? K + : [SelectionBuilder>] extends [never] ? K : never - : [SelectionBuilder>] extends [never] ? K + : [SelectionBuilder>] extends [never] ? K : never }[keyof Group] type ObjectBranches< @@ -6748,7 +6765,7 @@ export declare namespace Machine { Acceptance extends TransitionAcceptance > = { readonly [K in Extract, string>]: - & ([InvalidBranchSelection[K]>] extends [never] ? unknown : never) + & ([InvalidBranchSelection[K], R>] extends [never] ? unknown : never) & { /** Named groups of inspectable destinations used by transition resolvers. */ readonly branches: K @@ -6771,7 +6788,13 @@ export declare namespace Machine { /** 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: ObjectBranchResolver[K]>, true> + readonly resolve: ObjectBranchResolver< + Ev, + Em, + C, + BranchSelections[K], R>, + true + > } }[Extract, string>] type DataValue = A | ((context: C) => A) @@ -6803,7 +6826,7 @@ export declare namespace Machine { | InlineDestination | InlineUpdate | InlineCombined - | InlineInitial + | InlineRoot | { /** Restores the declared history reference, using its fallback on first entry. */ readonly history: ReferenceUnion> @@ -7135,13 +7158,13 @@ export declare namespace Machine { 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< + /** Constructs root-owned data from startup or fresh root-target input. */ readonly root?: ShortConstruction< { readonly input: Input }, Node > } : { - /** Constructs root-owned data once from startup input. */ readonly root: ShortConstruction< + /** Constructs root-owned data from startup or fresh root-target input. */ readonly root: ShortConstruction< { readonly input: Input }, Node > @@ -7215,10 +7238,16 @@ export declare namespace Machine { S, Node, Src, - StateActionContext>, In, Pa> & { + & StateActionContext>, In, Pa> + & { /** Root-owned data constructed before initial descendant values. */ readonly root: StateByIdentifier>> } + & (Src extends "" ? { + /** Fresh machine input used by root construction and root initial callbacks. */ readonly input: + RegisteredInput["Type"] + } + : {}) > & (Src extends ChoiceIdentifier ? { /** Required total transition for a transient choice state. */ @@ -7314,13 +7343,16 @@ export declare namespace Machine { }) : never) type ValidateObjectTransition = + & ("input" extends keyof T + ? T extends { readonly target: TargetReference } ? unknown : { readonly input: never } + : unknown) & { readonly [ K in Exclude< keyof T, | "target" | "update" - | "initial" + | "input" | "history" | "none" | "branches" @@ -7436,7 +7468,7 @@ export declare namespace Machine { & (C extends (...args: never[]) => unknown ? never : unknown) & HandlerShape< C, - & StateHandler>, In, Pa, R> + & StateHandler>, In, Pa, R & { readonly "~input": I }> & RootConstruction > & RootConstruction diff --git a/packages/effect-machine/src/internal/machine/command.ts b/packages/effect-machine/src/internal/machine/command.ts index 915dff29..931f4300 100644 --- a/packages/effect-machine/src/internal/machine/command.ts +++ b/packages/effect-machine/src/internal/machine/command.ts @@ -16,22 +16,6 @@ export interface Collected { readonly emittedEvents: Array } -const targetBuilderCache = new WeakMap>() - -export const getTargetBuilder = (machine: Machine.Any, path: string): any => { - let byPath = targetBuilderCache.get(machine) - if (byPath === undefined) { - byPath = new Map() - targetBuilderCache.set(machine, byPath) - } - if (byPath.has(path)) { - return byPath.get(path) - } - const builder = machine.makeTargetBuilder(path as any) - byPath.set(path, builder) - return builder -} - export const makeCollector = (machine: Machine.Any): Collected => { const commands: Array = [] const raisedEvents: Array = [] diff --git a/packages/effect-machine/src/internal/machine/configuration.ts b/packages/effect-machine/src/internal/machine/configuration.ts index e63731fc..3f6da38d 100644 --- a/packages/effect-machine/src/internal/machine/configuration.ts +++ b/packages/effect-machine/src/internal/machine/configuration.ts @@ -991,7 +991,7 @@ const configurationFromTargetPathSync = ( for (const ancestor of paths) { const ancestorNode = getNode(machine, ancestor) - if (ancestorNode.type !== "parallel") continue + if (ancestorNode.type !== "parallel" || ancestor === node.path) continue for (const child of ancestorNode.children) { if (pathSet.has(child) || !current.active.has(child)) continue for (const activePath of current.active) { @@ -1016,15 +1016,18 @@ export const configurationFromInitialTargetSync = ( machine: Machine.Any, current: ActiveConfiguration, target: InitialTargetInstruction -): ActiveConfiguration => - configurationFromTargetPathSync( - machine, - current, - target.path, - target.value, - target.values as Readonly> | undefined, - true - ) +): ActiveConfiguration => { + const partial = configurationFromTargetPathSync(machine, current, target.path, target.value, target.values, true) + if (target.children === undefined) return partial + const active = new Set(partial.active) + const values = new Map(partial.values) + for (const [path, value] of target.children) { + const node = getNode(machine, path) + active.add(path) + if (node.schema !== undefined) values.set(path, decodeStateValueSync(machine, node, value)) + } + return { ...partial, active, values } +} const configurationFromTargetSnapshotSync = ( machine: Machine.Any, diff --git a/packages/effect-machine/src/internal/machine/construction.ts b/packages/effect-machine/src/internal/machine/construction.ts new file mode 100644 index 00000000..d71349f8 --- /dev/null +++ b/packages/effect-machine/src/internal/machine/construction.ts @@ -0,0 +1,147 @@ +/** Converts public construction objects into owned planner instructions. */ +import type { Machine } from "../../Machine.js" +import * as Topology from "./topology.js" + +export const record = (value: unknown): Readonly> => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Machine construction must be an object") + } + return value as Readonly> +} + +const nodeValue = (node: Machine.StateNode, config: Readonly>): unknown => { + if (config.decoded !== undefined && typeof config.decoded !== "boolean") { + throw new Error("Machine decoded construction flag must be a boolean") + } + if (node.schema === undefined) { + if (Object.hasOwn(config, "data") || Object.hasOwn(config, "decoded")) { + throw new Error(`Machine structural state "${node.path}" cannot construct data`) + } + return undefined + } + if (config.decoded === true) { + if (!Object.hasOwn(config, "data")) throw new Error("Machine decoded construction requires data") + return config.data + } + return Topology.makeStateInput(Object.hasOwn(config, "data") ? config.data : {}) +} + +export const target = ( + nodes: Machine.StateNodes, + path: string, + raw: unknown = {}, + source?: string +): unknown => { + const selectedNode = nodes.byPath.get(path) + if (selectedNode?.type === "atomic" || selectedNode?.type === "final") { + const config = record(raw) + if (Reflect.ownKeys(config).some((key) => key !== "data" && key !== "decoded")) { + throw new Error("Machine leaf construction accepts only data and decoded") + } + return Topology.makeTarget(path, nodeValue(selectedNode, config) as { readonly _tag: PropertyKey }) + } + const children = new Map() + const visit = (path: string, raw: unknown): unknown => { + const config = record(raw) + if (Reflect.ownKeys(config).some((key) => key !== "data" && key !== "decoded" && key !== "states")) { + throw new Error("Machine state construction accepts only data, decoded, and states") + } + const node = nodes.byPath.get(path) + if (node === undefined || node.type === "history") throw new Error(`Machine invalid construction state "${path}"`) + const value = nodeValue(node, config) + if (Object.hasOwn(config, "states")) { + if (node.type !== "compound" && node.type !== "parallel") { + throw new Error(`Machine state "${path}" has no child construction`) + } + const states = record(config.states) + const keys = Reflect.ownKeys(states) + const active = node.children.filter((child) => nodes.byPath.get(child)?.type !== "history") + if ( + node.type === "compound" + ? keys.length !== 1 + : keys.length !== active.length && + !(keys.length === 1 && source !== undefined && (source === path || source.startsWith(`${path}.`))) + ) { + throw new Error( + `Machine explicit states for "${path}" must select ${node.type === "compound" ? "one child" : "every region"}` + ) + } + for (const key of keys) { + const child = path === "" ? String(key) : `${path}.${String(key)}` + if ( + typeof key !== "string" || + !(nodes.byPath.get(child)?.parent === path && nodes.byPath.get(child)?.type !== "history") + ) throw new Error(`Machine unknown child "${String(key)}"`) + children.set(child, visit(child, states[key])) + } + } + return value + } + const value = visit(path, raw) + const values = new Map(children) + values.set(path, value) + let selected = path + let config = record(raw) + while (config.states !== undefined) { + const node = nodes.byPath.get(selected)! + const states = record(config.states) + const keys = Object.keys(states) + if ( + keys.length !== 1 || + (node.type === "parallel" && + !(source !== undefined && (source === selected || source.startsWith(`${selected}.`)))) + ) break + const key = keys[0]! + selected = selected === "" ? key : `${selected}.${key}` + config = record(states[key]) + } + const node = nodes.byPath.get(selected)! + const selectedValue = values.get(selected) + const ancestors = Object.fromEntries( + [...values].filter(([p, v]) => v !== undefined && p !== selected && !p.startsWith(`${selected}.`)) + ) + if (node.type === "choice") return Topology.makeChoiceTarget(selected, node.parent ?? "", ancestors) + if (node.type === "atomic" || node.type === "final") { + return Topology.makeTarget(selected, selectedValue as { readonly _tag: PropertyKey }, { + values: ancestors as Readonly> + }) + } + const descendants = new Map([...children].filter(([p]) => p.startsWith(`${selected}.`))) + return { + ...Topology.makeInitialTarget(selected, selectedValue, ancestors), + ...(descendants.size === 0 ? {} : { children: descendants }) + } +} + +export const update = (nodes: Machine.StateNodes, path: string, raw: unknown): Topology.StateUpdate => { + const config = record(raw) + if (Reflect.ownKeys(config).some((key) => key !== "data" && key !== "decoded")) { + throw new Error("Machine update construction accepts only data and decoded") + } + const node = nodes.byPath.get(path) + if (node?.schema === undefined) throw new Error("Machine update requires a valued state") + return Topology.makeStateUpdate(path, nodeValue(node, config)) +} + +/** History fallbacks construct a complete tree, including the history owner. */ +export const snapshot = (nodes: Machine.StateNodes, raw: unknown, path = ""): unknown => { + const config = record(raw) + const node = nodes.byPath.get(path)! + target(nodes, path, config) + const value = nodeValue(node, config) + if (node.type === "choice") return Topology.makeChoiceTarget(path, node.parent!) + if (node.type === "atomic" || node.type === "final") return { path, value } + if (config.states === undefined) { + throw new Error("Machine history fallback requires a complete explicit tree") + } + const states = record(config.states) + const children = Object.fromEntries( + Object.entries(states).map(([key, child]) => [ + key, + snapshot(nodes, child, path === "" ? key : `${path}.${key}`) + ]) + ) + return node.type === "parallel" ? + { path, value, states: children } + : { path, value, state: Object.values(children)[0] } +} diff --git a/packages/effect-machine/src/internal/machine/declaration.ts b/packages/effect-machine/src/internal/machine/declaration.ts index f3d27233..4f03bcad 100644 --- a/packages/effect-machine/src/internal/machine/declaration.ts +++ b/packages/effect-machine/src/internal/machine/declaration.ts @@ -12,6 +12,7 @@ interface Source { readonly value: unknown } export interface Declaration { + readonly initialize?: (input: unknown) => unknown readonly root: State readonly sources: ReadonlyMap readonly branches: ReadonlyMap>>>> @@ -50,7 +51,7 @@ export const capture = ( const spec = { ...record(value, `Machine branch "${name}.${key}" must be a declaration`) } for (const field of Reflect.ownKeys(spec)) { if ( - typeof field !== "string" || !["target", "update", "initial", "history", "none", "title"].includes(field) + typeof field !== "string" || !["target", "update", "history", "none", "title"].includes(field) ) { throw new Error(`Machine branch "${name}.${key}" contains an unknown declaration field`) } @@ -76,7 +77,7 @@ export const selection = ( declaration: Declaration, config: Readonly> ): Topology.TargetSelection => { - const keys = ["target", "initial", "history", "none"].filter((key) => config[key] !== undefined) + const keys = ["target", "history", "none"].filter((key) => config[key] !== undefined) if (keys.length === 0 && config.update !== undefined) { const owner = reference(config.update, declaration.root) if (owner.kind !== "state") throw new Error("Machine update requires an active state reference") @@ -91,17 +92,13 @@ export const selection = ( return Topology.noneTargetSelection } const ref = reference(config[key], declaration.root) - if (ref.path === "") throw new Error("Machine root data changes use update; transitions select a descendant") if ((key === "history") !== (ref.kind === "history")) { throw new Error("Machine history references require a history transition") } - if (key === "initial" && ref.kind !== "state") { - throw new Error("Machine initial entry requires an active state reference") - } const update = config.update === undefined ? undefined : reference(config.update, declaration.root).path if (update !== undefined && key !== "target") throw new Error("Machine owner updates require an ordinary destination") return Topology.makeTargetSelection( - key === "initial" ? "initial" : ref.kind, + ref.kind, ref.path, key === "history" ? "full" : "branch", update diff --git a/packages/effect-machine/src/internal/machine/executionPlan.ts b/packages/effect-machine/src/internal/machine/executionPlan.ts index 36f47285..7165045c 100644 --- a/packages/effect-machine/src/internal/machine/executionPlan.ts +++ b/packages/effect-machine/src/internal/machine/executionPlan.ts @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect" import type { Machine, MachineTarget } from "../../Machine.js" -import { getTargetBuilder, type RuntimeCommand } from "./command.js" +import { type RuntimeCommand } from "./command.js" import { type ActiveConfiguration, compareDocumentOrder, @@ -391,7 +391,6 @@ const makeIndexedTransitionContext = ( event: any, machineReferences?: PlanningMachineReferences ): any => { - const source = descriptor.nodes[sourceIndex]! const parentIndex = descriptor.parentIndices[sourceIndex]! const ancestors: Record = {} for (const ancestorIndex of descriptor.ancestorIndices[sourceIndex]!) { @@ -405,8 +404,7 @@ const makeIndexedTransitionContext = ( containingState: parentIndex < 0 ? undefined : configuration.values[parentIndex], ancestors, event, - snapshot: snapshotFromIndexedState(descriptor, configuration), - target: getTargetBuilder(machine, source.path) + snapshot: snapshotFromIndexedState(descriptor, configuration) } } @@ -601,7 +599,12 @@ const collectIndexedEvaluatedTransition = ( const initialResolution = unresolvedTarget !== undefined && isInitialTarget(unresolvedTarget) ? resolveInitialTarget( machine, - activeConfigurationFromIndexedState(descriptor, state), + (() => { + const current = activeConfigurationFromIndexedState(descriptor, state) + return update === undefined + ? current + : { ...current, values: new Map(current.values).set(update.path, update.value) } + })(), unresolvedTarget, (selection.context as any).event ) @@ -830,8 +833,7 @@ const planIndexedFlatState = ( containingState: undefined, ancestors: {}, event, - snapshot: snapshotFromIndexedState(descriptor, current), - target: getTargetBuilder(machine, sourcePath) + snapshot: snapshotFromIndexedState(descriptor, current) }, transition.evaluate ) diff --git a/packages/effect-machine/src/internal/machine/implementation.ts b/packages/effect-machine/src/internal/machine/implementation.ts index 7733dd61..3fae9d99 100644 --- a/packages/effect-machine/src/internal/machine/implementation.ts +++ b/packages/effect-machine/src/internal/machine/implementation.ts @@ -28,13 +28,12 @@ export interface CapturedStateConfig { /** * Captured machine implementation consumed by semantic layers. * - * The public erased view retains its existing fields for compatibility. Internal - * code enters through this view so handler lookup cannot silently produce `any`. + * Internal code enters through this view so handler lookup cannot silently + * produce `any`. * Construction captures these containers before handing the machine to a planner. */ export interface MachineInternal extends Machine.Any { readonly handlers: Readonly> - readonly makeTargetBuilder: (source: string) => Machine.TargetBuilder } /** The single conversion from an erased public machine to its captured implementation. */ diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index fde94964..96c7520f 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -28,6 +28,7 @@ import type { } from "../../Machine.js" import * as Activities from "./activities.js" import * as Configuration from "./configuration.js" +import * as Construction from "./construction.js" import * as Declaration from "./declaration.js" import type { ChildAlreadyExistsError, InfiniteTransitionError, StartupError } from "./errors.js" import type { CapturedStateConfig } from "./implementation.js" @@ -42,7 +43,6 @@ 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, makeTargetBuilder, withFrom } from "./targetBuilder.js" import * as TargetReference from "./targetReference.js" import * as Topology from "./topology.js" @@ -107,7 +107,6 @@ const makeWithHandlers = ( machine.initial = self.initial machine.initialDefinition = self.initialDefinition machine.stateNodes = self.stateNodes - machine.makeTargetBuilder = self.makeTargetBuilder machine.handlers = handlers Protocol.copyProtocol(self, machine) return machine @@ -139,20 +138,6 @@ const plainTargetSelection = (selection: Topology.TargetSelection): Topology.Tar ? Topology.noneTargetSelection : Topology.makeTargetSelection(selection.kind, selection.path, selection.scope, selection.updatePath) -// The authored callback crosses an erased schema boundary here. Each builder -// retains its construction mode, and planning validates both resulting values. -const constructSelectionValue = ( - selection: Topology.TargetSelection, - context: any, - method: "from" | "decoded", - value: unknown -): unknown => { - if (selection.kind === "update") return context.owner[method](value) - if (selection.updatePath === undefined) return context.target[method](value) - const values = value as { readonly target: unknown; readonly update: unknown } - return context.target[method](values.target).update(context.owner[method](values.update)) -} - const transitionTargetSelection = ( selection: Topology.TargetSelection ): Machine.TransitionTargetSelection => @@ -167,11 +152,6 @@ const selectionUpdates = (selection: Topology.TargetSelection): ReadonlyArray Topology.makeTargetSelection("update", path, scope) - const captureDefinitionBranch = ( branch: unknown, selector: unknown, @@ -206,91 +186,6 @@ const captureDefinitionBranch = ( return { ...(branch as DefinitionBranch), selection } } -const makeUpdatingConstruction = ( - target: unknown, - ownerPath: string -): { readonly update: (update: unknown) => Topology.CombinedTarget } => - Object.freeze({ - update: (update: unknown) => { - if (!Topology.isStateUpdate(update) || update.path !== ownerPath) { - throw new Error(`Machine combined target must update its declared owner "${ownerPath}"`) - } - return Topology.makeCombinedTarget(target, update) - } - }) - -const makeUpdatingTargetBuilder = ( - builder: unknown, - ownerPath: string -): unknown => { - if (typeof builder !== "object" || builder === null) { - throw new Error("Machine combined target requires a state construction builder") - } - const updating: Record = {} - for (const property of Reflect.ownKeys(builder)) { - const descriptor = Object.getOwnPropertyDescriptor(builder, property) - if (descriptor === undefined) continue - if ( - (property === "from" || property === "decoded") && "value" in descriptor && typeof descriptor.value === "function" - ) { - const construct = descriptor.value - descriptor.value = (...args: ReadonlyArray) => makeUpdatingConstruction(construct(...args), ownerPath) - } - Object.defineProperty(updating, property, descriptor) - } - return Object.freeze(updating) -} - -const getSelectionBuilder = ( - target: Record, - selection: Topology.TargetSelection, - stateNodes: Machine.StateNodes, - source: string -): unknown => { - if (selection.kind === "none") return target.none - if (selection.kind === "update") { - return withFrom( - (value: unknown) => Topology.makeStateUpdate(selection.path!, value), - "leaf", - true - ) - } - let builder: any - let parts = selection.path!.split(".") - if (selection.kind === "history") { - builder = target.history - if (selection.path !== "") parts.unshift("") - } else if (selection.scope === "local") { - builder = target.local - const scope = getLocalTargetScope(stateNodes, source) - if (scope !== undefined) { - if (selection.path === scope) { - builder = builder.with - parts = [] - } else { - parts = (scope === "" ? selection.path! : selection.path!.slice(scope.length + 1)).split(".") - } - } - } else if (selection.scope === "branch") { - builder = target.branch - if (selection.path !== "") parts.unshift("") - } else { - builder = target.full - } - for (const part of parts) builder = builder[part] - if (selection.kind === "initial") builder = builder.initial - if ( - typeof builder !== "function" && - (typeof builder !== "object" || builder === null || typeof builder.from !== "function") - ) { - throw new Error(`Machine could not construct selected transition target "${selection.path}"`) - } - return selection.updatePath === undefined ? builder : makeUpdatingTargetBuilder(builder, selection.updatePath) -} - -const constructSelectedTarget = (builder: any): unknown => - typeof builder?.from === "function" ? builder.from() : builder() - const validateResolvedSelection = ( result: unknown, selection: Topology.TargetSelection, @@ -341,37 +236,13 @@ const runCapturedBranch = ( stateNodes: Machine.StateNodes, source: string ): unknown => { - const selectedTarget = getSelectionBuilder(context.target, branch.selection, stateNodes, source) - if (branch.resolve === undefined) return constructSelectedTarget(selectedTarget) - // This fresh context is owned by this evaluation and can be retained by user callbacks. const resolverContext: Record = { ...context, root: source === "" ? context.state : context.ancestors[""], decline: Topology.makeDeclined } - if (branch.selection.kind === "none") delete resolverContext.target - else resolverContext.target = selectedTarget - if (branch.selection.kind === "update") { - const ownerPath = branch.selection.path! - delete resolverContext.target - resolverContext.current = context.ancestors[ownerPath] ?? context.state - resolverContext.owner = getSelectionBuilder( - context.target, - makeStateUpdateSelection(ownerPath, branch.selection.scope === "local" ? "local" : "branch"), - stateNodes, - source - ) - } else if (branch.selection.updatePath !== undefined) { - const ownerPath = branch.selection.updatePath - resolverContext.current = context.ancestors[ownerPath] - resolverContext.owner = getSelectionBuilder( - context.target, - makeStateUpdateSelection(ownerPath, "branch"), - stateNodes, - source - ) - } - const resolved = branch.resolve(resolverContext, enqueue) + delete resolverContext.target + const resolved = branch.resolve!(resolverContext, enqueue) if (Topology.isDeclined(resolved)) { if (branch.declinable !== true) { throw new Error(`Machine transition for state "${source}" returned decline without declaring declinable: true`) @@ -379,7 +250,7 @@ const runCapturedBranch = ( return resolved } validateResolvedSelection(resolved, branch.selection, stateNodes) - return resolved === undefined ? constructSelectedTarget(selectedTarget) : resolved + return resolved } const topologyTargetPath = (selection: Topology.TargetSelection): string | undefined => @@ -426,92 +297,59 @@ const captureNamedBranches = ( })) } -const wrapSelectedBranchBuilder = ( - builder: unknown, - owner: object, - branchIndex: number, - branchKey: string, - updateBuilder?: Record unknown> +const constructBranch = ( + selection: Topology.TargetSelection, + stateNodes: Machine.StateNodes, + declaration: Declaration.Declaration, + raw: unknown = {}, + source?: string ): unknown => { - if (typeof builder === "function") { - const wrapped = (...args: ReadonlyArray) => { - const result = builder(...args) - if (updateBuilder !== undefined) { - return Object.freeze({ - update: Object.freeze( - Object.fromEntries( - Object.getOwnPropertyNames(updateBuilder).map(( - method - ) => [method, (value: unknown) => - Topology.makeSelectedBranch( - owner, - branchIndex, - branchKey, - result.update(updateBuilder[method]!(value)) - )] - ) - ) - ) - }) - } - return Topology.makeSelectedBranch(owner, branchIndex, branchKey, result) - } - for (const property of Reflect.ownKeys(builder)) { - if ( - property === "length" || property === "name" || property === "prototype" || property === "caller" || - property === "arguments" - ) continue - const descriptor = Object.getOwnPropertyDescriptor(builder, property) - if (descriptor === undefined) continue - if ("value" in descriptor && typeof descriptor.value === "function") { - descriptor.value = wrapSelectedBranchBuilder(descriptor.value, owner, branchIndex, branchKey, updateBuilder) - } - Object.defineProperty(wrapped, property, descriptor) - } - return wrapped + const config = Construction.record(raw) + if (selection.kind === "none" || selection.kind === "history" || selection.kind === "choice") { + if (Reflect.ownKeys(config).length > 0) throw new Error("Machine pseudo-state construction accepts no data") + if (selection.kind === "none") return undefined + const node = stateNodes.byPath.get(selection.path!)! + return selection.kind === "history" ? + Topology.makeHistoryTarget(node.path, node.parent!) + : Topology.makeChoiceTarget(node.path, node.parent!) } - if (typeof builder === "object" && builder !== null) { - const wrapped: Record = {} - for (const property of Reflect.ownKeys(builder)) { - const descriptor = Object.getOwnPropertyDescriptor(builder, property) - if (descriptor === undefined) continue - if ("value" in descriptor && typeof descriptor.value === "function") { - descriptor.value = wrapSelectedBranchBuilder(descriptor.value, owner, branchIndex, branchKey, updateBuilder) - } - Object.defineProperty(wrapped, property, descriptor) + if (selection.kind === "update") return Construction.update(stateNodes, selection.path!, config) + if (selection.path === "") { + if (Reflect.ownKeys(config).some((key) => key !== "input")) { + throw new Error("Machine root targets accept input only") } - return wrapped + if (declaration.initialize === undefined) throw new Error("Machine root initialization is unavailable") + return declaration.initialize(config.input) } - throw new Error(`Machine could not construct transition branch "${branchKey}"`) + if (selection.updatePath !== undefined) { + const { update, ...state } = config + return Topology.makeCombinedTarget( + Construction.target(stateNodes, selection.path!, state, source), + Construction.update(stateNodes, selection.updatePath, update) + ) + } + return Construction.target(stateNodes, selection.path!, config, source) } const makeBranchSelectors = ( - context: Record, branches: ReadonlyArray, owner: object, stateNodes: Machine.StateNodes, + declaration: Declaration.Declaration, source: string -): Readonly> => { - const select: Record = Object.create(null) - for (let branchIndex = 0; branchIndex < branches.length; branchIndex++) { - const branch = branches[branchIndex]! - select[branch.key] = wrapSelectedBranchBuilder( - getSelectionBuilder(context.target, branch.selection, stateNodes, source), - owner, - branchIndex, - branch.key, - branch.selection.updatePath === undefined - ? undefined - : getSelectionBuilder( - context.target, - makeStateUpdateSelection(branch.selection.updatePath, "branch"), - stateNodes, - source - ) as Record unknown> +): Readonly> => + Object.freeze(Object.fromEntries( + branches.map((branch, index) => [branch.key, (config?: unknown) => + Topology.makeSelectedBranch( + owner, + index, + branch.key, + branch.selection.kind === "none" + ? Topology.makeNoTarget() + : constructBranch(branch.selection, stateNodes, declaration, config, source) + )] ) - } - return Object.freeze(select) -} + )) const validateSelectedBranchResult = ( result: unknown, @@ -547,7 +385,7 @@ const normalizeObjectTransition = ( const allowed = [ "target", "update", - "initial", + "input", "history", "none", "branches", @@ -586,7 +424,7 @@ const normalizeObjectTransition = ( throw new Error("Machine branching transition requires a registered group and resolver") } if ( - ["target", "update", "initial", "history", "none", "data", "decoded"].some((key) => config[key] !== undefined) + ["target", "update", "input", "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, { @@ -613,21 +451,41 @@ const normalizeObjectTransition = ( if (resolve !== undefined && selection.kind !== "none") { throw new Error("Machine advanced construction requires a declared branch group") } - const method = hasData ? config.decoded === true ? "decoded" : "from" : undefined + const input = config.input + const constructInput = typeof input === "function" ? input : () => input + if (selection.path !== "" && Object.hasOwn(config, "input")) { + throw new Error("Machine input belongs only to root targets") + } + if (selection.path === "" && selection.kind !== "update" && (hasData || config.decoded !== undefined)) { + throw new Error("Machine root targets accept input instead of data") + } return { target: () => selection, reenter: config.reenter, declinable, - ...(!guarded && method === undefined && resolve === undefined ? {} : { - resolve: (context: Record, enqueue: unknown) => { - if (guarded && !guard(context)) return Topology.makeDeclined() - if (method !== undefined && construct !== undefined) { - return constructSelectionValue(selection, context, method, construct(context)) - } - if (resolve !== undefined) return resolve(context, enqueue) - return selection.kind === "none" ? undefined : constructSelectedTarget(context.target) + resolve: (context: Record, enqueue: unknown) => { + if (guarded && !guard(context)) return Topology.makeDeclined() + if (resolve !== undefined) return resolve(context, enqueue) + if (selection.path === "" && selection.kind !== "update") { + return constructBranch(selection, stateNodes, declaration, { input: constructInput(context) }) } - }) + const value = construct(context) + return constructBranch( + selection, + stateNodes, + declaration, + selection.updatePath === undefined + ? { + ...(hasData ? { data: value } : {}), + ...(config.decoded === undefined ? {} : { decoded: config.decoded }) + } + : { + ...(value.target === undefined ? {} : { data: value.target, decoded: config.decoded }), + update: { data: value.update, decoded: config.decoded } + }, + path + ) + } } } @@ -662,7 +520,7 @@ const captureTransition = ( decline: Topology.makeDeclined } delete resolverContext.target - resolverContext.select = makeBranchSelectors(context, branches, owner, stateNodes, path) + resolverContext.select = makeBranchSelectors(branches, owner, stateNodes, declaration, path) const selected = resolve(resolverContext, enqueue) if (Topology.isDeclined(selected)) { if (!declinable) { @@ -863,14 +721,19 @@ const makeHandle = (self: Definition.Any, declaration: Declaration.Declaration): const constructRoot = compiled.stateNodes.byPath.get("").schema === undefined ? () => undefined : InitialDeclaration.construction(config.root) - compiled.initial = (input: unknown) => Topology.makeInitialTarget("", constructRoot({ input })) + compiled.initial = (input: unknown) => ({ + ...Topology.makeInitialTarget("", constructRoot({ input })), + input: { value: 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, compiled.stateNodes, compiled.states, "", declaration, { "": captured.handlers }) + flattenHandlers(handlers, compiled.stateNodes, compiled.states, "", { + ...declaration, + initialize: (input) => compiled.initial(Protocol.decodeInputSync(compiled, compiled.input, input)) + }, { "": captured.handlers }) return makeWithHandlers(compiled, handlers) }) as Definition.Any["handle"] diff --git a/packages/effect-machine/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts index 8c69fcf6..e79f52c8 100644 --- a/packages/effect-machine/src/internal/machine/planner.ts +++ b/packages/effect-machine/src/internal/machine/planner.ts @@ -8,7 +8,7 @@ import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" import type * as Schema from "effect/Schema" import type { Enqueue, InitialEvent as MachineInitialEvent, Machine, MachineTarget } from "../../Machine.js" -import { getTargetBuilder, makeCollector, type RuntimeCommand } from "./command.js" +import { makeCollector, type RuntimeCommand } from "./command.js" import { type ActiveConfiguration, captureHistory, @@ -37,6 +37,7 @@ import { snapshotFromConfiguration, snapshotFromConfigurationAtPath } from "./configuration.js" +import * as Construction from "./construction.js" import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError, StoppedError } from "./errors.js" import { type CapturedStateConfig, toImpl } from "./implementation.js" import { isDataInitializer } from "./initialDeclaration.js" @@ -265,7 +266,8 @@ const collectStateInitializer = ( const completeHistoryConfiguration = ( machine: Machine.Any, configuration: ActiveConfiguration, - event: unknown + event: unknown, + input?: { readonly value: unknown } ) => { const active = new Set(configuration.active) const values = new Map(configuration.values) @@ -345,6 +347,7 @@ const completeHistoryConfiguration = ( containingState: getParentValue(machine, current, path), ancestors: getParentValues(machine, current, path), event, + ...(path === "" && input !== undefined ? { input: input.value } : {}), ...(isDataInitializer(initializer) ? {} : { builder: makeStateInitializeBuilder(machine, path) }) }) const initializedValues = getStateInitializeValues(path, initialized.value) @@ -373,6 +376,7 @@ const completeHistoryConfiguration = ( containingState: getParentValue(machine, current, path), ancestors: getParentValues(machine, current, path), event, + ...(path === "" && input !== undefined ? { input: input.value } : {}), ...(isDataInitializer(initializer) ? {} : { builder: makeStateInitializeBuilder(machine, path) }) }) const initializedValues = initialized === undefined @@ -432,8 +436,53 @@ export function resolveInitialConfiguration( target: InitialTargetInstruction, event: unknown ) { - const partial = configurationFromInitialTargetSync(machine, configuration, target) - return completeHistoryConfiguration(machine, partial, event) + let partial = configurationFromInitialTargetSync(machine, configuration, target) + if ( + target.children === undefined || + ![...target.children.keys()].some((path) => getNode(machine, path).type === "choice") + ) { + return completeHistoryConfiguration(machine, partial, event, target.input) + } + const commands: Array = [] + const raisedEvents: Array = [] + const emittedEvents: Array = [] + const transitions: Array = [] + for (const path of target.children?.keys() ?? []) { + const node = getNode(machine, path) + if (node.type !== "choice") continue + const choice = resolveChoiceTarget(machine, partial, makeChoiceTarget(path, node.parent!), event) + for (const routed of [choice.target, ...choice.additionalTargets]) { + const resolved = isInitialTarget(routed) ? + resolveInitialTarget(machine, partial, routed, event) + : isHistoryTarget(routed) + ? resolveHistoryTarget(machine, partial, routed, event) + : undefined + partial = normalizeTargetConfigurationSync( + machine, + partial, + (resolved?.target ?? routed) as Machine.Target + ) + if (resolved !== undefined) { + commands.push(...resolved.commands) + raisedEvents.push(...resolved.raisedEvents) + emittedEvents.push(...resolved.emittedEvents) + transitions.push(...resolved.transitions) + } + } + commands.push(...choice.commands) + raisedEvents.push(...choice.raisedEvents) + emittedEvents.push(...choice.emittedEvents) + transitions.push(...choice.transitions) + } + const completed = completeHistoryConfiguration(machine, partial, event, target.input) + if (transitions.length === 0) return completed + return { + ...completed, + commands: [...commands, ...completed.commands], + raisedEvents: [...raisedEvents, ...completed.raisedEvents], + emittedEvents: [...emittedEvents, ...completed.emittedEvents], + transitions: [...transitions, ...completed.transitions] + } } export function resolveInitialTarget( @@ -447,7 +496,7 @@ export function resolveInitialTarget( return { target: makeTarget(target.path as any, snapshot.value as any, { snapshot: snapshot as any, - values: Object.fromEntries(completed.configuration.values) as any + values: target.values as any }), commands: completed.commands, raisedEvents: completed.raisedEvents, @@ -495,7 +544,7 @@ function resolveHistoryTarget( } const collected = collectTransition(machine, fallback, { event, - target: getTargetBuilder(machine, target.parent).full[""], + target: (config: unknown) => Construction.snapshot(machine.stateNodes, config), owner: target.parent }) if (collected.state === undefined || isHistoryTarget(collected.state) || !isSnapshot(collected.state)) { @@ -732,8 +781,7 @@ const makeTransitionContext = < containingState: getParentValue(machine, configuration, path) as Machine.ParentStateValue, ancestors: getParentValues(machine, configuration, path) as Machine.ParentStateValues, event, - snapshot, - target: getTargetBuilder(machine, path) + snapshot }) const makeDoneContext = < @@ -755,8 +803,7 @@ const makeDoneContext = < ancestors: getParentValues(machine, configuration, path) as Machine.ParentStateValues, event, output: output as Machine.CompletionOutputByIdentifier, - snapshot, - target: getTargetBuilder(machine, path) + snapshot }) const collectStateActions = < @@ -859,8 +906,7 @@ const selectAlwaysTransitions = < Machine.StateIdentifier >, event, - snapshot: capturedSnapshot(), - target: getTargetBuilder(machine, path) + snapshot: capturedSnapshot() } }) evaluatedSources.set(path, candidate) @@ -1068,7 +1114,6 @@ const selectInvocationTransition = < containingState: getParentValue(machine, configuration, event.path), ancestors: getParentValues(machine, configuration, event.path), snapshot, - target: getTargetBuilder(machine, event.path), id: event.id, ...(event.type === "element" ? { element: event.element } @@ -1244,6 +1289,7 @@ const withChoiceValues = (target: unknown, values: Readonly = Omit & { readonly target: unknown } +type ConstructionContext = Omit diff --git a/packages/effect-machine/src/internal/machine/targetBuilder.ts b/packages/effect-machine/src/internal/machine/targetBuilder.ts deleted file mode 100644 index 23c1c7b0..00000000 --- a/packages/effect-machine/src/internal/machine/targetBuilder.ts +++ /dev/null @@ -1,525 +0,0 @@ -/** Constructs decoded snapshots and transition targets from a captured state tree. */ -import { hasProperty } from "effect/Predicate" -import type { Machine } from "../../Machine.js" -import { SnapshotBuilderStateTypeId } from "./symbols.js" -import * as Topology from "./topology.js" - -type SnapshotBuilderOptions = { - readonly mode: "initial" | "full" - readonly prefix: string -} - -type FromMethodKind = "leaf" | "nested" - -export const withFrom = ) => unknown>( - method: Method, - kind: FromMethodKind, - valued: boolean -): { - readonly decoded?: Method - readonly from: (...args: ReadonlyArray) => unknown -} => { - const builder: Record = {} - if (valued) { - Object.defineProperty(builder, "decoded", { - value: method, - enumerable: false - }) - } - Object.defineProperty(builder, "from", { - value: (...args: ReadonlyArray) => { - if (!valued) { - return method(undefined, ...args) - } - const omitted = args.length === 0 || (kind === "nested" && args.length === 1 && typeof args[0] === "function") - const input = omitted ? {} : args[0] - const rest = omitted ? args : args.slice(1) - return method(Topology.makeStateInput(input), ...rest) - }, - enumerable: false - }) - return builder as { - readonly decoded?: Method - readonly from: (...args: ReadonlyArray) => unknown - } -} - -const withInitial = ( - builder: Builder, - path: string, - valued: boolean, - values?: Readonly> -): Builder => { - const initial = withFrom( - (value: unknown) => Topology.makeInitialTarget(path, value, values), - "leaf", - valued - ) - Object.defineProperty(builder, "initial", { - value: Object.freeze(initial), - enumerable: false - }) - return builder -} - -export const makeSnapshotBuilder = ( - states: Machine.StateTree, - options: SnapshotBuilderOptions -): unknown => { - const builder: Record = {} - for (const key of Object.keys(states)) { - const definition = states[key]! - const pseudoType = (definition as { readonly type?: unknown }).type - if (pseudoType === "history") { - continue - } - const path = options.prefix === "" ? key : `${options.prefix}.${key}` - if (pseudoType === "choice") { - builder[key] = Object.freeze(() => Topology.makeChoiceTarget(path, getParentPathRuntime(path))) - continue - } - const node = Topology.getStateNodeDefinition(path, definition) - const method = withFrom( - makeSnapshotFactory(definition, key, options), - node.states === undefined ? "leaf" : "nested", - node.schema !== undefined - ) - builder[key] = Object.freeze( - node.states === undefined || options.mode !== "full" || options.prefix !== "" - ? method - : withInitial(method, path, node.schema !== undefined) - ) - } - return Object.freeze(builder) -} - -const makeParallelSnapshotBuilder = ( - states: Machine.StateTree, - options: SnapshotBuilderOptions, - regions: Readonly> -): unknown => { - const builder: Record = {} - Object.defineProperty(builder, SnapshotBuilderStateTypeId, { - value: regions, - enumerable: false - }) - for (const key of Object.keys(states)) { - const definition = states[key]! - const pseudoType = (definition as { readonly type?: unknown }).type - if (pseudoType === "history" || pseudoType === "choice") { - continue - } - if (hasProperty(regions, key)) { - continue - } - const path = options.prefix === "" ? key : `${options.prefix}.${key}` - const node = Topology.getStateNodeDefinition(path, definition) - const method = withFrom( - (value: unknown, selector?: (builder: unknown) => unknown) => { - const nextRegions: Record = {} - for (const regionKey of Object.keys(regions)) { - nextRegions[regionKey] = regions[regionKey] - } - nextRegions[key] = makeSnapshotForNode(definition, key, value, selector, options) - return makeParallelSnapshotBuilder(states, options, nextRegions) - }, - node.states === undefined ? "leaf" : "nested", - node.schema !== undefined - ) - builder[key] = method - } - return builder -} - -const getParallelSnapshotBuilderRegions = ( - path: string, - states: Machine.StateTree, - builder: unknown -): Readonly> => { - if (typeof builder !== "object" || builder === null || !hasProperty(builder, SnapshotBuilderStateTypeId)) { - throw new Error(`Machine expected parallel state "${path}" builder callback to return a builder`) - } - const regions = (builder as { readonly [SnapshotBuilderStateTypeId]: Readonly> })[ - SnapshotBuilderStateTypeId - ] - for (const key of Object.keys(states)) { - const pseudoType = (states[key] as { readonly type?: unknown }).type - if (pseudoType === "history" || pseudoType === "choice") { - continue - } - if (!hasProperty(regions, key)) { - throw new Error(`Machine expected parallel state "${path}" builder callback to provide region "${key}"`) - } - } - return regions -} - -/** - * A factory owns its lazily compiled child builder. The builder captures only - * immutable topology; every call constructs fresh values and configurations. - * Parallel builders remain per-call because they accumulate selected regions. - */ -const makeSnapshotFactory = ( - definition: Machine.TaggedSchema | Machine.StateNodeConfig, - key: string, - options: SnapshotBuilderOptions -): (value: unknown, selector?: (builder: unknown) => unknown) => Record => { - const path = options.prefix === "" ? key : `${options.prefix}.${key}` - const mode = options.mode - const node = Topology.getStateNodeDefinition(path, definition) - let childBuilder: unknown - return (value, selector) => { - const snapshot: Record = { path, value } - if (node.states === undefined) return snapshot - if (selector === undefined) { - throw new Error(`Machine expected state "${path}" builder to provide active child states`) - } - if (node.type === "parallel") { - const builder = makeParallelSnapshotBuilder(node.states, { mode, prefix: path }, {}) - snapshot.states = getParallelSnapshotBuilderRegions(path, node.states, selector(builder)) - return snapshot - } - if (childBuilder === undefined) { - const childStates = mode === "initial" && node.initial !== undefined - ? { [node.initial]: node.states[node.initial]! } - : node.states - childBuilder = makeSnapshotBuilder(childStates, { mode, prefix: path }) - } - snapshot.state = selector(childBuilder) - return snapshot - } -} - -const makeSnapshotForNode = ( - definition: Machine.TaggedSchema | Machine.StateNodeConfig, - key: string, - value: unknown, - selector: ((builder: unknown) => unknown) | undefined, - options: SnapshotBuilderOptions -): Record => makeSnapshotFactory(definition, key, options)(value, selector) - -export const getTargetBuilderNode = ( - stateNodes: Machine.StateNodes, - path: string -): Machine.StateNode => { - const node = stateNodes.byPath.get(path) - if (node === undefined) { - throw new Error(`Machine expected state path "${path}" to exist`) - } - return node -} - -export const getLocalTargetScope = ( - stateNodes: Machine.StateNodes, - source: string -): string | undefined => { - let current: string | undefined = source - while (current !== undefined) { - const node = stateNodes.byPath.get(current) - if (node === undefined) { - return undefined - } - if (node.type === "compound") { - return node.path - } - current = node.parent - } - return undefined -} - -const hasTargetValues = ( - values: Readonly> | undefined -): values is Readonly> => values !== undefined && Object.keys(values).length > 0 - -const makeTargetWithValues = ( - path: string, - value: unknown, - values: Readonly> | undefined -): Machine.Target => - hasTargetValues(values) - ? Topology.makeTarget(path as any, value as any, { values: values as any }) - : Topology.makeTarget(path as any, value as any) - -const getTargetBuilderDefinition = ( - states: Machine.StateTree, - targetPath: string -): Machine.TaggedSchema | Machine.StateNodeConfig => { - let children = states - let path = "" - let definition: Machine.TaggedSchema | Machine.StateNodeConfig | undefined - const segments = Object.hasOwn(states, "") && targetPath !== "" - ? ["", ...targetPath.split(".")] - : targetPath.split(".") - for (const key of segments) { - if (!hasProperty(children, key)) { - throw new Error(`Machine expected state path "${targetPath}" to exist`) - } - definition = children[key]! - path = path === "" ? key : `${path}.${key}` - const node = Topology.getStateNodeDefinition(path, definition) - children = node.states ?? {} - } - return definition! -} - -const makeParallelTarget = ( - states: Machine.StateTree, - node: Machine.StateNode, - value: unknown, - selector: ((builder: unknown) => unknown) | undefined, - values: Readonly> | undefined -): Machine.Target => { - if (selector === undefined) { - throw new Error(`Machine expected parallel target "${node.path}" builder to provide every active region`) - } - const snapshot = makeSnapshotForNode( - getTargetBuilderDefinition(states, node.path), - node.key, - value, - selector, - { mode: "full", prefix: node.parent ?? "" } - ) - return Topology.makeTarget(node.path as any, value as any, { - snapshot: snapshot as any, - values: values as any - }) -} - -const extendTargetValues = ( - values: Readonly> | undefined, - path: string, - value: unknown -): Readonly> => { - const next: Record = {} - if (values !== undefined) { - for (const key of Object.keys(values)) { - next[key] = values[key] - } - } - next[path] = value - return next -} - -const makeLocalTargetChildBuilder = ( - states: Machine.StateTree, - stateNodes: Machine.StateNodes, - parentPath: string, - values: Readonly> | undefined, - source: string -): unknown => { - const parent = getTargetBuilderNode(stateNodes, parentPath) - const builder: Record = {} - for ( - const childPath of Array.from(stateNodes.byPath.values()) - .filter((node) => node.parent === parent.path && node.type !== "history") - .map((node) => node.path) - ) { - const child = getTargetBuilderNode(stateNodes, childPath) - if (child.type === "choice") { - builder[child.key] = () => Topology.makeChoiceTarget(child.path, parent.path, values) - continue - } - const method = withFrom( - (value: unknown, selector?: (builder: unknown) => unknown) => { - if (child.type === "atomic" || child.type === "final") { - return makeTargetWithValues(child.path, value, values) - } - if (child.type === "parallel") { - if (source !== child.path && !(child.path === "" || source.startsWith(`${child.path}.`))) { - return makeParallelTarget(states, child, value, selector, values) - } - if (selector === undefined) { - throw new Error(`Machine expected target "${child.path}" builder to provide an active child state`) - } - return selector(makeLocalTargetChildBuilder( - states, - stateNodes, - child.path, - child.schema === undefined ? values : extendTargetValues(values, child.path, value), - source - )) - } - if (selector === undefined) { - throw new Error(`Machine expected target "${child.path}" builder to provide an active child state`) - } - return selector(makeLocalTargetChildBuilder( - states, - stateNodes, - child.path, - child.schema === undefined ? values : extendTargetValues(values, child.path, value), - source - )) - }, - child.type === "atomic" || child.type === "final" ? "leaf" : "nested", - child.schema !== undefined - ) - builder[child.key] = child.type === "atomic" || child.type === "final" - ? method - : withInitial(method, child.path, child.schema !== undefined, values) - } - return builder -} - -const makeLocalTargetBuilder = ( - states: Machine.StateTree, - stateNodes: Machine.StateNodes, - source: string -): unknown => { - const scope = getLocalTargetScope(stateNodes, source) - if (scope === undefined) { - return {} - } - const builder = makeLocalTargetChildBuilder(states, stateNodes, scope, undefined, source) as Record - const scopeNode = getTargetBuilderNode(stateNodes, scope) - if (scopeNode.schema !== undefined) { - builder.with = withFrom( - (value: unknown, selector?: (builder: unknown) => unknown) => { - if (selector === undefined) { - throw new Error(`Machine expected target "${scope}" builder to provide an active child state`) - } - return selector(makeLocalTargetChildBuilder(states, stateNodes, scope, { [scope]: value }, source)) - }, - "nested", - true - ) - } - return builder -} - -const addBranchTargetChildren = ( - builder: Record, - states: Machine.StateTree, - stateNodes: Machine.StateNodes, - parentPath: string, - values: Readonly> | undefined, - source: string -): void => { - const parent = getTargetBuilderNode(stateNodes, parentPath) - for ( - const childPath of Array.from(stateNodes.byPath.values()) - .filter((node) => node.parent === parent.path && node.type !== "history") - .map((node) => node.path) - ) { - const child = getTargetBuilderNode(stateNodes, childPath) - if (child.type === "choice") { - builder[child.key] = () => Topology.makeChoiceTarget(child.path, parent.path, values) - continue - } - builder[child.key] = makeBranchTargetNodeBuilder(states, stateNodes, child.path, values, source) - } -} - -const makeBranchTargetNodeBuilder = ( - states: Machine.StateTree, - stateNodes: Machine.StateNodes, - path: string, - values: Readonly> | undefined, - source: string -): unknown => { - const node = getTargetBuilderNode(stateNodes, path) - if (node.type === "atomic" || node.type === "final") { - return withFrom( - (value: unknown) => makeTargetWithValues(node.path, value, values), - "leaf", - node.schema !== undefined - ) - } - const builder = withFrom( - (value: unknown, selector?: (builder: unknown) => unknown) => { - if (node.type === "parallel") { - if (source !== node.path && !(node.path === "" || source.startsWith(`${node.path}.`))) { - return makeParallelTarget(states, node, value, selector, values) - } - if (selector === undefined) { - throw new Error(`Machine expected target "${node.path}" builder to provide an active child state`) - } - const nextBuilder: Record = {} - addBranchTargetChildren( - nextBuilder, - states, - stateNodes, - node.path, - node.schema === undefined ? values : extendTargetValues(values, node.path, value), - source - ) - return selector(nextBuilder) - } - if (selector === undefined) { - throw new Error(`Machine expected target "${node.path}" builder to provide an active child state`) - } - const nextBuilder: Record = {} - addBranchTargetChildren( - nextBuilder, - states, - stateNodes, - node.path, - node.schema === undefined ? values : extendTargetValues(values, node.path, value), - source - ) - return selector(nextBuilder) - }, - "nested", - node.schema !== undefined - ) as unknown as Record - withInitial(builder, node.path, node.schema !== undefined, values) - if (node.type !== "parallel" || source === node.path || node.path === "" || source.startsWith(`${node.path}.`)) { - addBranchTargetChildren(builder, states, stateNodes, node.path, values, source) - } - return builder -} - -const makeBranchTargetBuilder = ( - states: Machine.StateTree, - stateNodes: Machine.StateNodes, - source: string -): unknown => { - const rootPath = stateNodes.roots[0]! - const root = getTargetBuilderNode(stateNodes, rootPath) - return { - [root.key]: makeBranchTargetNodeBuilder(states, stateNodes, root.path, undefined, source) - } -} - -const makeHistoryTargetBuilder = ( - states: Machine.StateTree, - prefix: string -): unknown => { - const builder: Record = {} - for (const key of Object.keys(states)) { - const path = prefix === "" ? key : `${prefix}.${key}` - const definition = Topology.getStateNodeDefinition(path, states[key]!) - if (definition.type === "history") { - const parent = getParentPathRuntime(path) - builder[key] = () => Topology.makeHistoryTarget(path, parent) - continue - } - if (definition.states !== undefined) { - builder[key] = makeHistoryTargetBuilder(definition.states, path) - } - } - return builder -} - -const getParentPathRuntime = (path: string): string => { - const separator = path.lastIndexOf(".") - if (separator < 0) { - return "" - } - return path.slice(0, separator) -} - -export const makeTargetBuilder = ( - states: States, - stateNodes: Machine.StateNodes -) => { - const full = makeSnapshotBuilder(states, { mode: "full", prefix: "" }) as Machine.FullTargetBuilder - const history = makeHistoryTargetBuilder(states, "") as Machine.HistoryTargetBuilder - return >(source: Source): Machine.TargetBuilder => - ({ - none: Topology.makeNoTarget, - local: makeLocalTargetBuilder(states, stateNodes, source), - branch: makeBranchTargetBuilder(states, stateNodes, source), - full, - history - }) as Machine.TargetBuilder -} diff --git a/packages/effect-machine/src/internal/machine/topology.ts b/packages/effect-machine/src/internal/machine/topology.ts index 18019c15..997c74cf 100644 --- a/packages/effect-machine/src/internal/machine/topology.ts +++ b/packages/effect-machine/src/internal/machine/topology.ts @@ -55,6 +55,10 @@ export interface HistoryTarget { export interface InitialTarget { readonly [InitialTargetTypeId]: typeof InitialTargetTypeId readonly _tag: "InitialTarget" + /** Transient startup input, never retained in a configuration. */ + readonly input?: { readonly value: unknown } + /** Explicitly selected descendants and their construction values. */ + readonly children?: ReadonlyMap readonly path: string readonly value: unknown readonly values?: Readonly> diff --git a/packages/effect-machine/src/internal/testing/machine/finiteModel.ts b/packages/effect-machine/src/internal/testing/machine/finiteModel.ts index 0062ffae..f97c61c7 100644 --- a/packages/effect-machine/src/internal/testing/machine/finiteModel.ts +++ b/packages/effect-machine/src/internal/testing/machine/finiteModel.ts @@ -1217,101 +1217,52 @@ const makeStateTree = ( return tree } -const selectSnapshot = ( - builder: Record, +const construction = ( path: string, byPath: ReadonlyMap, requestedParts: ReadonlyArray | undefined, index: number, sourcePath?: string, requestedValue?: number -): unknown => { +): Readonly> => { const state = byPath.get(path)! - if (state.node._tag === "History") { - throw new Error(`MachineTest.compileModel cannot construct active history state "${path}"`) - } - if (state.node._tag === "Choice") { - return (builder[state.node.key] as () => unknown)() - } - const method = builder[state.node.key].decoded as ( - value: unknown, - selector?: (builder: any) => unknown - ) => unknown - const value = stateValue(state, path === requestedParts?.join(".") ? requestedValue : undefined) - if (state.node._tag === "Atomic" || state.node._tag === "Final") return method(value) - + if (state.node._tag === "History") throw new Error(`MachineTest cannot construct history state "${path}"`) + if (state.node._tag === "Choice") return {} + const data = stateValue(state, path === requestedParts?.join(".") ? requestedValue : undefined) + if (state.node._tag === "Atomic" || state.node._tag === "Final") return { data, decoded: true } const requestedChild = requestedParts?.[index + 1] + let keys: ReadonlyArray if (state.node._tag === "Parallel") { - const parallel = state.node - const sourceInside = sourcePath === path || sourcePath?.startsWith(`${path}.`) === true - if (sourceInside) { - const childKey = requestedChild ?? (sourcePath === path - ? parallel.states.find((child) => child._tag !== "History" && child._tag !== "Choice")!.key - : sourcePath!.slice(path.length + 1).split(".")[0]!) - return method( - value, - (children: Record) => - selectSnapshot( - children, - `${path}.${childKey}`, - byPath, - requestedChild === undefined ? undefined : requestedParts, - index + 1, - sourcePath, - requestedValue - ) + const inside = sourcePath === path || sourcePath?.startsWith(`${path}.`) === true + keys = inside ? + [ + requestedChild ?? (sourcePath === path + ? state.node.states.find((child) => child._tag !== "History" && child._tag !== "Choice")!.key + : sourcePath!.slice(path.length + 1).split(".")[0]!) + ] + : state.node.states.filter((child) => child._tag !== "History" && child._tag !== "Choice").map((child) => + child.key ) - } - return method(value, (children: Record) => { - let selected: unknown = children - for (const child of parallel.states.filter((child) => child._tag !== "History" && child._tag !== "Choice")) { - const isRequestedRegion = requestedChild === child.key - selected = selectSnapshot( - selected as Record, - `${path}.${child.key}`, + } else keys = [requestedChild ?? state.node.initial] + return { + data, + decoded: true, + states: Object.fromEntries( + keys.map(( + key + ) => [ + key, + construction( + `${path}.${key}`, byPath, - isRequestedRegion ? requestedParts : undefined, + key === requestedChild ? requestedParts : undefined, index + 1, sourcePath, requestedValue ) - } - return selected - }) - } - - const childKey = requestedChild ?? state.node.initial - const childPath = `${path}.${childKey}` - return method( - value, - (children: Record) => - selectSnapshot( - children, - childPath, - byPath, - requestedChild === undefined ? undefined : requestedParts, - index + 1, - sourcePath, - requestedValue - ) - ) -} - -const findSnapshot = (snapshot: unknown, path: string): unknown => { - if (typeof snapshot !== "object" || snapshot === null) return undefined - const current = snapshot as Record - if (current.path === path) return snapshot - if (current.state !== undefined) { - const found = findSnapshot(current.state, path) - if (found !== undefined) return found - } - if (typeof current.states === "object" && current.states !== null) { - for (const child of Object.values(current.states)) { - const found = findSnapshot(child, path) - if (found !== undefined) return found - } + ]) + ) } - return undefined } const selectableDefinitionTarget = ( @@ -1349,47 +1300,19 @@ const selectDefinitionTarget = ( } const resolveDefinitionTarget = ( - builder: any, + build: (config?: unknown) => unknown, source: string, target: string, byPath: ReadonlyMap, value?: number ): unknown => { const selected = byPath.get(target)! - if (selected.node._tag === "History") return builder() - if (selected.root !== byPath.get(source)!.root) { - const parts = target.split(".") - return selectSnapshot({ [parts[0]!]: builder }, parts[0]!, byPath, parts, 0, source, value) - } - const selectable = selectableDefinitionTarget(source, target, byPath) - if (selectable !== target) { - const bound = byPath.get(selectable)! - const parts = target.split(".") - return selectSnapshot( - { [bound.node.key]: builder }, - selectable, - byPath, - parts, - selectable.split(".").length - 1, - source, - value - ) - } - if (selected.node._tag === "Choice") return builder() - const input = { value: value ?? selected.node.value } - if (selected.node._tag === "Compound" || selected.node._tag === "Parallel") { - const parts = target.split(".") - return selectSnapshot( - { [selected.node.key]: builder }, - target, - byPath, - parts, - parts.length - 1, - source, - value - ) - } - return builder.from(input) + if (selected.node._tag === "History") return build() + const parts = target.split(".") + const path = selected.root !== byPath.get(source)!.root + ? parts[0]! + : selectableDefinitionTarget(source, target, byPath) + return build(construction(path, byPath, parts, path.split(".").length - 1, source, value)) } const makeHandlers = ( @@ -1448,16 +1371,15 @@ const makeHandlers = ( if (child._tag !== "History") return [] return [[child.key, { default: ({ target: root }: { readonly target: any }) => - root.from((target: Record) => { - const fallback = byPath.get(child.fallback)! - const parts = child.fallback.split(".") - const completeRoot = selectSnapshot(target, fallback.root, byPath, parts, 0) - if (findSnapshot(completeRoot, path) === undefined) { - throw new Error( - `MachineTest.compileModel could not construct history fallback for "${path}.${child.key}"` + root({ + states: { + [byPath.get(child.fallback)!.root]: construction( + byPath.get(child.fallback)!.root, + byPath, + child.fallback.split("."), + 0 ) } - return completeRoot }) }]] })) diff --git a/packages/effect-machine/test/internal/machine/rootStrategies.test.ts b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts index b13ef20b..8b7de764 100644 --- a/packages/effect-machine/test/internal/machine/rootStrategies.test.ts +++ b/packages/effect-machine/test/internal/machine/rootStrategies.test.ts @@ -248,3 +248,63 @@ it.effect("reuses only immutable startup builders and keeps input values indepen }) } })) + +it.effect("compares fresh root input and parallel initial constructors across planners", () => { + const root = Machine.state({ + type: "parallel", + states: { + Left: { fields: { count: Schema.Number } }, + Right: { fields: { label: Schema.String } } + } + }) + const targets = Machine.targets(root) + const machine = Machine.make({ + root, + input: Schema.Number, + events: Machine.events({ Reset: { count: Schema.Number }, Restart: { count: Schema.Number } }) + }).handle({ + initial: { Left: ({ input }) => ({ count: input }), Right: ({ input }) => ({ label: String(input) }) }, + on: { + Reset: { target: targets.root, input: ({ event }) => event.count }, + Restart: { target: targets.root, input: ({ event }) => event.count, reenter: true } + } + }) + return verifyPlannerStrategies({ + machine, + initialArgs: [1], + label: "fresh parallel root input", + events: [{ _tag: "Reset", count: 2 }, { _tag: "Restart", count: 3 }] + }) +}) + +it.effect("preserves owner updates while resolving compound initial descendants", () => { + const root = Machine.state({ + fields: { count: Schema.Number }, + states: { + Idle: {}, + Work: { fields: { title: Schema.String }, states: { Ready: { fields: { count: Schema.Number } } } } + } + }) + const targets = Machine.targets(root) + const machine = Machine.make({ root, events: Machine.events({ Open: {} }) }).handle({ + root: { count: 0 }, + initial: { target: targets.root.Idle }, + states: { + Idle: { + on: { + Open: { + target: targets.root.Work, + update: targets.root, + data: { target: { title: "new" }, update: { count: 1 } } + } + } + }, + Work: { initial: { target: targets.root.Work.Ready, data: ({ root }) => ({ count: root.count }) } } + } + }) + return verifyPlannerStrategies({ + machine, + label: "compound initial with retained owner update", + events: [{ _tag: "Open" }] + }) +}) diff --git a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts index 8b5fecbe..8c452348 100644 --- a/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts +++ b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts @@ -148,7 +148,7 @@ describe("machine planner and runtime strategies", () => { resolve: ({ event, state, select: { destination: target }, decline }) => event.value < 0 ? decline() - : target.decoded(new Count({ value: state.value + event.value })), + : target({ data: new Count({ value: state.value + event.value }), decoded: true }), declinable: true } } @@ -376,9 +376,11 @@ describe("machine planner and runtime strategies", () => { Save: { branches: "transition1", resolve: ({ ancestors: { Ready: current }, event, select: { destination: target } }) => - target.decoded(new Saving({ request: event.request })).update.decoded( - new Ready({ revision: current.revision + 1 }) - ) + target({ + data: new Saving({ request: event.request }), + decoded: true, + update: { data: new Ready({ revision: current.revision + 1 }), decoded: true } + }) } } }, @@ -511,7 +513,7 @@ describe("machine planner and runtime strategies", () => { states: { Outside: { on: { - Enter: { initial: targets7.root.Opened, decoded: true, data: () => (new Opened({})) } + Enter: { target: targets7.root.Opened, decoded: true, data: () => (new Opened({})) } } }, Opened: { @@ -1129,7 +1131,7 @@ describe("machine planner and runtime strategies", () => { choice: { branches: "transition1", resolve: ({ containingState, select }) => - containingState.authenticated ? select.authenticated.from() : select.anonymous.from() + containingState.authenticated ? select.authenticated({}) : select.anonymous({}) } }, Checking: { @@ -1375,7 +1377,9 @@ describe("machine planner and runtime strategies", () => { onSnapshot: { branches: "transition1", resolve: ({ snapshot, select }) => - snapshot.state === "stale" ? select.stale.decoded(new Failed({})) : select.unchanged() + snapshot.state === "stale" + ? select.stale({ data: new Failed({}), decoded: true }) + : select.unchanged() } }, on: { diff --git a/packages/effect-machine/test/internal/machine/transitionConstructionStrategies.test.ts b/packages/effect-machine/test/internal/machine/transitionConstructionStrategies.test.ts index c288af99..b67dbf7e 100644 --- a/packages/effect-machine/test/internal/machine/transitionConstructionStrategies.test.ts +++ b/packages/effect-machine/test/internal/machine/transitionConstructionStrategies.test.ts @@ -101,7 +101,7 @@ it.effect("compares atomic construction and verifies guards retain generic plann Branch: { branches: "transition1", reenter: true, - resolve: ({ state, select }) => select.saved.decoded(state) + resolve: ({ state, select }) => select.saved({ data: state, decoded: true }) }, Finish: { target: targets2.root.Done } } diff --git a/packages/effect-machine/test/machine/Choice.test.ts b/packages/effect-machine/test/machine/Choice.test.ts index f8f06221..c66d7f71 100644 --- a/packages/effect-machine/test/machine/Choice.test.ts +++ b/packages/effect-machine/test/machine/Choice.test.ts @@ -35,7 +35,7 @@ const machine = Machine.make({ passing: { title: "Score is at least 70", target: targets1.root.Flow.Approved }, failing: { target: targets1.root.Flow.Rejected } }, - transition1: { destination: { initial: targets1.root.Flow } } + transition1: { destination: { target: targets1.root.Flow } } }, root: States, events: Machine.eventsFromSchemas(Recheck) @@ -57,21 +57,21 @@ const machine = Machine.make({ resolve: ({ containingState, select }) => { const score = containingState.score return score === 100 - ? select.perfect.decoded(new Approved({})) + ? select.perfect({ data: new Approved({}), decoded: true }) : score < 0 - ? select.negative.decoded(new Rejected({})) + ? select.negative({ data: new Rejected({}), decoded: true }) : score === 0 - ? select.zero.decoded(new Rejected({})) + ? select.zero({ data: new Rejected({}), decoded: true }) : score >= 70 - ? select.passing.decoded(new Approved({})) - : select.failing.decoded(new Rejected({})) + ? select.passing({ data: new Approved({}), decoded: true }) + : select.failing({ data: new Rejected({}), decoded: true }) } } }, Approved: { on: { Recheck: { - initial: targets1.root.Flow, + target: targets1.root.Flow, decoded: true, data: ({ event }) => (new Flow({ score: event.score })) } @@ -221,8 +221,8 @@ describe("Machine choice pseudo-states", () => { score: containingState.score } return ancestors.Root.enabled && containingState.score >= 70 - ? select.approved.decoded(new Approved({})) - : select.rejected.decoded(new Rejected({})) + ? select.approved({ data: new Approved({}), decoded: true }) + : select.rejected({ data: new Rejected({}), decoded: true }) } } }, @@ -456,7 +456,8 @@ describe("Machine choice pseudo-states", () => { }, onDone: { branches: "transition1", - resolve: ({ state, select: { destination: target } }) => target.decoded(state, (flow) => flow.Routing()) + resolve: ({ state, select: { destination: target } }) => + target({ data: state, decoded: true, states: { Routing: {} } }) }, states: { Done: {}, @@ -611,9 +612,15 @@ describe("Machine choice pseudo-states", () => { history: { Recent: { default: ({ target }) => - target.from((to) => - to.Flow.decoded(new Flow({ score: 0 }), (flow) => flow.Active.decoded(new Active({}))) - ) + target({ + states: { + Flow: { + data: new Flow({ score: 0 }), + decoded: true, + states: { Active: { data: new Active({}), decoded: true } } + } + } + }) } }, states: { @@ -632,7 +639,7 @@ describe("Machine choice pseudo-states", () => { Resume: { branches: "transition3", resolve: ({ select: { destination: target } }) => - target.decoded(new Flow({ score: 2 }), (flow) => flow.Routing()) + target({ data: new Flow({ score: 2 }), decoded: true, states: { Routing: {} } }) } } } @@ -748,7 +755,7 @@ describe("Machine choice pseudo-states", () => { history: { Recent: { default: ({ target }) => - target.from((to) => to.Flow.decoded(new Flow({ score: 1 }), (flow) => flow.Routing())) + target({ states: { Flow: { data: new Flow({ score: 1 }), decoded: true, states: { Routing: {} } } } }) } }, states: { @@ -847,7 +854,7 @@ describe("Machine choice pseudo-states", () => { ) assert.deepStrictEqual(recheck?.branches[0]?.selection, { path: "Flow", - kind: "initial", + kind: "state", scope: "branch" }) }) diff --git a/packages/effect-machine/test/machine/Declarative.test.ts b/packages/effect-machine/test/machine/Declarative.test.ts index e9d9c0eb..1c905869 100644 --- a/packages/effect-machine/test/machine/Declarative.test.ts +++ b/packages/effect-machine/test/machine/Declarative.test.ts @@ -67,7 +67,7 @@ describe("declarative transitions", () => { Loading: { invoke: { src: "load", - onDone: { branches: "finish", resolve: ({ output, select }) => select.done.from({ value: output }) } + onDone: { branches: "finish", resolve: ({ output, select }) => select.done({ data: { value: output } }) } } }, Done: { output: ({ state }) => state.value } @@ -163,10 +163,10 @@ describe("declarative transitions", () => { branches: "checkout", resolve: ({ event, select }) => { calls++ - return select.review.from( - { orderId: "order-1" }, - (child) => child.Review.from({ total: event.count }) - ) + return select.review({ + data: { orderId: "order-1" }, + states: { Review: { data: { total: event.count } } } + }) } } } diff --git a/packages/effect-machine/test/machine/History.test.ts b/packages/effect-machine/test/machine/History.test.ts index a234a8d3..1ccc5bc6 100644 --- a/packages/effect-machine/test/machine/History.test.ts +++ b/packages/effect-machine/test/machine/History.test.ts @@ -181,10 +181,11 @@ const makeCheckoutMachine = ( EnterVerifying: { branches: "transition4", resolve: ({ select: { destination: target } }) => - target.decoded( - new Payment({ attempt: 2 }), - (payment) => payment.verifying.decoded(new Verifying({ challengeId: "challenge-7" })) - ) + target({ + data: new Payment({ attempt: 2 }), + decoded: true, + states: { verifying: { data: new Verifying({ challengeId: "challenge-7" }), decoded: true } } + }) } } }, @@ -343,33 +344,49 @@ const makeWorkspaceMachine = (initialized: Array) => { history: { recent: { default: ({ target }) => - target.from((to) => - to.workspace.decoded(new Workspace({ id: "fallback" }), (workspace) => - workspace - .editor.decoded( - new Editor({ documentId: "fallback" }), - (editor) => editor.writing.decoded(new Writing({ draft: "" })) - ) - .sidebar.decoded( - new Sidebar({ width: 200 }), - (sidebar) => sidebar.files.decoded(new Files({ directory: "/" })) - )) - ) + target({ + states: { + workspace: { + data: new Workspace({ id: "fallback" }), + decoded: true, + states: { + editor: { + data: new Editor({ documentId: "fallback" }), + decoded: true, + states: { writing: { data: new Writing({ draft: "" }), decoded: true } } + }, + sidebar: { + data: new Sidebar({ width: 200 }), + decoded: true, + states: { files: { data: new Files({ directory: "/" }), decoded: true } } + } + } + } + } + }) }, exact: { default: ({ target }) => - target.from((to) => - to.workspace.decoded(new Workspace({ id: "fallback" }), (workspace) => - workspace - .editor.decoded( - new Editor({ documentId: "fallback" }), - (editor) => editor.writing.decoded(new Writing({ draft: "" })) - ) - .sidebar.decoded( - new Sidebar({ width: 200 }), - (sidebar) => sidebar.files.decoded(new Files({ directory: "/" })) - )) - ) + target({ + states: { + workspace: { + data: new Workspace({ id: "fallback" }), + decoded: true, + states: { + editor: { + data: new Editor({ documentId: "fallback" }), + decoded: true, + states: { writing: { data: new Writing({ draft: "" }), decoded: true } } + }, + sidebar: { + data: new Sidebar({ width: 200 }), + decoded: true, + states: { files: { data: new Files({ directory: "/" }), decoded: true } } + } + } + } + } + }) } }, on: { @@ -485,15 +502,22 @@ const nestedHistoryMachine = Machine.make({ history: { exact: { default: ({ target }) => - target.from((to) => - to.workspace.decoded(new Workspace({ id: "fallback-workspace" }), (workspace) => - workspace - .editor.decoded( - new Editor({ documentId: "fallback" }), - (editor) => editor.writing.decoded(new Writing({ draft: "" })) - ) - .sidebar.decoded(new Search({ query: "fallback" }))) - ) + target({ + states: { + workspace: { + data: new Workspace({ id: "fallback-workspace" }), + decoded: true, + states: { + editor: { + data: new Editor({ documentId: "fallback" }), + decoded: true, + states: { writing: { data: new Writing({ draft: "" }), decoded: true } } + }, + sidebar: { data: new Search({ query: "fallback" }), decoded: true } + } + } + } + }) } }, states: { diff --git a/packages/effect-machine/test/machine/InitialEntry.test.ts b/packages/effect-machine/test/machine/InitialEntry.test.ts index 445a305d..301a1928 100644 --- a/packages/effect-machine/test/machine/InitialEntry.test.ts +++ b/packages/effect-machine/test/machine/InitialEntry.test.ts @@ -48,8 +48,8 @@ const makeMachine = () => { states: { closed: { on: { - Open: { initial: targets1.root.opened, data: () => ({ id: "team-1" }) }, - OpenInvalid: { initial: targets1.root.opened, data: () => ({ id: "" }) } + Open: { target: targets1.root.opened, data: () => ({ id: "team-1" }) }, + OpenInvalid: { target: targets1.root.opened, data: () => ({ id: "" }) } } }, opened: { @@ -95,7 +95,7 @@ const makeParallelMachine = () => { states: { outside: { on: { - EnterDashboard: { initial: targets2.root.dashboard, decoded: true, data: () => (new Dashboard({})) } + EnterDashboard: { target: targets2.root.dashboard, decoded: true, data: () => (new Dashboard({})) } } }, dashboard: { @@ -142,7 +142,7 @@ const makeChoiceMachine = () => { states: { outside: { on: { - EnterFlow: { initial: targets3.root.flow, decoded: true, data: () => (new Flow({})) } + EnterFlow: { target: targets3.root.flow, decoded: true, data: () => (new Flow({})) } } }, flow: { @@ -181,7 +181,7 @@ const makeStructuralMachine = () => { states: { outside: { on: { - EnterFlow: { initial: targets4.root.group } + EnterFlow: { target: targets4.root.group } } }, group: { @@ -227,8 +227,8 @@ const makeNestedMachine = () => { states: { closed: { on: { - OpenLocal: { initial: targets5.root.root.opened, data: () => ({ id: "local" }) }, - OpenBranch: { initial: targets5.root.root.opened, data: () => ({ id: "branch" }) } + OpenLocal: { target: targets5.root.root.opened, data: () => ({ id: "local" }) }, + OpenBranch: { target: targets5.root.root.opened, data: () => ({ id: "branch" }) } } }, opened: { diff --git a/packages/effect-machine/test/machine/LiveInspection.test.ts b/packages/effect-machine/test/machine/LiveInspection.test.ts index f7aa350a..35767415 100644 --- a/packages/effect-machine/test/machine/LiveInspection.test.ts +++ b/packages/effect-machine/test/machine/LiveInspection.test.ts @@ -31,7 +31,7 @@ const machine = Machine.make({ branches: "transition1", resolve: ({ event, select: { destination: target } }, enqueue) => { enqueue.emit(Emissions.Notice({ value: event.by })) - return target.decoded(new Idle({})) + return target({ data: new Idle({}), decoded: true }) } } } diff --git a/packages/effect-machine/test/machine/LocalTargetWith.test.ts b/packages/effect-machine/test/machine/LocalTargetWith.test.ts index c3a8fb48..de091e3a 100644 --- a/packages/effect-machine/test/machine/LocalTargetWith.test.ts +++ b/packages/effect-machine/test/machine/LocalTargetWith.test.ts @@ -44,7 +44,7 @@ describe("local compound target selection", () => { branches: "transition1", reenter: true, resolve: ({ event, select: { destination: target } }) => - target.from({ query: event.query }, (search) => search.Updated.from()) + target({ data: { query: event.query }, states: { Updated: {} } }) } }, states: { @@ -145,7 +145,7 @@ describe("local compound target selection", () => { onDone: { branches: "transition1", resolve: ({ output, select: { destination: target } }) => - target.from({ query: output }, (search) => search.Updated.from()) + target({ data: { query: output }, states: { Updated: {} } }) } } }, diff --git a/packages/effect-machine/test/machine/Machine.test.ts b/packages/effect-machine/test/machine/Machine.test.ts index fbfd3f64..cf6db5f8 100644 --- a/packages/effect-machine/test/machine/Machine.test.ts +++ b/packages/effect-machine/test/machine/Machine.test.ts @@ -294,7 +294,7 @@ describe("Machine", () => { branches: "refresh", reenter: true, resolve: ({ event, select }) => - event.route ? select.refresh.decoded(new Stable({})) : select.unchanged() + event.route ? select.refresh({ data: new Stable({}), decoded: true }) : select.unchanged() } } } @@ -625,7 +625,7 @@ describe("Machine", () => { branches: "transition1", resolve: ({ state, select: { destination: target } }) => { const { _tag: _, ...fields } = state - return target.from(fields) + return target({ data: fields }) } } } @@ -1300,13 +1300,12 @@ describe("Machine", () => { LocalWith: { target: targets14.root.Flow.Running }, Branch: { branches: "transition3", - resolve: ({ select: { destination: target } }) => - target.from((nested) => nested.NestedIdle.from()) + resolve: ({ select: { destination: target } }) => target({ states: { NestedIdle: {} } }) }, Full: { branches: "transition4", resolve: ({ select: { destination: target } }) => - target.from((flow) => flow.Nested.from((nested) => nested.NestedIdle.from())) + target({ states: { Nested: { states: { NestedIdle: {} } } } }) }, Finish: { target: targets14.root.Flow.Done } } @@ -1523,16 +1522,19 @@ describe("Machine", () => { Submit: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.from({ id: event.value }, (fulfillment) => - fulfillment - .inventory.from( - { warehouse: "warehouse-1" }, - (inventory) => inventory.reserved.from({ reservationId: event.value }) - ) - .shipping.from( - { address: "Main Street" }, - (shipping) => shipping.quoted.from({ quoteId: event.value }) - )) + target({ + data: { id: event.value }, + states: { + inventory: { + data: { warehouse: "warehouse-1" }, + states: { reserved: { data: { reservationId: event.value } } } + }, + shipping: { + data: { address: "Main Street" }, + states: { quoted: { data: { quoteId: event.value } } } + } + } + }) } } }, @@ -1619,7 +1621,7 @@ describe("Machine", () => { Submit: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.from({ id: "payment-2" }, (payment) => payment.authorized.from({ code: event.value })) + target({ data: { id: "payment-2" }, states: { authorized: { data: { code: event.value } } } }) } } }, @@ -1675,9 +1677,15 @@ describe("Machine", () => { Submit: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.from({ id: "workflow-2" }, (workflow) => - workflow.checkout.from({ id: "checkout-1" }, (checkout) => - checkout.quoted.from({ quoteId: event.value }))) + target({ + data: { id: "workflow-2" }, + states: { + checkout: { + data: { id: "checkout-1" }, + states: { quoted: { data: { quoteId: event.value } } } + } + } + }) } } }, @@ -2616,7 +2624,7 @@ describe("Machine", () => { resolve: ({ event, containingState, ancestors, select: { destination: target } }) => { assert.deepStrictEqual(containingState, payment) assert.deepStrictEqual(ancestors, { payment }) - return target.decoded(new AuthorizedPayment({ code: event.code })) + return target({ data: new AuthorizedPayment({ code: event.code }), decoded: true }) } } } @@ -2703,7 +2711,7 @@ describe("Machine", () => { branches: "transition1", resolve: ({ select: { destination: target } }) => { requiredResolverCalls++ - return target.decoded(new Done({})) + return target({ data: new Done({}), decoded: true }) } }, Raised: { @@ -2794,7 +2802,7 @@ describe("Machine", () => { branches: "transition2", resolve: ({ event, select, decline }, enqueue) => { if (event.code === "child") { - return select.authorize.decoded(new AuthorizedPayment({ code: event.code })) + return select.authorize({ data: new AuthorizedPayment({ code: event.code }), decoded: true }) } if (event.code === "consume") { return select.consume() @@ -3016,7 +3024,7 @@ describe("Machine", () => { branches: "transition1", resolve: ({ select: { destination: target } }) => { parentCalls++ - return target.decoded(new Finished({})) + return target({ data: new Finished({}), decoded: true }) } } }, @@ -3226,16 +3234,24 @@ describe("Machine", () => { Submit: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.decoded(new Fulfillment({ id: event.value }), (fulfillment) => - fulfillment - .inventory.decoded( - new Inventory({ warehouse: "warehouse-1" }), - (inventory) => inventory.reserved.decoded(new InventoryReserved({ reservationId: event.value })) - ) - .shipping.decoded( - new Shipping({ address: "Main Street" }), - (shipping) => shipping.quoted.decoded(new ShippingQuoted({ quoteId: event.value })) - )) + target({ + data: new Fulfillment({ id: event.value }), + decoded: true, + states: { + inventory: { + data: new Inventory({ warehouse: "warehouse-1" }), + decoded: true, + states: { + reserved: { data: new InventoryReserved({ reservationId: event.value }), decoded: true } + } + }, + shipping: { + data: new Shipping({ address: "Main Street" }), + decoded: true, + states: { quoted: { data: new ShippingQuoted({ quoteId: event.value }), decoded: true } } + } + } + }) } } }, @@ -3354,17 +3370,24 @@ describe("Machine", () => { Submit: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.decoded(new Fulfillment({ id: event.value }), (fulfillment) => - fulfillment - .inventory.decoded( - new Inventory({ warehouse: "warehouse-1" }), - (inventory) => - inventory.reserved.decoded(new InventoryReserved({ reservationId: event.value })) - ) - .shipping.decoded( - new Shipping({ address: "Main Street" }), - (shipping) => shipping.quoted.decoded(new ShippingQuoted({ quoteId: event.value })) - )) + target({ + data: new Fulfillment({ id: event.value }), + decoded: true, + states: { + inventory: { + data: new Inventory({ warehouse: "warehouse-1" }), + decoded: true, + states: { + reserved: { data: new InventoryReserved({ reservationId: event.value }), decoded: true } + } + }, + shipping: { + data: new Shipping({ address: "Main Street" }), + decoded: true, + states: { quoted: { data: new ShippingQuoted({ quoteId: event.value }), decoded: true } } + } + } + }) } } }, @@ -3509,10 +3532,14 @@ describe("Machine", () => { Submit: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.decoded(new Fulfillment({ id: event.value }), (fulfillment) => - fulfillment - .inventory.decoded(new Inventory({ warehouse: "warehouse-1" })) - .shipping.decoded(new Shipping({ address: "Main Street" }))) + target({ + data: new Fulfillment({ id: event.value }), + decoded: true, + states: { + inventory: { data: new Inventory({ warehouse: "warehouse-1" }), decoded: true }, + shipping: { data: new Shipping({ address: "Main Street" }), decoded: true } + } + }) } } }, @@ -3715,11 +3742,16 @@ describe("Machine", () => { ReserveInventory: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.decoded( - nextInventory, - (inventory) => - inventory.reserved.decoded(new InventoryReserved({ reservationId: event.reservationId })) - ) + target({ + data: nextInventory, + decoded: true, + states: { + reserved: { + data: new InventoryReserved({ reservationId: event.reservationId }), + decoded: true + } + } + }) } } }, @@ -3820,11 +3852,16 @@ describe("Machine", () => { ReserveInventory: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.decoded( - nextInventory, - (inventory) => - inventory.reserved.decoded(new InventoryReserved({ reservationId: event.reservationId })) - ) + target({ + data: nextInventory, + decoded: true, + states: { + reserved: { + data: new InventoryReserved({ reservationId: event.reservationId }), + decoded: true + } + } + }) } } }, @@ -3926,11 +3963,22 @@ describe("Machine", () => { ReserveInventory: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.decoded(nextFulfillment, (fulfillment) => - fulfillment.inventory.decoded(nextInventory, (inventory) => - inventory.reserved.decoded( - new InventoryReserved({ reservationId: event.reservationId }) - ))) + target({ + data: nextFulfillment, + decoded: true, + states: { + inventory: { + data: nextInventory, + decoded: true, + states: { + reserved: { + data: new InventoryReserved({ reservationId: event.reservationId }), + decoded: true + } + } + } + } + }) } } }, @@ -4032,8 +4080,13 @@ describe("Machine", () => { ReserveInventory: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.decoded(shipping, (shipping) => - shipping.quoted.decoded(new ShippingQuoted({ quoteId: event.reservationId }))) + target({ + data: shipping, + decoded: true, + states: { + quoted: { data: new ShippingQuoted({ quoteId: event.reservationId }), decoded: true } + } + }) } } }, @@ -4899,11 +4952,12 @@ describe("Machine", () => { branches: "transition1", resolve: ({ event, select: { destination: target } }, enqueue) => { enqueue.raise(new Resolve({})) - return target.decoded( - new InventoryReserved({ + return target({ + data: new InventoryReserved({ reservationId: event.reservationId - }) - ) + }), + decoded: true + }) } } } @@ -6703,7 +6757,7 @@ describe("Machine", () => { branches: "snapshots", resolve: ({ snapshot, select }) => snapshot.state === "ready" - ? select.ready.decoded(new Success({ requestId: snapshot.state })) + ? select.ready({ data: new Success({ requestId: snapshot.state }), decoded: true }) : select.unchanged() } } @@ -7262,8 +7316,11 @@ describe("Machine", () => { Submit: { branches: "transition1", resolve: ({ select: { destination: target } }) => - target.decoded(new Loading({ requestId: "request-1" }), (flow) => - flow.done.decoded(new Success({ requestId: "request-1" }))) + target({ + data: new Loading({ requestId: "request-1" }), + decoded: true, + states: { done: { data: new Success({ requestId: "request-1" }), decoded: true } } + }) } } }, @@ -7275,8 +7332,11 @@ describe("Machine", () => { onDone: { branches: "transition2", resolve: ({ state, select: { destination: target } }) => - target.decoded(state, (flow) => - flow.done.decoded(new Success({ requestId: state.requestId }))) + target({ + data: state, + decoded: true, + states: { done: { data: new Success({ requestId: state.requestId }), decoded: true } } + }) }, states: { done: {} diff --git a/packages/effect-machine/test/machine/MachineReferences.test.ts b/packages/effect-machine/test/machine/MachineReferences.test.ts index ed68d541..8942b6e6 100644 --- a/packages/effect-machine/test/machine/MachineReferences.test.ts +++ b/packages/effect-machine/test/machine/MachineReferences.test.ts @@ -261,7 +261,7 @@ describe("machine reference event channels", () => { if (parent !== undefined) { enqueue.sendTo(parent, ParentEvents.ChildReported({ value: 1 })) } - return target.decoded(new Reported({})) + return target({ data: new Reported({}), decoded: true }) } } } diff --git a/packages/effect-machine/test/machine/ObjectConstruction.test.ts b/packages/effect-machine/test/machine/ObjectConstruction.test.ts new file mode 100644 index 00000000..aaa215a3 --- /dev/null +++ b/packages/effect-machine/test/machine/ObjectConstruction.test.ts @@ -0,0 +1,119 @@ +import { assert, it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { Machine } from "../../src/index.js" + +it.effect("constructs complete parallel subtrees and resolves explicit nested choices", () => + Effect.gen(function*() { + const root = Machine.state({ + states: { + Idle: {}, + Work: { + type: "parallel", + states: { + Left: { + fields: { id: Schema.String }, + states: { + Empty: {}, + Route: { type: "choice" }, + Ready: { fields: { id: Schema.String } } + } + }, + Right: { fields: { count: Schema.Number }, states: { Waiting: {} } } + } + } + } + }) + const targets = Machine.targets(root) + const machine = Machine.make({ + root, + events: Machine.events({ Open: {} }), + branches: { open: { work: { target: targets.root.Work } } } + }).handle({ + initial: { target: targets.root.Idle }, + states: { + Idle: { + on: { + Open: { + branches: "open", + resolve: ({ select }) => + select.work({ + states: { + Left: { data: { id: "new" }, states: { Route: {} } }, + Right: { data: { count: 3 }, states: { Waiting: {} } } + } + }) + } + } + }, + Work: { + initial: { Left: { id: "default" }, Right: { count: 0 } }, + states: { + Left: { + initial: { target: targets.root.Work.Left.Empty }, + states: { + Route: { + choice: { + target: targets.root.Work.Left.Ready, + data: ({ containingState }) => ({ id: containingState.id }) + } + } + } + }, + Right: { initial: { target: targets.root.Work.Right.Waiting } } + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + const next = yield* Machine.plan(machine, initial.state, { _tag: "Open" }) + assert.isTrue(root.matches(next.next, "Work.Left.Ready")) + assert.isTrue(root.matches(next.next, "Work.Right.Waiting")) + assert.deepStrictEqual( + root.get(next.next, "Work.Right").pipe((v) => v._tag === "Some" ? v.value.count : undefined), + 3 + ) + })) + +it.effect("enters a compound's default child with a retained-owner replacement", () => + Effect.gen(function*() { + const root = Machine.state({ + fields: { revision: Schema.Number }, + states: { + Idle: {}, + Flow: { + fields: { title: Schema.String }, + states: { Editing: { fields: { title: Schema.String, revision: Schema.Number } } } + } + } + }) + const targets = Machine.targets(root) + const machine = Machine.make({ root, events: Machine.events({ Open: {} }) }).handle({ + root: { revision: 0 }, + initial: { target: targets.root.Idle }, + states: { + Idle: { + on: { + Open: { + target: targets.root.Flow, + update: targets.root, + data: { target: { title: "new" }, update: { revision: 1 } } + } + } + }, + Flow: { + initial: { + target: targets.root.Flow.Editing, + data: ({ state, root }) => ({ title: state.title, revision: root.revision }) + } + } + } + }) + const initial = yield* Machine.planInitial(machine) + const next = yield* Machine.plan(machine, initial.state, { _tag: "Open" }) + assert.isTrue(root.matches(next.next, "Flow.Editing")) + assert.deepStrictEqual(next.next.value, { _tag: "", revision: 1 }) + assert.deepStrictEqual( + root.get(next.next, "Flow.Editing").pipe((value) => value._tag === "Some" ? value.value.revision : undefined), + 1 + ) + })) diff --git a/packages/effect-machine/test/machine/Root.test.ts b/packages/effect-machine/test/machine/Root.test.ts index 0a1f83dc..0221b3fd 100644 --- a/packages/effect-machine/test/machine/Root.test.ts +++ b/packages/effect-machine/test/machine/Root.test.ts @@ -129,7 +129,7 @@ it("declines guarded child transitions and tries the root handler", async () => guard: ({ event }) => event.allowed, resolve: ({ select: { destination: target } }) => { constructed++ - return target.from() + return target({}) } } } @@ -270,7 +270,7 @@ it("retains current root fields when restoring descendant history", async () => on: { Leave: { target: targets5.root.Away } }, history: { recent: { - default: ({ target }) => target.from({ count: 999 }, (to) => to.Editing.from((editing) => editing.A.from())) + default: ({ target }) => target({ data: { count: 999 }, states: { Editing: { states: { A: {} } } } }) } }, states: { diff --git a/packages/effect-machine/test/machine/RootInput.test.ts b/packages/effect-machine/test/machine/RootInput.test.ts new file mode 100644 index 00000000..44157827 --- /dev/null +++ b/packages/effect-machine/test/machine/RootInput.test.ts @@ -0,0 +1,69 @@ +import { assert, it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { Machine } from "../../src/index.js" + +it.effect("passes fresh input only through root initialization without retaining it in root data", () => + Effect.gen(function*() { + const root = Machine.state({ + fields: { locale: Schema.String }, + states: { Loading: { fields: { request: Schema.String } }, Idle: {} } + }) + const targets = Machine.targets(root) + const machine = Machine.make({ + root, + input: Schema.Struct({ locale: Schema.String, request: Schema.String }), + events: Machine.events({ Finish: {}, Reload: { request: Schema.String } }), + branches: { reload: { root: { target: targets.root } } } + }).handle({ + root: ({ input }) => ({ locale: input.locale }), + initial: { target: targets.root.Loading, data: ({ input }) => ({ request: input.request }) }, + on: { + Reload: { + branches: "reload", + resolve: ({ event, select }) => select.root({ input: { locale: "it", request: event.request } }) + } + }, + states: { Loading: { on: { Finish: { target: targets.root.Idle } } } } + }) + const initial = yield* Machine.planInitial(machine, { locale: "en", request: "first" }) + assert.deepStrictEqual(initial.state.value, { _tag: "", locale: "en" }) + assert.deepStrictEqual(initial.state.state.value, { _tag: "Loading", request: "first" }) + const idle = yield* Machine.plan(machine, initial.state, { _tag: "Finish" }) + const reloaded = yield* Machine.plan(machine, idle.next, { _tag: "Reload", request: "second" }) + assert.deepStrictEqual(reloaded.next.value, { _tag: "", locale: "it" }) + assert.deepStrictEqual(reloaded.next.state.value, { _tag: "Loading", request: "second" }) + })) + +it.effect("restarts root lifecycle only with reenter and validates fresh input before committing", () => + Effect.gen(function*() { + const root = Machine.state({ + fields: { count: Schema.Number }, + states: { Loading: { fields: { id: Schema.String } }, Idle: {} } + }) + const targets = Machine.targets(root) + const machine = Machine.make({ + root, + input: Schema.Struct({ id: Schema.NonEmptyString, count: Schema.Number }), + events: Machine.events({ Finish: {}, Reset: { id: Schema.String }, Restart: { id: Schema.String } }) + }).handle({ + root: ({ input }) => ({ count: input.count }), + initial: { target: targets.root.Loading, data: ({ input }) => ({ id: input.id }) }, + on: { + Reset: { target: targets.root, input: ({ event }) => ({ id: event.id, count: 2 }) }, + Restart: { target: targets.root, input: ({ event }) => ({ id: event.id, count: 3 }), reenter: true } + }, + states: { Loading: { on: { Finish: { target: targets.root.Idle } } } } + }) + const initial = yield* Machine.planInitial(machine, { id: "one", count: 1 }) + const idle = yield* Machine.plan(machine, initial.state, { _tag: "Finish" }) + const reset = yield* Machine.plan(machine, idle.next, { _tag: "Reset", id: "two" }) + assert.isFalse(reset.microsteps[0]!.exitPaths.includes("")) + assert.isFalse(reset.microsteps[0]!.entryPaths.includes("")) + const restarted = yield* Machine.plan(machine, reset.next, { _tag: "Restart", id: "three" }) + assert.isTrue(restarted.microsteps[0]!.exitPaths.includes("")) + assert.isTrue(restarted.microsteps[0]!.entryPaths.includes("")) + const invalid = yield* Machine.plan(machine, restarted.next, { _tag: "Reset", id: "" }).pipe(Effect.result) + assert.strictEqual(invalid._tag, "Failure") + assert.deepStrictEqual(restarted.next.value, { _tag: "", count: 3 }) + assert.deepStrictEqual(restarted.next.state.value, { _tag: "Loading", id: "three" }) + })) diff --git a/packages/effect-machine/test/machine/RuntimeDifferential.test.ts b/packages/effect-machine/test/machine/RuntimeDifferential.test.ts index 9dbe088d..00d20943 100644 --- a/packages/effect-machine/test/machine/RuntimeDifferential.test.ts +++ b/packages/effect-machine/test/machine/RuntimeDifferential.test.ts @@ -196,11 +196,12 @@ describe("pure planning and managed runtime differential", () => { if (snapshot.state.path !== "Running") { throw new Error("expected Running snapshot") } - return target.decoded( - new Done({ + return target({ + data: new Done({ value: snapshot.state.states.Left.value.value + snapshot.state.states.Right.value.value - }) - ) + }), + decoded: true + }) } } }, @@ -211,7 +212,7 @@ describe("pure planning and managed runtime differential", () => { branches: "transition2", resolve: ({ state, select: { destination: target } }, enqueue) => { enqueue.raise(new Bump({})) - return target.decoded(new Left({ value: state.value + 1 })) + return target({ data: new Left({ value: state.value + 1 }), decoded: true }) } } } @@ -526,7 +527,7 @@ describe("pure planning and managed runtime differential", () => { record("transition:begin") enqueue.emit(new Notice({ label: "transition" })) enqueue.raise(new RaisedOne({})) - return target.decoded(new Working({})) + return target({ data: new Working({}), decoded: true }) } } } @@ -551,7 +552,7 @@ describe("pure planning and managed runtime differential", () => { resolve: ({ select: { destination: target } }, enqueue) => { record("raised:two") enqueue.emit(new Notice({ label: "raised-two" })) - return target.decoded(new Finished({})) + return target({ data: new Finished({}), decoded: true }) } } } diff --git a/packages/effect-machine/test/machine/Scheduling.test.ts b/packages/effect-machine/test/machine/Scheduling.test.ts index 42089af5..2ec1ea6d 100644 --- a/packages/effect-machine/test/machine/Scheduling.test.ts +++ b/packages/effect-machine/test/machine/Scheduling.test.ts @@ -34,7 +34,7 @@ describe("machine scheduling", () => { branches: "transition1", resolve: ({ state, select: { destination: target } }, enqueue) => { enqueue.raise(new Burst({})) - return target.decoded(state) + return target({ data: state, decoded: true }) } }, Burst: { @@ -44,7 +44,7 @@ describe("machine scheduling", () => { if (count < burstSize) { enqueue.raise(new Burst({})) } - return target.decoded(new SchedulingActive({ count })) + return target({ data: new SchedulingActive({ count }), decoded: true }) } } } diff --git a/packages/effect-machine/test/machine/SnapshotContext.test.ts b/packages/effect-machine/test/machine/SnapshotContext.test.ts index cd9380ae..bce97e24 100644 --- a/packages/effect-machine/test/machine/SnapshotContext.test.ts +++ b/packages/effect-machine/test/machine/SnapshotContext.test.ts @@ -95,7 +95,7 @@ describe("Machine transition snapshot context", () => { resolve: ({ snapshot, select }) => { captured = snapshot return States.matches(snapshot, "System.Network.Online") - ? select.online.decoded(new Playing({})) + ? select.online({ data: new Playing({}), decoded: true }) : select.unchanged() } } @@ -168,7 +168,7 @@ describe("Machine transition snapshot context", () => { branches: "transition1", resolve: ({ snapshot, select: { destination: target } }) => { captured.push(snapshot) - return target.decoded(new Playing({})) + return target({ data: new Playing({}), decoded: true }) } } } @@ -189,7 +189,7 @@ describe("Machine transition snapshot context", () => { branches: "transition2", resolve: ({ snapshot, select: { destination: target } }) => { captured.push(snapshot) - return target.decoded(new Offline({})) + return target({ data: new Offline({}), decoded: true }) } } } @@ -255,7 +255,7 @@ describe("Machine transition snapshot context", () => { resolve: ({ snapshot, select }) => { captured = snapshot return States.matches(snapshot, "System.Network.Online") - ? select.online.decoded(new Playing({})) + ? select.online({ data: new Playing({}), decoded: true }) : select.unchanged() } } @@ -342,7 +342,7 @@ describe("Machine transition snapshot context", () => { branches: "transition1", resolve: ({ snapshot, select: { destination: target } }) => { captured = snapshot - return target.decoded(new Restarted({})) + return target({ data: new Restarted({}), decoded: true }) } }, states: { diff --git a/packages/effect-machine/test/machine/StateUpdate.test.ts b/packages/effect-machine/test/machine/StateUpdate.test.ts index 52ccdb58..e100fa5e 100644 --- a/packages/effect-machine/test/machine/StateUpdate.test.ts +++ b/packages/effect-machine/test/machine/StateUpdate.test.ts @@ -50,14 +50,15 @@ describe("state value updates", () => { CreatePlan: { branches: "transition1", resolve: ({ ancestors: { Ready: current }, event, select: { destination: target } }) => - target.from({ request: event.input }).update.decoded( - State.cases.Ready.make({ ...current, notice: null }) - ) + target({ + data: { request: event.input }, + update: { data: State.cases.Ready.make({ ...current, notice: null }), decoded: true } + }) }, InvalidPlan: { branches: "transition2", resolve: ({ select: { destination: target } }) => - target.from({ request: "invalid" }).update.from({ notice: 1 } as any) + target({ data: { request: "invalid" }, update: { data: { notice: 1 } as any } }) } } }, @@ -150,7 +151,12 @@ describe("state value updates", () => { onDone: { branches: "transition1", resolve: ({ ancestors: { Ready: current }, output, select: { destination: target } }) => - target.from().update.decoded(State.cases.Ready.make({ ...current, day: output, notice: "Saved" })) + target({ + update: { + data: State.cases.Ready.make({ ...current, day: output, notice: "Saved" }), + decoded: true + } + }) } } } @@ -306,7 +312,7 @@ describe("state value updates", () => { Set: { branches: "transition1", resolve: ({ event, select }) => - event.changed ? select.changed.from({ count: 1 }) : select.unchanged() + event.changed ? select.changed({ data: { count: 1 } }) : select.unchanged() } } } @@ -441,7 +447,7 @@ describe("state value updates", () => { branches: "transition1", resolve: ({ ancestors: { scope: current }, decline, select: { destination: owner } }) => current.count < 2 - ? owner.from({ count: current.count + 1 }) + ? owner({ data: { count: current.count + 1 } }) : decline(), declinable: true } @@ -745,7 +751,7 @@ describe("state value updates", () => { enqueue.raise(Events.Raised()) enqueue.emit(Emissions.Changed({ count: 1 })) enqueue.sendTo(self, Events.Raised()) - return owner.from({ count: 1 }) + return owner({ data: { count: 1 } }) } }, Raised: { none: true } diff --git a/packages/effect-machine/test/machine/StructuralStates.test.ts b/packages/effect-machine/test/machine/StructuralStates.test.ts index 3db54fb9..f7f6b43b 100644 --- a/packages/effect-machine/test/machine/StructuralStates.test.ts +++ b/packages/effect-machine/test/machine/StructuralStates.test.ts @@ -92,7 +92,7 @@ const makeMachine = () => { branches: "transition1", resolve: ({ event, state, select: { destination: target } }) => { assert.strictEqual(state, undefined) - return target.from({ url: event.url }) + return target({ data: { url: event.url } }) } } } @@ -103,7 +103,7 @@ const makeMachine = () => { branches: "transition2", resolve: ({ event, state, select: { destination: target } }) => { assert.strictEqual(state._tag, "Loading") - return target.from({ duration: event.duration }, (ready) => ready.Paused.from()) + return target({ data: { duration: event.duration }, states: { Paused: {} } }) } } } @@ -119,7 +119,7 @@ const makeMachine = () => { branches: "transition3", resolve: ({ containingState, state, select: { destination: target } }) => { assert.strictEqual(state, undefined) - return target.from({ position: Math.min(0, containingState.duration) }) + return target({ data: { position: Math.min(0, containingState.duration) } }) } } } diff --git a/packages/effect-machine/test/machine/TransitionConstruction.test.ts b/packages/effect-machine/test/machine/TransitionConstruction.test.ts index 60b4b444..1f5364de 100644 --- a/packages/effect-machine/test/machine/TransitionConstruction.test.ts +++ b/packages/effect-machine/test/machine/TransitionConstruction.test.ts @@ -181,7 +181,7 @@ describe("transition construction", () => { resolve: ({ select }, enqueue) => { constructed++ enqueue.raise(events.Reset()) - return select.updated.from({ count: 1 }) + return select.updated({ data: { count: 1 } }) } } } diff --git a/packages/effect-machine/test/machine/Visualization.test.ts b/packages/effect-machine/test/machine/Visualization.test.ts index 4a56fc0f..6ce078d0 100644 --- a/packages/effect-machine/test/machine/Visualization.test.ts +++ b/packages/effect-machine/test/machine/Visualization.test.ts @@ -125,15 +125,19 @@ const makeMachine = (unsafeStart = false) => branches: "unsafe", resolve: ({ select }) => ({ - ...select.disabled.decoded(new Disabled({})), + ...select.disabled({ data: new Disabled({}), decoded: true }), result: { path: "application.workflow.idle", value: new Disabled({}) } }) as any } : { branches: "start", resolve: ({ select }) => - select.running.decoded(new Running({}), (running) => running.editing.decoded(new Editing({}))) - .update.decoded(new Workflow({})) + select.running({ + data: new Running({}), + decoded: true, + states: { editing: { data: new Editing({}), decoded: true } }, + update: { data: new Workflow({}), decoded: true } + }) }, Refresh: { update: targets1.root.application.workflow, decoded: true, data: () => (new Workflow({})) } } @@ -215,7 +219,11 @@ const makeLifecycleMachine = (unsafe: "always" | "done" | undefined = undefined) always: { branches: "transition1", resolve: ({ select: { destination: target } }) => { - const selected = target.decoded(new Workflow({}), (workflow) => workflow.complete.decoded(new Complete({}))) + const selected = target({ + data: new Workflow({}), + decoded: true, + states: { complete: { data: new Complete({}), decoded: true } } + }) return unsafe === "always" ? ({ ...selected, result: { path: "idle", value: new Running({}) } } as unknown as typeof selected) : selected @@ -229,7 +237,7 @@ const makeLifecycleMachine = (unsafe: "always" | "done" | undefined = undefined) onDone: { branches: "transition2", resolve: ({ select: { destination: target } }) => { - const selected = target.decoded(new Disabled({})) + const selected = target({ data: new Disabled({}), decoded: true }) return unsafe === "done" ? ({ ...selected, result: { path: "workflow", value: new Disabled({}) } } as unknown as typeof selected) : selected diff --git a/packages/effect-machine/test/testing/Coverage.test.ts b/packages/effect-machine/test/testing/Coverage.test.ts index 0f3c540d..a38d2e9c 100644 --- a/packages/effect-machine/test/testing/Coverage.test.ts +++ b/packages/effect-machine/test/testing/Coverage.test.ts @@ -32,7 +32,7 @@ const counterMachine = Machine.make({ branches: "transition1", reenter: true, resolve: ({ event, state, select: { destination: target } }) => - target.decoded(new Count({ value: state.value + event.amount })), + target({ data: new Count({ value: state.value + event.amount }), decoded: true }), declinable: true }, Finish: { target: targets1.root.done, decoded: true, data: () => (new Done({})) } @@ -85,7 +85,7 @@ const startupMachine = Machine.make({ branches: "transition1", resolve: ({ state, select }) => state.value === 0 - ? select.zero.decoded(new Count({ value: 1 })) + ? select.zero({ data: new Count({ value: 1 }), decoded: true }) : select.unchanged() }, on: { diff --git a/packages/effect-machine/test/testing/MachineTest.test.ts b/packages/effect-machine/test/testing/MachineTest.test.ts index d855c58c..0d7589cc 100644 --- a/packages/effect-machine/test/testing/MachineTest.test.ts +++ b/packages/effect-machine/test/testing/MachineTest.test.ts @@ -44,7 +44,7 @@ const makeTraceMachine = (onAction: () => void) => { branches: "transition1", resolve: ({ select: { destination: target } }) => { onAction() - return target.decoded(new Ready({ count: 0 })) + return target({ data: new Ready({ count: 0 }), decoded: true }) } } } @@ -55,7 +55,7 @@ const makeTraceMachine = (onAction: () => void) => { branches: "transition2", resolve: ({ event, state, select: { destination: target } }) => { onAction() - return target.decoded(new Ready({ count: state.count + event.amount })) + return target({ data: new Ready({ count: state.count + event.amount }), decoded: true }) } } } diff --git a/packages/effect-machine/test/testing/Probe.test.ts b/packages/effect-machine/test/testing/Probe.test.ts index 261ec9fc..9a225160 100644 --- a/packages/effect-machine/test/testing/Probe.test.ts +++ b/packages/effect-machine/test/testing/Probe.test.ts @@ -47,7 +47,7 @@ const machine = Machine.make({ branches: "transition3", resolve: ({ state, select: { destination: target } }, enqueue) => { enqueue.raise(new RaisedIncrement({})) - return target.decoded(new Counter({ count: state.count + 1 })) + return target({ data: new Counter({ count: state.count + 1 }), decoded: true }) } }, RaisedIncrement: { diff --git a/packages/effect-machine/test/testing/Runtime.test.ts b/packages/effect-machine/test/testing/Runtime.test.ts index c76a077c..28baca26 100644 --- a/packages/effect-machine/test/testing/Runtime.test.ts +++ b/packages/effect-machine/test/testing/Runtime.test.ts @@ -71,7 +71,7 @@ const causalMachine = Machine.make({ branches: "transition2", resolve: ({ state, select: { destination: target } }, enqueue) => { enqueue.raise(new InternalAdd({ amount: 10 })) - return target.decoded(new Counter({ count: state.count + 1 })) + return target({ data: new Counter({ count: state.count + 1 }), decoded: true }) } }, InternalAdd: { diff --git a/packages/effect-machine/test/testing/Verification.test.ts b/packages/effect-machine/test/testing/Verification.test.ts index 8e9c7c44..1842f0ad 100644 --- a/packages/effect-machine/test/testing/Verification.test.ts +++ b/packages/effect-machine/test/testing/Verification.test.ts @@ -44,7 +44,7 @@ const navigationMachine = Machine.make({ Go: { branches: "transition1", resolve: ({ select: { destination: target } }) => - target.decoded(new App({}), (app) => app.two.decoded(new Two({}))) + target({ data: new App({}), decoded: true, states: { two: { data: new Two({}), decoded: true } } }) } } }, @@ -78,13 +78,13 @@ const raisedNavigationMachine = Machine.make({ always: { branches: "transition1", resolve: ({ select: { destination: target } }) => - target.decoded(new App({}), (app) => app.one.decoded(new One({}))) + target({ data: new App({}), decoded: true, states: { one: { data: new One({}), decoded: true } } }) }, on: { Go: { branches: "transition2", resolve: ({ select: { destination: target } }) => - target.decoded(new App({}), (app) => app.one.decoded(new One({}))) + target({ data: new App({}), decoded: true, states: { one: { data: new One({}), decoded: true } } }) } } }, @@ -146,7 +146,7 @@ const conditionalMachine = Machine.make({ event.value < 0 ? select.negative() : event.value === 0 - ? select.zero.decoded(new Counter({ count: 0 })) + ? select.zero({ data: new Counter({ count: 0 }), decoded: true }) : select.positive() } } @@ -296,7 +296,7 @@ const reentryMachine = Machine.make({ branches: "transition1", reenter: true, resolve: ({ select: { destination: target } }) => - target.decoded(new App({}), (app) => app.one.decoded(new One({}))) + target({ data: new App({}), decoded: true, states: { one: { data: new One({}), decoded: true } } }) } }, states: { @@ -395,25 +395,39 @@ const historyMachine = Machine.make({ 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 }))) - ) - ) + target({ + states: { + workspace: { + data: new Workspace({}), + decoded: true, + states: { + editor: { + data: new Editor({}), + decoded: true, + states: { editing: { data: new Editing({ revision: 0 }), decoded: true } } + } + } + } + } + }) }, 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 }))) - ) - ) + target({ + states: { + workspace: { + data: new Workspace({}), + decoded: true, + states: { + editor: { + data: new Editor({}), + decoded: true, + states: { editing: { data: new Editing({ revision: 0 }), decoded: true } } + } + } + } + } + }) } }, on: { diff --git a/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts b/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts index d3fe4553..965504ef 100644 --- a/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts +++ b/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts @@ -91,14 +91,14 @@ const makeCounter = (state: { state.inFlight -= 1 const value = current.value + event.by enqueue.emit(new Changed({ value })) - return target.decoded(new Count({ value })) + return target({ data: new Count({ value }), decoded: true }) } }, Fail: { branches: "transition2", resolve: ({ state: current, select: { destination: target } }, enqueue) => { enqueue.emit(new Changed({ value: 999 })) - return target.decoded(current) + return target({ data: current, decoded: true }) } }, Finish: { @@ -110,14 +110,14 @@ const makeCounter = (state: { branches: "transition4", resolve: ({ state: current, select: { destination: target } }, enqueue) => { enqueue.raise(new Increment({ by: 1, block: false })) - return target.decoded(current) + return target({ data: current, decoded: true }) } }, SpawnFromAction: { branches: "transition5", resolve: ({ state: current, select: { destination: target } }, enqueue) => { enqueue.stop(UnsupportedChild) - return target.decoded(current) + return target({ data: current, decoded: true }) } } } diff --git a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts index 9db652eb..972cbbad 100644 --- a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts +++ b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts @@ -725,7 +725,7 @@ describe("AtomMachine", () => { branches: "transition1", resolve: ({ select: { destination: target } }) => { requiredResolverCalls++ - return target.decoded(new CanDone({})) + return target({ data: new CanDone({}), decoded: true }) } } } diff --git a/packages/effect-machine/typetest/machine/Choice.tst.ts b/packages/effect-machine/typetest/machine/Choice.tst.ts index 001fa469..d3c5b979 100644 --- a/packages/effect-machine/typetest/machine/Choice.tst.ts +++ b/packages/effect-machine/typetest/machine/Choice.tst.ts @@ -64,7 +64,7 @@ describe("Machine choice pseudo-states", () => { expect(context.containingState).type.toBe() expect(context.ancestors.Flow).type.toBe() expect(context.event).type.toBe>() - return context.select.destination.decoded(new Approved({})) + return context.select.destination({ data: new Approved({}), decoded: true }) } } }, @@ -99,7 +99,8 @@ describe("Machine choice pseudo-states", () => { choice: { branches: "transition1", // @ts-expect-error! - resolve: ({ select: { destination: target } }) => Effect.succeed(target.decoded(new Approved({}))) + resolve: ({ select: { destination: target } }) => + Effect.succeed(target({ data: new Approved({}), decoded: true })) } }, Approved: {}, @@ -208,8 +209,8 @@ describe("Machine choice pseudo-states", () => { choice: { branches: "transition1", resolve: ({ select: { destination: selectedTarget } }) => { - expect(selectedTarget.decoded).type.not.toBeCallableWith(new Approved({})) - return selectedTarget.decoded(new Rejected({})) + expect(selectedTarget).type.not.toBeCallableWith({ data: new Approved({}), decoded: true }) + return selectedTarget({ data: new Rejected({}), decoded: true }) } } }, diff --git a/packages/effect-machine/typetest/machine/Declarative.tst.ts b/packages/effect-machine/typetest/machine/Declarative.tst.ts index bb4c884d..f447fb04 100644 --- a/packages/effect-machine/typetest/machine/Declarative.tst.ts +++ b/packages/effect-machine/typetest/machine/Declarative.tst.ts @@ -175,7 +175,7 @@ describe("declarative inference", () => { }, initial: { target: Machine.targets(root).root.Idle } }) - expect(definition.handle).type.not.toBeCallableWith({ + expect(definition.handle).type.toBeCallableWith({ on: { Reset: { target: targets.root } }, root: () => { throw new Error("type-only constructor") @@ -239,17 +239,18 @@ describe("declarative inference", () => { Load: { branches: "complete", resolve: ({ event, select }) => { - expect(select.ready.from).type.toBeCallableWith({ count: 1 }) - expect(select.ready.from).type.not.toBeCallableWith({ message: "wrong branch" }) - expect(select.failed.from).type.toBeCallableWith({ message: "failed" }) + expect(select.ready).type.toBeCallableWith({ data: { count: 1 } }) + expect(select.ready).type.not.toBeCallableWith({ data: { message: "wrong branch" } }) + expect(select.failed).type.toBeCallableWith({ data: { message: "failed" } }) return event.count > 0 - ? select.ready.from({ count: event.count }) - : select.failed.from({ message: "zero" }) + ? select.ready({ data: { count: event.count } }) + : select.failed({ data: { message: "zero" } }) } }, Reset: { branches: "nested", - resolve: ({ select }) => select.child.from({ name: "nested" }, (child) => child.Child.from({ count: 1 })) + resolve: ({ select }) => + select.child({ data: { name: "nested" }, states: { Child: { data: { count: 1 } } } }) } } }, diff --git a/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts b/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts index 1dab606c..818574df 100644 --- a/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts +++ b/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts @@ -128,23 +128,81 @@ const deepHistoryFallback = ( readonly "": typeof DeepStates.node }, "Root.L1.L2.L3.L4.L5.L6.L7.L8.L9.L10.Hub"> ) => - target.from((tree) => - tree.Root.decoded( - new Root({}), - (root) => - root.L1.decoded(new Branch({}), (l1) => - l1.L2.decoded(new Branch({}), (l2) => - l2.L3.decoded(new Branch({}), (l3) => - l3.L4.decoded(new Branch({}), (l4) => - l4.L5.decoded(new Branch({}), (l5) => - l5.L6.decoded(new Branch({}), (l6) => - l6.L7.decoded(new Branch({}), (l7) => - l7.L8.decoded(new Branch({}), (l8) => - l8.L9.decoded(new Branch({}), (l9) => - l9.L10.decoded(new Branch({}), (l10) => - l10.Hub.decoded(new Hub({}), (hub) => hub.Idle.decoded(new Idle({}))))))))))))) - ) - ) + target({ + states: { + Root: { + data: new Root({}), + decoded: true, + states: { + L1: { + data: new Branch({}), + decoded: true, + states: { + L2: { + data: new Branch({}), + decoded: true, + states: { + L3: { + data: new Branch({}), + decoded: true, + states: { + L4: { + data: new Branch({}), + decoded: true, + states: { + L5: { + data: new Branch({}), + decoded: true, + states: { + L6: { + data: new Branch({}), + decoded: true, + states: { + L7: { + data: new Branch({}), + decoded: true, + states: { + L8: { + data: new Branch({}), + decoded: true, + states: { + L9: { + data: new Branch({}), + decoded: true, + states: { + L10: { + data: new Branch({}), + decoded: true, + states: { + Hub: { + data: new Hub({}), + decoded: true, + states: { Idle: { data: new Idle({}), decoded: true } } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }) const makeDeepMachine = () => Machine.make({ root: DeepStates, diff --git a/packages/effect-machine/typetest/machine/History.tst.ts b/packages/effect-machine/typetest/machine/History.tst.ts index 0649900b..3fc9e57b 100644 --- a/packages/effect-machine/typetest/machine/History.tst.ts +++ b/packages/effect-machine/typetest/machine/History.tst.ts @@ -93,29 +93,43 @@ const completeNestedFallback = ( 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({}))) - }) - ) + target({ + states: { + App: { + decoded: true, + data: new App({ session: "fallback" }), + states: { + Workspace: { + decoded: true, + data: new Workspace({}), + states: { + Editor: { + decoded: true, + data: new Editor({}), + states: { Editing: { decoded: true, data: new Editing({}) } } + }, + Sidebar: { decoded: true, data: new Sidebar({}) } + } + } + } + } + } + }) + const constructedNestedFallback = ( target: Machine.Machine.HistoryDefaultTargetBuilder<{ readonly "": typeof NestedStates.node }, "App.Workspace">, session: string ) => - target.from((tree) => - tree.App.from({ session }, (app) => - app.Workspace.from((workspace) => - workspace - .Editor.from((editor) => editor.Editing.from()) - .Sidebar.from() - )) - ) + target({ + states: { + App: { + data: { session }, + states: { Workspace: { states: { Editor: { states: { Editing: {} } }, Sidebar: {} } } } + } + } + }) describe("Machine history states", () => { it("separates active and history identifiers", () => { expect< @@ -315,22 +329,28 @@ describe("Machine history states", () => { readonly "": typeof States.node }, "checkout"> >() - return target.from((tree) => - tree.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - ) + return target({ + states: { + checkout: { + data: new Checkout({ orderId: "fallback" }), + decoded: true, + states: { shipping: { data: new Shipping({ address: "" }), decoded: true } } + } + } + }) } }, exact: { default: ({ target }) => - target.from((tree) => - tree.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - ) + target({ + states: { + checkout: { + data: new Checkout({ orderId: "fallback" }), + decoded: true, + states: { shipping: { data: new Shipping({ address: "" }), decoded: true } } + } + } + }) } }, states: { @@ -374,22 +394,28 @@ describe("Machine history states", () => { recent: { default: ({ target }) => { expect(target).type.not.toHaveProperty("support") - return target.from((tree) => - tree.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - ) + return target({ + states: { + checkout: { + data: new Checkout({ orderId: "fallback" }), + decoded: true, + states: { shipping: { data: new Shipping({ address: "" }), decoded: true } } + } + } + }) } }, exact: { default: ({ target }) => - target.from((tree) => - tree.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - ) + target({ + states: { + checkout: { + data: new Checkout({ orderId: "fallback" }), + decoded: true, + states: { shipping: { data: new Shipping({ address: "" }), decoded: true } } + } + } + }) } }, states: { @@ -942,21 +968,27 @@ describe("Machine history states", () => { history: { recent: { default: ({ target }) => - target.from((tree) => - tree.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - ) + target({ + states: { + checkout: { + data: new Checkout({ orderId: "fallback" }), + decoded: true, + states: { shipping: { data: new Shipping({ address: "" }), decoded: true } } + } + } + }) }, exact: { default: ({ target }) => - target.from((tree) => - tree.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - ) + target({ + states: { + checkout: { + data: new Checkout({ orderId: "fallback" }), + decoded: true, + states: { shipping: { data: new Shipping({ address: "" }), decoded: true } } + } + } + }) } }, states: { @@ -993,21 +1025,27 @@ describe("Machine history states", () => { history: { recent: { default: ({ target }) => - target.from((tree) => - tree.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - ) + target({ + states: { + checkout: { + data: new Checkout({ orderId: "fallback" }), + decoded: true, + states: { shipping: { data: new Shipping({ address: "" }), decoded: true } } + } + } + }) }, exact: { default: ({ target }) => - target.from((tree) => - tree.checkout.decoded( - new Checkout({ orderId: "fallback" }), - (checkout) => checkout.shipping.decoded(new Shipping({ address: "" })) - ) - ) + target({ + states: { + checkout: { + data: new Checkout({ orderId: "fallback" }), + decoded: true, + states: { shipping: { data: new Shipping({ address: "" }), decoded: true } } + } + } + }) } }, states: { @@ -1158,16 +1196,24 @@ describe("Machine history states", () => { history: { recent: { default: ({ target }) => - target.from((tree) => - tree.outer.decoded( - new Checkout({ orderId: "fallback" }), - (outer) => - outer.all.decoded(new Payment({ attempt: 1 }), (all) => - all - .shipping.decoded(new Shipping({ address: "" })) - .card.decoded(new CardEntry({ cardNumber: "" }))) - ) - ) + target({ + states: { + outer: { + data: new Checkout({ orderId: "fallback" }), + decoded: true, + states: { + all: { + data: new Payment({ attempt: 1 }), + decoded: true, + states: { + shipping: { data: new Shipping({ address: "" }), decoded: true }, + card: { data: new CardEntry({ cardNumber: "" }), decoded: true } + } + } + } + } + } + }) } }, states: { diff --git a/packages/effect-machine/typetest/machine/InitialDeclaration.tst.ts b/packages/effect-machine/typetest/machine/InitialDeclaration.tst.ts index ef42caa6..751adbb9 100644 --- a/packages/effect-machine/typetest/machine/InitialDeclaration.tst.ts +++ b/packages/effect-machine/typetest/machine/InitialDeclaration.tst.ts @@ -105,7 +105,7 @@ describe("handler-owned initial declarations", () => { ...baseline, initial: { target: targets.root.Idle, resolve: () => ({}) } }) - expect(definition.handle).type.not.toBeCallableWith({ initial: baseline.initial }) + expect(definition.handle).type.not.toBeCallableWith({ target: baseline.initial }) expect(definition.handle).type.not.toBeCallableWith({ ...baseline, states: { Session: {} } }) expect(definition.handle).type.not.toBeCallableWith({ ...baseline, diff --git a/packages/effect-machine/typetest/machine/InitialEntry.tst.ts b/packages/effect-machine/typetest/machine/InitialEntry.tst.ts index bb012734..41ca873d 100644 --- a/packages/effect-machine/typetest/machine/InitialEntry.tst.ts +++ b/packages/effect-machine/typetest/machine/InitialEntry.tst.ts @@ -39,7 +39,7 @@ describe("declared initial entry types", () => { states: { closed: { on: { - Open: { initial: targets1.root.opened, decoded: true, data: () => (new Opened({ id: "team-1" })) } + Open: { target: targets1.root.opened, decoded: true, data: () => (new Opened({ id: "team-1" })) } } }, opened: { @@ -63,7 +63,7 @@ describe("declared initial entry types", () => { states: { closed: { on: { - Open: { initial: targets1.root.opened, data: () => ({ id: "team-1" }) } + Open: { target: targets1.root.opened, data: () => ({ id: "team-1" }) } } }, opened: { @@ -90,7 +90,11 @@ describe("declared initial entry types", () => { Open: { branches: "transition3", resolve: ({ select: { destination: target } }) => - target.decoded(new Opened({ id: "team-1" }), (opened) => opened.loading.decoded(new Loading({}))) + target({ + data: new Opened({ id: "team-1" }), + decoded: true, + states: { loading: { data: new Loading({}), decoded: true } } + }) } } }, @@ -120,12 +124,16 @@ describe("declared initial entry types", () => { Open: { branches: "transition4", resolve: ({ select: { destination: target } }) => { - expect(target.initial).type.not.toBeAssignableTo<() => unknown>() - expect(target).type.toHaveProperty("initial") - expect(target.initial.decoded).type.not.toBeCallableWith() - expect(target.initial.decoded).type.toBeCallableWith(new Opened({ id: "team-1" })) - expect(target.initial.from).type.toBeCallableWith({ id: "team-1" }) - return target.decoded(new Opened({ id: "team-1" }), (opened) => opened.loading.decoded(new Loading({}))) + expect(target).type.not.toBeCallableWith() + expect(target).type.not.toHaveProperty("initial") + expect(target).type.not.toBeCallableWith({ decoded: true }) + expect(target).type.toBeCallableWith({ data: new Opened({ id: "team-1" }), decoded: true }) + expect(target).type.toBeCallableWith({ data: { id: "team-1" } }) + return target({ + data: new Opened({ id: "team-1" }), + decoded: true, + states: { loading: { data: new Loading({}), decoded: true } } + }) } } } diff --git a/packages/effect-machine/typetest/machine/Machine.tst.ts b/packages/effect-machine/typetest/machine/Machine.tst.ts index d0682252..6a2749eb 100644 --- a/packages/effect-machine/typetest/machine/Machine.tst.ts +++ b/packages/effect-machine/typetest/machine/Machine.tst.ts @@ -725,7 +725,7 @@ describe("Machine", () => { expect(enqueue.sendTo).type.not.toBeCallableWith(worker, new Down({})) expect(enqueue.stop).type.toBeCallableWith(worker) expect(enqueue.stop).type.not.toBeCallableWith("worker") - return target.decoded(new Down({})) + return target({ data: new Down({}), decoded: true }) } } } @@ -2267,10 +2267,10 @@ describe("Machine", () => { branches: "transition1", resolve: ({ state, select: { destination: target } }) => { const { _tag: _, ...fields } = state - expect(target.from).type.toBeCallableWith({ ...fields, attempt: 1 }) - expect(target.from).type.not.toBeCallableWith(fields) - expect(target.from).type.not.toBeCallableWith({ ...fields, attempt: "invalid" }) - return target.from({ ...fields, attempt: 1 }) + expect(target).type.toBeCallableWith({ data: { ...fields, attempt: 1 } }) + expect(target).type.not.toBeCallableWith({ data: fields }) + expect(target).type.not.toBeCallableWith({ data: { ...fields, attempt: "invalid" } }) + return target({ data: { ...fields, attempt: 1 } }) } } } @@ -2956,17 +2956,17 @@ describe("Machine", () => { resolve: ({ event, select, state }) => { expect(event).type.toBe() expect(state).type.toBe() - expect(select.recognized.decoded).type.toBeCallableWith(new Down({})) + expect(select.recognized).type.toBeCallableWith({ data: new Down({}), decoded: true }) expect(select.measured).type.toBeCallableWith() switch (event.userId.length) { case 0: return select.measured() case 1: - return select.named.decoded(new Down({})) + return select.named({ data: new Down({}), decoded: true }) case 2: return select.active() default: - return select.recognized.decoded(new Down({})) + return select.recognized({ data: new Down({}), decoded: true }) } } } @@ -3114,7 +3114,7 @@ describe("Machine", () => { if (context.event.userId === "consume") { return context.select.consumed() } - return context.select.accepted.decoded(new Down({})) + return context.select.accepted({ data: new Down({}), decoded: true }) }, declinable: true } @@ -3360,7 +3360,7 @@ describe("Machine", () => { resolve: ({ event, state, select: { destination: target } }) => { expect(event).type.toBe() expect(state).type.toBe() - return target.decoded(new SignedIn({ userId: event.userId })) + return target({ data: new SignedIn({ userId: event.userId }), decoded: true }) } } } @@ -3502,7 +3502,7 @@ describe("Machine", () => { expect(event).type.toBe() expect(output).type.toBe() expect(state).type.toBe() - return target.decoded(new Down({})) + return target({ data: new Down({}), decoded: true }) } }, states: { signedOut: {}, signedIn: {} } @@ -3944,7 +3944,7 @@ describe("Machine", () => { branches: "transition1", resolve: ({ output, select: { destination: target } }) => { expect(output).type.toBe() - return target.decoded(new Down({})) + return target({ data: new Down({}), decoded: true }) } }, states: { signedOut: {}, signedIn: { output: ({ state }) => state.userId } } diff --git a/packages/effect-machine/typetest/machine/ObjectConstruction.tst.ts b/packages/effect-machine/typetest/machine/ObjectConstruction.tst.ts new file mode 100644 index 00000000..1820dd60 --- /dev/null +++ b/packages/effect-machine/typetest/machine/ObjectConstruction.tst.ts @@ -0,0 +1,89 @@ +import { Schema } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" + +describe("object construction", () => { + it("binds data and complete child selections to each declared branch", () => { + const root = Machine.state({ + states: { + Idle: {}, + Checkout: { + fields: { cartId: Schema.String }, + states: { + Review: { fields: { total: Schema.Number } }, + Form: { fields: { email: Schema.String } } + } + }, + Work: { + type: "parallel", + states: { + Left: { fields: { count: Schema.Number } }, + Right: {} + } + } + } + }) + const targets = Machine.targets(root) + Machine.make({ + root, + events: Machine.events({ Open: {} }), + branches: { + open: { checkout: { target: targets.root.Checkout }, work: { target: targets.root.Work } } + } + }).handle({ + initial: { target: targets.root.Idle }, + states: { + Idle: { + on: { + Open: { + branches: "open", + resolve: ({ select }) => { + expect(select.checkout).type.not.toBeCallableWith() + expect(select.checkout).type.not.toBeCallableWith({ data: { cartId: 1 } }) + expect(select.checkout).type.not.toBeCallableWith({ data: () => ({ cartId: "a" }) }) + expect(select.checkout).type.not.toBeCallableWith({ data: { cartId: "a" }, states: {} }) + expect(select.checkout).type.not.toBeCallableWith({ data: { cartId: "a" }, states: { Missing: {} } }) + expect(select.checkout).type.not.toBeCallableWith({ data: { cartId: "a" }, states: { Review: {} } }) + expect(select.checkout).type.not.toBeCallableWith({ + data: { cartId: "a" }, + states: { Review: { data: { total: "wrong" } } } + }) + const two = { + data: { cartId: "a" }, + states: { Review: { data: { total: 1 } }, Form: { data: { email: "a" } } } + } + expect(select.checkout).type.not.toBeCallableWith(two) + expect(select.checkout).type.toBeCallableWith({ data: { cartId: "a" } }) + expect(select.checkout).type.toBeCallableWith({ + data: { cartId: "a" }, + states: { Review: { data: { total: 1 } } } + }) + expect(select.checkout).type.not.toBeCallableWith({ decoded: true, data: { cartId: "a" } }) + expect(select.checkout).type.toBeCallableWith({ + decoded: true, + data: { _tag: "Checkout", cartId: "a" }, + states: { Review: { data: { total: 1 } } } + }) + expect(select.work).type.not.toBeCallableWith({ data: {} }) + expect(select.work).type.not.toBeCallableWith({ states: {} }) + expect(select.work).type.not.toBeCallableWith({ states: { Left: { data: { count: 1 } } } }) + expect(select.work).type.toBeCallableWith({ states: { Left: { data: { count: 1 } }, Right: {} } }) + return select.checkout({ data: { cartId: "a" }, states: { Review: { data: { total: 1 } } } }) + } + } + } + }, + Checkout: { + initial: { + target: targets.root.Checkout.Review, + data: (context) => { + expect(context).type.not.toHaveProperty("input") + return { total: 0 } + } + } + }, + Work: { initial: { Left: { count: 0 } } } + } + }) + }) +}) diff --git a/packages/effect-machine/typetest/machine/Readiness.tst.ts b/packages/effect-machine/typetest/machine/Readiness.tst.ts index 3cddedd8..8f73854d 100644 --- a/packages/effect-machine/typetest/machine/Readiness.tst.ts +++ b/packages/effect-machine/typetest/machine/Readiness.tst.ts @@ -229,7 +229,11 @@ describe("executable machine readiness", () => { history: { recent: { default: ({ target }) => - target.from((tree) => tree.Flow.decoded(new Flow({}), (flow) => flow.Idle.decoded(new Idle({})))) + target({ + states: { + Flow: { data: new Flow({}), decoded: true, states: { Idle: { data: new Idle({}), decoded: true } } } + } + }) } }, states: { diff --git a/packages/effect-machine/typetest/machine/RootInput.tst.ts b/packages/effect-machine/typetest/machine/RootInput.tst.ts new file mode 100644 index 00000000..4ee80816 --- /dev/null +++ b/packages/effect-machine/typetest/machine/RootInput.tst.ts @@ -0,0 +1,70 @@ +import { Schema } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../../src/index.js" + +describe("root input construction", () => { + it("requires fresh typed input only when targeting root", () => { + const root = Machine.state({ + states: { + Loading: { fields: { request: Schema.String }, states: { Busy: {} } }, + Idle: {} + } + }) + const targets = Machine.targets(root) + const definition = Machine.make({ + root, + input: Schema.Struct({ request: Schema.String }), + events: Machine.events({ Reload: { request: Schema.String }, Branch: {} }), + branches: { reset: { root: { target: targets.root } } } + }) + const machine = definition.handle({ + initial: { + target: targets.root.Loading, + data: ({ input }) => { + expect(input).type.toBe<{ readonly request: string }>() + return { request: input.request } + } + }, + on: { + Reload: { target: targets.root, input: ({ event }) => ({ request: event.request }) }, + Branch: { + branches: "reset", + resolve: ({ select }) => { + expect(select.root).type.not.toBeCallableWith() + expect(select.root).type.not.toBeCallableWith({ data: { request: "a" } }) + expect(select.root).type.not.toBeCallableWith({ input: { request: 1 } }) + expect(select.root).type.not.toBeCallableWith({ input: () => ({ request: "a" }) }) + return select.root({ input: { request: "a" } }) + } + } + }, + states: { + Loading: { + initial: { target: targets.root.Loading.Busy }, + on: { + Reload: { target: targets.root.Loading, data: ({ event }) => ({ request: event.request }) } + } + } + } + }) + expect(Machine.planInitial(machine, { request: "a" })).type.not.toBe() + const initial = { target: targets.root.Idle } + const states = { Loading: { initial: { target: targets.root.Loading.Busy } } } + expect(definition.handle).type.not.toBeCallableWith({ initial, states, on: { Reload: { target: targets.root } } }) + expect(definition.handle).type.not.toBeCallableWith({ + initial, + states, + on: { Reload: { target: targets.root, input: { request: 1 } } } + }) + expect(definition.handle).type.not.toBeCallableWith({ + initial, + states, + on: { Reload: { target: targets.root, input: { request: "a" }, data: {} } } + }) + expect(definition.handle).type.not.toBeCallableWith({ + initial, + states, + on: { Reload: { target: targets.root.Idle, input: { request: "a" } } } + }) + }) +}) diff --git a/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts b/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts index 82113844..7837e5d8 100644 --- a/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts +++ b/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts @@ -62,7 +62,7 @@ describe("Machine transition snapshot context", () => { expect(States.matches).type.toBeCallableWith(snapshot, "Root.Right.RightIdle") expect(States.get).type.toBeCallableWith(snapshot, "Root.Right.RightIdle") expect(States.getSnapshot).type.toBeCallableWith(snapshot, "Root.Right.RightIdle") - return target.decoded(new LeftIdle({})) + return target({ data: new LeftIdle({}), decoded: true }) } }, states: { @@ -80,7 +80,7 @@ describe("Machine transition snapshot context", () => { resolve: ({ snapshot, select: { destination: target } }) => { expect(snapshot).type.toBe>() expect(States.matches(snapshot, "Root.Right.RightIdle")).type.toBe() - return target.decoded(new LeftDone({})) + return target({ data: new LeftDone({}), decoded: true }) } } } @@ -142,7 +142,7 @@ describe("Machine transition snapshot context", () => { branches: "transition1", resolve: (context) => { expect(context).type.not.toHaveProperty("snapshot") - return context.select.destination.decoded(new Active({})) + return context.select.destination({ data: new Active({}), decoded: true }) } } }, diff --git a/packages/effect-machine/typetest/machine/StateUpdate.tst.ts b/packages/effect-machine/typetest/machine/StateUpdate.tst.ts index bc4f615a..16b025b7 100644 --- a/packages/effect-machine/typetest/machine/StateUpdate.tst.ts +++ b/packages/effect-machine/typetest/machine/StateUpdate.tst.ts @@ -76,9 +76,12 @@ describe("Machine state-value updates", () => { resolve: ({ ancestors, select, state }) => { expect(state).type.toBe() expect(ancestors["root.work.auth"]).type.toBe() - expect(select.owner.decoded).type.toBeCallableWith(new Auth({ user: "next" })) - expect(select.owner.from).type.toBeCallableWith({ user: "next" }) - return select.owner.decoded(new Auth({ user: "next" })) + expect(select.owner).type.toBeCallableWith({ + data: new Auth({ user: "next" }), + decoded: true + }) + expect(select.owner).type.toBeCallableWith({ data: { user: "next" } }) + return select.owner({ data: new Auth({ user: "next" }), decoded: true }) } } } @@ -158,12 +161,20 @@ describe("Machine state-value updates", () => { branches: "signIn", resolve: ({ ancestors, select }) => { expect(ancestors["root.work.auth"]).type.toBe() - expect(select.signedIn.from).type.toBeCallableWith({}) - expect(select.signedIn.decoded).type.toBeCallableWith(new SignedIn({})) - const selected = select.signedIn.decoded(new SignedIn({})) - expect(selected.update.from).type.toBeCallableWith({ user: "next" }) - expect(selected.update.decoded).type.toBeCallableWith(new Auth({ user: "next" })) - return selected.update.decoded(new Auth({ user: "next" })) + expect(select.signedIn).type.not.toBeCallableWith({ data: {} }) + expect(select.signedIn).type.toBeCallableWith({ + data: {}, + update: { data: { user: "next" } } + }) + expect(select.signedIn).type.not.toBeCallableWith({ + data: {}, + update: { data: { user: 1 } } + }) + return select.signedIn({ + data: new SignedIn({}), + decoded: true, + update: { data: new Auth({ user: "next" }), decoded: true } + }) } } } @@ -231,7 +242,7 @@ describe("Machine state-value updates", () => { Tick: { branches: "signIn", // @ts-expect-error! The declared retained owner must be constructed before returning a branch. - resolve: ({ select }) => select.signedIn.decoded(new SignedIn({})) + resolve: ({ select }) => select.signedIn({ data: new SignedIn({}), decoded: true }) } } }, @@ -421,7 +432,7 @@ describe("Machine state-value updates", () => { branches: "change", declinable: true, resolve: ({ select, decline, ancestors }) => - ancestors.root.revision === 0 ? select.changed.from({ revision: 1 }) : decline() + ancestors.root.revision === 0 ? select.changed({ data: { revision: 1 } }) : decline() } } }, diff --git a/packages/effect-machine/typetest/machine/StructuralStates.tst.ts b/packages/effect-machine/typetest/machine/StructuralStates.tst.ts index d851095e..99256747 100644 --- a/packages/effect-machine/typetest/machine/StructuralStates.tst.ts +++ b/packages/effect-machine/typetest/machine/StructuralStates.tst.ts @@ -79,34 +79,46 @@ describe("structural active state types", () => { { 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 })) - ) - ) + expect(target).type.not.toBeCallableWith({}) + expect(target).type.not.toBeCallableWith({ data: {}, states: { player: {} } }) + type Tree = Parameters[0] + const valid = { + states: { + player: { + states: { + transport: { states: { Empty: {} } }, + settings: { states: { Audible: { data: { volume: 1 } } } } + } + } + } + } satisfies Tree + expect(target).type.toBeCallableWith(valid) + expect(target).type.not.toBeCallableWith({ + states: { + player: { + states: { + transport: { states: { Empty: {} } } + } + } + } + }) + expect(target).type.not.toBeCallableWith({ + states: { + player: { + states: { + transport: { states: { Empty: { data: {} } } }, + settings: { states: { Audible: {} } } + } + } + } + }) + target({ + states: { + player: { + states: { transport: { states: { Empty: {} } }, settings: { states: { Audible: { data: { volume: 1 } } } } } + } + } + }) }) it("restricts value access while retaining structural snapshot queries", () => { type Snapshot = Machine.Snapshot @@ -168,7 +180,7 @@ describe("structural active state types", () => { Loaded: { branches: "transition1", resolve: ({ event, select: { destination: target } }) => - target.from({ duration: event.duration }, (ready) => ready.Paused.from()) + target({ data: { duration: event.duration }, states: { Paused: {} } }) } } }, @@ -188,10 +200,10 @@ describe("structural active state types", () => { expect(ancestors).type.toBe<{ readonly "player.transport.Ready": Ready }>() - return target.from( - { duration: containingState.duration }, - (ready) => ready.Playing.from({ position: 0 }) - ) + return target({ + data: { duration: containingState.duration }, + states: { Playing: { data: { position: 0 } } } + }) } } } diff --git a/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts b/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts index 85a60184..d1fd9b17 100644 --- a/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts +++ b/packages/effect-machine/typetest/machine/TransitionConstruction.tst.ts @@ -224,7 +224,7 @@ describe("transition construction", () => { throw new Error("type-only constructor") } }) - expect(definition.handle).type.not.toBeCallableWith({ + expect(definition.handle).type.toBeCallableWith({ states: { Idle: { on: { @@ -348,7 +348,7 @@ describe("transition construction", () => { reenter: true, resolve: ({ event, select }) => { expect(event.text).type.toBe() - return select.ready.from({ text: event.text }) + return select.ready({ data: { text: event.text } }) } } } diff --git a/scripts/fixtures/consumer/consumer.ts b/scripts/fixtures/consumer/consumer.ts index f4c6590d..bb32fe85 100644 --- a/scripts/fixtures/consumer/consumer.ts +++ b/scripts/fixtures/consumer/consumer.ts @@ -38,7 +38,10 @@ const machine = Machine.make({ states: { Idle: { on: { - Start: { branches: "start", resolve: ({ select }) => select.cached.decoded(State.cases.Loading.make({})) } + Start: { + branches: "start", + resolve: ({ select }) => select.cached({ data: State.cases.Loading.make({}), decoded: true }) + } } }, Loading: { diff --git a/scripts/fixtures/consumer/deep-bound.ts b/scripts/fixtures/consumer/deep-bound.ts index 6440cbbd..b91888b5 100644 --- a/scripts/fixtures/consumer/deep-bound.ts +++ b/scripts/fixtures/consumer/deep-bound.ts @@ -118,12 +118,17 @@ const machine = definition.handle({ Begin: { branches: "ready", resolve: ({ select: { ready: target } }) => - target.decoded( - State.cases.Ready.make({}), - (ready) => - ready.Editor.decoded(State.cases.Editor.make({}), (editor) => - editor.Editing.decoded(State.cases.Editing.make({ value: "ready" }))) - ) + target({ + data: State.cases.Ready.make({}), + decoded: true, + states: { + Editor: { + data: State.cases.Editor.make({}), + decoded: true, + states: { Editing: { data: State.cases.Editing.make({ value: "ready" }), decoded: true } } + } + } + }) } } }, @@ -155,7 +160,7 @@ const machine = definition.handle({ branches: "notice", resolve: ({ event, select }, enqueue) => { enqueue.emit(Emissions.Notice({ value: event.value })) - return select.saved.decoded(State.cases.Saving.make({ value: event.value })) + return select.saved({ data: State.cases.Saving.make({ value: event.value }), decoded: true }) } }, ChildCompleted: { diff --git a/scripts/invoke-autocomplete.test.mjs b/scripts/invoke-autocomplete.test.mjs index 6c894f19..9c0ea472 100644 --- a/scripts/invoke-autocomplete.test.mjs +++ b/scripts/invoke-autocomplete.test.mjs @@ -72,7 +72,7 @@ definition.handle({ }) 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() }, + onDone: { branches: "complete", resolve: ({ /*done-context*/ ...context }) => context.select.ready({ /*done-exact-target*/ }) }, onFailure: { target: targets.root./*done-target*/Failed, data: ({ /*failure-context*/ ...context }) => ({}) } } } } }) definition.handle({ root: { count: 0 }, initial: { target: targets.root.Loading }, states: { Loading: { @@ -94,7 +94,7 @@ always: { /*transition-selector*/ } 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: { branches: "complete", resolve: ({ /*branch-resolve-context*/ ...context }) => context.select./*branch-select-keys*/ready({ /*transition-exact-target*/ }) } } } }) 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() } } } }) @@ -204,7 +204,7 @@ test("contextually completes Effect invocation factories while authoring", () => assert.equal(doneTarget.has("Failed"), true) const exactTarget = completions("done-exact-target") - assert.equal(exactTarget.has("from"), true) + assert.equal(exactTarget.has("data"), true) assert.equal(exactTarget.has("full"), false) assert.equal(exactTarget.has("Done"), false) @@ -238,7 +238,7 @@ test("contextually completes transition definitions while authoring", () => { assert.equal(initialOperations.has("reenter"), false) const initialContext = completions("initial-context") - assert.equal(initialContext.has("input"), false) + assert.equal(initialContext.has("input"), true) assert.equal(initialContext.has("root"), true) assert.equal(initialContext.has("state"), true) assert.equal(completions("root-data-context").has("input"), true) @@ -260,7 +260,7 @@ test("contextually completes transition definitions while authoring", () => { assert.equal(context.has("root"), true) const exactTarget = completions("transition-exact-target") - assert.equal(exactTarget.has("from"), true) + assert.equal(exactTarget.has("data"), true) assert.equal(exactTarget.has("full"), false) assert.equal(exactTarget.has("Done"), false)