diff --git a/.changeset/wise-snapshots-query.md b/.changeset/wise-snapshots-query.md new file mode 100644 index 0000000..bf506a8 --- /dev/null +++ b/.changeset/wise-snapshots-query.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": minor +--- + +Allow state query helpers to inspect extracted snapshot subtrees, and add +equality-aware `AtomMachine.selectSnapshot` and `selectSnapshotChild` +combinators. diff --git a/README.md b/README.md index 0f1239b..1fe22c7 100644 --- a/README.md +++ b/README.md @@ -204,9 +204,10 @@ Binding a shared runtime once is the canonical form for service-backed 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` and -`AtomMachine.matches` for typed, equality-aware derivations. React applications -using `@effect/atom-react` need a `RegistryProvider`. +`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`. ## Persistence diff --git a/docs/agent-guide.md b/docs/agent-guide.md index c32f8bd..85c1279 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -357,6 +357,15 @@ States.getSnapshot(snapshot, "Route.Ready") States.matches(snapshot, "Route.Ready.Saving") ``` +Snapshots returned by `getSnapshot` can be queried again with `get`, +`getSnapshot`, or `matches`. Paths remain absolute and are restricted to the +extracted snapshot and its descendants: + +```ts +const ready = Option.getOrThrow(States.getSnapshot(snapshot, "Route.Ready")) +States.matches(ready, "Route.Ready.Saving") +``` + All paths are checked against the definition. `context.parent` is the immediate typed parent (`undefined` at a root). Use `parents` when another ancestor is needed: @@ -642,11 +651,16 @@ the `DefinedStates` object: ```ts AtomMachine.select(machineAtom, "Ready") +AtomMachine.selectSnapshot(machineAtom, "Ready") AtomMachine.matches(machineAtom, "Ready.Saving") AtomMachine.selectChild(childAtom, "Editing") +AtomMachine.selectSnapshotChild(childAtom, "Editing") AtomMachine.matchesChild(childAtom, "Editing") ``` +`select` returns only the decoded state value. Use `selectSnapshot` when a +component needs the selected node's compound or parallel child topology. + Like ordinary Effect Atom combinators, each selector call returns a derived atom. Define it at a stable composition boundary or memoize it when constructing it inside a component. diff --git a/src/Machine.ts b/src/Machine.ts index fca4346..447f936 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -2341,14 +2341,26 @@ export declare namespace Machine { readonly initial: InitialBuilder /** - * Returns the decoded value for an active state path. + * Returns the decoded value for an active state path. The supplied + * snapshot may be a complete root snapshot or a snapshot previously + * extracted from this definition. Extracted snapshots accept only their + * own absolute path and descendant paths. * * @since 0.4.0 */ - readonly get: >( - snapshot: Snapshot, - path: Path - ) => Option.Option> + readonly get: { + >( + snapshot: Snapshot, + path: Path + ): Option.Option> + < + const From extends StateIdentifier, + const Path extends StateIdentifier + >( + snapshot: SnapshotByIdentifier, + path: Path & (Path extends NoInfer | `${NoInfer}.${string}` ? unknown : never) + ): Option.Option> + } /** * Returns the decoded value for an active state path together with all of @@ -2366,24 +2378,48 @@ export declare namespace Machine { ) => Option.Option> /** - * Returns the snapshot for an active state path. + * Returns the snapshot for an active state path. The supplied snapshot may + * be a complete root snapshot or a snapshot previously extracted from this + * definition. Extracted snapshots accept only their own absolute path and + * descendant paths. * * @since 0.4.0 */ - readonly getSnapshot: >( - snapshot: Snapshot, - path: Path - ) => Option.Option> + readonly getSnapshot: { + >( + snapshot: Snapshot, + path: Path + ): Option.Option> + < + const From extends StateIdentifier, + const Path extends StateIdentifier + >( + snapshot: SnapshotByIdentifier, + path: Path & (Path extends NoInfer | `${NoInfer}.${string}` ? unknown : never) + ): Option.Option> + } /** - * Returns whether a state path is active in the snapshot. + * Returns whether a state path is active in the snapshot. The supplied + * snapshot may be a complete root snapshot or a snapshot previously + * extracted from this definition. Extracted snapshots accept only their + * own absolute path and descendant paths. * * @since 0.4.0 */ - readonly matches: >( - snapshot: Snapshot, - path: Path - ) => boolean + readonly matches: { + >( + snapshot: Snapshot, + path: Path + ): boolean + < + const From extends StateIdentifier, + const Path extends StateIdentifier + >( + snapshot: SnapshotByIdentifier, + path: Path & (Path extends NoInfer | `${NoInfer}.${string}` ? unknown : never) + ): boolean + } } /** diff --git a/src/internal/machine/atom.ts b/src/internal/machine/atom.ts index 4df2888..05f600b 100644 --- a/src/internal/machine/atom.ts +++ b/src/internal/machine/atom.ts @@ -435,9 +435,13 @@ type SnapshotValueByIdentifier> = Node extends { readonly path: Path; readonly value: infer Value } ? Value : never : never +type SnapshotByIdentifier> = SnapshotNode extends infer Node ? + Node extends { readonly path: Path } ? Node : never + : never + type ChildState = RefState> -const selectSnapshot = < +const selectValueByPath = < State extends Machine.Machine.AtomicSnapshot, Path extends SnapshotIdentifier >( @@ -448,6 +452,15 @@ const selectSnapshot = < Option.map((snapshot) => snapshot.value) ) as Option.Option> +const selectSnapshotByPath = < + State extends Machine.Machine.AtomicSnapshot, + Path extends SnapshotIdentifier +>( + snapshot: State, + path: Path +): Option.Option> => + Topology.getSnapshotByPath(snapshot, path) as Option.Option> + export const select = < State extends Machine.Machine.AtomicSnapshot, Event, @@ -461,7 +474,24 @@ export const select = < ): Atom.Atom< AsyncResult.AsyncResult>, StartError | Error> > => - Atom.mapResult(self.result, (snapshot) => selectSnapshot(snapshot, path)).pipe( + Atom.mapResult(self.result, (snapshot) => selectValueByPath(snapshot, path)).pipe( + Atom.withEquality(Equal.equals) + ) + +export const selectSnapshot = < + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + const Path extends SnapshotIdentifier +>( + self: MachineAtom, + path: Path +): Atom.Atom< + AsyncResult.AsyncResult>, StartError | Error> +> => + Atom.mapResult(self.result, (snapshot) => selectSnapshotByPath(snapshot, path)).pipe( Atom.withEquality(Equal.equals) ) @@ -480,7 +510,25 @@ export const selectChild = < > => Atom.mapResult( self.result, - Option.flatMap((snapshot) => selectSnapshot(snapshot, path)) + Option.flatMap((snapshot) => selectValueByPath(snapshot, path)) + ).pipe(Atom.withEquality(Equal.equals)) + +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> + > +> => + Atom.mapResult( + self.result, + Option.flatMap((snapshot) => selectSnapshotByPath(snapshot, path)) ).pipe(Atom.withEquality(Equal.equals)) export const matches = < diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index dbff1e7..86cff20 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -713,10 +713,11 @@ export const defineStates: DefineStates = (, - get: ((snapshot, path) => - Topology.getSnapshotByPath(snapshot, path).pipe( - Option.map((snapshot) => snapshot.value) - )) as Machine.DefinedStates["get"], + get: + ((snapshot: Machine.AtomicSnapshot, path: string) => + Topology.getSnapshotByPath(snapshot, path).pipe( + Option.map((snapshot) => snapshot.value) + )) as Machine.DefinedStates["get"], getWithParents: ((snapshot, path) => { const parents: Record = {} return Topology.getSnapshotByPath(snapshot, path, parents).pipe( @@ -724,7 +725,9 @@ export const defineStates: DefineStates = (["getWithParents"], getSnapshot: Topology.getSnapshotByPath as unknown as Machine.DefinedStates["getSnapshot"], - matches: (snapshot, path) => Option.isSome(Topology.getSnapshotByPath(snapshot, path)) + matches: + ((snapshot: Machine.AtomicSnapshot, path: string) => + Option.isSome(Topology.getSnapshotByPath(snapshot, path))) as Machine.DefinedStates["matches"] } }) as DefineStates diff --git a/src/unstable/reactivity/AtomMachine.ts b/src/unstable/reactivity/AtomMachine.ts index 229fe04..88277d4 100644 --- a/src/unstable/reactivity/AtomMachine.ts +++ b/src/unstable/reactivity/AtomMachine.ts @@ -295,6 +295,10 @@ type SnapshotValueByIdentifier> = Node extends { readonly path: Path; readonly value: infer Value } ? Value : never : never +type SnapshotByIdentifier> = SnapshotNode extends infer Node ? + Node extends { readonly path: Path } ? Node : never + : never + type ChildState = RefState> /** @@ -339,6 +343,27 @@ export const select: < AsyncResult.AsyncResult>, StartError | Error> > = internal.select +/** + * Selects the typed logical snapshot for an active state path. + * + * Unlike {@link select}, the selected value retains its child snapshot + * topology. The derived atom suppresses structurally equal updates. Keep the + * returned atom stable when constructing it inside a component. + * + * @category combinators + * @since 0.7.0 + */ +export const selectSnapshot: < + State extends Machine.Machine.AtomicSnapshot, + Event, + Error, + Output, + StartError, + const Path extends SnapshotIdentifier +>(self: MachineAtom, path: Path) => Atom.Atom< + AsyncResult.AsyncResult>, StartError | Error> +> = internal.selectSnapshot + /** * Selects the typed value for an active state path in an invoked child. * @@ -367,6 +392,28 @@ export const selectChild: < > > = internal.selectChild +/** + * Selects the typed logical snapshot for an active state path in an invoked + * child. + * + * An inactive child or state path produces `Option.none()`. Unlike + * {@link selectChild}, the selected value retains its child snapshot topology. + * The derived atom suppresses structurally equal updates. + * + * @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> + > +> = internal.selectSnapshotChild + /** * Returns whether a state path is active. * diff --git a/test/machine/Machine.test.ts b/test/machine/Machine.test.ts index 7880d1e..b951653 100644 --- a/test/machine/Machine.test.ts +++ b/test/machine/Machine.test.ts @@ -426,6 +426,24 @@ describe("Machine", () => { ) assert.strictEqual(states.matches(snapshot, "fulfillment.shipping"), true) assert.strictEqual(states.matches(snapshot, "fulfillment.shipping.quoted"), false) + + const fulfillmentSnapshot = Option.getOrThrow(states.getSnapshot(snapshot, "fulfillment")) + assert.deepStrictEqual(states.get(fulfillmentSnapshot, "fulfillment.inventory"), Option.some(inventory)) + assert.deepStrictEqual( + states.getSnapshot(fulfillmentSnapshot, "fulfillment.shipping"), + Option.some(fulfillmentSnapshot.states.shipping) + ) + assert.strictEqual(states.matches(fulfillmentSnapshot, "fulfillment.inventory.checking"), true) + assert.strictEqual(states.matches(fulfillmentSnapshot, "fulfillment.inventory.reserved"), false) + + const inventorySnapshot = Option.getOrThrow( + states.getSnapshot(fulfillmentSnapshot, "fulfillment.inventory") + ) + assert.deepStrictEqual(states.get(inventorySnapshot, "fulfillment.inventory"), Option.some(inventory)) + assert.deepStrictEqual( + states.get(inventorySnapshot, "fulfillment.inventory.checking"), + Option.some(checking) + ) }) it.effect("initial builder constructs compound initial snapshots", () => diff --git a/test/unstable/reactivity/AtomMachine.test.ts b/test/unstable/reactivity/AtomMachine.test.ts index 6ebd418..70ef7b9 100644 --- a/test/unstable/reactivity/AtomMachine.test.ts +++ b/test/unstable/reactivity/AtomMachine.test.ts @@ -171,11 +171,13 @@ describe("AtomMachine", () => { const Impostor = Machine.child("counter", makeCounterMachine()) const impostorAtoms = parentAtoms.child(Impostor) const selectedCount = AtomMachine.selectChild(childAtoms, "Count") + const selectedCountSnapshot = AtomMachine.selectSnapshotChild(childAtoms, "Count") const countMatches = AtomMachine.matchesChild(childAtoms, "Count") const parentRef = yield* AtomRegistry.getResult(registry, parentAtoms.ref) const directChild = yield* parentRef.child(Child) assert(Option.isNone(directChild)) assert(Option.isNone(yield* AtomRegistry.getResult(registry, selectedCount))) + assert(Option.isNone(yield* AtomRegistry.getResult(registry, selectedCountSnapshot))) assert.strictEqual(yield* AtomRegistry.getResult(registry, countMatches), false) yield* Effect.sync(() => registry.set(childAtoms.send, new Finish({ by: 1 }))) const inactiveSend = yield* Effect.sync(() => registry.get(childAtoms.send)) @@ -204,6 +206,12 @@ describe("AtomMachine", () => { const selectedInitial = yield* waitForResult(registry, selectedCount, Option.isSome) assert(Option.isSome(selectedInitial)) assert.strictEqual(selectedInitial.value.value, 0) + const selectedInitialSnapshot = yield* waitForResult(registry, selectedCountSnapshot, Option.isSome) + assert(Option.isSome(selectedInitialSnapshot)) + assert.deepStrictEqual(selectedInitialSnapshot.value, { + path: "Count", + value: new Count({ value: 0 }) + }) assert.strictEqual(yield* AtomRegistry.getResult(registry, countMatches), true) yield* Effect.sync(() => registry.set(childAtoms.send, new Finish({ by: 2 }))) @@ -220,11 +228,19 @@ describe("AtomMachine", () => { ) assert(Option.isSome(selectedUpdated)) assert.strictEqual(selectedUpdated.value.value, 2) + const selectedUpdatedSnapshot = yield* waitForResult( + registry, + selectedCountSnapshot, + (state) => Option.isSome(state) && state.value.value.value === 2 + ) + assert(Option.isSome(selectedUpdatedSnapshot)) + assert.strictEqual(selectedUpdatedSnapshot.value.value.value, 2) yield* Effect.sync(() => registry.set(parentAtoms.send, new ReadValue({}))) const inactive = yield* waitForResult(registry, childAtoms.ref, Option.isNone) assert(Option.isNone(inactive)) assert(Option.isNone(yield* waitForResult(registry, selectedCount, Option.isNone))) + assert(Option.isNone(yield* waitForResult(registry, selectedCountSnapshot, Option.isNone))) assert.strictEqual(yield* AtomRegistry.getResult(registry, countMatches), false) }))) @@ -342,16 +358,31 @@ describe("AtomMachine", () => { const registry = yield* makeRegistry const bridge = AtomMachine.make(machine) const ready = AtomMachine.select(bridge, "Ready") + const readySnapshot = AtomMachine.selectSnapshot(bridge, "Ready") const editor = AtomMachine.select(bridge, "Ready.editor") + const editorSnapshot = AtomMachine.selectSnapshot(bridge, "Ready.editor") const editing = AtomMachine.select(bridge, "Ready.editor.Editing") + const editingSnapshot = AtomMachine.selectSnapshot(bridge, "Ready.editor.Editing") const saving = AtomMachine.select(bridge, "Ready.editor.Saving") + const savingSnapshot = AtomMachine.selectSnapshot(bridge, "Ready.editor.Saving") const online = AtomMachine.matches(bridge, "Ready.network.Online") const offline = AtomMachine.matches(bridge, "Ready.network.Offline") assert(Option.isSome(yield* AtomRegistry.getResult(registry, ready))) + const selectedReadySnapshot = yield* AtomRegistry.getResult(registry, readySnapshot) + assert(Option.isSome(selectedReadySnapshot)) + assert.strictEqual(selectedReadySnapshot.value.path, "Ready") + assert.strictEqual(selectedReadySnapshot.value.states.editor.path, "Ready.editor") assert(Option.isSome(yield* AtomRegistry.getResult(registry, editor))) + const selectedEditorSnapshot = yield* AtomRegistry.getResult(registry, editorSnapshot) + assert(Option.isSome(selectedEditorSnapshot)) + assert.strictEqual(selectedEditorSnapshot.value.state.path, "Ready.editor.Editing") assert(Option.isSome(yield* AtomRegistry.getResult(registry, editing))) + const selectedEditingSnapshot = yield* AtomRegistry.getResult(registry, editingSnapshot) + assert(Option.isSome(selectedEditingSnapshot)) + assert.strictEqual(selectedEditingSnapshot.value.path, "Ready.editor.Editing") assert(Option.isNone(yield* AtomRegistry.getResult(registry, saving))) + assert(Option.isNone(yield* AtomRegistry.getResult(registry, savingSnapshot))) assert.strictEqual(yield* AtomRegistry.getResult(registry, online), true) assert.strictEqual(yield* AtomRegistry.getResult(registry, offline), false) }))) diff --git a/typetest/machine/Machine.tst.ts b/typetest/machine/Machine.tst.ts index 5daa251..f4efe61 100644 --- a/typetest/machine/Machine.tst.ts +++ b/typetest/machine/Machine.tst.ts @@ -367,6 +367,19 @@ describe("Machine", () => { expect(UpStates.get).type.not.toBeCallableWith(snapshot, "up.missing") expect(UpStates.getWithParents).type.not.toBeCallableWith(snapshot, "up.missing") + const upSnapshot = Option.getOrThrow(UpStates.getSnapshot(snapshot, "up")) + const authSnapshot = Option.getOrThrow(UpStates.getSnapshot(upSnapshot, "up.auth")) + expect(UpStates.get(upSnapshot, "up.auth.signedOut")).type.toBe>() + expect(UpStates.getSnapshot(upSnapshot, "up.sync")).type.toBe< + Option.Option> + >() + expect(UpStates.matches(authSnapshot, "up.auth.signedIn")).type.toBe() + expect(UpStates.get(authSnapshot, "up.auth")).type.toBe>() + expect(UpStates.get).type.not.toBeCallableWith(authSnapshot, "up.sync.idle") + expect(UpStates.getSnapshot).type.not.toBeCallableWith(authSnapshot, "up") + expect(UpStates.matches).type.not.toBeCallableWith(authSnapshot, "signedOut") + expect(UpStates.matches).type.not.toBeCallableWith(authSnapshot, "down") + const other = Machine.defineStates({ other: Down }) expect(UpStates.get).type.not.toBeCallableWith(other.initial.other(new Down({})), "up") expect(UpStates.getWithParents).type.not.toBeCallableWith(other.initial.other(new Down({})), "up") diff --git a/typetest/unstable/reactivity/AtomMachine.tst.ts b/typetest/unstable/reactivity/AtomMachine.tst.ts index 677f90a..e2b8d69 100644 --- a/typetest/unstable/reactivity/AtomMachine.tst.ts +++ b/typetest/unstable/reactivity/AtomMachine.tst.ts @@ -126,8 +126,10 @@ describe("AtomMachine", () => { const parent = null as unknown as Parent const child = null as unknown as AtomMachine.ChildOf const selected = AtomMachine.select(parent, "Idle") + const selectedSnapshot = AtomMachine.selectSnapshot(parent, "Idle") const matched = AtomMachine.matches(parent, "Idle") const childSelected = AtomMachine.selectChild(child, "Idle") + const childSelectedSnapshot = AtomMachine.selectSnapshotChild(child, "Idle") const childMatched = AtomMachine.matchesChild(child, "Idle") expect().type.toBe< @@ -136,20 +138,29 @@ describe("AtomMachine", () => { expect().type.toBe< Atom.Atom, StartFailure | RuntimeFailure>> >() + expect>().type.toBe< + Option.Option> + >() expect().type.toBe< Atom.Atom> >() expect>().type.toBe>() + expect>().type.toBe< + Option.Option> + >() expect>().type.toBe() expect>().type.toBe>() + expect>().type.toBe>() expect>().type.toBe>() expect, StartFailure>>().type.toBe() expect>().type.toBe< AtomMachine.ChildMachineAtom >() expect(AtomMachine.select).type.not.toBeCallableWith(parent, "Missing") + expect(AtomMachine.selectSnapshot).type.not.toBeCallableWith(parent, "Missing") expect(AtomMachine.matches).type.not.toBeCallableWith(parent, "Missing") expect(AtomMachine.selectChild).type.not.toBeCallableWith(child, "Missing") + expect(AtomMachine.selectSnapshotChild).type.not.toBeCallableWith(child, "Missing") expect(AtomMachine.matchesChild).type.not.toBeCallableWith(child, "Missing") expect(AtomMachine.select).type.not.toBeCallableWith(parent, States, "Idle") expect(AtomMachine.matches).type.not.toBeCallableWith(parent, States, "Idle") @@ -163,7 +174,9 @@ describe("AtomMachine", () => { const parent = null as unknown as Parent const root = AtomMachine.select(parent, "Ready") const region = AtomMachine.select(parent, "Ready.editor") + const regionSnapshot = AtomMachine.selectSnapshot(parent, "Ready.editor") const leaf = AtomMachine.select(parent, "Ready.editor.Editing") + const leafSnapshot = AtomMachine.selectSnapshot(parent, "Ready.editor.Editing") const matched = AtomMachine.matches(parent, "Ready.network.Online") const path = null as unknown as "Dormant" | "Ready.editor.Editing" const selectedUnion = AtomMachine.select(parent, path) @@ -171,7 +184,13 @@ describe("AtomMachine", () => { expect>().type.toBe>() expect>().type.toBe>() + expect>().type.toBe< + Option.Option> + >() expect>().type.toBe>() + expect>().type.toBe< + Option.Option> + >() expect>().type.toBe() expect>().type.toBe>() expect>().type.toBe() @@ -183,9 +202,13 @@ describe("AtomMachine", () => { const NestedChild = Machine.child("nested", null as unknown as NestedMachine) const child = null as unknown as AtomMachine.ChildOf const childSelected = AtomMachine.selectChild(child, "Ready.editor.Saving") + const childSelectedSnapshot = AtomMachine.selectSnapshotChild(child, "Ready.editor.Saving") const childMatched = AtomMachine.matchesChild(child, "Ready.network.Offline") expect>().type.toBe>() + expect>().type.toBe< + Option.Option> + >() expect>().type.toBe() expect>().type.toBe>() expect>().type.toBe>()