diff --git a/.changeset/reactive-event-acceptance.md b/.changeset/reactive-event-acceptance.md new file mode 100644 index 0000000..0e86be4 --- /dev/null +++ b/.changeset/reactive-event-acceptance.md @@ -0,0 +1,12 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add `AtomMachine.can` for reactive event-acceptance queries with lifecycle-aware failures and stable derived atom identity. + +Declare a projection once from a concrete event or an atom containing a changing event, then apply it to compatible machine bridges: + +```ts +const submitAllowed = AtomMachine.can(AuthEvents.Submitted()) +const canSubmitAtom = submitAllowed(authMachineAtom) +``` diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index ccfe6ca..947a0e1 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -784,6 +784,19 @@ The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable equality-aware derivations. Repeating one of these calls with the same bridge and state path returns the same atom. +Use `AtomMachine.can` to project concrete event acceptance. Declare the +projection once and apply it to compatible bridges: + +```ts +const submitAllowed = AtomMachine.can(Events.Submit({ draft })) +const canSubmitAtom = submitAllowed(machineAtom) +``` + +Pass an `Atom` instead when the event payload changes reactively. +Each projection returns the same derived atom for repeated applications to one +bridge. Startup and runtime failures remain typed, while done and stopped +machines return `false`. + Use `useMachineAtom` from `@typeonce/effect-machine-react` when one React subtree owns the machine. It mounts the machine without subscribing the owner to state. Pass the returned machine atom through props or Context, then call @@ -800,6 +813,7 @@ preserving lazy registry startup and disposal: ```ts const processAtoms = AtomMachine.bind(runtime).family(processMachine, { atoms: { + canStart: AtomMachine.can(ProcessEvents.Start()), details: AtomMachine.select("Processing"), ready: AtomMachine.matches("Ready"), send: (machine) => machine.send diff --git a/packages/effect-machine/docs/effect-atom-react.md b/packages/effect-machine/docs/effect-atom-react.md index 7562f38..d9fa4cc 100644 --- a/packages/effect-machine/docs/effect-atom-react.md +++ b/packages/effect-machine/docs/effect-atom-react.md @@ -185,42 +185,26 @@ 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: +`AtomMachine.can` turns one concrete event input into a reusable machine +projection. Declare the projection once, then apply it to the React-owned +machine. Repeated applications to the same machine return the same atom: ```tsx -import { Effect, Equal } from "effect" -import { Atom } from "effect/unstable/reactivity" -import { Machine } from "@typeonce/effect-machine" +import { AtomMachine } from "@typeonce/effect-machine/reactivity" 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)) +import { AuthEvents } from "../machines/auth-machine" + +const submitAllowed = AtomMachine.can(AuthEvents.Submitted()) function SubmitButton() { const machine = useAuthMachine() - const [canSubmitAtom] = useState(() => makeCanSubmitAtom(machine)) - const canSubmit = useAtomSuspense(canSubmitAtom).value + const canSubmit = useAtomSuspense(submitAllowed(machine)).value const send = useAtomSet(machine.send) return ( @@ -229,9 +213,22 @@ function SubmitButton() { ``` 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. +and invalid event input for an active machine remains a +`MachineSchemaDecodeError`. Done and stopped machines return `false`. + +When acceptance depends on a changing payload, project an event atom instead: + +```ts +import { Atom } from "effect/unstable/reactivity" + +const submitEvent = Atom.map(draftAtom, (draft) => + AuthEvents.Submitted({ draft })) + +const submitAllowed = AtomMachine.can(submitEvent) +``` + +Changes to `draftAtom` recompute acceptance. The event atom contains the event +input itself rather than an `AsyncResult`. ## Whole-result and custom selections diff --git a/packages/effect-machine/src/internal/machine/atom.ts b/packages/effect-machine/src/internal/machine/atom.ts index 6b4055a..b74dde9 100644 --- a/packages/effect-machine/src/internal/machine/atom.ts +++ b/packages/effect-machine/src/internal/machine/atom.ts @@ -79,6 +79,8 @@ const preparedByMachineAtom = new WeakMap< Atom.Atom, any>> >() +const machineByMachineAtom = new WeakMap() + type WeakFamilyEntry = { readonly ref: WeakRef } @@ -422,7 +424,8 @@ const makeChildSelector = ( } const makeFromRefAtom = ( - ref: Atom.Atom, StartError>> + ref: Atom.Atom, StartError>>, + machine: Machine.Machine.Any ): MachineAtom => { const snapshot = Atom.readable(( get @@ -498,7 +501,7 @@ const makeFromRefAtom = ( const optionalRef = Atom.mapResult(ref, Option.some) const child = makeChildSelector(optionalRef as any) - return { + const result = { ref, snapshot, state: Atom.mapResult(snapshot, (snapshot) => snapshot.state), @@ -507,6 +510,8 @@ const makeFromRefAtom = ( stop, child } + machineByMachineAtom.set(result, machine) + return result } type SnapshotNode = State extends Machine.Machine.AtomicSnapshot ? @@ -716,6 +721,57 @@ export const matchesChild = < Option.exists((snapshot) => Option.isSome(Topology.getSnapshotByPath(snapshot, path))) ).pipe(Atom.withEquality(Equal.equals))) +export const can = (event: unknown) => { + const byBridge = new WeakMap>>() + return (self: MachineAtom): Atom.Atom> => { + const cached = byBridge.get(self) + if (cached !== undefined) return cached + + const machine = machineByMachineAtom.get(self) + const query: ( + state: Machine.Machine.Snapshot, + event: unknown + ) => Effect.Effect = machine === undefined + ? () => Effect.die(new Error("AtomMachine.can requires a machine atom created by AtomMachine")) + : internalMachine.can(machine) as ( + state: Machine.Machine.Snapshot, + event: unknown + ) => Effect.Effect + + const result = Atom.readable((get): AsyncResult.AsyncResult => { + const current = get(self.snapshot) + const previous = get.self>() + if (AsyncResult.isInitial(current)) { + return AsyncResult.initial(current.waiting) + } else if (AsyncResult.isFailure(current)) { + return AsyncResult.failureWithPrevious(current.cause, { + previous, + waiting: current.waiting + }) + } else if (current.value.status === "error") { + return AsyncResult.failureWithPrevious(current.value.cause, { + previous, + waiting: current.waiting + }) + } else if (current.value.status !== "active") { + return AsyncResult.success(false, { waiting: current.waiting }) + } + + const input = Atom.isAtom(event) ? get(event) : event + const exit = Effect.runSyncExit(query(current.value.state, input)) + return exit._tag === "Success" + ? AsyncResult.success(exit.value, { waiting: current.waiting }) + : AsyncResult.failureWithPrevious(exit.cause, { + previous, + waiting: current.waiting + }) + }).pipe(Atom.withEquality(Equal.equals)) + + byBridge.set(self, result) + return result + } +} + type MachineResumeRequirementsOf = MachineResumeRequirements< Machine.Machine.Services, Machine.Machine.Event, @@ -796,7 +852,7 @@ export const make: { } = ((machine: Machine.Machine.Any, ...args: ReadonlyArray) => { const prepared = Atom.make(() => internalMachine.prepare(machine as any, ...(args as []))) const ref = Atom.make((get) => startPreparedMachineAtomEffect(get, prepared as any)) - const result = makeFromRefAtom(ref as any) + const result = makeFromRefAtom(ref as any, machine) preparedByMachineAtom.set(result, prepared as any) return result }) as any @@ -815,7 +871,7 @@ export const resume: { ): ResumedMachineAtomOf } = ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot) => { const ref = Atom.make((get) => resumeMachineAtomEffect(get, machine, snapshot)) - return makeFromRefAtom(ref as any) + return makeFromRefAtom(ref as any, machine) }) as any const makeWithRuntime = ( @@ -825,7 +881,7 @@ const makeWithRuntime = ( ): MachineAtom => { const prepared = runtime.atom(() => internalMachine.prepare(machine as any, ...(args as []))) const ref = runtime.atom((get) => startPreparedMachineAtomEffect(get, prepared as any)) - const result = makeFromRefAtom(ref as any) + const result = makeFromRefAtom(ref as any, machine) preparedByMachineAtom.set(result, prepared as any) return result } @@ -836,7 +892,7 @@ const resumeWithRuntime = ( snapshot: Machine.Machine.Snapshot ): MachineAtom => { const ref = runtime.atom((get) => resumeMachineAtomEffect(get, machine, snapshot)) - return makeFromRefAtom(ref as any) + return makeFromRefAtom(ref as any, machine) } type FamilyBridge = MachineAtom | ChildMachineAtom diff --git a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts index 809e1c3..0d92c90 100644 --- a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts +++ b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts @@ -358,6 +358,7 @@ type ChildSnapshot = Machine.Machine.Sna const InvalidSelectorPathTypeId = "~effect/reactivity/AtomMachine/InvalidSelectorPath" const SelectorProjectionTypeId = "~effect/reactivity/AtomMachine/SelectorProjection" +const InvalidCanEventTypeId = "~effect/reactivity/AtomMachine/InvalidCanEvent" type SelectorProjectionKind = | "select" @@ -374,6 +375,30 @@ interface SelectorProjection = [Input] extends [AcceptedEvent] ? unknown : { + readonly [InvalidCanEventTypeId]: { + readonly input: Input + readonly accepted: AcceptedEvent + } +} + +interface CanProjection { + < + State extends Machine.Machine.AtomicSnapshot, + AcceptedEvent, + Error, + Output, + StartError, + Emitted + >( + self: + & MachineAtom + & EnsureCanEvent + ): Atom.Atom< + AsyncResult.AsyncResult + > +} + type EnsureSelectorPath = [Path] extends [SnapshotIdentifier] ? unknown : { readonly [InvalidSelectorPathTypeId]: Path } @@ -695,6 +720,37 @@ export const matches: { ): Atom.Atom> } = dual(2, internal.matches) +/** + * Reactively tests whether a concrete event would be accepted by a running + * machine. + * + * Declare the returned projection once, then apply it to compatible machine + * bridges. Repeated applications to the same bridge return the same atom. An + * event atom is read reactively when acceptance depends on a changing payload. + * + * Startup remains in the source `AsyncResult`. Active snapshots use + * {@link Machine.can}; done and stopped snapshots produce `false`, while + * runtime and schema failures remain in the typed failure channel. + * + * **Example** + * + * ```ts + * const submitAllowed = AtomMachine.can(AuthEvents.Submitted()) + * const canSubmitAtom = submitAllowed(authMachineAtom) + * + * const submitEvent = Atom.map(draftAtom, (draft) => + * AuthEvents.Submitted({ draft })) + * const reactiveSubmitAllowed = AtomMachine.can(submitEvent) + * ``` + * + * @category combinators + * @since 0.31.0 + */ +export const can: { + (event: Atom.Atom): CanProjection + (event: Input): CanProjection +} = internal.can + /** * Returns whether a state path is active in a directly owned child. * diff --git a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts index f50199b..5cef39b 100644 --- a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts +++ b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts @@ -214,9 +214,11 @@ describe("AtomMachine", () => { Done: {} }) const bridge = AtomMachine.resume(machine, { path: "Count" as const, value: new Count({ value: 5 }) }) + const canFinish = AtomMachine.can(new Finish({ by: 1 }))(bridge) const firstRegistry = AtomRegistry.make() const secondRegistry = AtomRegistry.make() + assert.strictEqual(yield* AtomRegistry.getResult(firstRegistry, canFinish), true) const first = yield* AtomRegistry.getResult(firstRegistry, bridge.ref) const firstAgain = yield* AtomRegistry.getResult(firstRegistry, bridge.ref) const second = yield* AtomRegistry.getResult(secondRegistry, bridge.ref) @@ -474,18 +476,22 @@ describe("AtomMachine", () => { } }) - it("does not retain abandoned bridges through the selector cache", async () => { + it("does not retain abandoned bridges through projection caches", async () => { if (globalThis.gc === undefined) return + const canFinish = AtomMachine.can(new Finish({ by: 1 })) const refs = (() => { const bridge = AtomMachine.make(makeCounterMachine()) const selector = AtomMachine.selectSnapshot(bridge, "Count") + const acceptance = canFinish(bridge) return { + acceptance: new WeakRef(acceptance), bridge: new WeakRef(bridge), selector: new WeakRef(selector) } })() + assert.strictEqual(await waitForCollection(refs.acceptance), true) assert.strictEqual(await waitForCollection(refs.selector), true) assert.strictEqual(await waitForCollection(refs.bridge), true) }) @@ -495,6 +501,7 @@ describe("AtomMachine", () => { const registry = yield* makeRegistry const atoms = AtomMachine.family(makeInputCounterMachine(), { atoms: { + canFinish: AtomMachine.can(new Finish({ by: 1 })), count: AtomMachine.select("Count"), equal: (machine) => machine.state.pipe(Atom.withEquality(() => true)), ref: (machine) => machine.ref, @@ -508,6 +515,7 @@ describe("AtomMachine", () => { const count = atoms.count(4) const state = atoms.state(4) assert.strictEqual(state.keepAlive, false) + assert.strictEqual(yield* AtomRegistry.getResult(registry, atoms.canFinish(4)), true) assert.strictEqual(atoms.equal(4).equals(AsyncResult.initial(), AsyncResult.success({} as never)), true) yield* mount(registry, state) yield* Effect.sync(() => registry.set(send, new Finish({ by: 5 }))) @@ -572,6 +580,10 @@ describe("AtomMachine", () => { const boundCounter = makeBoundCounter(3) yield* mount(registry, boundCounter.state) assert.strictEqual((yield* AtomRegistry.getResult(registry, boundCounter.state)).value.value, 3) + assert.strictEqual( + yield* AtomRegistry.getResult(registry, AtomMachine.can(new Finish({ by: 1 }))(boundCounter)), + true + ) }))) it.effect("provides equality-aware typed state selectors", () => @@ -609,6 +621,155 @@ describe("AtomMachine", () => { assert.strictEqual(doneMatchNotifications, 1) }))) + it.effect("projects static and reactive event acceptance across runtime lifecycles", () => + Effect.scoped(Effect.gen(function*() { + class CanIdle extends Schema.TaggedClass("AtomCanIdle")("CanIdle", {}) {} + class CanDone extends Schema.TaggedClass("AtomCanDone")("CanDone", {}) {} + class Check extends Schema.TaggedClass("AtomCanCheck")("Check", { + accept: Schema.Boolean + }) {} + class Complete extends Schema.TaggedClass("AtomCanComplete")("Complete", {}) {} + const events = Machine.events(Check, Complete) + const states = Machine.states({ + CanIdle, + CanDone: { schema: CanDone, type: "final" } + }) + let requiredResolverCalls = 0 + let declinableResolverCalls = 0 + const machine = Machine.make({ + states: states.states, + events, + initial: (to) => to.CanIdle().resolve(({ target }) => target.decoded(new CanIdle({}))) + }).handle({ + CanIdle: { + on: { + Check: (to) => + to.none.resolve(({ event, decline }) => { + declinableResolverCalls++ + return event.accept ? undefined : decline() + }, { declinable: true }), + Complete: (to) => + to.full.CanDone().resolve(({ target }) => { + requiredResolverCalls++ + return target.decoded(new CanDone({})) + }) + } + }, + CanDone: {} + }) + const registry = yield* makeRegistry + const bridge = AtomMachine.make(machine) + const checkEvent = Atom.make(events.Check({ accept: false })) + const checkAllowed = AtomMachine.can(checkEvent) + const completeAllowed = AtomMachine.can(events.Complete()) + const canCheck = checkAllowed(bridge) + const canComplete = completeAllowed(bridge) + let canCheckNotifications = 0 + + assert.strictEqual(checkAllowed(bridge), canCheck) + assert.strictEqual(completeAllowed(bridge), canComplete) + assert.notStrictEqual(AtomMachine.can(events.Complete())(bridge), canComplete) + yield* mount(registry, canCheck) + yield* Effect.acquireRelease( + Effect.sync(() => + registry.subscribe(canCheck, () => { + canCheckNotifications++ + }, { immediate: true }) + ), + (release) => Effect.sync(release) + ) + assert.strictEqual(yield* AtomRegistry.getResult(registry, canCheck), false) + assert.strictEqual(declinableResolverCalls, 1) + assert.strictEqual(yield* AtomRegistry.getResult(registry, canComplete), true) + assert.strictEqual(requiredResolverCalls, 0) + + yield* Effect.sync(() => registry.set(checkEvent, events.Check({ accept: false }))) + assert.strictEqual(yield* AtomRegistry.getResult(registry, canCheck), false) + assert.strictEqual(canCheckNotifications, 1) + yield* Effect.sync(() => registry.set(checkEvent, events.Check({ accept: true }))) + assert.strictEqual(yield* waitForResult(registry, canCheck, (accepted) => accepted), true) + assert.strictEqual(declinableResolverCalls, 3) + assert.strictEqual(canCheckNotifications, 2) + + const invalid = AtomMachine.can({ _tag: "Check", accept: "invalid" } as any)(bridge) + const invalidError = yield* AtomRegistry.getResult(registry, invalid).pipe(Effect.flip) + assert.instanceOf(invalidError, Machine.MachineSchemaDecodeError) + + yield* Effect.sync(() => registry.set(bridge.send, events.Complete())) + yield* waitForResult(registry, bridge.snapshot, (snapshot) => snapshot.status === "done") + assert.strictEqual(yield* waitForResult(registry, canCheck, (accepted) => !accepted), false) + assert.strictEqual(yield* AtomRegistry.getResult(registry, canComplete), false) + assert.strictEqual(requiredResolverCalls, 1) + + const stoppedBridge = AtomMachine.make(machine) + const stoppedCanComplete = completeAllowed(stoppedBridge) + yield* mount(registry, stoppedCanComplete) + assert.strictEqual(yield* AtomRegistry.getResult(registry, stoppedCanComplete), true) + yield* Effect.sync(() => registry.set(stoppedBridge.stop, undefined)) + yield* waitForResult(registry, stoppedBridge.snapshot, (snapshot) => snapshot.status === "stopped") + assert.strictEqual( + yield* waitForResult(registry, stoppedCanComplete, (accepted) => !accepted), + false + ) + }))) + + it.effect("propagates machine startup and runtime failures through acceptance projections", () => + Effect.scoped(Effect.gen(function*() { + class Published extends Schema.TaggedClass("AtomCanPublished")("Published", { + value: Schema.Number + }) {} + const emissions = Machine.emittedEvents(Published) + const startupMachine = Machine.make({ + states: CounterStates.states, + events: Machine.events(Finish), + emittedEvents: emissions, + initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + }).handle({ + Count: { + entry: (_, enqueue) => { + enqueue.emit(emissions.Published({ value: "invalid" } as never)) + } + }, + Done: {} + }) + const registry = yield* makeRegistry + const startupBridge = AtomMachine.make(startupMachine) + const startupCanFinish = AtomMachine.can(new Finish({ by: 1 }))(startupBridge) + const startupError = yield* AtomRegistry.getResult(registry, startupCanFinish).pipe(Effect.flip) + assert.instanceOf(startupError, Machine.MachineSchemaDecodeError) + + class FaultIdle extends Schema.TaggedClass("AtomCanFaultIdle")("FaultIdle", {}) {} + class FaultLoading extends Schema.TaggedClass("AtomCanFaultLoading")("FaultLoading", {}) {} + class Begin extends Schema.TaggedClass("AtomCanBegin")("Begin", {}) {} + const failure = new Error("runtime failed") + const faultMachine = Machine.make({ + states: { FaultIdle, FaultLoading }, + events: Machine.events(Begin), + initial: (to) => to.FaultIdle().resolve(({ target }) => target.decoded(new FaultIdle({}))) + }).handle({ + FaultIdle: { + on: { + Begin: (to) => to.full.FaultLoading().resolve(({ target }) => target.decoded(new FaultLoading({}))) + } + }, + FaultLoading: { + invoke: (from) => from.effect("fail", () => Effect.die(failure)) + } + }) + const faultBridge = AtomMachine.make(faultMachine) + const canBegin = AtomMachine.can(new Begin({}))(faultBridge) + yield* mount(registry, canBegin) + assert.strictEqual(yield* AtomRegistry.getResult(registry, canBegin), true) + yield* Effect.sync(() => registry.set(faultBridge.send, new Begin({}))) + yield* waitForResult(registry, faultBridge.snapshot, (snapshot) => snapshot.status === "error") + + const result = registry.get(canBegin) + assert(AsyncResult.isFailure(result)) + assert.strictEqual(Cause.squash(result.cause), failure) + assert(Option.isSome(result.previousSuccess)) + assert.strictEqual(result.previousSuccess.value.value, false) + }))) + it.effect("selects compound and parallel state paths from the bridge snapshot", () => Effect.scoped(Effect.gen(function*() { const states = Machine.states({ diff --git a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts index b6573e7..efd3c3f 100644 --- a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts +++ b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts @@ -363,10 +363,36 @@ describe("AtomMachine", () => { expect(invalidChild).type.not.toBeCallableWith(child) }) + it("infers static and reactive event acceptance projections", () => { + type Snapshot = Machine.Machine.Snapshot + type Parent = AtomMachine.MachineAtom< + Snapshot, + Machine.Machine.EventInput, + RuntimeFailure, + never, + StartFailure + > + const parent = null as unknown as Parent + const Events = Machine.events(Tick) + const staticProjection = AtomMachine.can(Events.Tick()) + const reactiveProjection = AtomMachine.can(Atom.make(Events.Tick())) + const staticCan = staticProjection(parent) + const reactiveCan = reactiveProjection(parent) + const invalidProjection = AtomMachine.can(new InternalTick({})) + + expect>().type.toBe() + expect>().type.toBe() + expect>().type.toBe< + StartFailure | RuntimeFailure | Machine.MachineSchemaDecodeError + >() + expect(invalidProjection).type.not.toBeCallableWith(parent) + }) + it("infers keyed root family inputs and exact projected atoms", () => { + const Events = Machine.events(Tick) const machine = Machine.make({ states: States.states, - events: Machine.events(Tick), + events: Events, input: Schema.String, initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) }).handle({ @@ -374,6 +400,7 @@ describe("AtomMachine", () => { }) const atoms = AtomMachine.family(machine, { atoms: { + canTick: AtomMachine.can(Events.Tick()), selected: AtomMachine.select("Idle"), snapshot: AtomMachine.selectSnapshot("Idle"), matched: AtomMachine.matches("Idle"), @@ -383,12 +410,14 @@ describe("AtomMachine", () => { }) const selected = atoms.selected("one") + const canTick = atoms.canTick("one") const snapshot = atoms.snapshot("one") const matched = atoms.matched("one") const state = atoms.state("one") const send = atoms.send("one") expect>().type.toBe>() + expect>().type.toBe() expect>().type.toBe< Option.Option> >()