Escape

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.

read-paths.ts
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.

check.ts
import { validate } from 'quario'

const problems = validate(definition, {})

for (const message of problems) console.error(message)
process.exitCode = problems.length ? 1 : 0

Run 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.

invoice.report.json
{
  "$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 reset without its break are all examples.
  • The document uses a reserved or blocked name.
  • A string has to compile, as an expression, a template, a data path or a reducer does.
  • A required name 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.

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

check.ts
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 = 1

Passing 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.

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

check.ts
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 = 1

The 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.

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