Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/quiet-machines-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@typeonce/effect-machine": minor
---

Add `Machine.can` for testing whether a concrete public event would select a transition from a snapshot. It preserves schema failures, honors declinable handlers and hierarchy, and does not execute transition lifecycle or collected work.

Add `AtomMachine.factory` and bound `factory` for reusable, fully inferred machine bridge constructors. Every call creates a fresh lazy bridge, while `ReturnType<typeof constructor>` preserves the exact machine and bound runtime error types.
35 changes: 33 additions & 2 deletions packages/effect-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,9 @@ const definition = Machine.make({
})
```

Handlers see both protocols. Typed `send` and `Machine.plan` accept only public
events. Event tags must be unique and public/internal tags must be disjoint.
Handlers see both protocols. Typed `send`, `Machine.can`, and `Machine.plan`
accept only public events. Event tags must be unique and public/internal tags
must be disjoint.

Export the descriptor returned by `Machine.events` instead of exporting its
schemas. This keeps the deferred constructors as the standard way to create
Expand Down Expand Up @@ -554,6 +555,23 @@ remain total and cannot use declinable transitions. Completion and invocation
outcomes have no ancestor candidate: declining one ignores that lifecycle
occurrence and leaves the current configuration active.

Use `Machine.can` when a caller needs to test a concrete event against a
snapshot. The direct and machine-specialized forms have the same semantics:

```ts
const canSubmit = yield * Machine.can(machine, snapshot, Submit({ draft }))

const canMachine = Machine.can(machine)
const canCancel = yield * canMachine(snapshot, Cancel())
```

`can` returns `true` when at least one required or non-declined handler accepts
the event. Targetless transitions count as accepted. Invalid event input fails
with `MachineSchemaDecodeError`; a valid unhandled event returns `false`.
Declinable resolvers run to decide acceptance, but collected commands,
emissions, and raised events are discarded. Required resolvers and transition
lifecycle do not run.

## Statechart capabilities

`Machine.states` supports:
Expand Down Expand Up @@ -746,6 +764,19 @@ const counterAtom = AtomMachine.bind(runtime).make(Counter)

Binding a shared runtime once is the canonical form for service-backed
applications. Service-free machines can use `AtomMachine.make(Counter)`.
Use `factory` when the same definition constructs several independent bridges:

```ts
const MachineAtoms = AtomMachine.bind(runtime)
const makeProcessMachine = MachineAtoms.factory(ProcessMachine)

const first = makeProcessMachine({ processId: "first" })
const second = makeProcessMachine({ processId: "second" })
type ProcessMachineAtom = ReturnType<typeof makeProcessMachine>
```

Each call creates a fresh lazy bridge. `factory` does not cache by input;
`AtomMachine.family` remains the keyed shared-identity interface.

The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable
`send` and `stop` atoms, and `child(descriptor)`. Use `AtomMachine.select`,
Expand Down
52 changes: 51 additions & 1 deletion packages/effect-machine/docs/effect-atom-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { createContext, type ReactNode, useContext } from "react"
import { AuthMachine, type AuthMachineInput } from "../machines/auth-machine"
import { MachineAtoms } from "../lib/atom-runtime"

const makeAuthMachine = (input: AuthMachineInput) => MachineAtoms.make(AuthMachine, input)
const makeAuthMachine = MachineAtoms.factory(AuthMachine)
type AuthMachineAtom = ReturnType<typeof makeAuthMachine>

const AuthMachineContext = createContext<AuthMachineAtom | null>(null)
Expand Down Expand Up @@ -183,6 +183,56 @@ function SubmitButton() {
`useAtomSet` mounts the writable atom and does not subscribe the component to
its value.

## Query concrete event acceptance

`Machine.can` composes with `machine.snapshot` through a derived atom. Declare
the projection once for a module-level machine, or create it once alongside a
React-owned machine:

```tsx
import { Effect, Equal } from "effect"
import { Atom } from "effect/unstable/reactivity"
import { Machine } from "@typeonce/effect-machine"
import { useAtomSet, useAtomSuspense } from "@effect/atom-react"
import { useState } from "react"

const makeCanSubmitAtom = (machine: AuthMachineAtom) =>
Atom.make((get) =>
get.result(machine.snapshot).pipe(
Effect.flatMap((snapshot) => {
if (snapshot.status === "active") {
return Machine.can(AuthMachine, snapshot.state, { _tag: "Submitted" })
}
if (snapshot.status === "error") {
return Effect.failCause(snapshot.cause)
}
return Effect.succeed(false)
})
)
).pipe(Atom.withEquality(Equal.equals))

