Guide

Query.fn

Construct reusable parameterized queries without hiding their read requirements.

Query.fn defines a reusable function that constructs synchronous queries. Ordinary arguments can shape the returned value while the function retains the state-read requirements inferred from its generator body.

Use it when several query instances share one topology-dependent calculation. The arguments describe how to ask; active facets still supply the answer.

Turn a repeated read into vocabulary.

A reusable query can accept ordinary arguments without hiding what it reads. Each call specializes the answer while Motive preserves the shared requirements.

1 · Repeat the read

Two answers duplicate one query shape.

The compact and detailed projections read the same three facets and differ only in formatting. Both are correct, but every new presentation repeats the topology-dependent part of the calculation.

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,
  warehouse: Schema.String,
  initialItems: Schema.Array(Schema.String),
});

const compactStatusQuery = Query.gen(function* () {
  yield* PackingList;
  yield* Packing;
  const { items } = yield* Collecting;
  return `${String(items.length)} left`;
});

const detailedStatusQuery = Query.gen(function* () {
  const { orderId } = yield* PackingList;
  const { warehouse } = yield* Packing;
  const { items } = yield* Collecting;
  return `${orderId} · ${warehouse} · ${String(items.length)} items remaining`;
});

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

export class Packing extends State.Compound<Packing>()("Packing", {
  warehouse: 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 Packing({ warehouse: input.warehouse }),
    new Collecting({ items: input.initialItems }),
  ],
});

const perspective = PackingListChart.at(Collecting);
export const compactStatus = perspective.select(compactStatusQuery);
export const detailedStatus = perspective.select(detailedStatusQuery);

2 · Name the boundary

Parameterize the answer, not the reads.

Query.fn accepts a StatusFormat argument and returns a query for that format. Both calls retain the same inferred strict reads, produce the same answers as before, and become absent at the same perspective boundary.

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,
  warehouse: Schema.String,
  initialItems: Schema.Array(Schema.String),
});

export const packingStatus = Query.fn(function* (format: StatusFormat) {
  const { orderId } = yield* PackingList;
  const { warehouse } = yield* Packing;
  const { items } = yield* Collecting;

  return format === "compact"
    ? `${String(items.length)} left`
    : `${orderId} · ${warehouse} · ${String(items.length)} items remaining`;
});

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

export class Packing extends State.Compound<Packing>()("Packing", {
  warehouse: 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") {}

type StatusFormat = "compact" | "detailed";

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

const perspective = PackingListChart.at(Collecting);
export const compactStatus = perspective.select(packingStatus("compact"));
export const detailedStatus = perspective.select(packingStatus("detailed"));

Duplication can hide the shared query

The first frame defines compact and detailed status queries separately. Both read PackingList, Packing, and Collecting; only the returned presentation differs. The duplicated generators make those identical requirements harder to recognize and easier to drift.

Two concrete Query.gen values are not wrong. They are simply two computations. The repetition is the signal that this domain has one reusable query with an authoring-time choice.

Query.fn constructs queries

The second frame moves the generator body into packingStatus. Calling packingStatus("compact") or packingStatus("detailed") returns an ordinary Query that can be placed, selected, composed, or yielded like any other query.

The StatusFormat argument is a normal TypeScript value. It is available when the query is constructed and remains fixed for that query instance. It is not stored in the chart, decoded from an event, or read from a facet.

Requirements belong to the function too

Query.fn records the generator's strict, optional, and event requirements on the reusable QueryFunction type. Every query returned by a call carries those same requirements into its placement.

Here both formats require PackingList + Packing + Collecting, so both selections are valid at the Collecting perspective and both become Absent after FinishPacking. Parameterizing the return value does not widen the perspective or weaken a read.

This is the important difference from hiding Query.gen behind an untyped callback boundary: the query function itself remains legible to Motive's placement checker and to APIs that accept a QueryFunction directly.

Query.fn is not Effect.fn

The similar names describe boundaries in different computational models. Query.fn constructs a pure, synchronous read of one statechart step or snapshot. It has no Effect error channel, service requirements, retry policy, interruption, or tracing span.

Arguments that arrive from an untrusted boundary must be decoded before constructing the query. Query.fn preserves their TypeScript contract; it does not replace Schema at an input boundary.

Later, transition and action sites can contextually supply arguments such as the accepted event or source state to a query function. Those callback queries deserve their own treatment because the placement site—not an ordinary caller—owns the argument shape.

Use Query.gen for one concrete multi-read computation. Use Query.fn when a reusable callable boundary is part of the domain vocabulary.

Test the function

  1. Add a "warehouse" format and return only the strict Packing.warehouse value.
  2. Add Query.option(Ready) to the function and inspect how the optional requirement appears on every returned query.
  3. Move either selection to the Packing perspective and verify that the strict Collecting read is still rejected.