Skip to content

Query

A query is a question a client asks across keys: “an account’s ledger, a page at a time”, “withdrawals over 1,000 across all accounts, largest first”. A view answers questions about one key. A projection writes the store that can answer the others. A query serves that store.

Like a command, a query is a message. It declares its input and its result, the middleware that guards it, and the rejections it may return. Its Layer is one handler: it checks who is asking, then reads the store. An Api gives clients a typed method for it.

Account a-1MoneyWithdrawnLedgerprojectionledger tableD1AccountLedgerquery · handlerClienttyped from the Api
AggregateQueryProjectionClient
The projection writes the store. The query reads it for a client, after judging who is asking.

The bank’s ledger is a table the Ledger projection writes. AccountLedger reads one account’s rows from it, a page at a time.

packages/domain/src/Ledger/AccountLedger.query.ts
export class AccountLedger extends Domain.Query("AccountLedger", {
input: { account: AccountId },
success: LedgerRow,
paged: true,
waitsFor: [Ledger],
middleware: [Session],
rejects: [NotOwner],
}) {}
  • input holds the query’s fields, like a command’s. Filters, sort orders and scopes are fields too.
  • success is the result. With paged: true it is one entry of a page, and the result is { entries, cursor }. Leave paged out for a query with one result.
  • waitsFor names the projections to wait on when a caller asks to read its own write. See Read your own write.
  • middleware and rejects work as they do for commands and views. The handler fails with a rejection to refuse a caller.

LedgerRow is a schema, so the rows are checked and encoded on the way to the client:

packages/domain/src/Ledger/LedgerStore.ts
export const LedgerRow = Schema.Struct({
id: Schema.String,
accountId: Schema.String,
kind: Schema.String,
amount: Cents,
balanceAfter: Cents,
at: Schema.Number,
});

Add the query to the model’s queries, like a projection.

packages/domain/src/Bank.model.ts
projections: [Ledger, AccountCards],
streams: [AccountCard],
sagas: [ExecuteTransfer],
queries: [AccountLedger],

A query’s last argument is its annotations. Its description is the MCP tool’s description, the CLI subcommand’s help, and the operation’s description in the OpenAPI document.

packages/domain/src/Ledger/AccountLedger.query.ts
export class AccountLedger extends Domain.Query(
"AccountLedger",
{
input: { account: AccountId },
success: LedgerRow,
paged: true,
waitsFor: [Ledger],
middleware: [Session],
rejects: [NotOwner],
},
{ description: "One account's ledger, a page at a time." },
) {}

The handler lives in a Layer, built once with everything it needs: the store, and the AccountSummary view it checks the owner in. It receives the input and, for a paged query, the page asked for: after, the cursor of the previous page, and take, already capped by the Layer’s take.max.

packages/domain/src/Ledger/AccountLedger.query.ts
export const AccountLedgerLive = AccountLedger.toLayer(
Effect.gen(function* () {
const store = yield* LedgerStore;
const summaries = yield* AccountSummary;
return Effect.fn(function* ({ account }, page) {
yield* Effect.annotateCurrentSpan("bank.account", account);
const summary = yield* summaries.get(Account.ref(account)).pipe(Effect.orDie);
yield* mustOwn(Option.getOrNull(summary)?.customerId ?? null);
return yield* store.forAccount(account, page);
});
}),
{ take: { default: 50, max: 200 } },
);

The options tune how it runs, so they sit on the Layer, not the declaration. take limits page sizes: 50 rows when the client doesn’t ask, and never more than 200, whatever it asks for (50 and 500 by default). The handler records what of the input reaches a trace with Effect.annotateCurrentSpan, on the query’s own span; nothing else does.

It reads through the same LedgerStore port the projection writes through, so it sits next to the declaration in the domain package. The server provides the port with D1, and a story with memory. Merge the Layer into the model’s handlers, with the projections and policies.

The handler may also read what the query’s middleware provides, such as the current actor, to scope its store query by the caller. It may require RuntimeContext, as a database binding does.

An Api lists the queries it serves next to its commands and reads. Left out, queries serves every query of the model.

packages/domain/src/CustomerApi.api.ts
export class CustomerApi extends Domain.Api("CustomerApi", {
model: Bank,
commands: ["RegisterCustomer", "DepositMoney", "WithdrawMoney"],
views: ["AccountSummary"],
queries: ["AccountLedger"],
}) {}

Its client calls the query as a method. Most calls are plain reads, a screen loading or the next page, and get what the store holds now:

const page = yield* bank.AccountLedger({ account: accountId }, { take: 20 });

