4. Queries
Queries are the read path. You declare resources with query_resource!, optional reports
with report_resource!, and register both on a query_engine!. Riverbase then serves .list,
.item, .meta, and .rept routes. None of them can change aggregate state.
A query resource
A resource describes the public fields, default sort, the physical table binding, error codes, and the row policy:
riverbase_core::query_resource! {
TodoQueryResource name "todo" {
meta {
title: "Todo",
description: "Todo items read model.",
default_order: [created.desc, id.desc],
scope: none,
}
fields {
field id { preset: Uuid, label: "Todo ID", identifier }
field title { preset: String, label: "Title" }
field done { preset: Boolean, label: "Done" }
field notes { preset: String, label: "Notes" }
field created { preset: Datetime, label: "Created" }
field version { preset: Integer, label: "Version", no_sort }
}
binding source "todo_items" {
id => "_id",
created => "_created",
}
errors {
list: ("TDO-002", "Failed to list todo items."),
item: ("TDO-003", "Failed to fetch todo item by id."),
}
policy: public("internal catalog listing; no tenant row filter", "SEC-01-2026-08-18"),
}
}
| Section | Purpose |
|---|---|
fields | The client-facing schema. identifier marks the key used by .item/{id}. |
binding | Maps logical field names to physical columns of todo_items. Unlisted fields map to columns of the same name. |
default_order | Sort applied when the client sends none. |
scope | Whether the routes take a URL scope segment (none here). |
errors | Stable codes for list and item failures. |
policy | The row policy. Every resource must declare one; see chapter 7. |
Presets (Uuid, String, Boolean, Integer, Datetime, …) drive validation, the OpenAPI
schema, SQL lowering, and default sortability. Scalar presets are sortable by default; Json,
Array, Textsearch, and None are not. Override per field with sortable or no_sort. A
non-empty meta { order_fields: [...] } replaces those flags with an explicit allowlist. Sort
coverage is checked when the engine spawns, so a bad sort field fails at startup, not at request
time.
The todo service has no tenants yet, so it declares policy: public(reason, sign-off). The macro
refuses public without both strings. A real multi-tenant resource uses policy: required with a
scope_policy block.
A report
Reports are parameterized, read-only queries with their own handler:
riverbase_core::report_resource! {
TodoSummaryReportResource name "todo_summary" {
meta {
title: "Todo Summary",
description: "Todo completion report.",
default_order: [created.desc],
scope: none,
}
filters {
field done { preset: Boolean, label: "Done" }
field created { preset: Datetime, label: "Created" }
}
params {
param from_date { type: Date, label: "From date", required }
param to_date { type: Date, label: "To date" }
}
binding source "todo_items" {
id => "_id",
created => "_created",
}
errors {
report: ("TDO-010", "Failed to run todo summary report."),
}
policy: public("internal catalog listing; no tenant row filter", "SEC-01-2026-08-18"),
handler (_self, _ctx, session, _request, query) {
use riverbase_core::query::ReportOutput;
let (rows, total) = _self
.executor
.query_list_with_total::<serde_json::Value>(query)
.await
.map_err(|e| riverbase_core::base::StorageError::with(
"TDO-010", "Failed to run todo summary report.", e.to_string(),
))?;
session
.emit_activity("report.todo_summary", serde_json::json!({ "rows": rows.len(), "total": total }))
.await?;
Ok(ReportOutput::Rows { rows, total })
}
}
}
The handler can compute and aggregate, and it may emit an audit activity. It must not write authoritative or projection state.
The query engine
riverbase_core::query_engine! {
engine TodoQueryEngine {
meta {
actor_pool_size: 4,
}
component: {},
}
resources {
TodoQueryResource,
}
reports {
TodoSummaryReportResource,
}
}
The pool size is the number of concurrent reads, enforced by a Tokio semaphore
(query_pool_size; actor_pool_size is accepted as an alias). Spawn the engine in domain! with
the same store the command engine uses:
let query = Arc::new(TodoQueryEngine::spawn(ctx.clone(), store.clone(), runtime.logstore.clone()).await?);
Call it over HTTP
# List, newest first by default
curl -s "http://127.0.0.1:8080/api/riverbase.todo/todo.list"
# Filter with a URL-encoded `query` ({"done":false}); sort and page with plain parameters
curl -s "http://127.0.0.1:8080/api/riverbase.todo/todo.list?query=%7B%22done%22%3Afalse%7D&sort=created.asc&limit=10&page=1"
# One row
curl -s "http://127.0.0.1:8080/api/riverbase.todo/todo.item/{uuid}"
# Field, label and sort metadata for UI builders
curl -s "http://127.0.0.1:8080/api/riverbase.todo/todo.meta"
# A report: POST, with parameters and paging in the payload
curl -s -X POST "http://127.0.0.1:8080/api/riverbase.todo/todo_summary.rept" \
-H "content-type: application/json" \
-d '{"payload":{"params":{"from_date":"2026-01-01"},"limit":50,"page":1}}'
List routes take limit, page, sort (created.desc,title.asc), include/exclude field lists,
and a query object whose keys are field.operator statements:
{ "done": false, "title.has": "milk", ".or": [{ "notes.has": "2%" }, { "notes.has": "oat" }] }
The engine validates every statement against the declared fields and presets, lowers it to SQL
through the binding, and ANDs the server policy filter in front of it. A client can never widen what
the policy allows. Page size is capped by query_max_limit (default 500). The full wire format is
in Queries.
Read vs write
| Belongs on the query path | Belongs on the command path |
|---|---|
| Listing, filtering, sorting | Creating, updating, deleting authoritative state |
| Reports and aggregates | Emitting activities and outbox messages |
| Metadata for forms and grids | Enforcing aggregate invariants |
If an operation changes persisted state, it is a command.
Checklist
-
query_resource!with fields, a binding, error codes, and apolicy -
query_engine!registers the resource and any reports - Command and query engines share one store
-
.listand.itemreturn the rows created in chapter 3
Next: 5. Custom endpoints.