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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/wise-snapshots-query.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
66 changes: 51 additions & 15 deletions src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2341,14 +2341,26 @@ export declare namespace Machine {
readonly initial: InitialBuilder<States>

/**
* 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: <Path extends StateIdentifier<States>>(
snapshot: Snapshot<States>,
path: Path
) => Option.Option<StateByIdentifier<States, Path>>
readonly get: {
<Path extends StateIdentifier<States>>(
snapshot: Snapshot<States>,
path: Path
): Option.Option<StateByIdentifier<States, Path>>
<
const From extends StateIdentifier<States>,
const Path extends StateIdentifier<States>
>(
snapshot: SnapshotByIdentifier<States, From>,
path: Path & (Path extends NoInfer<From> | `${NoInfer<From>}.${string}` ? unknown : never)
): Option.Option<StateByIdentifier<States, Path>>
}

/**
* Returns the decoded value for an active state path together with all of
Expand All @@ -2366,24 +2378,48 @@ export declare namespace Machine {
) => Option.Option<StateWithParents<States, Path>>

/**
* 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: <Path extends StateIdentifier<States>>(
snapshot: Snapshot<States>,
path: Path
) => Option.Option<SnapshotByIdentifier<States, Path>>
readonly getSnapshot: {
<Path extends StateIdentifier<States>>(
snapshot: Snapshot<States>,
path: Path
): Option.Option<SnapshotByIdentifier<States, Path>>
<
const From extends StateIdentifier<States>,
const Path extends StateIdentifier<States>
>(
snapshot: SnapshotByIdentifier<States, From>,
path: Path & (Path extends NoInfer<From> | `${NoInfer<From>}.${string}` ? unknown : never)
): Option.Option<SnapshotByIdentifier<States, Path>>
}

/**
* 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: <Path extends StateIdentifier<States>>(
snapshot: Snapshot<States>,
path: Path
) => boolean
readonly matches: {
<Path extends StateIdentifier<States>>(
snapshot: Snapshot<States>,
path: Path
): boolean
<
const From extends StateIdentifier<States>,
const Path extends StateIdentifier<States>
>(
snapshot: SnapshotByIdentifier<States, From>,
path: Path & (Path extends NoInfer<From> | `${NoInfer<From>}.${string}` ? unknown : never)
): boolean
}
}

/**
Expand Down
54 changes: 51 additions & 3 deletions src/internal/machine/atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,9 +435,13 @@ type SnapshotValueByIdentifier<State, Path extends SnapshotIdentifier<State>> =
Node extends { readonly path: Path; readonly value: infer Value } ? Value : never
: never

type SnapshotByIdentifier<State, Path extends SnapshotIdentifier<State>> = SnapshotNode<State> extends infer Node ?
Node extends { readonly path: Path } ? Node : never
: never

type ChildState<Child extends Machine.ChildMachine.Any> = RefState<Machine.ChildMachine.Ref<Child>>

const selectSnapshot = <
const selectValueByPath = <
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
Path extends SnapshotIdentifier<State>
>(
Expand All @@ -448,6 +452,15 @@ const selectSnapshot = <
Option.map((snapshot) => snapshot.value)
) as Option.Option<SnapshotValueByIdentifier<State, Path>>

const selectSnapshotByPath = <
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
Path extends SnapshotIdentifier<State>
>(
snapshot: State,
path: Path
): Option.Option<SnapshotByIdentifier<State, Path>> =>
Topology.getSnapshotByPath(snapshot, path) as Option.Option<SnapshotByIdentifier<State, Path>>

export const select = <
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
Event,
Expand All @@ -461,7 +474,24 @@ export const select = <
): Atom.Atom<
AsyncResult.AsyncResult<Option.Option<SnapshotValueByIdentifier<State, Path>>, 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<string, unknown>,
Event,
Error,
Output,
StartError,
const Path extends SnapshotIdentifier<State>
>(
self: MachineAtom<State, Event, Error, Output, StartError>,
path: Path
): Atom.Atom<
AsyncResult.AsyncResult<Option.Option<SnapshotByIdentifier<State, Path>>, StartError | Error>
> =>
Atom.mapResult(self.result, (snapshot) => selectSnapshotByPath(snapshot, path)).pipe(
Atom.withEquality(Equal.equals)
)

Expand All @@ -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<ChildState<Child>>
>(
self: ChildMachineAtom<Child, StartError>,
path: Path
): Atom.Atom<
AsyncResult.AsyncResult<
Option.Option<SnapshotByIdentifier<ChildState<Child>, Path>>,
StartError | RefError<Machine.ChildMachine.Ref<Child>>
>
> =>
Atom.mapResult(
self.result,
Option.flatMap((snapshot) => selectSnapshotByPath(snapshot, path))
).pipe(Atom.withEquality(Equal.equals))

export const matches = <
Expand Down
13 changes: 8 additions & 5 deletions src/internal/machine/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,18 +713,21 @@ export const defineStates: DefineStates = (<const States extends Machine.StateSc
return {
states,
initial: makeSnapshotBuilder(states, { mode: "initial", prefix: "" }) as Machine.InitialBuilder<States>,
get: ((snapshot, path) =>
Topology.getSnapshotByPath(snapshot, path).pipe(
Option.map((snapshot) => snapshot.value)
)) as Machine.DefinedStates<States>["get"],
get:
((snapshot: Machine.AtomicSnapshot<string, unknown>, path: string) =>
Topology.getSnapshotByPath(snapshot, path).pipe(
Option.map((snapshot) => snapshot.value)
)) as Machine.DefinedStates<States>["get"],
getWithParents: ((snapshot, path) => {
const parents: Record<string, unknown> = {}
return Topology.getSnapshotByPath(snapshot, path, parents).pipe(
Option.map((snapshot) => ({ value: snapshot.value, parents }))
)
}) as Machine.DefinedStates<States>["getWithParents"],
getSnapshot: Topology.getSnapshotByPath as unknown as Machine.DefinedStates<States>["getSnapshot"],
matches: (snapshot, path) => Option.isSome(Topology.getSnapshotByPath(snapshot, path))
matches:
((snapshot: Machine.AtomicSnapshot<string, unknown>, path: string) =>
Option.isSome(Topology.getSnapshotByPath(snapshot, path))) as Machine.DefinedStates<States>["matches"]
}
}) as DefineStates

Expand Down
47 changes: 47 additions & 0 deletions src/unstable/reactivity/AtomMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ type SnapshotValueByIdentifier<State, Path extends SnapshotIdentifier<State>> =
Node extends { readonly path: Path; readonly value: infer Value } ? Value : never
: never

type SnapshotByIdentifier<State, Path extends SnapshotIdentifier<State>> = SnapshotNode<State> extends infer Node ?
Node extends { readonly path: Path } ? Node : never
: never

type ChildState<Child extends Machine.ChildMachine.Any> = RefState<Machine.ChildMachine.Ref<Child>>

/**
Expand Down Expand Up @@ -339,6 +343,27 @@ export const select: <
AsyncResult.AsyncResult<Option.Option<SnapshotValueByIdentifier<State, Path>>, 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<string, unknown>,
Event,
Error,
Output,
StartError,
const Path extends SnapshotIdentifier<State>
>(self: MachineAtom<State, Event, Error, Output, StartError>, path: Path) => Atom.Atom<
AsyncResult.AsyncResult<Option.Option<SnapshotByIdentifier<State, Path>>, StartError | Error>
> = internal.selectSnapshot

/**
* Selects the typed value for an active state path in an invoked child.
*
Expand Down Expand Up @@ -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<ChildState<Child>>
>(self: ChildMachineAtom<Child, StartError>, path: Path) => Atom.Atom<
AsyncResult.AsyncResult<
Option.Option<SnapshotByIdentifier<ChildState<Child>, Path>>,
StartError | RefError<Machine.ChildMachine.Ref<Child>>
>
> = internal.selectSnapshotChild

/**
* Returns whether a state path is active.
*
Expand Down
18 changes: 18 additions & 0 deletions test/machine/Machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () =>
Expand Down
Loading