The result is decoded from the success schema. The error type lists the query’s rejections, its middleware’s errors, ConsistencyTimeout when it has waitsFor, and RpcClientError, so Effect.catchTag handles each.

A Foldkit app asks a query through a Command from @alchemy.run/domain/Foldkit. See Integrate with Foldkit: Ask a query.

List the query’s name in expose to publish it beyond the typed client: as QUERY /AccountLedger with toHttpApi, as bank account-ledger --account a-1 with toCli, or as a read-only tool with toToolkit.

Paging is by cursor. The handler returns cursor: null on the last page; otherwise the client passes the cursor back as after.

const first = yield* bank.AccountLedger({ account: accountId }, { take: 20 });
if (first.cursor) {
const next = yield* bank.AccountLedger({ account: accountId }, { after: first.cursor, take: 20 });
}

The cursor means nothing to the framework: the store chooses it. Page on the store’s sort key (keyset paging), so a row written between two pages is neither skipped nor repeated. The bank’s ledger sorts by time, then id:

packages/server/src/Ledger/D1LedgerStore.ts
`SELECT id, account_id AS accountId, kind, amount, balance_after AS balanceAfter, at FROM ledger WHERE account_id = ?${
from ? " AND (at > ? OR (at = ? AND id > ?))" : ""
} ORDER BY at, id LIMIT ?`,

Filters and sort orders are fields of the input. A query states exactly what a client may ask, so the store can index it and the handler can judge it. To let a client choose the order, declare the orders it may choose from:

input: { account: AccountId, sort: Schema.Literals(["newest", "largest"]) },

Keyset paging works under any order, as long as the order is total: sort by the requested field, then by id to break ties, and put both values in the cursor. For “largest first”, the store reads the next page after the last row’s amount and id:

SELECT ... FROM ledger
WHERE account_id = ? AND (amount < ? OR (amount = ? AND id < ?))
ORDER BY amount DESC, id DESC LIMIT ?

Each order needs its own index (here on account_id, amount, id). That is why an order is one of a few declared values, not a column name the client sends.

A cursor only means something under the order that produced it, so make it carry the order, and give it a schema rather than joining values with a separator: ids such as Account/a-1:3 may contain any separator. Encode the cursor as base64url JSON, so the client sees an opaque string:

const LargestFirst = Schema.Struct({ order: Schema.Literal("largest"), amount: Cents, id: Schema.String });
const LargestFirstCursor = Schema.StringFromBase64Url.pipe(Schema.decodeTo(Schema.fromJsonString(LargestFirst)));
// the last row of a page → the next page's `after`
const cursorOf = (row: LedgerRow) =>
Schema.encodeSync(LargestFirstCursor)({ order: "largest", amount: row.amount, id: row.id });
// `after` → where the next page starts: None for a cursor from another order, or not a cursor at all
const parseCursor = Schema.decodeUnknownOption(LargestFirstCursor);

When parseCursor returns None, fail with a declared rejection (InvalidCursor) rather than return a wrong page. A client that changes the order starts again from the first page.

Decide who may ask, and which rows they see

Section titled “Decide who may ask, and which rows they see”

The handler decides both. It checks the caller first: here, whether the caller owns the account or is an agent, through the same mustOwn rule the account’s views use. It fails with a rejection the query declares, which reaches the client typed.

It reads who is asking from what the middleware provides, and anything else it needs (a view’s client, a permissions store) from what its Layer resolved. When a caller passes atLeast, the query waits for its write before the handler runs.

It then scopes its store query by the caller, so a page holds only rows the caller may see, and comes back full:

return Effect.fn(function* ({ min }, page) {
const actor = yield* CurrentActor;
return yield* store.largeWithdrawals({ min, branchId: actor.branchId }, page);
});

Like a view, a query is a security boundary: to show another audience other rows, declare another query.

A client never reads an aggregate directly. A query can: its handler resolves the aggregate’s client and reads an instance’s state as of its latest commit, so it needs no atLeast. The handler checks the caller, and returns only what success declares:

packages/domain/src/Account/AccountBalance.query.ts
export class AccountBalance extends Domain.Query("AccountBalance", {
input: { account: AccountId },
success: Schema.Struct({ balance: Cents, frozen: Schema.Boolean }),
middleware: [Session],
rejects: [NotOwner, NotOpen],
}) {}
export const AccountBalanceLive = AccountBalance.toLayer(
Effect.gen(function* () {
const accounts = yield* Account;
return Effect.fn(function* ({ account }) {
const state = yield* accounts.get(Account.ref(account));
if (state._tag !== "Open") return yield* new NotOpen();
yield* mustOwn(state.customerId);
return { balance: state.balance, frozen: state.frozen !== null };
});
}),
);

