diff --git a/.changeset/quiet-machines-query.md b/.changeset/quiet-machines-query.md new file mode 100644 index 0000000..dc2a432 --- /dev/null +++ b/.changeset/quiet-machines-query.md @@ -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` preserves the exact machine and bound runtime error types. diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 1a5eb2e..ccfe6ca 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -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 @@ -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: @@ -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 +``` + +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`, diff --git a/packages/effect-machine/docs/effect-atom-react.md b/packages/effect-machine/docs/effect-atom-react.md index e9c583e..7562f38 100644 --- a/packages/effect-machine/docs/effect-atom-react.md +++ b/packages/effect-machine/docs/effect-atom-react.md @@ -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 const AuthMachineContext = createContext(null) @@ -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 ( + + ) +} +``` + +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 diff --git a/packages/effect-machine/docs/machine-review.md b/packages/effect-machine/docs/machine-review.md index 4078151..60021d9 100644 --- a/packages/effect-machine/docs/machine-review.md +++ b/packages/effect-machine/docs/machine-review.md @@ -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 diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index 2f4428e..ce91261 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -9301,6 +9301,120 @@ export const enabled: < state: Machine.Snapshot ) => ReadonlyArray> = 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, + const Emits extends ReadonlyArray, + const Input extends Schema.Top = typeof Schema.Void, + UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, + E = never, + R = never, + InitialE = never, + InitialR = never, + FinalStates extends Machine.StateIdentifier = never, + Output = never, + OutputStates extends Machine.StateIdentifier = never, + InputEvents extends ReadonlyArray = Events, + ParentEvents extends ReadonlyArray = readonly [] + >( + machine: + & Machine< + States, + Events, + Input, + UnhandledStates, + E, + R, + InitialE, + InitialR, + FinalStates, + Output, + Emits, + OutputStates, + InputEvents, + ParentEvents + > + & EnsureExecutable + & Machine.RootCompatible + ): ( + state: Machine.Snapshot, + event: Machine.EventInputOf + ) => Effect.Effect + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + const Input extends Schema.Top = typeof Schema.Void, + UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, + E = never, + R = never, + InitialE = never, + InitialR = never, + FinalStates extends Machine.StateIdentifier = never, + Output = never, + OutputStates extends Machine.StateIdentifier = never, + InputEvents extends ReadonlyArray = Events, + ParentEvents extends ReadonlyArray = readonly [] + >( + machine: + & Machine< + States, + Events, + Input, + UnhandledStates, + E, + R, + InitialE, + InitialR, + FinalStates, + Output, + Emits, + OutputStates, + InputEvents, + ParentEvents + > + & EnsureExecutable + & Machine.RootCompatible, + state: Machine.Snapshot, + event: Machine.EventInputOf + ): Effect.Effect +} = internal.can as any + /** * Plans the next state snapshot synchronously. * diff --git a/packages/effect-machine/src/internal/machine/atom.ts b/packages/effect-machine/src/internal/machine/atom.ts index 0572da8..6b4055a 100644 --- a/packages/effect-machine/src/internal/machine/atom.ts +++ b/packages/effect-machine/src/internal/machine/atom.ts @@ -801,6 +801,9 @@ export const make: { return result }) as any +export const factory = + ((machine: Machine.Machine.Any) => (...args: ReadonlyArray) => (make as any)(machine, ...args)) as any + export const resume: { ( machine: @@ -901,19 +904,25 @@ export const familyChild = ( export const bind = ( runtime: Atom.AtomRuntime -): Bound => ({ - make: +): Bound => { + const makeBound = ((machine: Machine.Machine.Any, ...args: ReadonlyArray) => makeWithRuntime(runtime, machine, args)) as Bound< Services, RuntimeError - >["make"], - resume: - ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot) => - resumeWithRuntime(runtime, machine, snapshot)) as Bound["resume"], - family: ((machine: Machine.Machine.Any, options: FamilyOptions) => - makeFamily( - (input) => makeWithRuntime(runtime, machine, [input]), - options - )) as Bound["family"] -}) + >["make"] + return { + make: makeBound, + factory: + ((machine: Machine.Machine.Any) => (...args: ReadonlyArray) => + (makeBound as any)(machine, ...args)) as Bound["factory"], + resume: + ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot) => + resumeWithRuntime(runtime, machine, snapshot)) as Bound["resume"], + family: ((machine: Machine.Machine.Any, options: FamilyOptions) => + makeFamily( + (input) => makeWithRuntime(runtime, machine, [input]), + options + )) as Bound["family"] + } +} diff --git a/packages/effect-machine/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts index b074f10..ca1fcee 100644 --- a/packages/effect-machine/src/internal/machine/machine.ts +++ b/packages/effect-machine/src/internal/machine/machine.ts @@ -2176,6 +2176,8 @@ export const enabled = < state: Machine.Snapshot ): ReadonlyArray> => internalPlanner.enabled(machine as any, state) +export const can = internalPlanner.can + export const plan: < const States extends Machine.StateSchemas, const Events extends ReadonlyArray, diff --git a/packages/effect-machine/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts index 62fdea3..f4c8678 100644 --- a/packages/effect-machine/src/internal/machine/planner.ts +++ b/packages/effect-machine/src/internal/machine/planner.ts @@ -1876,6 +1876,17 @@ export const enabled = < return tags } +const canSync = ( + machine: Machine.Any, + state: Machine.Snapshot, + 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, @@ -2300,6 +2311,20 @@ const planningEffect = (thunk: () => A): Effect.Effect(thunk: () => A): Effect.Effect => + 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, unknown]) => { + const query = (state: Machine.Snapshot, 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, event: unknown) => planningEffect(() => planSync(machine as any, state, event as any)) diff --git a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts index c66cef7..809e1c3 100644 --- a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts +++ b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts @@ -906,7 +906,7 @@ type ResumedMachineAtomOf = Machine > /** - * An `AtomMachine` factory with one owned Effect runtime. + * `AtomMachine` constructors bound to one owned Effect runtime. * * @category models * @since 0.4.0 @@ -929,6 +929,22 @@ export interface Bound { ...args: MachineInputArgsOf ) => MachineAtomOf + /** + * Specializes a machine definition into a reusable bridge constructor. + * + * Every call creates an independent machine bridge. Startup remains lazy + * and begins only when an `AtomRegistry` reads or mounts the bridge. + * + * @since 0.30.0 + */ + readonly factory: ( + machine: + & M + & EnsureBoundRequirements> + & EnsureMachineExecutable> + & Machine.Machine.RootCompatible>> + ) => (...args: MachineInputArgsOf) => MachineAtomOf + /** Creates a lazy bridge from a decoded logical snapshot. */ readonly resume: ( machine: @@ -1140,6 +1156,33 @@ export const make: { > } = internal.make +/** + * Specializes a machine definition into a reusable bridge constructor. + * + * The returned function preserves the machine's startup input arity and exact + * bridge type. Every call creates a fresh `MachineAtom`; it does not cache by + * input or start the machine before an `AtomRegistry` reads or mounts it. + * + * **Example** + * + * ```ts + * const makeSearchMachine = AtomMachine.factory(searchMachine) + * const search = makeSearchMachine({ query: "effect" }) + * + * type SearchMachineAtom = ReturnType + * ``` + * + * @category constructors + * @since 0.30.0 + */ +export const factory: ( + machine: + & M + & EnsureNoExternalRequirements>> + & EnsureMachineExecutable> + & Machine.Machine.RootCompatible>> +) => (...args: MachineInputArgsOf) => MachineAtomOf = internal.factory + /** * Creates a lazy atom bridge from a decoded logical snapshot. * @@ -1162,11 +1205,12 @@ export const resume: { } = internal.resume /** - * Creates an `AtomMachine` factory that owns a shared Effect runtime. + * Binds `AtomMachine` constructors to a shared Effect runtime. * * Use this when an application runs many machines from the same service layer. - * The returned factory keeps runtime provisioning at the composition boundary, - * while every call to `make` still creates an independent machine bridge. + * The returned interface keeps runtime provisioning at the composition seam, + * while every call to `make` or a specialized `factory` still creates an + * independent machine bridge. * * @category constructors * @since 0.4.0 diff --git a/packages/effect-machine/test/machine/Machine.test.ts b/packages/effect-machine/test/machine/Machine.test.ts index aab14d1..936c21d 100644 --- a/packages/effect-machine/test/machine/Machine.test.ts +++ b/packages/effect-machine/test/machine/Machine.test.ts @@ -1567,6 +1567,16 @@ describe("Machine", () => { ) assertMachineSchemaDecodeError(error, "event", { event: "NonEmptySubmit" }) + + const canError = yield* Effect.flip( + Machine.can( + machine, + { path: "NonEmptyIdle" as const, value: new NonEmptyIdle({ userId: "user-1" }) }, + unsafeTagged({ _tag: "NonEmptySubmit", value: "" }) + ) + ) + + assertMachineSchemaDecodeError(canError, "event", { event: "NonEmptySubmit" }) })) it.effect("surfaces sent event decode failures through the machine lifecycle", () => @@ -2265,6 +2275,92 @@ describe("Machine", () => { }) })) + it.effect("queries concrete event acceptance without executing required transitions", () => + Effect.gen(function*() { + class Idle extends Schema.TaggedClass("CanIdle")("Idle", {}) {} + class Done extends Schema.TaggedClass("CanDone")("Done", {}) {} + class Check extends Schema.TaggedClass("CanCheck")("Check", { + accept: Schema.Boolean + }) {} + class Consume extends Schema.TaggedClass("CanConsume")("Consume", {}) {} + class Finish extends Schema.TaggedClass("CanFinish")("Finish", {}) {} + class Ignore extends Schema.TaggedClass("CanIgnore")("Ignore", {}) {} + class Raised extends Schema.TaggedClass("CanRaised")("Raised", {}) {} + const states = Machine.states({ + Idle, + Done: { schema: Done, type: "final" } + }) + let requiredResolverCalls = 0 + let declinableResolverCalls = 0 + let raisedResolverCalls = 0 + let lifecycleCalls = 0 + const machine = Machine.make({ + states: states.states, + events: Machine.events(Check, Consume, Finish, Ignore), + internalEvents: Machine.internalEvents(Raised), + initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + }).handle({ + Idle: { + exit: () => { + lifecycleCalls++ + }, + on: { + Check: (to) => + to.none.resolve(({ event, decline }, enqueue) => { + declinableResolverCalls++ + if (!event.accept) return decline() + enqueue.raise(new Raised({})) + return undefined + }, { declinable: true }), + Consume: (to) => + to.none.resolve(() => { + requiredResolverCalls++ + return undefined + }), + Finish: (to) => + to.full.Done().resolve(({ target }) => { + requiredResolverCalls++ + return target.decoded(new Done({})) + }), + Raised: (to) => + to.none.resolve(() => { + raisedResolverCalls++ + return undefined + }) + } + }, + Done: { + entry: () => { + lifecycleCalls++ + } + } + }) + + const initial = yield* Machine.planInitial(machine) + const canMachine = Machine.can(machine) + + assert.isTrue(yield* Machine.can(machine, initial.state, new Check({ accept: true }))) + assert.isFalse(yield* canMachine(initial.state, new Check({ accept: false }))) + assert.strictEqual(declinableResolverCalls, 2) + assert.strictEqual(raisedResolverCalls, 0) + + assert.isTrue(yield* canMachine(initial.state, new Consume({}))) + assert.isTrue(yield* canMachine(initial.state, new Finish({}))) + assert.strictEqual(requiredResolverCalls, 0) + assert.strictEqual(lifecycleCalls, 0) + + assert.isFalse(yield* canMachine(initial.state, new Ignore({}))) + + yield* Machine.plan(machine, initial.state, new Check({ accept: true })) + assert.strictEqual(raisedResolverCalls, 1) + + const finished = yield* Machine.plan(machine, initial.state, new Finish({})) + assert.isTrue(finished.done) + assert.strictEqual(requiredResolverCalls, 1) + assert.strictEqual(lifecycleCalls, 2) + assert.isFalse(yield* canMachine(finished.next, new Finish({}))) + })) + it.effect("lets declinable child handlers yield to ancestors without retaining queued work", () => Effect.gen(function*() { class Notice extends Schema.TaggedClass("DeclineNotice")("Notice", {}) {} @@ -2325,6 +2421,9 @@ describe("Machine", () => { "declinable" ) const initial = yield* Machine.planInitial(machine) + assert.isTrue(yield* Machine.can(machine, initial.state, new Authorize({ code: "child" }))) + assert.isTrue(yield* Machine.can(machine)(initial.state, new Authorize({ code: "consume" }))) + assert.isTrue(yield* Machine.can(machine, initial.state, new Authorize({ code: "parent" }))) const child = yield* Machine.plan(machine, initial.state, new Authorize({ code: "child" })) assert.strictEqual(child.next.path, "payment") if (child.next.path === "payment") assert.strictEqual(child.next.state.path, "payment.authorized") @@ -2399,6 +2498,7 @@ describe("Machine", () => { const initial = yield* Machine.planInitial(machine) assert.deepStrictEqual(Machine.enabled(machine, initial.state), ["Ping"]) + assert.isFalse(yield* Machine.can(machine, initial.state, new Ping({}))) const planned = yield* Machine.plan(machine, initial.state, new Ping({})) assert.deepStrictEqual(planned.next, initial.state) assert.deepStrictEqual(planned.microsteps, []) @@ -2494,6 +2594,10 @@ describe("Machine", () => { }) const initial = yield* Machine.planInitial(machine) + assert.isTrue(yield* Machine.can(machine, initial.state, new Ping({ handleRight: true }))) + assert.strictEqual(parentCalls, 0) + assert.isTrue(yield* Machine.can(machine, initial.state, new Ping({ handleRight: false }))) + assert.strictEqual(parentCalls, 0) const descendant = yield* Machine.plan(machine, initial.state, new Ping({ handleRight: true })) assert.strictEqual(descendant.next.path, "root") assert.deepStrictEqual(descendant.microsteps[0]?.transitions.map(({ source }) => source), ["root.right"]) diff --git a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts index 452532a..f50199b 100644 --- a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts +++ b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest" -import { Cause, Data, Deferred, Effect, Fiber, Option, Ref, Schema, Stream } from "effect" +import { Cause, Data, Deferred, Effect, Fiber, Layer, Option, Ref, Schema, Stream } from "effect" import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity" import { Machine } from "../../../src/index.js" import { AtomMachine } from "../../../src/unstable/reactivity/index.js" @@ -554,6 +554,26 @@ describe("AtomMachine", () => { }) }))) + it.effect("creates fresh inferred bridges from reusable constructors", () => + Effect.scoped(Effect.gen(function*() { + const registry = yield* makeRegistry + const makeCounter = AtomMachine.factory(makeInputCounterMachine()) + const first = makeCounter(1) + const second = makeCounter(2) + assert.notStrictEqual(first, second) + + yield* mount(registry, first.state) + yield* mount(registry, second.state) + assert.strictEqual((yield* AtomRegistry.getResult(registry, first.state)).value.value, 1) + assert.strictEqual((yield* AtomRegistry.getResult(registry, second.state)).value.value, 2) + + const bound = AtomMachine.bind(Atom.runtime(Layer.empty)) + const makeBoundCounter = bound.factory(makeInputCounterMachine()) + const boundCounter = makeBoundCounter(3) + yield* mount(registry, boundCounter.state) + assert.strictEqual((yield* AtomRegistry.getResult(registry, boundCounter.state)).value.value, 3) + }))) + it.effect("provides equality-aware typed state selectors", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry diff --git a/packages/effect-machine/typetest/machine/Machine.tst.ts b/packages/effect-machine/typetest/machine/Machine.tst.ts index 1ef99f3..c5579bc 100644 --- a/packages/effect-machine/typetest/machine/Machine.tst.ts +++ b/packages/effect-machine/typetest/machine/Machine.tst.ts @@ -787,6 +787,15 @@ describe("Machine", () => { DownInitial.decoded(new Down({})), new SignInCompleted({ userId: "user-1" }) ) + expect(Machine.can).type.not.toBeCallableWith( + machine, + DownInitial.decoded(new Down({})), + new SignInCompleted({ userId: "user-1" }) + ) + expect(Machine.can(machine)).type.not.toBeCallableWith( + DownInitial.decoded(new Down({})), + new SignInCompleted({ userId: "user-1" }) + ) const publicEvents = Machine.events(SignIn) const overlappingInternalEvents = Machine.internalEvents(SignIn) expect(Machine.make).type.not.toBeCallableWith({ @@ -1042,7 +1051,22 @@ describe("Machine", () => { expect(Machine.enabled).type.toBeCallableWith(machine, DownInitial.decoded(new Down({}))) expect(Machine.isFinal).type.toBeCallableWith(machine, DownInitial.decoded(new Down({}))) + const can = Machine.can( + machine, + DownInitial.decoded(new Down({})), + new SignIn({ userId: "user-1" }) + ) + const canMachine = Machine.can(machine) + expect>().type.toBe() + expect>().type.toBe() + expect(canMachine).type.toBeCallableWith( + DownInitial.decoded(new Down({})), + new SignIn({ userId: "user-1" }) + ) + expect(Machine.plan).type.not.toBeCallableWith(machine, new Down({}), new SignIn({ userId: "user-1" })) + expect(Machine.can).type.not.toBeCallableWith(machine, new Down({}), new SignIn({ userId: "user-1" })) + expect(canMachine).type.not.toBeCallableWith(new Down({}), new SignIn({ userId: "user-1" })) expect(Machine.enabled).type.not.toBeCallableWith(machine, new Down({})) expect(Machine.isFinal).type.not.toBeCallableWith(machine, new Down({})) }) diff --git a/packages/effect-machine/typetest/machine/MachineReferences.tst.ts b/packages/effect-machine/typetest/machine/MachineReferences.tst.ts index 58e289b..51b757e 100644 --- a/packages/effect-machine/typetest/machine/MachineReferences.tst.ts +++ b/packages/effect-machine/typetest/machine/MachineReferences.tst.ts @@ -218,11 +218,18 @@ describe("machine reference event channels", () => { null as unknown as Machine.Machine.Snapshot, Events.Ping() ) + expect(Machine.can).type.not.toBeCallableWith(requiredParentMachine) + expect(Machine.can).type.not.toBeCallableWith( + requiredParentMachine, + null as unknown as Machine.Machine.Snapshot, + Events.Ping() + ) expect(Machine.resume).type.not.toBeCallableWith( requiredParentMachine, null as unknown as Machine.Machine.Snapshot ) expect(AtomMachine.make).type.not.toBeCallableWith(requiredParentMachine) + expect(AtomMachine.factory).type.not.toBeCallableWith(requiredParentMachine) expect(AtomMachine.resume).type.not.toBeCallableWith( requiredParentMachine, null as unknown as Machine.Machine.Snapshot diff --git a/packages/effect-machine/typetest/machine/Readiness.tst.ts b/packages/effect-machine/typetest/machine/Readiness.tst.ts index 2080e5d..2588c15 100644 --- a/packages/effect-machine/typetest/machine/Readiness.tst.ts +++ b/packages/effect-machine/typetest/machine/Readiness.tst.ts @@ -78,13 +78,17 @@ describe("executable machine readiness", () => { it("rejects an unimplemented choice at every planning and execution boundary", () => { expect(Machine.planInitial).type.not.toBeCallableWith(choiceIncomplete) expect(Machine.plan).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot, new Tick({})) + expect(Machine.can).type.not.toBeCallableWith(choiceIncomplete) + expect(Machine.can).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot, new Tick({})) expect(Machine.start).type.not.toBeCallableWith(choiceIncomplete) expect(Machine.resume).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot) expect(from.child).type.not.toBeCallableWith(Machine.child("choice", choiceIncomplete)) expect(MachineTest.run).type.not.toBeCallableWith(choiceIncomplete, { events: [] }) expect(AtomMachine.make).type.not.toBeCallableWith(choiceIncomplete) + expect(AtomMachine.factory).type.not.toBeCallableWith(choiceIncomplete) expect(AtomMachine.resume).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot) expect(bound.make).type.not.toBeCallableWith(choiceIncomplete) + expect(bound.factory).type.not.toBeCallableWith(choiceIncomplete) expect(bound.resume).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot) expect(ClusterMachine.make).type.not.toBeCallableWith("Choice", choiceIncomplete, { version: "1" }) }) @@ -92,13 +96,17 @@ describe("executable machine readiness", () => { it("rejects an unimplemented history default at every planning and execution boundary", () => { expect(Machine.planInitial).type.not.toBeCallableWith(historyIncomplete) expect(Machine.plan).type.not.toBeCallableWith(historyIncomplete, historySnapshot, new Tick({})) + expect(Machine.can).type.not.toBeCallableWith(historyIncomplete) + expect(Machine.can).type.not.toBeCallableWith(historyIncomplete, historySnapshot, new Tick({})) expect(Machine.start).type.not.toBeCallableWith(historyIncomplete) expect(Machine.resume).type.not.toBeCallableWith(historyIncomplete, historySnapshot) expect(from.child).type.not.toBeCallableWith(Machine.child("history", historyIncomplete)) expect(MachineTest.run).type.not.toBeCallableWith(historyIncomplete, { events: [] }) expect(AtomMachine.make).type.not.toBeCallableWith(historyIncomplete) + expect(AtomMachine.factory).type.not.toBeCallableWith(historyIncomplete) expect(AtomMachine.resume).type.not.toBeCallableWith(historyIncomplete, historySnapshot) expect(bound.make).type.not.toBeCallableWith(historyIncomplete) + expect(bound.factory).type.not.toBeCallableWith(historyIncomplete) expect(bound.resume).type.not.toBeCallableWith(historyIncomplete, historySnapshot) expect(ClusterMachine.make).type.not.toBeCallableWith("History", historyIncomplete, { version: "1" }) }) @@ -106,13 +114,17 @@ describe("executable machine readiness", () => { it("rejects an unimplemented output at every planning and execution boundary", () => { expect(Machine.planInitial).type.not.toBeCallableWith(outputIncomplete) expect(Machine.plan).type.not.toBeCallableWith(outputIncomplete, outputSnapshot, new Tick({})) + expect(Machine.can).type.not.toBeCallableWith(outputIncomplete) + expect(Machine.can).type.not.toBeCallableWith(outputIncomplete, outputSnapshot, new Tick({})) expect(Machine.start).type.not.toBeCallableWith(outputIncomplete) expect(Machine.resume).type.not.toBeCallableWith(outputIncomplete, outputSnapshot) expect(from.child).type.not.toBeCallableWith(Machine.child("output", outputIncomplete)) expect(MachineTest.run).type.not.toBeCallableWith(outputIncomplete, { events: [] }) expect(AtomMachine.make).type.not.toBeCallableWith(outputIncomplete) + expect(AtomMachine.factory).type.not.toBeCallableWith(outputIncomplete) expect(AtomMachine.resume).type.not.toBeCallableWith(outputIncomplete, outputSnapshot) expect(bound.make).type.not.toBeCallableWith(outputIncomplete) + expect(bound.factory).type.not.toBeCallableWith(outputIncomplete) expect(bound.resume).type.not.toBeCallableWith(outputIncomplete, outputSnapshot) expect(ClusterMachine.make).type.not.toBeCallableWith("Output", outputIncomplete, { version: "1" }) }) @@ -164,12 +176,16 @@ describe("executable machine readiness", () => { const plannedInitial = Machine.planInitial(complete) const planned = Machine.plan(complete, completeSnapshot, new Tick({})) + const can = Machine.can(complete, completeSnapshot, new Tick({})) + const canComplete = Machine.can(complete) const started = Machine.start(complete) const resumed = Machine.resume(complete, completeSnapshot) const trace = MachineTest.run(complete, { events: [new Tick({})] }) const atom = AtomMachine.make(complete) + const makeAtom = AtomMachine.factory(complete) const resumedAtom = AtomMachine.resume(complete, completeSnapshot) const boundAtom = bound.make(complete) + const makeBoundAtom = bound.factory(complete) const boundResumedAtom = bound.resume(complete, completeSnapshot) const cluster = ClusterMachine.make("Complete", complete, { version: "1" }) @@ -183,6 +199,10 @@ describe("executable machine readiness", () => { expect["state"]>().type.toBe< Machine.Machine.Snapshot >() + expect>().type.toBe() + expect(canComplete).type.toBeCallableWith(completeSnapshot, new Tick({})) + expect(makeAtom).type.toBeCallableWith() + expect(makeBoundAtom).type.toBeCallableWith() expect["next"]>().type.toBe< Machine.Machine.Snapshot >() diff --git a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts index 4d179e3..b6573e7 100644 --- a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts +++ b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts @@ -428,7 +428,10 @@ describe("AtomMachine", () => { send: (machine) => machine.send } }) + const makeBoundMachine = AtomMachine.bind(runtime).factory(machine) + const boundMachine = makeBoundMachine("one") type FamilyFailure = Atom.Failure> + type FactoryFailure = Atom.Failure const childMachine = makeMachine() const Child = Machine.childFamily(childMachine) @@ -449,6 +452,8 @@ describe("AtomMachine", () => { }) expect>().type.toBe() + expect>().type.toBe() + expect(AtomMachine.factory).type.not.toBeCallableWith(machine) expect().type.toBe() expect(AtomMachine.family).type.not.toBeCallableWith(machine, { atoms: { state: (machine: AtomMachine.MachineAtom) => machine.state } @@ -465,6 +470,39 @@ describe("AtomMachine", () => { expect(AtomMachine.make).type.toBeCallableWith(makeMachine()) }) + it("specializes machine definitions into inferred bridge constructors", () => { + const makeBridge = AtomMachine.factory(makeMachine()) + const bridge = makeBridge() + type Bridge = ReturnType + + expect(makeBridge).type.toBeCallableWith() + expect(makeBridge).type.not.toBeCallableWith("input") + expect().type.toBe() + expect>().type.toBe>() + + const inputMachine = Machine.make({ + states: States.states, + events: Machine.events(Tick), + input: Schema.String, + initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + }).handle({ Idle: {} }) + const makeInputBridge = AtomMachine.factory(inputMachine) + expect(makeInputBridge).type.toBeCallableWith("input") + expect(makeInputBridge).type.not.toBeCallableWith() + expect(makeInputBridge).type.not.toBeCallableWith(1) + + const runtime = Atom.runtime( + Layer.effectDiscard(Effect.fail({ _tag: "StartFailure" } as const satisfies StartFailure)) + ) + const makeBoundBridge = AtomMachine.bind(runtime).factory(makeMachine()) + const boundBridge = makeBoundBridge() + type BoundFailure = Atom.Failure["result"]> + + expect>().type.toBe() + expect().type.toBe() + expect().type.toBe>() + }) + it("preserves bound runtime errors in the result failure channel", () => { const runtime = Atom.runtime( Layer.effectDiscard(Effect.fail({ _tag: "StartFailure" } as const satisfies StartFailure)) @@ -515,8 +553,10 @@ describe("AtomMachine", () => { const bound = AtomMachine.bind(runtime) expect(AtomMachine.make).type.not.toBeCallableWith(incomplete) + expect(AtomMachine.factory).type.not.toBeCallableWith(incomplete) expect(AtomMachine.make).type.not.toBeCallableWith(runtime, incomplete) expect(bound.make).type.not.toBeCallableWith(incomplete) + expect(bound.factory).type.not.toBeCallableWith(incomplete) const complete = incomplete.handle({ Done: { @@ -528,7 +568,9 @@ describe("AtomMachine", () => { expect().type.toBe() expect(AtomMachine.make).type.toBeCallableWith(complete) + expect(AtomMachine.factory).type.toBeCallableWith(complete) expect(AtomMachine.make).type.not.toBeCallableWith(runtime, complete) expect(bound.make).type.toBeCallableWith(complete) + expect(bound.factory).type.toBeCallableWith(complete) }) })