Queries
Count rows without writing a route
A query gives a page data to display: a count, a total or a series of points. With an existing /books page and a book resource, this example creates a route that counts the books:
import { AddQuery } from "@antelopejs/interface-dms-builder";
const result = await AddQuery("/books", {
name: "bookCount",
resource: "book",
template: "count",
});
if (result.ok) {
console.log(result.data.route); // "/dms/pages/books/stats/book-count"
} else {
console.error(result.error);
}
After the backend reloads, a signed-in user with access to /books can request that URL through the frontend. It returns { "value": N }, where N is the number of rows. The operation writes a model method and a page route; it does not add a visible block. Use the returned route as the block's fetchUrl.
Choose an unused query name. For an editor that must preview before saving, use PreviewQuery and page drafts instead of calling AddQuery on each change.
Query reference
The route is guarded with the page's own permission, as a parameter decorator on that route alone: whoever may open the page may read what it derives, and no other route on the controller is affected.
A query is addressed by a QueryRef, lives on a page, and reads from a resource. Templates decide what a query can compute; you pick one by id, fill in its parameters, and the builder emits the chain.
Queries read back structurally: GetPageStructure reports the queries it finds on a page in PageStructure.queries, whether the builder wrote them or a human did.
Address a Query
QueryRef is <pageRef>@<name>, e.g. /books@inStockCount. The page part says where the route lives; the resource the query reads is recorded in the query itself, not in its ref.
import type { QueryRef } from "@antelopejs/interface-dms-builder";
const query: QueryRef = "/books@inStockCount";
List the Templates
ListQueryTemplates(resourceType?) returns the templates currently registered. It is a plain read, and the registry is live, so call it rather than hard-coding ids.
import { ListQueryTemplates } from "@antelopejs/interface-dms-builder";
const templates = await ListQueryTemplates("database-table");
for (const template of templates) {
console.log(template.id, template.title, template.output);
}
QueryTemplateDescriptor:
| Field | Type | Description |
|---|---|---|
id | string | The template id, e.g. count or aggregate. This is AddQueryInput.template. |
resourceType | "database-table" | The kind of resource the template reads. |
title | string | A human-readable title. |
description | string | What the template computes. |
output | QueryOutputKind | The shape the query's route responds with. |
params | ConfigSchema | The template's parameter schema, in the same vocabulary as the block catalog. |
QueryOutputKind says what the route responds with:
| Kind | Response | Templates |
|---|---|---|
"scalar" | { value } — one number | count, aggregate |
"series" | { series } — one point per group, each { x, y } | series |
A series point carries its group as x and what was measured as y. A bucketed
group is a number that sorts — 2026, 202601, 20260114 — rather than a label:
the group is also what the points are ordered by, and a label belongs to a
response that knows the reader's locale.
Read params the way you read a block's config schema, described in Catalog — it tells you which keys AddQueryInput.params accepts, and a key the template does not declare fails with invalid_config.
Add a Query
import { AddQuery } from "@antelopejs/interface-dms-builder";
const result = await AddQuery("/books", {
name: "inStockCount",
resource: "book",
template: "count",
params: {
where: [{ field: "stock", op: "gt", value: 0 }],
},
});
if (result.ok) {
console.log(result.data.query); // "/books@inStockCount"
console.log(result.data.route); // "/dms/pages/books/stats/in-stock-count"
}
AddQueryInput:
| Option | Type | Description |
|---|---|---|
name | string | The query name, a camelCase identifier. Required. It names the route method, and the model method too. |
resource | ResourceRef | The resource the query reads. Required, and it must exist. |
template | string | A QueryTemplateDescriptor.id. Required. |
params | Record<string, QueryParamValue> | The template's parameters. |
endpoint | string | The route path relative to the page, served at /dms/pages<pageRef><endpoint>. Defaults to /stats/<kebab-name>. |
response | QueryResponseShape | How the answer is arranged for the block reading it. Defaults to what the calculation computes. |
compare | boolean | Also answer the preceding period, so a card can show a variation. |
The result carries both addresses: query is the QueryRef to configure or remove it by, route is the URL a block fetches from: the endpoint under the page, at /dms/pages<pageRef><endpoint> (pageRoutePath(pageRef, endpoint) from @antelopejs/interface-dms/page). The DMS serves the routes of a page controller under its own /dms/pages prefix, since the page's own path is the page.
A few rules the operation enforces:
namemust be a camelCase identifier, and must not collide with a member the page or the model already has. A collision fails withduplicate_namein thequeryscope.endpointmust start with/, and must not collide with another route on the page. Any path is allowed otherwise, because read-back identifies a query by its shape rather than by its URL.- When a model method of that name already exists compiling a different chain, the model method is emitted under a free suffixed name, so the queries already sharing the original keep their behavior.
Filter the Rows
QueryFilter is one conjunct of a template's where. Filters combine with AND.
| Field | Type | Description |
|---|---|---|
field | string | A field of the resource. It must exist and must not be opaque. |
op | FilterOp | The comparison. |
value | QueryParamValue | A plain scalar baked into the chain, or a QueryParamBinding. |
FilterOp is "eq" | "ne" | "gt" | "ge" | "lt" | "le". The ordering operators (gt, ge, lt, le) are rejected on boolean and select fields.
QueryParamValue is unknown, and a query's values are data: an ExprValue anywhere in a query's params is refused with invalid_config, since a raw expression would put uncheckable code inside a model body. The other sentinels are described in Value Encodings.
import { AddQuery } from "@antelopejs/interface-dms-builder";
await AddQuery("/books", {
name: "revenue",
resource: "book",
template: "aggregate",
params: {
op: "sum",
field: "price",
where: [{ field: "archived", op: "eq", value: false }],
},
});
Bind a Value at Request Time
QueryParamBinding replaces a baked-in filter value with one supplied per request. It becomes a parameter of the model method and a declared parameter on the route.
import { AddQuery } from "@antelopejs/interface-dms-builder";
await AddQuery("/books", {
name: "cheaperThan",
resource: "book",
template: "count",
params: {
where: [
{ field: "price", op: "lt", value: { $param: { name: "maxPrice" } } },
],
},
});
// GET /dms/pages/books/stats/cheaper-than?maxPrice=20
| Field | Type | Description |
|---|---|---|
$param.name | string | A camelCase identifier. The route exposes the value under this name. |
$param.in | "query" | "param" | Where the route reads the value from. Defaults to "query". |
name and in describe the route, not the model: the route reads the value from that source, coerces it, and passes it positionally to the model method, whose own parameter is named from the template.
Two rules to plan for:
in: "param"reads a path segment, so the endpoint must declare a matching:name. The default/stats/…endpoint has none, so a path-bound value forces you to supply an endpoint that does. A mismatch fails withinvalid_configrather than emitting a route whose value never binds.- Two filters may bind the same parameter name, but only when they agree on its type and source.
import { AddQuery } from "@antelopejs/interface-dms-builder";
await AddQuery("/books", {
name: "countByAuthor",
resource: "book",
template: "count",
endpoint: "/stats/by-author/:authorId",
params: {
where: [
{
field: "authorId",
op: "eq",
value: { $param: { name: "authorId", in: "param" } },
},
],
},
});
Measure per Group
The series template answers one point per group rather than one number, which is what a chart, a ranking or any other multi-point block reads. Group by a field's own value, or by a period of a date field.
import { AddQuery } from "@antelopejs/interface-dms-builder";
await AddQuery("/dashboard", {
name: "monthlyRevenue",
resource: "order",
template: "series",
params: {
op: "sum",
field: "amount",
groupBy: "createdAt",
bucket: "month",
timezone: "Europe/Brussels",
where: [{ field: "status", op: "eq", value: "paid" }],
},
});
// GET /dms/pages/dashboard/stats/monthly-revenue
// { series: [{ x: 202601, y: 1250 }, { x: 202602, y: 980 }] }
| Option | Type | Description |
|---|---|---|
op | "count" | "sum" | "avg" | "min" | "max" | What to measure in each group. Defaults to count. |
field | string | The numeric field the measure reads. Required for every op but count. |
groupBy | string | The field the rows are grouped by. Required. |
bucket | "day" | "month" | "quarter" | "year" | Groups a date field by period instead of by exact value. Only for a date field. |
timezone | string | IANA zone the period is computed in. |
orderBy | "group" | "measure" | Order the points by their group (a timeline) or by what was measured (a ranking). Defaults to group. |
direction | "asc" | "desc" | Defaults to asc. |
limit | number | Keep only the first N points after ordering — a top N. At most 1000. |
where | QueryFilter[] | As for every other template. |
Three rules worth planning for:
- A series is always ordered. Databases return groups in no particular order — MongoDB scrambles them — so an ordering is emitted whether or not you ask for one. Omitting
orderBygives a timeline: the group, ascending. - Omitting
timezonebuckets in UTC. A row near a period boundary may belong to a different bucket in the reader's local time. Pass the zone the reader uses for reporting. - A top N is an ordering plus a limit, not a separate template:
orderBy: "measure",direction: "desc",limit: 5.
There is no week bucket. Neither database adapter exposes an ISO week, and deriving one from the day of the year puts the first days of January in the previous year's week — a wrong week being worse than a missing one.
A series reads back like any other query: GetPageStructure reports its template and the parameters that produced it, including the ordering the engine applies when you omit one. Editing and removing it work as they do for a scalar query — the model method goes with the route.
A chain edited past what the grammar accepts is the exception, and the rule is the same as everywhere else: it reads as opaque, keeps its endpoint, resource, modelMethod and output, and the builder will list and remove it but never rewrite it.
Arrange the Answer
One calculation feeds several kinds of block, so response says how its answer is dressed rather than making you describe it more than once.
| Shape | Answer | Read by |
|---|---|---|
series | { series } — the points | A bare chart |
card | { value, series, previousValue?, delta?, comparisonSeries? } | A chart card |
value | { value, previousValue?, delta?, sparkline? } | A KPI card |
items | { items } — the points as ranked entries | A ranked list |
The arrangements that need more than a property name are built by helpers @antelopejs/interface-dms/base publishes, which the generated route imports. That keeps the route to one expression, and is why a query whose answer is arranged still reads back as the parameters that produced it.
Two rules worth planning for:
- The headline figure is taken from the points. Counts and sums add up, a min or max is the min or max of the group figures — but an average is the average of the group averages, which is not the average over all the rows whenever the groups are uneven.
compareneeds a period the route supplies. It re-asks the same model method with thecompareFrom/compareTobounds the request carried. A calculation whose dates are baked in has nothing to shift, so it is not compared and no second call is emitted.
import { AddQuery } from "@antelopejs/interface-dms-builder";
await AddQuery("/dashboard", {
name: "revenue",
resource: "order",
template: "series",
params: {
op: "sum",
field: "amount",
groupBy: "createdAt",
bucket: "month",
where: [
{ field: "createdAt", op: "ge", value: { $param: { name: "from" } } },
{ field: "createdAt", op: "le", value: { $param: { name: "to" } } },
],
},
response: "card",
compare: true,
});
// GET /dms/pages/dashboard/stats/revenue?from=…&to=…&compareFrom=…&compareTo=…
// { value, series, previousValue, delta, comparisonSeries }
Configure a Query
ConfigureQuery(query, patch, opts?) recompiles the query from the merged patch and replaces both emitted members. Fields you leave out keep their current value, read back off disk.
import { ConfigureQuery } from "@antelopejs/interface-dms-builder";
await ConfigureQuery("/books@inStockCount", {
params: { where: [{ field: "stock", op: "ge", value: 5 }] },
});
The patch is a Partial<AddQueryInput>, so resource, template, params and endpoint are all patchable. The query keeps the name in its QueryRef: a name in the patch is not applied.
A query that no longer reads back as an editable template fails with opaque_target. That is deliberate — an opaque chain means a human owns it now, and regenerating it from a template it no longer matches would revert their edit. Remove it and add a new one if you need to.
Remove a Query
RemoveQuery(query, opts?) deletes the query's route from the page, and drops the model method it called once nothing else calls it — a method shared with another query stays. It works on an opaque query too.
import { RemoveQuery } from "@antelopejs/interface-dms-builder";
await RemoveQuery("/books@cheaperThan");
Read a Query Back
QueryStructure is a query as found on disk. Read-back is purely structural: a route reads as a query when its shape and the chain it calls both parse, so a hand-written route matching the grammar is as editable as a generated one, and any edit that keeps the chain in-grammar is simply re-read.
| Field | Type | Description |
|---|---|---|
name | string | The query name. Always present. |
endpoint | string | The route path relative to the page's slug, as AddQueryInput.endpoint takes it. Always present. |
resource | ResourceRef | The resource the route's model parameter points at. |
modelMethod | string | The model method the route calls. Sharing is driven by the query's name: a query looks for its own name and numbered forks of it, so two differently-named queries never share a method however identical their chains. |
output | QueryOutputKind | The output shape, read from the property the route's response carries — so a query whose chain no longer parses still reports what it answers. |
template | string | The template the chain was compiled from. |
params | Record<string, unknown> | The parameters that produced the chain. |
routeParams | { name: string; in: "query" | "param" | "header" }[] | The route's declared parameters — the source of truth for a bound value's name and in. header appears only on hand-written routes. |
opaque | boolean | true when the query could not be reversed to an editable template. |
opaqueReason | "unparseable_route" | "unparseable_chain" | Why it is opaque. Set when opaque === true. |
The two opaque reasons say which half failed:
unparseable_route— the GET wrapper is not the recognizedreturn { value: await model.m(...) }shape.unparseable_chain— the model method it calls is not a chain any registered template accepts: hand-written, edited out of grammar, or emitted by a newer builder.
A query that no longer parses still reports its endpoint, resource and modelMethod; only template and params go missing. The builder can therefore always see and remove what it cannot edit.
import { GetPageStructure } from "@antelopejs/interface-dms-builder";
import { pageRoutePath } from "@antelopejs/interface-dms/page";
const result = await GetPageStructure("/books");
if (result.ok) {
const editable = result.data.queries.filter((query) => !query.opaque);
const urls = result.data.queries.map((query) => pageRoutePath("/books", query.endpoint));
}
Next Steps
- Value Encodings - Write configuration values that are not plain literals