Cross-step queries
A query runs against the whole run's outputs, not just one step — so it can reference other steps. That's the thing raw JSONata can't do (it's single-document, while stepbook gives a query the whole run), and it's where the real debugging questions live: which items did extract produce that classify dropped? The query's base is the step you're inspecting (stepbook q extract …); cross-step features name the other step explicitly. It reuses outputs already in memory — no extra I/O.
Because the base is already the queried step, you don't set from to its name — from is a path within that step's output, not the step itself. The examples below inspect extract and reach into classify, two array outputs in the same run:
// extract
[ { "id": 1, "name": "ada" }, { "id": 2, "name": "bab" }, { "id": 3, "name": "boole" } ]
// classify
[ { "id": 1, "label": "spam" }, { "id": 3, "label": "spam" }, { "id": 2, "label": "ham" } ]Membership
Filter by a set derived from another step. A where in may be a sub-query that names a step and projects a single field:
# stepbook q extract …
where: { field: id, in: { step: classify, select: id, where: { field: label, eq: spam } } }
select: name→ extract rows whose id appears among the spam-labelled classify ids. The sub-query takes the same where/select you'd write anywhere else; it just projects one field to form the set. It compiles to a plain in against the other step ($$ is the run's whole outputs map):
((extract)[id in ($$.classify)[label = "spam"].id]).name["ada", "boole"]Joins
Graft another step's columns onto each row. join matches on a key and brings in selected columns; where/select/etc. can then use them:
# stepbook q extract …
join: { step: classify, on: id, select: [label], type: left }
where: { field: label, eq: spam }
select: [name, label](((extract)@$r.( $m := ($$.classify[id = $r.id])[0]; $merge([$r, { "label": $m.label }]) ))[label = "spam"]).{"name": name, "label": label}[
{ "name": "ada", "label": "spam" },
{ "name": "boole", "label": "spam" }
]on: a shared key, or{ left, right }when the steps name it differently.type:left(default — keep unmatched rows) orinner(drop them).select: columns to bring (a list), or an alias map{ verdict: label }to rename. On non-unique keys the first match wins; on a name collision the joined column wins (alias it to avoid that).
Joins need object rows on both sides (you're merging keyed columns). A single join per query is supported today — chained/multi-step joins are not yet.
How it works
There's no join engine or index — the compiler roots the query at the queried step in the run's global outputs map and emits JSONata that navigates to the other step ($$.<step>, where $$ is the run's whole outputs map) natively, as the compiled snippets above show. Membership becomes an in against the sub-query's projected field; a join becomes a correlated lookup (@$r + $merge) that grafts the matched columns. JSONata does the cross-document work.