Supplying the data
Intent
quario renders a payload you have already resolved. Your application fetches,
joins and reshapes, then passes one object to render. The engine opens no
connection, holds no driver, and knows no credential.
That division is the whole integration story, and it’s a design position rather than an unfinished one.
Design decisions
quario never opens a socket. The engine reads the object you pass and walks it. There is no database driver, no HTTP client, and no connection string anywhere in the render path. A report therefore runs wherever your application already runs, and a security review has one less surface to examine. Trust and CSP describes the rest of that boundary.
One payload holds as many collections as you like. data is a JSONPath
into the object you passed, so $.orders[*] and $.customers[*] are two
record sets in one payload. A reader who assumes one input means one table
writes one report per query and never needs to.
Parameters travel two ways, and choosing between them is yours.
Scopes defines both anchors. A reporting period
belongs in $.input, because it changes per render and arrives with the data.
A region or a threshold that the data doesn’t carry belongs in
$.params. Declared there, it makes a render that forgets
it fail before any expression runs, instead of printing a blank.
Resolving the payload is yours, and it finishes before the call. render
returns a promise because a target may work asynchronously, not because the
engine fetches anything. The one place the engine waits for your code is an
asynchronous registered function. That
fills a gap in a row rather than selecting the rows.
A definition stays portable because it names no source. It carries a JSONPath into a shape, not a query against a system. The same file renders in a web request, a scheduled job, a test and a developer’s laptop, against whatever produced the shape that time.
API walkthrough
Handing render one object
render(target, data) takes the payload as its second argument. Everything
the report reads comes from there, and the engine copies nothing onto it.
const orders = await listOrders({ from: '2026-07-01', to: '2026-09-30' })
const report = q.report(definition)
const output = await report.render(target, { orders })$.input is that object, untouched. Scopes
covers what each anchor reads once it’s in.
Selecting from more than one collection
data narrows the payload to the rows one report walks. Two definitions
over the same object select different sets, so a single fetch can serve a
statement and a customer list.
{ "data": "$.orders[*]" }{ "data": "$.customers[*]" }const payload = {
orders: await listOrders({ from: '2026-07-01', to: '2026-09-30' }),
customers: await listCustomers(),
}
const statement = q.report(definition)
const directory = q.report(customerList)
const [a, b] = await Promise.all([
statement.render(target, payload),
directory.render(target, payload),
])A band outside the detail still reads the whole payload through $.input, so a
header can count the customers a row-level report never walks.
Declaring what a report needs
params on the document names the values a report reads as $.params. A
bare value is the parameter’s value, and it travels with the file. A plain
object is a declaration, closed at type, default and label.
{
"data": "$.orders[*]",
"params": {
"threshold": 1000,
"region": { "type": "string", "label": "Region" }
}
}A declaration without a default makes the parameter required. The engine refuses a render
that supplies no value for it, before any expression runs. type names a JSON
kind: string, number, boolean, array or object. A null default
beside a type makes the parameter optional, and a render may then supply
null.
render(target, data, params) supplies the values, and stream takes them
in the same place. A render may supply only a key the document names, and a
supplied value answers to the declared type where there is one. A bare value
has no type, so a render may replace it with any JSON value. Values stay JSON,
frozen for the one render.
const output = await report.render(target, data, { region: 'EU' })report.params lists the declarations in document order, each { name, required, type?, default?, label? }. A label marks a parameter a person
fills in, so a host draws its form from the labelled ones. The engine never
renders a label.
Put the reporting period in the payload and the threshold in params. A
definition that hard-coded the period would need one copy per period. A
threshold in params keeps its value in the document. A caller that changes it
says so in the render call, not in the payload.
An object value needs wrapping. The engine reads any plain object under
params as a declaration, so a constant whose value is an object goes under
default, as in { "config": { "default": { "retries": 3 } } }. An empty
object, { "config": {} }, declares a required parameter with no type.
Combining two sources
Fetch in parallel, join in your own code, and pass the result. The engine sees one shape and never learns there were two systems behind it.
const period = { from: '2026-07-01', to: '2026-09-30' }
const [orders, customers] = await Promise.all([listOrders(period), listCustomers()])
const byId = new Map(customers.map((c) => [c.id, c]))
const rows = orders.map((o) => ({ ...o, customer: byId.get(o.customerId)?.name ?? null }))
const report = q.report(definition)
const output = await report.render(target, { orders: rows })Reshape here rather than in the definition. An expression can read a nested field, but it can’t join two collections. A report that tried would state a relationship your database already knows.
Fetching again when a parameter changes
A parameter changes on your side, so the round trip is yours too. Resolve the new data, then render the same compiled report again.
const report = q.report(definition)
const run = async (period: { from: string; to: string }) => report.render(target, { orders: await listOrders(period) })
const q3 = await run({ from: '2026-07-01', to: '2026-09-30' })
const q4 = await run({ from: '2026-10-01', to: '2026-12-31' })Compile once and render per payload. report() is the expensive call and
render() the cheap one, so a long-running service holds the compiled report
and gives it fresh data.
What the boundary buys
Your dependency tree holds no drivers, and the rendering path holds no credentials. There is nothing to allow through a firewall, because nothing dials out. A definition reads the same in production and in a test, because the shape is the only contract between them.