diff --git a/.changeset/retained-atom-machine-families.md b/.changeset/retained-atom-machine-families.md new file mode 100644 index 0000000..7ac7ad5 --- /dev/null +++ b/.changeset/retained-atom-machine-families.md @@ -0,0 +1,18 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add `AtomMachine.family` and `AtomMachine.familyChild` for keyed machine atoms that retain their machine bridge and use weak family values when the runtime supports them. + +The machine startup input is the root family key. Define each public readonly or writable atom once, then look it up directly from React without `useMemo`: + +```ts +const processAtoms = AtomMachine.family(processMachine, { + atoms: { + details: AtomMachine.select("Processing"), + send: (machine) => machine.send + } +}) +``` + +Root and child selectors now also support data-last calls such as `AtomMachine.select("Processing")(machine)`. diff --git a/package.json b/package.json index dc35a92..bc0a3d2 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "packageManager": "pnpm@10.17.1", "scripts": { "build": "pnpm --recursive --filter \"./packages/**\" run build", - "test": "vitest run", + "test": "node --expose-gc ./node_modules/vitest/vitest.mjs run", "test:types": "pnpm --dir packages/effect-machine test:types", "devtools": "pnpm --dir packages/devtools dev", "visualizer": "pnpm devtools", diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 669eb0b..31c55f3 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -750,16 +750,45 @@ applications. Service-free machines can use `AtomMachine.make(Counter)`. The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable `send` and `stop` atoms, and `child(descriptor)`. Use `AtomMachine.select`, `AtomMachine.selectSnapshot`, and `AtomMachine.matches` for typed, -equality-aware derivations. React applications using `@effect/atom-react` need -a `RegistryProvider`. +equality-aware derivations. Hooks from `@effect/atom-react` use a shared default +registry. Add `RegistryProvider` only when a subtree needs separate registry +identity or disposal. + +For a machine with startup input, `AtomMachine.family` uses that input as the +family key and exposes direct atom families. Each returned atom retains its +private machine bridge while preserving lazy registry startup and disposal: + +```ts +const processAtoms = AtomMachine.bind(runtime).family(processMachine, { + atoms: { + details: AtomMachine.select("Processing"), + ready: AtomMachine.matches("Ready"), + send: (machine) => machine.send + } +}) + +const detailsAtom = processAtoms.details(input) +const sendAtom = processAtoms.send(input) +``` + +The key follows Effect `Equal` and `Hash` semantics. Equal input values reuse +the same public atom while it remains reachable. The family does not keep an +unbounded strong cache on platforms with weak references. Keep keys immutable. Descriptors reconstructed from a `Machine.childFamily` resolve the same child bridge by machine identity and id: ```ts const Plant = Machine.childFamily(plantMachine) -const plantAtom = centralAtom.child(Plant(selectedPlantId)) -const brokenAtom = AtomMachine.matchesChild(plantAtom, "Broken") +const plants = AtomMachine.familyChild(centralAtom, { + child: (plantId: string) => Plant(plantId), + atoms: { + broken: AtomMachine.matchesChild("Broken"), + send: (plant) => plant.send + } +}) + +const brokenAtom = plants.broken(selectedPlantId) ``` Emissions stay streams rather than becoming retained atom state: diff --git a/packages/effect-machine/docs/effect-atom-react.md b/packages/effect-machine/docs/effect-atom-react.md index 555ecbd..ad938a2 100644 --- a/packages/effect-machine/docs/effect-atom-react.md +++ b/packages/effect-machine/docs/effect-atom-react.md @@ -1,6 +1,6 @@ # Effect Atom and React patterns -This guide records the folder organization and three integration patterns +This guide records the folder organization and four integration patterns validated in the process app. Use the API reference for individual AtomMachine operations. Read the [Effect Machine agent guide](./agent-guide.md) for statechart modeling, transitions, services, and testing. @@ -74,35 +74,56 @@ export const counterStateAtom = AtomMachine.select( Consumers read `counterStateAtom` and use `counterMachineAtom.send` directly. Do not add a redundant `counterSendAtom` alias. -## 2. A machine with startup input selected through a family +## 2. A keyed machine with startup input -Use the family key as machine identity. The same value can also be startup -input: +`AtomMachine.family` uses the machine input as both startup input and family +key. It returns one direct atom family for each entry in `atoms`: ```ts import { machineAtoms } from "@/lib/atom-runtime" import { AtomMachine } from "@typeonce/effect-machine/reactivity" -import { Atom } from "effect/unstable/reactivity" import { processMachine } from "./machine" -export const processFamily = Atom.family((query: string) => { - const machine = machineAtoms.make(processMachine, { query }) - - return { - detailsAtom: AtomMachine.select(machine, "process"), - resultAtom: AtomMachine.select(machine, "process.Ready"), - sendAtom: machine.send - } +export const processAtoms = machineAtoms.family(processMachine, { + atoms: { + details: AtomMachine.select("process"), + result: AtomMachine.select("process.Ready"), + send: (machine) => machine.send + }, + label: (input, name) => `process:${input.query}:${name}` }) ``` -A consumer calls `processFamily(query)` and uses the returned focused atoms. If -several nested components need the same scope, an optional Context can expose -`ReturnType`. The provider resolves the query once instead -of drilling it through every component. +React consumes each projected family directly: + +```tsx +const input = { query } +const details = useAtomValue(processAtoms.details(input)) +const send = useAtomSet(processAtoms.send(input)) +``` + +Each public atom retains its private machine bridge. Keeping only `details` or +only `send` is safe. The bridge still starts lazily in the registry and stops +when that registry releases or disposes it. A writable source remains writable, +and a projection keeps the source atom's equality function. + +The family uses Effect `Equal` and `Hash` semantics. Equal records such as +`{ query: "effect" }` select the same family value even when reconstructed. +Keep inputs immutable because mutating a hashed key makes later lookup +unreliable. Different input values select independent machines. If a changing +value should update one running workflow, model the change as an event instead +of putting it in the machine input. -Changing `query` selects another family member and therefore another machine. -If a changing value should update the current workflow, model it as an event. +Service-free machines use the module function directly: + +```ts +export const processAtoms = AtomMachine.family(processMachine, { + atoms: { + details: AtomMachine.select("process"), + send: (machine) => machine.send + } +}) +``` ## 3. Reusing one machine definition for multiple instances @@ -127,8 +148,6 @@ export function makeDialogScope() { export type DialogScope = ReturnType ``` -Choose one of the following ownership forms. - ### React-tree-owned instance ```tsx @@ -150,56 +169,10 @@ Each provider owns one independent dialog. Descendants use a small `DialogScope` through props when Context is unnecessary. Do not add a wrapper component whose only job is forwarding the scope. -### Stable keyed instances shared across scattered components - -Use one private family to create the scope for an ID. Public selector families -reach that same scope: - -```ts -import { Atom } from "effect/unstable/reactivity" - -const dialogScopeFamily = Atom.family((dialogId: string) => { - const scope = makeDialogScope() - - return { - isOpenAtom: scope.isOpenAtom.pipe( - Atom.withLabel(`dialog:${dialogId}:isOpen-source`) - ), - openStateAtom: scope.openStateAtom.pipe( - Atom.withLabel(`dialog:${dialogId}:openState-source`) - ), - sendAtom: scope.sendAtom.pipe( - Atom.withLabel(`dialog:${dialogId}:send-source`) - ) - } -}) - -export const dialogIsOpenFamily = Atom.family((dialogId: string) => { - const scope = dialogScopeFamily(dialogId) - - return Atom.transform( - scope.sendAtom, - (get) => get(scope.isOpenAtom) - ).pipe(Atom.withLabel(`dialog:${dialogId}:isOpen`)) -}) - -export const dialogOpenStateFamily = Atom.family((dialogId: string) => { - const scope = dialogScopeFamily(dialogId) - - return Atom.transform( - scope.sendAtom, - (get) => get(scope.openStateAtom) - ).pipe(Atom.withLabel(`dialog:${dialogId}:openState`)) -}) -``` - -Components using the same `dialogId` share one machine. Different IDs create -independent machines. The two public projections let consumers subscribe -independently. Both remain writable through `Atom.transform`, so either can send -the inferred dialog events. - -Use `dialogId` in atom labels for diagnostics. Do not pass it into -`dialogMachine` as unused fake input. +For a no-input machine, use one module-level bridge or an explicitly owned +React scope. Do not add a family key that the machine does not consume. When an +ID is part of startup semantics, declare it in the machine input and use +`AtomMachine.family`. ## 4. Selecting process-owned child machines @@ -211,18 +184,23 @@ const Plant = Machine.childFamily(plantMachine) export const centralMachineAtom = machineAtoms.make(centralMachine) -export const plantScopeFamily = Atom.family((plantId: string) => { - const plant = centralMachineAtom.child(Plant(plantId)) - - return { - stateAtom: plant.state, - isBrokenAtom: AtomMachine.matchesChild(plant, "Broken"), - sendAtom: plant.send, - stopAtom: plant.stop +export const plantAtoms = AtomMachine.familyChild(centralMachineAtom, { + child: (plantId: string) => Plant(plantId), + atoms: { + state: (plant) => plant.state, + isBroken: AtomMachine.matchesChild("Broken"), + send: (plant) => plant.send, + stop: (plant) => plant.stop } }) + +const broken = useAtomValue(plantAtoms.isBroken(plantId)) +const send = useAtomSet(plantAtoms.send(plantId)) ``` +`familyChild` keeps child lookup separate from root machine startup. Each +projected atom retains the child bridge returned for its key. + `Plant(plantId)` may be reconstructed wherever the id is available. Child lookup and bridge reuse match by machine identity and id, not descriptor object identity. Before the parent spawns that child, selectors contain `Option.none` diff --git a/packages/effect-machine/docs/machine-review.md b/packages/effect-machine/docs/machine-review.md index d91d8d1..e1e781d 100644 --- a/packages/effect-machine/docs/machine-review.md +++ b/packages/effect-machine/docs/machine-review.md @@ -65,27 +65,16 @@ const handlers = { Review check: search for `.resolve(...)` callbacks that only return an empty `target.from()` and remove the callback. -## Let `Atom.family` own keyed identity +## Use retained families for keyed machine input -Treat `useMemo` around an atom family lookup as a warning sign. `Atom.family` -already returns the same retained object for the same key, including when -separate components perform the lookup. - -```tsx -// Redundant and local to one component -const scope = useMemo(() => processFamily(processId), [processId]) - -// The family owns identity -const scope = processFamily(processId) -``` - -If the component constructs the atoms or machine scope directly, move that -construction into a module-level family: +Effect Atom keeps a family value for an equal key while that returned value is +reachable. Current runtimes may hold family values through `WeakRef`. Retaining +one field from a composite family value does not retain the composite itself: ```ts -export const processFamily = Atom.family((processId: string) => { - const machine = machineAtoms.make(processMachine, { processId }) - +// Unsafe when consumers retain only stateAtom or sendAtom +const processScope = Atom.family((input: ProcessInput) => { + const machine = machineAtoms.make(processMachine, input) return { stateAtom: AtomMachine.select(machine, "process"), sendAtom: machine.send @@ -93,17 +82,33 @@ export const processFamily = Atom.family((processId: string) => { }) ``` -Use a stable domain key. A new key means a different machine instance. Send an -event when a value should update the current workflow instead. +Use `AtomMachine.family` for an input-bearing machine. It returns direct atom +families whose atoms retain the private machine bridge: + +```ts +export const processAtoms = machineAtoms.family(processMachine, { + atoms: { + state: AtomMachine.select("process"), + send: (machine) => machine.send + } +}) + +const stateAtom = processAtoms.state(input) +const sendAtom = processAtoms.send(input) +``` + +No component `useMemo` is needed. The registry retains the public atom while a +hook subscribes to it, and that atom retains the machine owner. Equal inputs +use Effect `Equal` and `Hash` semantics and select the same family value. -`useMemo` may still be useful for unrelated expensive calculations. It should -not establish atom or machine identity. For one instance owned only by a React -subtree, use a lazy `useState(makeScope)` initializer as described in the React -guide. +For a no-input machine, use one module-level bridge or a lazy +`useState(makeScope)` value owned by a React subtree. Do not add an unused key. -Review check: search for `useMemo` around atom creation, family lookup, or -`machineAtoms.make`. Replace component-local identity with `Atom.family`, or -with an intentional component-owned scope. +Review check: search for composite `Atom.family` values that own a machine, +`useMemo` around family lookup, and component-local calls to +`machineAtoms.make`. Replace an input-bearing machine with +`AtomMachine.family`. Give a no-input instance an explicit module or React-tree +owner. ## Justify each `RegistryProvider` diff --git a/packages/effect-machine/src/internal/machine/atom.ts b/packages/effect-machine/src/internal/machine/atom.ts index 007128d..f85ecba 100644 --- a/packages/effect-machine/src/internal/machine/atom.ts +++ b/packages/effect-machine/src/internal/machine/atom.ts @@ -8,6 +8,7 @@ import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as Equal from "effect/Equal" import * as Fiber from "effect/Fiber" +import * as MutableHashMap from "effect/MutableHashMap" import * as Option from "effect/Option" import type * as Schema from "effect/Schema" import type * as Scope from "effect/Scope" @@ -78,6 +79,47 @@ const preparedByMachineAtom = new WeakMap< Atom.Atom, any>> >() +type WeakFamilyEntry = { + readonly ref: WeakRef +} + +// This follows Atom.family, but cleanup is generation-aware. A finalizer for a +// collected value must not remove a newer value installed for the same key. +const retainedFamily = ( + makeValue: (key: Key) => Value +): (key: Key) => Value => { + if (typeof WeakRef === "undefined" || typeof FinalizationRegistry === "undefined") { + return Atom.family(makeValue) + } + + const values = MutableHashMap.empty>() + const registry = new FinalizationRegistry<{ + readonly key: Key + readonly entry: WeakFamilyEntry + }>(({ entry, key }) => { + const current = MutableHashMap.get(values, key) + if (Option.isSome(current) && current.value === entry) { + MutableHashMap.remove(values, key) + } + }) + + return (key) => { + const current = MutableHashMap.get(values, key) + if (Option.isSome(current)) { + const value = current.value.ref.deref() + if (value !== undefined) { + return value + } + } + + const value = makeValue(key) + const entry = { ref: new WeakRef(value) } + MutableHashMap.set(values, key, entry) + registry.register(value, { key, entry }) + return value + } +} + const runMachineAtomEffect = ( get: Atom.AtomContext, start: Effect.Effect, StartError, Requirements> @@ -738,6 +780,69 @@ const resumeWithRuntime = ( return makeFromRefAtom(ref as any) } +type FamilyBridge = MachineAtom | ChildMachineAtom + +type FamilyOptions = { + readonly atoms: Readonly Atom.Atom>> + readonly label?: (key: any, atomName: string) => string | undefined +} + +const retainFamilyOwner = >( + owner: { readonly bridge: FamilyBridge }, + source: Source, + label: string | undefined +): Atom.WithoutSerializable => { + const retained = { owner, source } + let atom: Atom.Atom = Atom.transform( + source, + (get) => get(retained.source), + { initialValueTarget: source } + ).pipe( + Atom.withEquality((value, next) => retained.source.equals(value, next)) + ) + if (label !== undefined) { + atom = atom.pipe(Atom.withLabel(label)) + } + return atom as unknown as Atom.WithoutSerializable +} + +const makeFamily = ( + makeBridge: (key: any) => FamilyBridge, + options: FamilyOptions +): Readonly Atom.Atom>> => { + const owners = retainedFamily((key: any) => ({ bridge: makeBridge(key) })) + const atoms: Record Atom.Atom> = {} + + for (const atomName of Object.keys(options.atoms)) { + const project = options.atoms[atomName]! + atoms[atomName] = retainedFamily((key: any) => { + const owner = owners(key) + const source = project(owner.bridge) + return retainFamilyOwner(owner, source, options.label?.(key, atomName)) + }) + } + + return atoms +} + +export const family = ( + machine: Machine.Machine.Any, + options: FamilyOptions +): Readonly Atom.Atom>> => + makeFamily( + (input) => (make as any)(machine, input), + options + ) + +export const familyChild = ( + parent: MachineAtom | ChildMachineAtom, + options: FamilyOptions & { readonly child: (key: any) => Machine.ChildMachine.Any } +): Readonly Atom.Atom>> => + makeFamily( + (key) => parent.child(options.child(key)), + options + ) + export const bind = ( runtime: Atom.AtomRuntime ): Bound => ({ @@ -749,5 +854,10 @@ export const bind = ( >["make"], resume: ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot) => - resumeWithRuntime(runtime, machine, snapshot)) as Bound["resume"] + 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/unstable/reactivity/AtomMachine.ts b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts index 3e7d5f8..5584576 100644 --- a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts +++ b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts @@ -4,6 +4,7 @@ * @since 0.4.0 */ +import { dual } from "effect/Function" import type * as Option from "effect/Option" import type * as Schema from "effect/Schema" import type * as Scope from "effect/Scope" @@ -351,6 +352,37 @@ type SnapshotByIdentifier> = Snaps type ChildState = RefState> +type ChildSnapshot = Machine.Machine.Snapshot< + Machine.Machine.States +> + +const InvalidSelectorPathTypeId = "~effect/reactivity/AtomMachine/InvalidSelectorPath" +const SelectorProjectionTypeId = "~effect/reactivity/AtomMachine/SelectorProjection" + +type SelectorProjectionKind = + | "select" + | "selectSnapshot" + | "matches" + | "selectChild" + | "selectSnapshotChild" + | "matchesChild" + +interface SelectorProjection { + readonly [SelectorProjectionTypeId]: { + readonly kind: Kind + readonly path: Path + } +} + +type EnsureSelectorPath = [Path] extends [SnapshotIdentifier] ? unknown : { + readonly [InvalidSelectorPathTypeId]: Path +} + +type EnsureValuedSelectorPath = [Path] extends [ValuedSnapshotIdentifier] ? unknown + : { + readonly [InvalidSelectorPathTypeId]: Path + } + /** * Selects the typed value for an active state path. * @@ -385,17 +417,49 @@ type ChildState = RefState, - Event, - Error, - Output, - StartError, - Emitted, - const Path extends ValuedSnapshotIdentifier ->(self: MachineAtom, path: Path) => Atom.Atom< - AsyncResult.AsyncResult>, StartError | Error> -> = internal.select +export const select: { + < + State extends Machine.Machine.AtomicSnapshot = never, + Event = never, + Error = never, + Output = never, + StartError = never, + Emitted = never, + const Path extends ValuedSnapshotIdentifier = ValuedSnapshotIdentifier + >(path: Path): + & SelectorProjection<"select", Path> + & ((self: MachineAtom) => Atom.Atom< + AsyncResult.AsyncResult>, StartError | Error> + >) + (path: Path): + & SelectorProjection<"select", Path> + & (< + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + Emitted + >( + self: MachineAtom & EnsureValuedSelectorPath + ) => Atom.Atom< + AsyncResult.AsyncResult< + Option.Option>>>, + StartError | Error + > + >) + < + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + Emitted, + const Path extends ValuedSnapshotIdentifier + >(self: MachineAtom, path: Path): Atom.Atom< + AsyncResult.AsyncResult>, StartError | Error> + > +} = dual(2, internal.select) /** * Selects the typed logical snapshot for an active state path. @@ -407,17 +471,49 @@ export const select: < * @category combinators * @since 0.7.0 */ -export const selectSnapshot: < - State extends Machine.Machine.AtomicSnapshot, - Event, - Error, - Output, - StartError, - Emitted, - const Path extends SnapshotIdentifier ->(self: MachineAtom, path: Path) => Atom.Atom< - AsyncResult.AsyncResult>, StartError | Error> -> = internal.selectSnapshot +export const selectSnapshot: { + < + State extends Machine.Machine.AtomicSnapshot = never, + Event = never, + Error = never, + Output = never, + StartError = never, + Emitted = never, + const Path extends SnapshotIdentifier = SnapshotIdentifier + >(path: Path): + & SelectorProjection<"selectSnapshot", Path> + & ((self: MachineAtom) => Atom.Atom< + AsyncResult.AsyncResult>, StartError | Error> + >) + (path: Path): + & SelectorProjection<"selectSnapshot", Path> + & (< + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + Emitted + >( + self: MachineAtom & EnsureSelectorPath + ) => Atom.Atom< + AsyncResult.AsyncResult< + Option.Option>>>, + StartError | Error + > + >) + < + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + Emitted, + const Path extends SnapshotIdentifier + >(self: MachineAtom, path: Path): Atom.Atom< + AsyncResult.AsyncResult>, StartError | Error> + > +} = dual(2, internal.selectSnapshot) /** * Selects the typed value for an active state path in a directly owned child. @@ -436,16 +532,45 @@ export const selectSnapshot: < * @category combinators * @since 0.4.0 */ -export const selectChild: < - Child extends Machine.ChildMachine.Any, - StartError, - const Path extends ValuedSnapshotIdentifier> ->(self: ChildMachineAtom, path: Path) => Atom.Atom< - AsyncResult.AsyncResult< - Option.Option, Path>>, - StartError | RefError> +export const selectChild: { + < + Child extends Machine.ChildMachine.Any = never, + StartError = never, + const Path extends ValuedSnapshotIdentifier> = ValuedSnapshotIdentifier> + >(path: Path): + & SelectorProjection<"selectChild", Path> + & ((self: ChildMachineAtom) => Atom.Atom< + AsyncResult.AsyncResult< + Option.Option, Path>>, + StartError | RefError> + > + >) + (path: Path): + & SelectorProjection<"selectChild", Path> + & (< + Child extends Machine.ChildMachine.Any, + StartError + >( + self: ChildMachineAtom & EnsureValuedSelectorPath, Path> + ) => Atom.Atom< + AsyncResult.AsyncResult< + Option.Option< + SnapshotValueByIdentifier, Extract>>> + >, + StartError | RefError> + > + >) + < + Child extends Machine.ChildMachine.Any, + StartError, + const Path extends ValuedSnapshotIdentifier> + >(self: ChildMachineAtom, path: Path): Atom.Atom< + AsyncResult.AsyncResult< + Option.Option, Path>>, + StartError | RefError> + > > -> = internal.selectChild +} = dual(2, internal.selectChild) /** * Selects the typed logical snapshot for an active state path in an invoked @@ -458,16 +583,43 @@ export const selectChild: < * @category combinators * @since 0.7.0 */ -export const selectSnapshotChild: < - Child extends Machine.ChildMachine.Any, - StartError, - const Path extends SnapshotIdentifier> ->(self: ChildMachineAtom, path: Path) => Atom.Atom< - AsyncResult.AsyncResult< - Option.Option, Path>>, - StartError | RefError> +export const selectSnapshotChild: { + < + Child extends Machine.ChildMachine.Any = never, + StartError = never, + const Path extends SnapshotIdentifier> = SnapshotIdentifier> + >(path: Path): + & SelectorProjection<"selectSnapshotChild", Path> + & ((self: ChildMachineAtom) => Atom.Atom< + AsyncResult.AsyncResult< + Option.Option, Path>>, + StartError | RefError> + > + >) + (path: Path): + & SelectorProjection<"selectSnapshotChild", Path> + & (< + Child extends Machine.ChildMachine.Any, + StartError + >( + self: ChildMachineAtom & EnsureSelectorPath, Path> + ) => Atom.Atom< + AsyncResult.AsyncResult< + Option.Option, Extract>>>>, + StartError | RefError> + > + >) + < + Child extends Machine.ChildMachine.Any, + StartError, + const Path extends SnapshotIdentifier> + >(self: ChildMachineAtom, path: Path): Atom.Atom< + AsyncResult.AsyncResult< + Option.Option, Path>>, + StartError | RefError> + > > -> = internal.selectSnapshotChild +} = dual(2, internal.selectSnapshotChild) /** * Returns whether a state path is active. @@ -501,18 +653,45 @@ export const selectSnapshotChild: < * @category combinators * @since 0.4.0 */ -export const matches: < - State extends Machine.Machine.AtomicSnapshot, - Event, - Error, - Output, - StartError, - Emitted, - const Path extends SnapshotIdentifier ->( - self: MachineAtom, - path: Path -) => Atom.Atom> = internal.matches +export const matches: { + < + State extends Machine.Machine.AtomicSnapshot = never, + Event = never, + Error = never, + Output = never, + StartError = never, + Emitted = never, + const Path extends SnapshotIdentifier = SnapshotIdentifier + >(path: Path): + & SelectorProjection<"matches", Path> + & ((self: MachineAtom) => Atom.Atom< + AsyncResult.AsyncResult + >) + (path: Path): + & SelectorProjection<"matches", Path> + & (< + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + Emitted + >( + self: MachineAtom & EnsureSelectorPath + ) => Atom.Atom>) + < + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + Emitted, + const Path extends SnapshotIdentifier + >( + self: MachineAtom, + path: Path + ): Atom.Atom> +} = dual(2, internal.matches) /** * Returns whether a state path is active in a directly owned child. @@ -524,13 +703,34 @@ export const matches: < * @category combinators * @since 0.4.0 */ -export const matchesChild: < - Child extends Machine.ChildMachine.Any, - StartError, - const Path extends SnapshotIdentifier> ->(self: ChildMachineAtom, path: Path) => Atom.Atom< - AsyncResult.AsyncResult>> -> = internal.matchesChild +export const matchesChild: { + < + Child extends Machine.ChildMachine.Any = never, + StartError = never, + const Path extends SnapshotIdentifier> = SnapshotIdentifier> + >(path: Path): + & SelectorProjection<"matchesChild", Path> + & ((self: ChildMachineAtom) => Atom.Atom< + AsyncResult.AsyncResult>> + >) + (path: Path): + & SelectorProjection<"matchesChild", Path> + & (< + Child extends Machine.ChildMachine.Any, + StartError + >( + self: ChildMachineAtom & EnsureSelectorPath, Path> + ) => Atom.Atom< + AsyncResult.AsyncResult>> + >) + < + Child extends Machine.ChildMachine.Any, + StartError, + const Path extends SnapshotIdentifier> + >(self: ChildMachineAtom, path: Path): Atom.Atom< + AsyncResult.AsyncResult>> + > +} = dual(2, internal.matchesChild) const BoundRequirementsTypeId = "~effect/reactivity/AtomMachine/BoundRequirements" @@ -602,6 +802,98 @@ type MachineAtomOf = MachineAtom< Machine.Machine.EmittedEvent > +type FamilyBridge = MachineAtom | ChildMachineAtom + +type RootFamilySelectorProjection = + | SelectorProjection<"select", ValuedSnapshotIdentifier> + | SelectorProjection<"selectSnapshot", SnapshotIdentifier> + | SelectorProjection<"matches", SnapshotIdentifier> + +type ChildFamilySelectorProjection = + | SelectorProjection<"selectChild", ValuedSnapshotIdentifier>> + | SelectorProjection<"selectSnapshotChild", SnapshotIdentifier>> + | SelectorProjection<"matchesChild", SnapshotIdentifier>> + +type FamilyProjectionRecord = Readonly< + Record Atom.Atom) | SelectorProjection> +> + +type FamilyProjectedSelectorAtom< + Kind extends SelectorProjectionKind, + Path extends string, + Bridge extends FamilyBridge +> = Bridge extends + MachineAtom ? + Kind extends "select" ? Atom.Atom< + AsyncResult.AsyncResult< + Option.Option>>>, + StartError | Error + > + > + : Kind extends "selectSnapshot" ? Atom.Atom< + AsyncResult.AsyncResult< + Option.Option>>>, + StartError | Error + > + > + : Kind extends "matches" ? Atom.Atom> + : never + : Bridge extends ChildMachineAtom ? Kind extends "selectChild" ? Atom.Atom< + AsyncResult.AsyncResult< + Option.Option< + SnapshotValueByIdentifier< + ChildSnapshot, + Extract>> + > + >, + StartError | RefError> + > + > + : Kind extends "selectSnapshotChild" ? Atom.Atom< + AsyncResult.AsyncResult< + Option.Option< + SnapshotByIdentifier, Extract>>> + >, + StartError | RefError> + > + > + : Kind extends "matchesChild" ? Atom.Atom< + AsyncResult.AsyncResult>> + > + : never + : never + +type FamilyProjectedAtom = Projection extends + SelectorProjection ? FamilyProjectedSelectorAtom + : Projection extends (bridge: Bridge) => infer Source ? Source extends Atom.Atom ? Source : never + : never + +type FamilyAtoms< + Key, + Bridge extends FamilyBridge, + Projections extends Readonly> +> = { + readonly [Name in keyof Projections]: ( + key: Key + ) => Atom.WithoutSerializable> +} + +const FamilyInputRequiredTypeId = "~effect/reactivity/AtomMachine/FamilyInputRequired" + +type EnsureFamilyInput = [Machine.Machine.Input] extends [never] ? { + readonly [FamilyInputRequiredTypeId]: "AtomMachine.family requires a machine with startup input" + } + : unknown + +type FamilyOptions< + Key, + Bridge extends FamilyBridge, + Projections extends Readonly> +> = { + readonly atoms: Projections + readonly label?: (key: Key, atomName: keyof Projections & string) => string | undefined +} + type ResumedMachineAtomOf = MachineAtom< Machine.Machine.Snapshot>, Machine.Machine.EventInput>, @@ -644,8 +936,123 @@ export interface Bound { & Machine.Machine.RootCompatible>>, snapshot: Machine.Machine.Snapshot> ) => ResumedMachineAtomOf + + /** + * Creates retained atom families for independent machine inputs using the + * bound runtime. + * + * Each machine input is also the family key. Every projected atom retains + * its private machine bridge while the projected atom remains reachable. + * + * @since 0.28.0 + */ + readonly family: < + M extends Machine.Machine.Any, + const Projections extends FamilyProjectionRecord< + MachineAtomOf, RuntimeError>, + RootFamilySelectorProjection>>> + > + >( + machine: + & M + & EnsureBoundRequirements> + & EnsureMachineExecutable> + & Machine.Machine.RootCompatible>> + & EnsureFamilyInput>, + options: FamilyOptions< + Machine.Machine.Input>, + MachineAtomOf, RuntimeError>, + Projections + > + ) => FamilyAtoms, MachineAtomOf, Projections> } +/** + * Creates retained atom families for independent machine inputs. + * + * The machine input is both startup input and family identity. Each property + * in `atoms` projects one public atom family from a private machine bridge. + * Retaining any projected atom retains that bridge without keeping its + * registry runtime mounted. Keys follow Effect `Equal` and `Hash` semantics. + * The optional `label` function labels each public projected atom. + * + * **Example** + * + * ```ts + * const processAtoms = AtomMachine.family(processMachine, { + * atoms: { + * ready: AtomMachine.matches("Ready"), + * state: (machine) => machine.state, + * send: (machine) => machine.send + * } + * }) + * + * const readyAtom = processAtoms.ready("effect") + * const sendAtom = processAtoms.send("effect") + * ``` + * + * @category constructors + * @since 0.28.0 + */ +export const family: < + M extends Machine.Machine.Any, + const Projections extends FamilyProjectionRecord< + MachineAtomOf, never>, + RootFamilySelectorProjection>>> + > +>( + machine: + & M + & EnsureNoExternalRequirements>> + & EnsureMachineExecutable> + & Machine.Machine.RootCompatible>> + & EnsureFamilyInput>, + options: FamilyOptions< + Machine.Machine.Input>, + MachineAtomOf, never>, + Projections + > +) => FamilyAtoms, MachineAtomOf, Projections> = internal.family as any + +/** + * Creates retained atom families for keyed direct-child lookup. + * + * The `child` function maps each family key to one direct child descriptor. + * Every projected atom retains the resulting child bridge while the projected + * atom remains reachable. Keys follow Effect `Equal` and `Hash` semantics. + * + * **Example** + * + * ```ts + * const Plant = Machine.childFamily(plantMachine) + * + * const plantAtoms = AtomMachine.familyChild(parentMachineAtom, { + * child: (plantId: string) => Plant(plantId), + * atoms: { + * broken: AtomMachine.matchesChild("Broken"), + * state: (child) => child.state, + * send: (child) => child.send + * } + * }) + * ``` + * + * @category constructors + * @since 0.28.0 + */ +export const familyChild: < + Key, + Parent extends MachineAtom | ChildMachineAtom, + Child extends Machine.ChildMachine.Any, + const Projections extends FamilyProjectionRecord, ChildFamilySelectorProjection> +>( + parent: Parent, + options: { + readonly child: (key: Key) => Child + readonly atoms: Projections + readonly label?: (key: Key, atomName: keyof Projections & string) => string | undefined + } +) => FamilyAtoms, Projections> = internal.familyChild as any + /** * Creates atoms backed by a running machine. * diff --git a/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts index fddd53f..85bd1b7 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, Deferred, Effect, Fiber, Option, Ref, Schema, Stream } from "effect" +import { Cause, Data, Deferred, Effect, Fiber, 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" @@ -81,6 +81,42 @@ const makeCounterMachine = () => Done: {} }) +const makeInputCounterMachine = () => + Machine.make({ + states: CounterStates.states, + events: Machine.events(Finish), + input: Schema.Number, + initial: (to) => to.Count().resolve(({ input, target }) => target.decoded(new Count({ value: input }))) + }).handle({ + Count: { + on: { + Finish: (to) => + to.full.Count().resolve(({ state, event, target }) => + target.decoded(new Count({ value: state.value + event.by })) + ) + } + }, + Done: {} + }) + +const forceGc = async () => { + for (let attempt = 0; attempt < 10; attempt++) { + globalThis.gc?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +const waitForCollection = async (ref: WeakRef) => { + for (let attempt = 0; attempt < 100; attempt++) { + globalThis.gc?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + if (ref.deref() === undefined) { + return true + } + } + return false +} + describe("AtomMachine", () => { it.effect("observes prepared live inspection before atom startup", () => Effect.scoped(Effect.gen(function*() { @@ -339,6 +375,133 @@ describe("AtomMachine", () => { assert.strictEqual(yield* AtomRegistry.getResult(registry, matches), false) }))) + it.effect("creates retained atom families for dynamically spawned children", () => + Effect.scoped(Effect.gen(function*() { + const registry = yield* makeRegistry + const childMachine = makeCounterMachine() + const Child = Machine.childFamily(childMachine) + const parent = Machine.make({ + states: { Count }, + events: Machine.events(), + initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + }).handle({ + Count: { + invoke: (from) => + from.effect("spawn-counter", ({ children }) => children.spawn(Child("dynamic"))).onDone((to) => to.none) + .onFailure((to) => to.none) + } + }) + const parentAtoms = AtomMachine.make(parent) + const children = AtomMachine.familyChild(parentAtoms, { + child: (id: string) => Child(id), + atoms: { + count: AtomMachine.selectChild("Count"), + matches: AtomMachine.matchesChild("Count"), + send: (child) => child.send + }, + label: (id, name) => `counter:${id}:${name}` + }) + const count = children.count("dynamic") + const send = children.send("dynamic") + + assert.strictEqual(children.count("dynamic"), count) + assert.notStrictEqual(children.count("missing"), count) + assert.strictEqual(count.label?.[0], "counter:dynamic:count") + yield* mount(registry, count) + const initial = yield* waitForResult(registry, count, Option.isSome) + assert(Option.isSome(initial)) + assert.strictEqual(initial.value.value, 0) + + yield* Effect.sync(() => registry.set(send, new Finish({ by: 3 }))) + const updated = yield* waitForResult( + registry, + count, + (value) => Option.isSome(value) && value.value.value === 3 + ) + assert(Option.isSome(updated)) + assert.strictEqual(updated.value.value, 3) + assert.strictEqual(yield* AtomRegistry.getResult(registry, children.matches("dynamic")), true) + assert.strictEqual(yield* AtomRegistry.getResult(registry, children.matches("missing")), false) + }))) + + it("uses Effect key equality without retaining family values permanently", async () => { + class FamilyKey extends Data.Class<{ readonly id: string }> {} + const machine = makeInputCounterMachine() + const atoms = AtomMachine.family(machine, { + atoms: { + state: (machine) => machine.state + } + }) + const equalFirst = new FamilyKey({ id: "same" }) + const equalSecond = new FamilyKey({ id: "same" }) + const plainFirst = { id: "same" } + const plainSecond = { id: "same" } + const plainDifferent = { id: "different" } + const anyInputMachine = Machine.make({ + states: CounterStates.states, + events: Machine.events(), + input: Schema.Any, + initial: (to) => to.Count().resolve(({ target }) => target.decoded(new Count({ value: 0 }))) + }).handle({ Count: {}, Done: {} }) + const anyInputAtoms = AtomMachine.family(anyInputMachine, { + atoms: { state: (machine) => machine.state } + }) + + assert.strictEqual(anyInputAtoms.state(equalFirst), anyInputAtoms.state(equalSecond)) + assert.strictEqual(anyInputAtoms.state(plainFirst), anyInputAtoms.state(plainSecond)) + assert.notStrictEqual(anyInputAtoms.state(plainFirst), anyInputAtoms.state(plainDifferent)) + assert.strictEqual(atoms.state(1), atoms.state(1)) + assert.notStrictEqual(atoms.state(1), atoms.state(2)) + + if (globalThis.gc !== undefined) { + const weak = (() => { + const atom = atoms.state(99) + return new WeakRef(atom) + })() + assert.strictEqual(await waitForCollection(weak), true) + } + }) + + it.effect("retains one keyed machine owner through every public projection", () => + Effect.scoped(Effect.gen(function*() { + const registry = yield* makeRegistry + const atoms = AtomMachine.family(makeInputCounterMachine(), { + atoms: { + count: AtomMachine.select("Count"), + equal: (machine) => machine.state.pipe(Atom.withEquality(() => true)), + ref: (machine) => machine.ref, + send: (machine) => machine.send, + state: (machine) => machine.state + } + }) + const send = atoms.send(4) + + yield* Effect.promise(forceGc) + const count = atoms.count(4) + const state = atoms.state(4) + assert.strictEqual(state.keepAlive, false) + assert.strictEqual(atoms.equal(4).equals(AsyncResult.initial(), AsyncResult.success({} as never)), true) + yield* mount(registry, state) + yield* Effect.sync(() => registry.set(send, new Finish({ by: 5 }))) + + const updated = yield* waitForResult(registry, count, (value) => Option.isSome(value) && value.value.value === 9) + assert(Option.isSome(updated)) + assert.strictEqual(updated.value.value, 9) + + const secondRegistry = AtomRegistry.make() + assert.strictEqual((yield* AtomRegistry.getResult(secondRegistry, state)).value.value, 4) + secondRegistry.dispose() + + const ref = yield* AtomRegistry.getResult(registry, atoms.ref(4)) + const stopped = yield* Machine.watch(ref).pipe( + Stream.runCollect, + Effect.forkScoped + ) + yield* Effect.sync(() => registry.dispose()) + const events = Array.from(yield* Fiber.join(stopped)) + assert.strictEqual(events.at(-1)?._tag, "Stopped") + }))) + it.effect("exposes snapshots and sends events", () => Effect.scoped(Effect.gen(function*() { const registry = yield* makeRegistry diff --git a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts index ec4724a..4d179e3 100644 --- a/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts +++ b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts @@ -330,6 +330,137 @@ describe("AtomMachine", () => { expect(AtomMachine.select).type.not.toBeCallableWith(parent, "Ready.network") }) + it("supports data-last root and child selectors", () => { + type Snapshot = Machine.Machine.Snapshot + type Parent = AtomMachine.MachineAtom + const parent = null as unknown as Parent + const selected = AtomMachine.select("Ready.editor.Editing")(parent) + const selectedSnapshot = AtomMachine.selectSnapshot("Ready.editor")(parent) + const matched = AtomMachine.matches("Ready.network.Online")(parent) + const invalid = AtomMachine.select("Ready.editor.Missing") + + const Child = Machine.child( + "nested", + null as unknown as Machine.Machine + ) + const child = null as unknown as AtomMachine.ChildOf + const childSelected = AtomMachine.selectChild("Ready.editor.Saving")(child) + const childSelectedSnapshot = AtomMachine.selectSnapshotChild("Ready.editor")(child) + const childMatched = AtomMachine.matchesChild("Ready.network.Offline")(child) + const invalidChild = AtomMachine.matchesChild("Ready.network.Missing") + + expect>().type.toBe>() + expect>().type.toBe< + Option.Option> + >() + expect>().type.toBe() + expect(invalid).type.not.toBeCallableWith(parent) + expect>().type.toBe>() + expect>().type.toBe< + Option.Option> + >() + expect>().type.toBe() + expect(invalidChild).type.not.toBeCallableWith(child) + }) + + it("infers keyed root family inputs and exact projected atoms", () => { + const machine = 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 atoms = AtomMachine.family(machine, { + atoms: { + selected: AtomMachine.select("Idle"), + snapshot: AtomMachine.selectSnapshot("Idle"), + matched: AtomMachine.matches("Idle"), + state: (machine) => machine.state, + send: (machine) => machine.send + } + }) + + const selected = atoms.selected("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< + Option.Option> + >() + expect>().type.toBe() + expect>().type.toBe>() + expect ? Event : never>().type.toBe< + Machine.Machine.EventInput + >() + expect(atoms.selected).type.not.toBeCallableWith(1) + expect(AtomMachine.family).type.not.toBeCallableWith(makeMachine(), { + atoms: { + state: (machine: AtomMachine.MachineAtom) => machine.state + } + }) + }) + + it("preserves bound runtime errors and child protocols through families", () => { + const machine = Machine.make({ + states: States.states, + events: Machine.events(Tick), + input: Schema.String, + initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + }).handle({ + Idle: { + invoke: (from) => from.effect("read-multiplier", () => Effect.as(Multiplier, undefined)).onDone((to) => to.none) + } + }) + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(Multiplier, 2), + Layer.effectDiscard(Effect.fail({ _tag: "StartFailure" } as const satisfies StartFailure)) + ) + ) + const atoms = AtomMachine.bind(runtime).family(machine, { + atoms: { + result: (machine) => machine.result, + send: (machine) => machine.send + } + }) + type FamilyFailure = Atom.Failure> + + const childMachine = makeMachine() + const Child = Machine.childFamily(childMachine) + const parent = AtomMachine.make( + Machine.make({ + states: States.states, + events: Machine.events(), + initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({}))) + }).handle({ Idle: {} }) + ) + const children = AtomMachine.familyChild(parent, { + child: (id: string) => Child(id), + atoms: { + selected: AtomMachine.selectChild("Idle"), + matched: AtomMachine.matchesChild("Idle"), + send: (child) => child.send + } + }) + + expect>().type.toBe() + expect().type.toBe() + expect(AtomMachine.family).type.not.toBeCallableWith(machine, { + atoms: { state: (machine: AtomMachine.MachineAtom) => machine.state } + }) + expect>>().type.toBe>() + expect>>().type.toBe() + expect extends Atom.Writable ? Event : never>().type.toBe< + Machine.Machine.EventInput + >() + expect(children.selected).type.not.toBeCallableWith(1) + }) + it("accepts machines without external requirements", () => { expect(AtomMachine.make).type.toBeCallableWith(makeMachine()) }) diff --git a/scripts/invoke-autocomplete.test.mjs b/scripts/invoke-autocomplete.test.mjs index 8f6e1b2..7eddf20 100644 --- a/scripts/invoke-autocomplete.test.mjs +++ b/scripts/invoke-autocomplete.test.mjs @@ -7,8 +7,57 @@ import ts from "typescript" const projectRoot = path.resolve(import.meta.dirname, "../packages/effect-machine") const virtualFile = path.join(projectRoot, "invoke-autocomplete.fixture.ts") const source = ` -import { Effect, Stream } from "effect" +import { Effect, Schema, Stream } from "effect" import { Machine } from "./src/index.js" +import { AtomMachine } from "./src/unstable/reactivity/index.js" + +class AtomIdle extends Schema.TaggedClass("AtomIdle")("AtomIdle", {}) {} +class AtomReady extends Schema.TaggedClass("AtomReady")("AtomReady", {}) {} +const AtomStates = Machine.states({ AtomIdle, AtomReady }) +const atomDefinition = Machine.make({ + states: AtomStates.states, + events: Machine.events(), + input: Schema.String, + initial: (to) => to.AtomIdle().resolve(({ target }) => target.decoded(new AtomIdle({}))) +}).handle({ AtomIdle: {}, AtomReady: {} }) + +AtomMachine.family(atomDefinition, { + atoms: { + selected: AtomMachine.select("AtomIdle"), + snapshot: AtomMachine.selectSnapshot("AtomIdle"), + matched: AtomMachine.matches("AtomReady") + } +}) + +const atomChildDefinition = Machine.make({ + states: AtomStates.states, + events: Machine.events(), + initial: (to) => to.AtomIdle().resolve(({ target }) => target.decoded(new AtomIdle({}))) +}).handle({ AtomIdle: {}, AtomReady: {} }) +const AtomChild = Machine.childFamily(atomChildDefinition) +const atomParent = AtomMachine.make(Machine.make({ + states: AtomStates.states, + events: Machine.events(), + initial: (to) => to.AtomIdle().resolve(({ target }) => target.decoded(new AtomIdle({}))) +}).handle({ AtomIdle: {}, AtomReady: {} })) +AtomMachine.familyChild(atomParent, { + child: (id: string) => AtomChild(id), + atoms: { + childSelected: AtomMachine.selectChild("AtomIdle"), + childMatched: AtomMachine.matchesChild("AtomReady") + } +}) +AtomMachine.familyChild(atomParent, { + child: (id: string) => AtomChild(id), + atoms: { + // @ts-expect-error empty paths keep the completion position unfiltered + childSelectedCompletion: AtomMachine.selectChild(""), + // @ts-expect-error empty paths keep the completion position unfiltered + childSnapshotCompletion: AtomMachine.selectSnapshotChild(""), + // @ts-expect-error empty paths keep the completion position unfiltered + childMatchedCompletion: AtomMachine.matchesChild("") + } +}) const States = Machine.states({ Loading: {}, Done: {}, Failed: {} }) const definition = Machine.make({ @@ -180,6 +229,38 @@ const completions = (marker) => { return new Set(service.getCompletionsAtPosition(virtualFile, position, {})?.entries.map((entry) => entry.name)) } +const stringCompletions = (prefix) => { + const position = source.indexOf(prefix) + assert.notEqual(position, -1) + return new Set(service.getCompletionsAtPosition(virtualFile, position + prefix.length, {})?.entries.map((entry) => entry.name)) +} + +test("contextually completes data-last AtomMachine selectors", () => { + const selected = stringCompletions('selected: AtomMachine.select("') + assert.equal(selected.has("AtomIdle"), true) + assert.equal(selected.has("AtomReady"), true) + + const snapshot = stringCompletions('snapshot: AtomMachine.selectSnapshot("') + assert.equal(snapshot.has("AtomIdle"), true) + assert.equal(snapshot.has("AtomReady"), true) + + const matched = stringCompletions('matched: AtomMachine.matches("') + assert.equal(matched.has("AtomIdle"), true) + assert.equal(matched.has("AtomReady"), true) + + const childSelected = stringCompletions('childSelectedCompletion: AtomMachine.selectChild("') + assert.equal(childSelected.has("AtomIdle"), true) + assert.equal(childSelected.has("AtomReady"), true) + + const childSnapshot = stringCompletions('childSnapshotCompletion: AtomMachine.selectSnapshotChild("') + assert.equal(childSnapshot.has("AtomIdle"), true) + assert.equal(childSnapshot.has("AtomReady"), true) + + const childMatched = stringCompletions('childMatchedCompletion: AtomMachine.matchesChild("') + assert.equal(childMatched.has("AtomIdle"), true) + assert.equal(childMatched.has("AtomReady"), true) +}) + test("contextually completes Effect invocation factories while authoring", () => { const sources = completions("invoke-sources") assert.deepEqual([...sources].filter((name) => ["effect", "stream", "timer", "logic", "child"].includes(name)).sort(), [