Validating definitions
Intent
A definition lives outside your type system. Nothing tells you it broke until someone renders it, which in practice means a customer opening an invoice. This is how to compile one on purpose, read what came back, and hold the result for as long as your service runs.
Design decisions
plan is one traversal read three ways. A host that validates and renders
in a loop, such as an editor, would otherwise descend the document twice.
plan returns the compiled report, the structured problems, and the anchors
each compiled source reads, all from a single descent.
report throws on the first problem. It’s the call for a definition you
already trust. When you want every problem at once, ask plan or validate
instead of catching in a loop.
validate flattens the same problems into strings. Its output is exactly
each problem’s message. That makes it the right call for a build script that
prints and exits. It’s the wrong one for anything that needs the location as
data.
A problem keeps its own offsets. Every problem carries its own diagnostic where an engine authenticated the fault, not merely the first. An editor underlining the offending characters needs all of them.
Unknown keys come before the problems beneath them. A mistyped key has no
position in the documented shape to slot into. The engine therefore reports it
ahead of the known keys it sits among. Every schema object with fixed keys is a
closed set, which is why visable is an error rather than an ignored field.
isDiagnostic answers for the delegated engines only. It’s true for a
located diagnostic raised by the expression, template or query engines, and
authenticated by identity rather than by shape. An error that merely looks like
one doesn’t pass.
quario’s own verdicts carry no code. Examples are an unknown reducer, a
fold handed something it can’t reduce, and image bytes the engine won’t vouch
for. These are quario’s judgements about your document, and they carry structured path
and source instead of an engine’s error code. Branch on those.
No guard separates a verdict from a registered function’s throw. The two carry identical locations, so the engine can’t answer the question and declines to pretend otherwise. A host that needs the distinction throws its own error class from its own functions.
A warning isn’t a problem at lower severity. A warning names a declaration the engine will quietly drop, reported after every compile has already succeeded. A document carrying only warnings compiles and renders exactly as before, and a host that ignores the field notices nothing.
The JSON Schema proves shapes. plan proves the document. quario ships
a JSON Schema of the report document, generated from the same declarations the
engine’s types come from. It checks each value where it stands, which is what
an editor or a model can use before the engine sees anything. A rule that joins
two values, or a string that has to compile, is beyond it. plan stays the
authority on whether a definition renders.
Compiling is the expensive call. Every expression in the document parses into a closure at compile time. Rendering walks data through closures that already exist, which is why the compiled report is the thing worth holding.
API walkthrough
Checking a definition before it renders
q.plan(schema, funcs?) returns { report, problems, anchors, readPaths, uses, warnings, params }. report
is the compiled report, or null while the document has problems. params
lists the parameters the document declares, the same list report.params
holds. It’s there whether or not the document compiled, so an editor can draw
a parameter form beside the problems. anchors maps each compiled source’s
schema path to the anchors and handles it reads, so a tool can tell whether a
node may move. A placement is safe only where every anchor that node reads binds
there.
plan.readPaths maps the same schema paths to the member paths each
source reads, such as ["@", "qty"] or ["$", "input", "customer"]. Check a
sample or a feed against it before you render. A missing field reads as
null, so a render alone never shows the mismatch. Only @ and $.input
read your data. A path ends in null where the source reads on through a key
the definition doesn’t name, such as @.tags[0] or an #each item. The
compiled report carries the same map as report.readPaths.
const { readPaths } = q.plan(definition)
readPaths['detail.columns[0].value'] // [['@', 'qty'], ['@', 'unitPrice']]plan.warnings lists declarations nothing will read, in document order,
each { path, message }. They carry no source and no diagnostic, because
nothing raised. There are four today. One is a format on a run that never renders a
single value. Another is a currency on a cell not formatted as one. The third
is a table whose fixed widths total under 100, and the fourth a split whose
slot widths leave some of their share unclaimed. The list is deliberately
incomplete, since it sees the definition alone, without data. validate()
doesn’t change, and returns problems only.
validate(schema, funcs?) returns the problems alone, as strings, in
document order. It’s a root export rather than an instance method, because
checking a document’s shape needs no instance configuration.
q.report(schema, funcs?) compiles and throws on the first problem.
import { validate } from 'quario'
const problems = validate(definition, {})
for (const message of problems) console.error(message)
process.exitCode = problems.length ? 1 : 0Run this over the definitions you ship, in the same job that runs your tests. It needs no data and no render, so it costs a second and catches the class of breakage that otherwise waits for a customer.
Checking against the JSON Schema
quario/schema.json is the JSON Schema of the report document, in the
2020-12 dialect. Point an editor at it with a top-level $schema string. The
engine checks that the value is a string and reads nothing else, so the key
costs a render nothing. Only the top level takes it. Anywhere else, $schema
is an unknown key. No hosted URL exists, so name the installed file.
{
"$schema": "./node_modules/quario/lib/schema.json",
"data": "$.invoice.lines[*]",
"detail": { "columns": [{ "header": "Item", "value": "{{ @.item }}" }] }
}A document can pass the JSON Schema and still fail plan, for four reasons:
- A rule joins two or more values. Width shares that total more than 100, a
group name that isn’t unique, and a
resetwithout itsbreakare all examples. - The document uses a reserved or blocked name.
- A string has to compile, as an expression, a template, a
datapath or a reducer does. - A
requiredname isn’t one a target reads.
With a model, give it the file as a tool’s input schema, and pass what it
returns to plan before anything renders.
import schema from 'quario/schema.json' with { type: 'json' }
const tool = { name: 'draft_report', input_schema: schema }
// What the model sends back is still untrusted.
const { report, problems } = q.plan(definition)This works in a tool’s ordinary input schema, in editors, and with Gemini, which doesn’t enforce the patterns. The strict structured-output modes of OpenAI and Anthropic don’t accept the file. OpenAI’s strict mode requires every key, and a report document leaves most keys out. The strict mode of Anthropic allows 24 optional keys in one request, and the report document has more than 290.
Requiring a declaration a target might withdraw
Every withdrawal is silent by design. A target that can’t honour a declaration drops it rather than failing. That’s exactly what lets one definition serve four document shapes, and it’s also how a document quietly loses something it needed.
"required" at the report root names the declarations this document can’t
do without, as { "required": { "uppercase": true } }. Nothing reads it at
render time: no target sees the marking, and no output changes because of it.
q.plan(schema, funcs?, { targets }) checks it. targets
takes the capabilities descriptors the published targets export beside their
factories. Every required declaration one of them withdraws or leaves unread
becomes a problem of its own at required.<name>. That nulls the report the
way any other problem does. An approximated declaration isn’t one.
Approximation is the target doing its best with what you asked for.
REQUIRABLE exports the set a document may mark. A name outside that set
is a definition error, because a marking nothing could check would go quiet
exactly when you needed it. The paper is in the set. A landscape statement
marking page.orientation gets a problem from plan when one of the named
targets is CSV, which withdraws the paper.
import { capabilities as htmlCapabilities } from '@quario/html'
import { capabilities as xlsxCapabilities } from '@quario/xlsx'
const plan = q.plan(definition, {}, { targets: [htmlCapabilities, xlsxCapabilities] })
for (const problem of plan.problems) console.error(`${problem.path}: ${problem.message}`)
if (!plan.report) process.exitCode = 1Passing no descriptors checks nothing, so this is additive throughout: a build that doesn’t name its targets behaves as it did before.
plan.uses shows every fate, not only the required ones. It maps each
name a descriptor answers for to the schema paths where the document writes it.
Join it with a descriptor to list what a target withdraws, leaves unread,
or approximates. It needs no option, adds no problem, and is there while the
document has problems.
import { capabilities as csvCapabilities } from '@quario/csv'
const { uses } = q.plan(definition)
for (const [name, paths] of Object.entries(uses)) {
const fate = csvCapabilities.declarations[name]
if (fate !== 'resolved') console.log(name, fate, paths)
}
// uppercase withdrawn ['detail.columns[0].style.uppercase']Reading a problem
problem.path is the schema path the fault sits at, such as
detail.columns[3].value. It tells you which node to open.
problem.source is the author source behind it, present when the problem
came from compiling one.
problem.message is the whole located sentence, and it’s what validate
returns for that problem.
problem.diagnostic is the engine’s own located error, present when an
engine authenticated the fault. Its offsets are zero-based into the source
shown in brackets, with end exclusive.
const { report, problems, warnings } = q.plan(definition, {})
for (const problem of problems) {
console.error(`${problem.path}: ${problem.message}`)
if (problem.source) console.error(` in ${problem.source}`)
}
for (const warning of warnings) console.warn(`${warning.path}: ${warning.message}`)
if (!report) process.exitCode = 1The engine freezes problems, so a reporting pass can’t change what it’s describing.
Decide your own severity. This site’s own build fails on a warning, because a documented declaration that does nothing is documentation that’s wrong.
Telling an engine’s fault from your own
isDiagnostic(e) is true for a located diagnostic and nothing else. What
it buys you is trust in the code and the offsets on that error. They’re an
engine’s, not a lookalike’s.
code, start and end come from whichever engine decided the fault.
Expression errors preserve the expression engine’s, and template errors use
absolute offsets into the displayed template. A traversal budget exceeded at
render time carries limit and actual instead of offsets.
A registered function’s throw keeps its class behind the path it failed at.
isDiagnostic is false for it, and so is any test for quario’s own verdicts.
Your own error class is the way to mark your own failures. It must be the class that carries the marker. The engine rebuilds a located copy from the constructor and the message, so a field you set on the instance doesn’t survive.
error.cause carries the throw as it happened. Because that copy is
rebuilt, the stack saying which line of your function threw used to go with the
original. The copy now carries it: read cause for the throw itself, and the
message for where quario was when it happened. The viewer’s and the editor’s
own property checks do the same. A page or fonts value either element
refuses throws a TypeError naming the property, carrying the layout’s account
of the value behind it.
import { isDiagnostic } from 'quario'
try {
await report.render(target, data)
} catch (error) {
if (isDiagnostic(error)) console.error(error.code, error.start, error.end)
else throw error
}Diagnostics sit outside the frozen authoring surface, so their codes may change with the engines behind them. Log them, and don’t build control flow on a particular code.
Holding a compiled report
A compiled report is the object your service keeps between requests. Compile it
at startup, or the first time something asks for a definition, and give
render a fresh dataset per request.
report.stream(data) is the raw event seam, one generator of report events
per call. It’s what a custom target consumes and what the official targets sit
behind.
report.names is the union of free variables across every expression in
the document. It excludes engine anchors and group handles, so what’s left
is the set of names your registry has to satisfy.
report.functions lists the registry functions the document calls, in
call-first-seen order. Each carries a name, an arity, and its own doc string
where it has one.
report.paths is the query topology behind data. Metadata read through
$.input doesn’t change it.
What invalidates a held report is the definition and the registry behind it. Change either and compile again. Nothing invalidates it on its own, and no data you render through it can.
The instance matters as much as the definition. Presentation reads the
instance’s locale, currency and timezone, so the same definition compiled by an
en-IE instance and a de-DE one renders €1,234.50 and 1.234,50 $. A
service presenting in several locales holds an instance, and a compiled report,
for each.