Skip to content

Commit bb179c8

Browse files
Add snapshot subtree selectors
1 parent d4c4bd2 commit bb179c8

11 files changed

Lines changed: 267 additions & 26 deletions

File tree

.changeset/wise-snapshots-query.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Allow state query helpers to inspect extracted snapshot subtrees, and add
6+
equality-aware `AtomMachine.selectSnapshot` and `selectSnapshotChild`
7+
combinators.

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,10 @@ Binding a shared runtime once is the canonical form for service-backed
204204
applications. Service-free machines can use `AtomMachine.make(Counter)`.
205205

206206
The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable
207-
`send` and `stop` atoms, and `child(descriptor)`. Use `AtomMachine.select` and
208-
`AtomMachine.matches` for typed, equality-aware derivations. React applications
209-
using `@effect/atom-react` need a `RegistryProvider`.
207+
`send` and `stop` atoms, and `child(descriptor)`. Use `AtomMachine.select`,
208+
`AtomMachine.selectSnapshot`, and `AtomMachine.matches` for typed,
209+
equality-aware derivations. React applications using `@effect/atom-react` need
210+
a `RegistryProvider`.
210211

211212
## Persistence
212213

docs/agent-guide.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ States.getSnapshot(snapshot, "Route.Ready")
357357
States.matches(snapshot, "Route.Ready.Saving")
358358
```
359359

360+
Snapshots returned by `getSnapshot` can be queried again with `get`,
361+
`getSnapshot`, or `matches`. Paths remain absolute and are restricted to the
362+
extracted snapshot and its descendants:
363+
364+
```ts
365+
const ready = Option.getOrThrow(States.getSnapshot(snapshot, "Route.Ready"))
366+
States.matches(ready, "Route.Ready.Saving")
367+
```
368+
360369
All paths are checked against the definition. `context.parent` is the immediate
361370
typed parent (`undefined` at a root). Use `parents` when another ancestor is
362371
needed:
@@ -642,11 +651,16 @@ the `DefinedStates` object:
642651

643652
```ts
644653
AtomMachine.select(machineAtom, "Ready")
654+
AtomMachine.selectSnapshot(machineAtom, "Ready")
645655
AtomMachine.matches(machineAtom, "Ready.Saving")
646656
AtomMachine.selectChild(childAtom, "Editing")
657+
AtomMachine.selectSnapshotChild(childAtom, "Editing")
647658
AtomMachine.matchesChild(childAtom, "Editing")
648659
```
649660

661+
`select` returns only the decoded state value. Use `selectSnapshot` when a
662+
component needs the selected node's compound or parallel child topology.
663+
650664
Like ordinary Effect Atom combinators, each selector call returns a derived
651665
atom. Define it at a stable composition boundary or memoize it when constructing
652666
it inside a component.

src/Machine.ts

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2341,14 +2341,26 @@ export declare namespace Machine {
23412341
readonly initial: InitialBuilder<States>
23422342

23432343
/**
2344-
* Returns the decoded value for an active state path.
2344+
* Returns the decoded value for an active state path. The supplied
2345+
* snapshot may be a complete root snapshot or a snapshot previously
2346+
* extracted from this definition. Extracted snapshots accept only their
2347+
* own absolute path and descendant paths.
23452348
*
23462349
* @since 0.4.0
23472350
*/
2348-
readonly get: <Path extends StateIdentifier<States>>(
2349-
snapshot: Snapshot<States>,
2350-
path: Path
2351-
) => Option.Option<StateByIdentifier<States, Path>>
2351+
readonly get: {
2352+
<Path extends StateIdentifier<States>>(
2353+
snapshot: Snapshot<States>,
2354+
path: Path
2355+
): Option.Option<StateByIdentifier<States, Path>>
2356+
<
2357+
const From extends StateIdentifier<States>,
2358+
const Path extends StateIdentifier<States>
2359+
>(
2360+
snapshot: SnapshotByIdentifier<States, From>,
2361+
path: Path & (Path extends NoInfer<From> | `${NoInfer<From>}.${string}` ? unknown : never)
2362+
): Option.Option<StateByIdentifier<States, Path>>
2363+
}
23522364

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

23682380
/**
2369-
* Returns the snapshot for an active state path.
2381+
* Returns the snapshot for an active state path. The supplied snapshot may
2382+
* be a complete root snapshot or a snapshot previously extracted from this
2383+
* definition. Extracted snapshots accept only their own absolute path and
2384+
* descendant paths.
23702385
*
23712386
* @since 0.4.0
23722387
*/
2373-
readonly getSnapshot: <Path extends StateIdentifier<States>>(
2374-
snapshot: Snapshot<States>,
2375-
path: Path
2376-
) => Option.Option<SnapshotByIdentifier<States, Path>>
2388+
readonly getSnapshot: {
2389+
<Path extends StateIdentifier<States>>(
2390+
snapshot: Snapshot<States>,
2391+
path: Path
2392+
): Option.Option<SnapshotByIdentifier<States, Path>>
2393+
<
2394+
const From extends StateIdentifier<States>,
2395+
const Path extends StateIdentifier<States>
2396+
>(
2397+
snapshot: SnapshotByIdentifier<States, From>,
2398+
path: Path & (Path extends NoInfer<From> | `${NoInfer<From>}.${string}` ? unknown : never)
2399+
): Option.Option<SnapshotByIdentifier<States, Path>>
2400+
}
23772401

23782402
/**
2379-
* Returns whether a state path is active in the snapshot.
2403+
* Returns whether a state path is active in the snapshot. The supplied
2404+
* snapshot may be a complete root snapshot or a snapshot previously
2405+
* extracted from this definition. Extracted snapshots accept only their
2406+
* own absolute path and descendant paths.
23802407
*
23812408
* @since 0.4.0
23822409
*/
2383-
readonly matches: <Path extends StateIdentifier<States>>(
2384-
snapshot: Snapshot<States>,
2385-
path: Path
2386-
) => boolean
2410+
readonly matches: {
2411+
<Path extends StateIdentifier<States>>(
2412+
snapshot: Snapshot<States>,
2413+
path: Path
2414+
): boolean
2415+
<
2416+
const From extends StateIdentifier<States>,
2417+
const Path extends StateIdentifier<States>
2418+
>(
2419+
snapshot: SnapshotByIdentifier<States, From>,
2420+
path: Path & (Path extends NoInfer<From> | `${NoInfer<From>}.${string}` ? unknown : never)
2421+
): boolean
2422+
}
23872423
}
23882424

23892425
/**

src/internal/machine/atom.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,9 +435,13 @@ type SnapshotValueByIdentifier<State, Path extends SnapshotIdentifier<State>> =
435435
Node extends { readonly path: Path; readonly value: infer Value } ? Value : never
436436
: never
437437

438+
type SnapshotByIdentifier<State, Path extends SnapshotIdentifier<State>> = SnapshotNode<State> extends infer Node ?
439+
Node extends { readonly path: Path } ? Node : never
440+
: never
441+
438442
type ChildState<Child extends Machine.ChildMachine.Any> = RefState<Machine.ChildMachine.Ref<Child>>
439443

440-
const selectSnapshot = <
444+
const selectValueByPath = <
441445
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
442446
Path extends SnapshotIdentifier<State>
443447
>(
@@ -448,6 +452,15 @@ const selectSnapshot = <
448452
Option.map((snapshot) => snapshot.value)
449453
) as Option.Option<SnapshotValueByIdentifier<State, Path>>
450454

455+
const selectSnapshotByPath = <
456+
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
457+
Path extends SnapshotIdentifier<State>
458+
>(
459+
snapshot: State,
460+
path: Path
461+
): Option.Option<SnapshotByIdentifier<State, Path>> =>
462+
Topology.getSnapshotByPath(snapshot, path) as Option.Option<SnapshotByIdentifier<State, Path>>
463+
451464
export const select = <
452465
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
453466
Event,
@@ -461,7 +474,24 @@ export const select = <
461474
): Atom.Atom<
462475
AsyncResult.AsyncResult<Option.Option<SnapshotValueByIdentifier<State, Path>>, StartError | Error>
463476
> =>
464-
Atom.mapResult(self.result, (snapshot) => selectSnapshot(snapshot, path)).pipe(
477+
Atom.mapResult(self.result, (snapshot) => selectValueByPath(snapshot, path)).pipe(
478+
Atom.withEquality(Equal.equals)
479+
)
480+
481+
export const selectSnapshot = <
482+
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
483+
Event,
484+
Error,
485+
Output,
486+
StartError,
487+
const Path extends SnapshotIdentifier<State>
488+
>(
489+
self: MachineAtom<State, Event, Error, Output, StartError>,
490+
path: Path
491+
): Atom.Atom<
492+
AsyncResult.AsyncResult<Option.Option<SnapshotByIdentifier<State, Path>>, StartError | Error>
493+
> =>
494+
Atom.mapResult(self.result, (snapshot) => selectSnapshotByPath(snapshot, path)).pipe(
465495
Atom.withEquality(Equal.equals)
466496
)
467497

@@ -480,7 +510,25 @@ export const selectChild = <
480510
> =>
481511
Atom.mapResult(
482512
self.result,
483-
Option.flatMap((snapshot) => selectSnapshot(snapshot, path))
513+
Option.flatMap((snapshot) => selectValueByPath(snapshot, path))
514+
).pipe(Atom.withEquality(Equal.equals))
515+
516+
export const selectSnapshotChild = <
517+
Child extends Machine.ChildMachine.Any,
518+
StartError,
519+
const Path extends SnapshotIdentifier<ChildState<Child>>
520+
>(
521+
self: ChildMachineAtom<Child, StartError>,
522+
path: Path
523+
): Atom.Atom<
524+
AsyncResult.AsyncResult<
525+
Option.Option<SnapshotByIdentifier<ChildState<Child>, Path>>,
526+
StartError | RefError<Machine.ChildMachine.Ref<Child>>
527+
>
528+
> =>
529+
Atom.mapResult(
530+
self.result,
531+
Option.flatMap((snapshot) => selectSnapshotByPath(snapshot, path))
484532
).pipe(Atom.withEquality(Equal.equals))
485533

486534
export const matches = <

src/internal/machine/machine.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -713,18 +713,21 @@ export const defineStates: DefineStates = (<const States extends Machine.StateSc
713713
return {
714714
states,
715715
initial: makeSnapshotBuilder(states, { mode: "initial", prefix: "" }) as Machine.InitialBuilder<States>,
716-
get: ((snapshot, path) =>
717-
Topology.getSnapshotByPath(snapshot, path).pipe(
718-
Option.map((snapshot) => snapshot.value)
719-
)) as Machine.DefinedStates<States>["get"],
716+
get:
717+
((snapshot: Machine.AtomicSnapshot<string, unknown>, path: string) =>
718+
Topology.getSnapshotByPath(snapshot, path).pipe(
719+
Option.map((snapshot) => snapshot.value)
720+
)) as Machine.DefinedStates<States>["get"],
720721
getWithParents: ((snapshot, path) => {
721722
const parents: Record<string, unknown> = {}
722723
return Topology.getSnapshotByPath(snapshot, path, parents).pipe(
723724
Option.map((snapshot) => ({ value: snapshot.value, parents }))
724725
)
725726
}) as Machine.DefinedStates<States>["getWithParents"],
726727
getSnapshot: Topology.getSnapshotByPath as unknown as Machine.DefinedStates<States>["getSnapshot"],
727-
matches: (snapshot, path) => Option.isSome(Topology.getSnapshotByPath(snapshot, path))
728+
matches:
729+
((snapshot: Machine.AtomicSnapshot<string, unknown>, path: string) =>
730+
Option.isSome(Topology.getSnapshotByPath(snapshot, path))) as Machine.DefinedStates<States>["matches"]
728731
}
729732
}) as DefineStates
730733

src/unstable/reactivity/AtomMachine.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ type SnapshotValueByIdentifier<State, Path extends SnapshotIdentifier<State>> =
295295
Node extends { readonly path: Path; readonly value: infer Value } ? Value : never
296296
: never
297297

298+
type SnapshotByIdentifier<State, Path extends SnapshotIdentifier<State>> = SnapshotNode<State> extends infer Node ?
299+
Node extends { readonly path: Path } ? Node : never
300+
: never
301+
298302
type ChildState<Child extends Machine.ChildMachine.Any> = RefState<Machine.ChildMachine.Ref<Child>>
299303

300304
/**
@@ -339,6 +343,27 @@ export const select: <
339343
AsyncResult.AsyncResult<Option.Option<SnapshotValueByIdentifier<State, Path>>, StartError | Error>
340344
> = internal.select
341345

346+
/**
347+
* Selects the typed logical snapshot for an active state path.
348+
*
349+
* Unlike {@link select}, the selected value retains its child snapshot
350+
* topology. The derived atom suppresses structurally equal updates. Keep the
351+
* returned atom stable when constructing it inside a component.
352+
*
353+
* @category combinators
354+
* @since 0.7.0
355+
*/
356+
export const selectSnapshot: <
357+
State extends Machine.Machine.AtomicSnapshot<string, unknown>,
358+
Event,
359+
Error,
360+
Output,
361+
StartError,
362+
const Path extends SnapshotIdentifier<State>
363+
>(self: MachineAtom<State, Event, Error, Output, StartError>, path: Path) => Atom.Atom<
364+
AsyncResult.AsyncResult<Option.Option<SnapshotByIdentifier<State, Path>>, StartError | Error>
365+
> = internal.selectSnapshot
366+
342367
/**
343368
* Selects the typed value for an active state path in an invoked child.
344369
*
@@ -367,6 +392,28 @@ export const selectChild: <
367392
>
368393
> = internal.selectChild
369394

395+
/**
396+
* Selects the typed logical snapshot for an active state path in an invoked
397+
* child.
398+
*
399+
* An inactive child or state path produces `Option.none()`. Unlike
400+
* {@link selectChild}, the selected value retains its child snapshot topology.
401+
* The derived atom suppresses structurally equal updates.
402+
*
403+
* @category combinators
404+
* @since 0.7.0
405+
*/
406+
export const selectSnapshotChild: <
407+
Child extends Machine.ChildMachine.Any,
408+
StartError,
409+
const Path extends SnapshotIdentifier<ChildState<Child>>
410+
>(self: ChildMachineAtom<Child, StartError>, path: Path) => Atom.Atom<
411+
AsyncResult.AsyncResult<
412+
Option.Option<SnapshotByIdentifier<ChildState<Child>, Path>>,
413+
StartError | RefError<Machine.ChildMachine.Ref<Child>>
414+
>
415+
> = internal.selectSnapshotChild
416+
370417
/**
371418
* Returns whether a state path is active.
372419
*

test/machine/Machine.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,24 @@ describe("Machine", () => {
426426
)
427427
assert.strictEqual(states.matches(snapshot, "fulfillment.shipping"), true)
428428
assert.strictEqual(states.matches(snapshot, "fulfillment.shipping.quoted"), false)
429+
430+
const fulfillmentSnapshot = Option.getOrThrow(states.getSnapshot(snapshot, "fulfillment"))
431+
assert.deepStrictEqual(states.get(fulfillmentSnapshot, "fulfillment.inventory"), Option.some(inventory))
432+
assert.deepStrictEqual(
433+
states.getSnapshot(fulfillmentSnapshot, "fulfillment.shipping"),
434+
Option.some(fulfillmentSnapshot.states.shipping)
435+
)
436+
assert.strictEqual(states.matches(fulfillmentSnapshot, "fulfillment.inventory.checking"), true)
437+
assert.strictEqual(states.matches(fulfillmentSnapshot, "fulfillment.inventory.reserved"), false)
438+
439+
const inventorySnapshot = Option.getOrThrow(
440+
states.getSnapshot(fulfillmentSnapshot, "fulfillment.inventory")
441+
)
442+
assert.deepStrictEqual(states.get(inventorySnapshot, "fulfillment.inventory"), Option.some(inventory))
443+
assert.deepStrictEqual(
444+
states.get(inventorySnapshot, "fulfillment.inventory.checking"),
445+
Option.some(checking)
446+
)
429447
})
430448

431449
it.effect("initial builder constructs compound initial snapshots", () =>

0 commit comments

Comments
 (0)