function SubmitButton() {
const machine = useAuthMachine()
const [canSubmitAtom] = useState(() => makeCanSubmitAtom(machine))
const canSubmit = useAtomSuspense(canSubmitAtom).value
const send = useAtomSet(machine.send)

return (
<button
disabled={!canSubmit}
onClick={() => send({ _tag: "Submitted" })}
>
Continue
</button>
)
}
```

Startup still suspends, startup and runtime failures reach the error boundary,
and invalid event input remains a `MachineSchemaDecodeError`. Done and stopped
machines return `false`. When the event payload itself changes reactively, read
it from another atom inside the same derived atom.

## Whole-result and custom selections

Reading the full result is correct when a component renders the complete
Expand Down
3 changes: 2 additions & 1 deletion packages/effect-machine/docs/machine-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ Use `useMachineAtom` when one React subtree owns the workflow, including a
machine with startup input:

```tsx
const machine = useMachineAtom(() => machineAtoms.make(processMachine, input))
const makeProcessMachine = machineAtoms.factory(processMachine)
const machine = useMachineAtom(() => makeProcessMachine(input))
```

Pass the stable machine through props or Context. Startup input is captured
Expand Down
114 changes: 114 additions & 0 deletions packages/effect-machine/src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9301,6 +9301,120 @@ export const enabled: <
state: Machine.Snapshot<States>
) => ReadonlyArray<Machine.TagOf<Events[number]>> = internal.enabled as any

/**
* Tests whether a concrete event would select at least one transition from a
* decoded snapshot.
*
* **Details**
*
* Required handlers are accepted from their structural eligibility.
* Declinable handlers run their resolver only far enough to decide whether
* they accept the event. Any commands, emissions, or raised events collected
* during that check are discarded.
*
* Event input is decoded through the machine's public event protocol. Invalid
* input fails with `MachineSchemaDecodeError`. Final snapshots and valid events
* with no accepting handler return `false`.
*
* **Gotchas**
*
* This query does not execute transitions or stabilize the resulting machine.
* It does not run entry, exit, always, completion, child lifecycle, or command
* effects. A `true` result therefore describes event acceptance only.
*
* **Example**
*
* ```ts
* const canCheckout = Machine.can(checkoutMachine)
*
* const canSubmit = yield* canCheckout(snapshot, {
* _tag: "SubmitOrder"
* })
* ```
*
* @category getters
* @since 0.30.0
*/
export const can: {
<
const States extends Machine.StateSchemas,
const Events extends ReadonlyArray<Machine.TaggedSchema>,
const Emits extends ReadonlyArray<Machine.TaggedSchema>,
const Input extends Schema.Top = typeof Schema.Void,
UnhandledStates extends Machine.StateIdentifier<States> = Machine.StateIdentifier<States>,
E = never,
R = never,
InitialE = never,
InitialR = never,
FinalStates extends Machine.StateIdentifier<States> = never,
Output = never,
OutputStates extends Machine.StateIdentifier<States> = never,
InputEvents extends ReadonlyArray<Machine.TaggedSchema> = Events,
ParentEvents extends ReadonlyArray<Machine.TaggedSchema> = readonly []
>(
machine:
& Machine<
States,
Events,
Input,
UnhandledStates,
E,
R,
InitialE,
InitialR,
FinalStates,
Output,
Emits,
OutputStates,
InputEvents,
ParentEvents
>
& EnsureExecutable<States, UnhandledStates, OutputStates>
& Machine.RootCompatible<ParentEvents>
): (
state: Machine.Snapshot<States>,
event: Machine.EventInputOf<InputEvents>
) => Effect.Effect<boolean, MachineSchemaDecodeError>
<
const States extends Machine.StateSchemas,
const Events extends ReadonlyArray<Machine.TaggedSchema>,
const Emits extends ReadonlyArray<Machine.TaggedSchema>,
const Input extends Schema.Top = typeof Schema.Void,
UnhandledStates extends Machine.StateIdentifier<States> = Machine.StateIdentifier<States>,
E = never,
R = never,
InitialE = never,
InitialR = never,
FinalStates extends Machine.StateIdentifier<States> = never,
Output = never,
OutputStates extends Machine.StateIdentifier<States> = never,
InputEvents extends ReadonlyArray<Machine.TaggedSchema> = Events,
ParentEvents extends ReadonlyArray<Machine.TaggedSchema> = readonly []
>(
machine:
& Machine<
States,
Events,
Input,
UnhandledStates,
E,
R,
InitialE,
InitialR,
FinalStates,
Output,
Emits,
OutputStates,
InputEvents,
ParentEvents
>
& EnsureExecutable<States, UnhandledStates, OutputStates>
& Machine.RootCompatible<ParentEvents>,
state: Machine.Snapshot<States>,
event: Machine.EventInputOf<InputEvents>
): Effect.Effect<boolean, MachineSchemaDecodeError>
} = internal.can as any

/**
* Plans the next state snapshot synchronously.
*
Expand Down
33 changes: 21 additions & 12 deletions packages/effect-machine/src/internal/machine/atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,9 @@ export const make: {
return result
}) as any

export const factory =
((machine: Machine.Machine.Any) => (...args: ReadonlyArray<unknown>) => (make as any)(machine, ...args)) as any

export const resume: {
<M extends Machine.Machine.Any>(
machine:
Expand Down Expand Up @@ -901,19 +904,25 @@ export const familyChild = (

export const bind = <Services, RuntimeError>(
runtime: Atom.AtomRuntime<Services, RuntimeError>
): Bound<Services, RuntimeError> => ({
make:
): Bound<Services, RuntimeError> => {
const makeBound =
((machine: Machine.Machine.Any, ...args: ReadonlyArray<unknown>) =>
makeWithRuntime(runtime, machine, args)) as Bound<
Services,
RuntimeError
>["make"],
resume:
((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot<any>) =>
resumeWithRuntime(runtime, machine, snapshot)) as Bound<Services, RuntimeError>["resume"],
family: ((machine: Machine.Machine.Any, options: FamilyOptions) =>
makeFamily(
(input) => makeWithRuntime(runtime, machine, [input]),
options
)) as Bound<Services, RuntimeError>["family"]
})
>["make"]
return {
make: makeBound,
factory:
((machine: Machine.Machine.Any) => (...args: ReadonlyArray<unknown>) =>
(makeBound as any)(machine, ...args)) as Bound<Services, RuntimeError>["factory"],
resume:
((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot<any>) =>
resumeWithRuntime(runtime, machine, snapshot)) as Bound<Services, RuntimeError>["resume"],
family: ((machine: Machine.Machine.Any, options: FamilyOptions) =>
makeFamily(
(input) => makeWithRuntime(runtime, machine, [input]),
options
)) as Bound<Services, RuntimeError>["family"]
}
}
2 changes: 2 additions & 0 deletions packages/effect-machine/src/internal/machine/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2176,6 +2176,8 @@ export const enabled = <
state: Machine.Snapshot<States>
): ReadonlyArray<Machine.TagOf<Events[number]>> => internalPlanner.enabled(machine as any, state)

export const can = internalPlanner.can

export const plan: <
const States extends Machine.StateSchemas,
const Events extends ReadonlyArray<Machine.TaggedSchema>,
Expand Down
25 changes: 25 additions & 0 deletions packages/effect-machine/src/internal/machine/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1876,6 +1876,17 @@ export const enabled = <
return tags
}

const canSync = (
machine: Machine.Any,
state: Machine.Snapshot<any>,
event: unknown
): boolean => {
const decodedEvent = decodeEventSync(machine, event)
if (isFinalState(machine, state)) return false
const configuration = normalizeConfigurationSync(machine, state)
return selectEventTransitions(machine, configuration, decodedEvent as any).length > 0
}

const microstep = <
const States extends Machine.StateSchemas,
const Events extends ReadonlyArray<Machine.TaggedSchema>,
Expand Down Expand Up @@ -2300,6 +2311,20 @@ const planningEffect = <A>(thunk: () => A): Effect.Effect<A, InfiniteTransitionE
}
})

const schemaEffect = <A>(thunk: () => A): Effect.Effect<A, MachineSchemaDecodeError> =>
Effect.suspend(() => {
try {
return Effect.succeed(thunk())
} catch (error) {
return error instanceof MachineSchemaDecodeError ? Effect.fail(error) : Effect.die(error)
}
})

export const can = (...args: readonly [Machine.Any] | readonly [Machine.Any, Machine.Snapshot<any>, unknown]) => {
const query = (state: Machine.Snapshot<any>, event: unknown) => schemaEffect(() => canSync(args[0], state, event))
return args.length === 1 ? query : query(args[1], args[2])
}

export const plan = (machine: Machine.Any, state: Machine.Snapshot<any>, event: unknown) =>
planningEffect(() => planSync(machine as any, state, event as any))

Expand Down
Loading