For its history, or to follow it live, read a view of it instead.

A projection writes its store after the command commits, so a plain read can be a moment behind. Right after its own command, a client passes the command’s receipt as atLeast, and the query waits until that write is in the store before it reads:

const receipt = yield* bank.WithdrawMoney(accountId, { amount: 30 }, mfa);
// receipt: { stream: "Account/a-1", version: 7 }
const page = yield* bank.AccountLedger({ account: accountId }, { atLeast: receipt });

atLeast is optional, and only for this moment. A read with no write behind it, a screen loading or the next page, leaves it out.

waitsFor tells the query which projections to wait on. The caller never names them, nor any key: it passes the receipt. The server reads that command’s events back from its aggregate and routes each through every projection in waitsFor, the same way the projection routes them when it handles them, to find the keys to wait on. Here, WithdrawMoney on a-1 recorded MoneyWithdrawn at seq 7, and Ledger is keyed by account, so the query waits until Ledger has handled seq 7 for a-1.

The projections can be keyed differently. Each finds its own keys in the same events:

export class BranchTotals extends Domain.Projection("BranchTotals", {
from: [Account],
key: BranchId,
keyOf: { MoneyDeposited: (e) => e.branchId },
on: [MoneyDeposited],
}) {}
export class BranchActivity extends Domain.Query("BranchActivity", {
input: { branch: BranchId },
success: ActivityRow,
paged: true,
waitsFor: [Ledger, BranchTotals],
}) {}
const receipt = yield* bank.DepositMoney(accountId, { amount: 100, branchId: "b-9" });
const page = yield* bank.BranchActivity({ branch: "b-9" }, { atLeast: receipt });

The deposit’s one event, MoneyDeposited { branchId: "b-9" }, lands on Ledger key a-1 and on BranchTotals key b-9, and the query waits for both. A projection that doesn’t consume the receipt’s aggregate has nothing to wait for.

waitsFor takes views and streams too, for a handler that reads them. A view is waited on for the keys the command’s events reach, and a stream through the projection that writes it:

export class AccountOverview extends Domain.Query("AccountOverview", {
input: { account: AccountId },
success: Overview,
waitsFor: [AccountSummary, AccountCard],
middleware: [Session],
rejects: [NotOwner],
}) {}

The handler then reads summaries.get(ref) and cards.peek(ref) plainly: the engine already waited. A view waits only for an aggregate it reads directly, and a stream only for the aggregates its writer reads.

After commands to two aggregate instances, pass both receipts. The query waits for each, through every projection in waitsFor:

const out = yield* bank.WithdrawMoney(checking, { amount: 30 }, mfa);
const into = yield* bank.DepositMoney(savings, { amount: 30 });
const total = yield* bank.CombinedBalance({ accounts: [checking, savings] }, { atLeast: [out, into] });

A wait longer than 10 seconds fails with ConsistencyTimeout, typed, so the client can retry or show that the data is behind.

Only the stores of the projections in waitsFor are covered. Without waitsFor, atLeast waits for nothing, and data the handler reads from anywhere else may still be behind. After two commands to the same aggregate instance, the later receipt is enough. A command a saga or a policy sends (here, the debit of a transfer) is not the client’s own write, so it has no receipt to pass: poll for it, or watch a view.

A story asks a query with the context Story.provide set, as it sends commands: the handler runs, and the result goes through its schema as a client would get it. Give the story a store, with the other handlers.

packages/domain/test/Bank.story.test.ts
it.effect("the ledger is as private as the account: agents read it, other customers can't", () =>
story(
given(a1, opened(), new MoneyDeposited({ amount: 10_000, balanceAfter: 10_000 })),
as(agent("support-7")),
query(AccountLedger, { account: a1.id }, [{ kind: "MoneyDeposited", amount: 10_000 }]),
as(alex),
query(AccountLedger, { account: a1.id }),
rejected(new NotOwner()),
),
);

Expect the rows in order, each a partial match; a partial result, for a query with one result; or a function over the result, to check a cursor. Without an expectation, the query must be rejected, and the next step asserts how. Projections run to the end after every when, so a story needs no atLeast.

To check the middleware and atLeast too, ask it through the Api’s clientInProcess. See Testing an Api.

A query runs where its Api runs: on Cloudflare, in the Worker, like a view’s get. It has no key, so no Durable Object owns it, and the store it reads is bound to the Worker. Its handler reads views from their Durable Objects, and atLeast reads checkpoints from the projection’s.

Each run is a Domain.query AccountLedger span with its outcome, the rows it returned, and what the handler annotated. See Observability.

Next, Policy reacts to events by acting on the world.