Guide

Queries

Derive pure answers while preserving checked state-read requirements.

A Query is a synchronous, pure description of an answer and the active state it depends on. It does not copy facets into a second store. Its read requirements remain attached to the query so Motive can check them against the assembled topology.

Begin with the callback-shaped combinators. They keep one- and two-state projections short; the generator form belongs on the next page, when several dependent reads need a more linear shape.

Ask the active configuration a question.

A query derives an answer from active facets without taking ownership of those facts. Its dependencies stay visible to the type system and the assembled chart.

1 · Project one facet

Start with a callback.

Query.map reads Collecting and gives its facet to a pure callback. The result is an item count whose dependency on Collecting remains part of the query.

import { Query, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const FinishPacking = Schema.TaggedStruct("FinishPacking", {});

const PackingListInput = Schema.Struct({
  orderId: Schema.String,
  initialItems: Schema.Array(Schema.String),
});

export class PackingList extends State.Compound<PackingList>()("PackingList", {
  orderId: Schema.String,
}) {
  static states = States.make(() => [Collecting, Ready]);
}

export class Collecting extends State.Atomic<Collecting>()("Collecting", {
  items: Schema.Array(Schema.String),
}) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(FinishPacking, Ready),
  ]);
}

class Ready extends State.Atomic<Ready>()("Ready") {}

export const PackingListChart = Statechart.make(PackingList, {
  input: PackingListInput,
  init: ({ input }) => [
    new PackingList({ orderId: input.orderId }),
    new Collecting({ items: input.initialItems }),
  ],
});

export const itemCount = PackingListChart.at(Collecting).select(
  Query.map(Collecting, ({ items }) => items.length),
);

2 · Combine active facts

Let the topology prove both reads.

Query.zipWith reads PackingList and Collecting together. The Collecting perspective proves that the child and its root ancestor are coactive.

import { Query, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const FinishPacking = Schema.TaggedStruct("FinishPacking", {});

const PackingListInput = Schema.Struct({
  orderId: Schema.String,
  initialItems: Schema.Array(Schema.String),
});

export class PackingList extends State.Compound<PackingList>()("PackingList", {
  orderId: Schema.String,
}) {
  static states = States.make(() => [Collecting, Ready]);
}

export class Collecting extends State.Atomic<Collecting>()("Collecting", {
  items: Schema.Array(Schema.String),
}) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(FinishPacking, Ready),
  ]);
}

class Ready extends State.Atomic<Ready>()("Ready") {}

export const PackingListChart = Statechart.make(PackingList, {
  input: PackingListInput,
  init: ({ input }) => [
    new PackingList({ orderId: input.orderId }),
    new Collecting({ items: input.initialItems }),
  ],
});

export const packingSummary = PackingListChart.at(Collecting).select(
  Query.zipWith(
    PackingList,
    Collecting,
    ({ orderId }, { items }) => `${orderId} · ${String(items.length)} items`,
  ),
);

Queries describe reads

Query.map(Collecting, callback) names one strict read. When the query runs, the callback receives the current Collecting facet and returns an ordinary value—in this case, the number of items.

The callback is synchronous and pure. It does not fetch the state, run an Effect, or subscribe to changes. The query is a reusable value that describes how to derive an answer when its required state is available.

A perspective proves presence

PackingListChart.at(Collecting) establishes the configuration in which the query is meaningful. Calling .select(query) attaches the derived answer to that perspective. While Collecting is active, the selection evaluates to Some(answer); after FinishPacking exits the state, it evaluates to None.

This absence belongs to the perspective, not to the callback result. The callback receives a real Collecting facet or does not run at all—it never receives a fabricated partial state.

Combine facts that are coactive

The second frame uses Query.zipWith to derive one summary from the root PackingList facet and the child Collecting facet. The Collecting perspective proves both reads: an active child always has its root ancestor active with it.

Changing one read to a sibling state would not be justified by that perspective. Motive carries the requirements through the query and rejects an impossible strict read when the chart is assembled.

Read hierarchy as hierarchy

Configuration is not a bag of state-path strings. A Compound state has exactly one active immediate executable child, while a Parallel state has one active child in every region. Queries can preserve those different shapes:

const phase = yield * Query.substate(Checkout);
const regions = yield * Query.substates(Fulfilling);

Query.substate(Checkout) returns the exact child union declared by Checkout.states, so phase._tag is exhaustive. Query.substates(Fulfilling) returns a region-keyed object whose values retain each region's exact child union. Adding a child or region changes the result type instead of hiding new topology behind an unchecked string.

Use Query.option(...) around either instruction when the containing state is not proven active. Optionality describes whether the owner is present; Compound versus Parallel still determines the shape once it is.

Queries preserve ownership

A query does not widen a facet's lifetime. The summary can mention orderId and items together while both owners are active, but it disappears when Collecting exits even though the root order id remains.

That is the central distinction: facets own facts; queries derive answers. When a derivation needs several reads, branching, or intermediate values, Query.gen gives the same requirements a linear authoring form.

Test the read

  1. Change the Query.map callback to return the first item instead of the count.
  2. Add another field to PackingList and include it in the Query.zipWith summary.
  3. Replace Collecting with Ready in the strict read and inspect the topology error at assembly.