Chuyển tới nội dung chính

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:

domain/src/query.rs
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"),
}
}
SectionPurpose
fieldsThe client-facing schema. identifier marks the key used by .item/{id}.
bindingMaps logical field names to physical columns of todo_items. Unlisted fields map to columns of the same name.
default_orderSort applied when the client sends none.
scopeWhether the routes take a URL scope segment (none here).
errorsStable codes for list and item failures.
policyThe 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.

Why the todo resource is public

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:

domain/src/query.rs
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​

domain/src/query.rs
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 pathBelongs on the command path
Listing, filtering, sortingCreating, updating, deleting authoritative state
Reports and aggregatesEmitting activities and outbox messages
Metadata for forms and gridsEnforcing aggregate invariants

If an operation changes persisted state, it is a command.

Checklist​

  • query_resource! with fields, a binding, error codes, and a policy
  • query_engine! registers the resource and any reports
  • Command and query engines share one store
  • .list and .item return the rows created in chapter 3

Next: 5. Custom endpoints.