[data-reveal]{opacity:1!important;transform:none!important}
Queries
Interface reference

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:

FieldTypeDescription
idstringThe template id, e.g. count or aggregate. This is AddQueryInput.template.
resourceType"database-table"The kind of resource the template reads.
titlestringA human-readable title.
descriptionstringWhat the template computes.
outputQueryOutputKindThe shape the query's route responds with.
paramsConfigSchemaThe template's parameter schema, in the same vocabulary as the block catalog.

QueryOutputKind says what the route responds with:

KindResponseTemplates
"scalar"{ value } — one numbercount, 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:

OptionTypeDescription
namestringThe query name, a camelCase identifier. Required. It names the route method, and the model method too.
resourceResourceRefThe resource the query reads. Required, and it must exist.
templatestringA QueryTemplateDescriptor.id. Required.
paramsRecord<string, QueryParamValue>The template's parameters.
endpointstringThe route path relative to the page, served at /dms/pages<pageRef><endpoint>. Defaults to /stats/<kebab-name>.
responseQueryResponseShapeHow the answer is arranged for the block reading it. Defaults to what the calculation computes.
comparebooleanAlso 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:

  • name must be a camelCase identifier, and must not collide with a member the page or the model already has. A collision fails with duplicate_name in the query scope.
  • endpoint must 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.

FieldTypeDescription
fieldstringA field of the resource. It must exist and must not be opaque.
opFilterOpThe comparison.
valueQueryParamValueA 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
FieldTypeDescription
$param.namestringA 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 with invalid_config rather 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 }] }
OptionTypeDescription
op"count" | "sum" | "avg" | "min" | "max"What to measure in each group. Defaults to count.
fieldstringThe numeric field the measure reads. Required for every op but count.
groupBystringThe 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.
timezonestringIANA 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.
limitnumberKeep only the first N points after ordering — a top N. At most 1000.
whereQueryFilter[]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 orderBy gives a timeline: the group, ascending.
  • Omitting timezone buckets 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.

ShapeAnswerRead by
series{ series } — the pointsA 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 entriesA 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.
  • compare needs a period the route supplies. It re-asks the same model method with the compareFrom / compareTo bounds 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.

FieldTypeDescription
namestringThe query name. Always present.
endpointstringThe route path relative to the page's slug, as AddQueryInput.endpoint takes it. Always present.
resourceResourceRefThe resource the route's model parameter points at.
modelMethodstringThe 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.
outputQueryOutputKindThe output shape, read from the property the route's response carries — so a query whose chain no longer parses still reports what it answers.
templatestringThe template the chain was compiled from.
paramsRecord<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.
opaquebooleantrue 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 recognized return { 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