Escape

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.

report.ts
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.

two definitions, one payload
{ "data": "$.orders[*]" }
the sibling definition
{ "data": "$.customers[*]" }
report.ts
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.

a constant and a declaration
{
  "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.

report.ts
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.

report.ts
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.

report.ts
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.