Includes & Relations
Resolve references with include(), pick columns with select(), inspect permissions, and run recursive queries.
Includes
Use include(...) to load related rows as nested objects in a single query result. Pass true to resolve a relation, or a nested object to follow multi-hop references.
Rust has deliberately separate query forms. Use flat_join(...) when you need fields from both
tables in one flat result: it is an inner join, so each matching pair is an output occurrence and a
root row can produce more than one result. Use ArraySubquery when you need a named nested array
on each root result. Rust does not turn a forward reference into the same nullable nested-object
shape that TypeScript include(...) does.
Including a relation adds the resolved object alongside the foreign-key column — it does not replace it. For example, include({ project: true }) gives you both projectId (the FK string) and project (the resolved row).
todos: s.table(
{
title: s.string(),
done: s.boolean(),
priority: s.int().optional(),
description: s.string().optional(),
owner_id: s.uuid().optional(),
parentId: s.uuid().optional(),
projectId: s.uuid().optional(),
},
{
parent: s.rel("todos", "parentId"),
project: s.rel("projects", "projectId"),
children: s.reverse("todos", "parent"),
},
),
projects: s.table(
{
name: s.string(),
},
{ todos: s.reverse("todos", "project") },
),export async function readTodosWithIncludes(db: Db) {
return db.all(
app.todos.where({ done: false }).include({ project: true, parent: { project: true } }),
);
}pub async fn read_todos_with_project(client: &JazzClient) -> jazz::tools::Result<usize> {
let query = Query::from("todos")
.filter(eq(col("done"), lit(false)))
.flat_join("projects", "todos.project_id", "projects.id");
let rows = client.query_results(query, None).await?;
Ok(rows.len())
}Reverse relations
Declare reverse relations explicitly on the target table. For example, if todos declares
project: s.rel("projects", "projectId"), projects can declare
todos: s.reverse("todos", "project") and load it with include({ todos: true }).
The reverse relation returns matching source rows as an array. Its name is your choice;
Jazz does not generate an inverse or infer names from UUID columns.
export async function readProjectsWithTodos(db: Db) {
return db.all(app.projects.include({ todos: app.todos.where({ done: false }) }));
}pub fn build_projects_with_todos_query() -> Query {
Query::from("projects").array_subquery(
ArraySubquery::new("todos_via_project", "todos", "project_id", "id")
.filter(eq(col("done"), lit(false))),
)
}In TypeScript, you can chain .where(), .select(), .orderBy() and other query methods on the
included reverse relation. In Rust, configure the ArraySubquery itself with .filter(...),
.select(...), .order_by(...), .limit(...), and .offset(...) before passing it to
.array_subquery(...).
Rust also has join_via(...), join_via_column(...), and join_via_row_id(...) for an
existential relation check: they keep a root row only when a matching related row exists, without
adding that related row to the result. Use flat_join(...) or ArraySubquery when the related
data must be returned.
Select
Use select(...) to narrow a row to id plus the columns you pick. You can combine it with include(...), and select within included rows too.
export async function readTodoTitlesWithSelectedProject(db: Db) {
return db.all(
app.todos
.select("title")
.where({ done: false })
.include({ project: app.projects.select("name") }),
);
}pub async fn read_todo_titles(client: &JazzClient) -> jazz::tools::Result<usize> {
let query = Query::from("todos").select(["title", "done"]);
let rows = client.query(query, None).await?;
Ok(rows.len())
}Partial large values
For a bytes, text, or JSON column, the object form of select(...) returns
just the requested primitive slice or JSON subtree. The range end is exclusive.
Text coordinates use JavaScript UTF-16 code units by default, so they match
String.prototype.slice; use fromUtf8 and toUtf8 when interoperating with
a byte-oriented protocol. Jazz rejects a range that would split a UTF-8 code
point or UTF-16 surrogate pair rather than rounding it.
const [document] = await db.all(
app.documents.where({ id: documentId }).select({
bytes: { from: 1_000_000, to: 2_000_000 },
text: { from: 4, to: 124 },
utf8Text: { fromUtf8: 4, toUtf8: 67 },
metadata: { at: "/chapters/0/title" },
}),
);
// document.bytes is Uint8Array; text and utf8Text are strings; metadata is
// the decoded JSON value at that RFC 6901 pointer.The object form is schema-aware: byte ranges are for bytes, text ranges are
for string, and { at } is for json. Selecting a field by name continues
to return the complete primitive.
Partial object selections currently apply to the root query only. An included relation may select complete fields by name, but rejects object-form partial selections until Jazz can carry terminal-specific demand through relation evaluation (#2090).
Until exact chunk demand reaches every query terminal, Jazz may materialize the selected carrier column before applying the requested slice. It never needs to materialize unselected columns; exact chunk-demand propagation is tracked in #2090.
Missing references
Jazz is distributed and supports offline edits, so a referenced row won't always be available locally — it might not have synced yet, or another peer might have deleted it.
When you don't load a reference, the FK column contains its raw value (the UUID string) and the row always appears in results.
When you load a forward reference with TypeScript include, the source row still appears if the
target can't be resolved — for example, because it has not synced yet, was deleted, or is
hidden by permissions. The included field is null. A nullable, unset reference also produces
null.
Rust's flat_join(...) is an inner join, so it filters out source rows without a matching target.
An ArraySubquery is optional by default: its parent still appears with an empty array when no
readable child row matches.
Requiring includes
Use .requireIncludes() when every included, non-nullable forward reference must resolve. It drops
a source row if one of those required targets is unavailable.
const requiredReferences = s.defineApp({
customers: s.table({ name: s.string() }, { orders: s.reverse("orders", "customer") }),
orders: s.table({ customerId: s.uuid() }, { customer: s.rel("customers", "customerId") }),
});
export async function readOrdersWithRequiredCustomer(db: Db) {
return db.all(requiredReferences.orders.include({ customer: true }).requireIncludes());
}pub fn build_todos_with_required_project() -> Query {
Query::from("todos")
.filter(eq(col("done"), lit(false)))
.array_subquery(
ArraySubquery::new("project", "projects", "id", "project_id")
.requirement(ArraySubqueryRequirement::AtLeastOne),
)
}requireIncludes() only filters non-nullable forward references (FK → row). Nullable forward
references and reverse relations (such as authoredPosts) are unaffected. When used inside a nested
include, it applies at that nesting level only.
In Rust, set the corresponding correlated ArraySubquery to
.requirement(ArraySubqueryRequirement::AtLeastOne). That likewise filters the parent when no
readable related row matches. This is explicit because the normal Rust ArraySubquery behavior is
to keep the parent and return an empty array.
Magic columns
Jazz exposes computed columns for edit metadata ($createdBy, $createdAt, $updatedBy, $updatedAt). See Magic columns for details and examples.
Recursive queries with gather and hopTo
If your data has self-referencing relations (e.g. a todo with a parent that points to another
todo), use gather(...) to walk the graph recursively and collect all reachable rows in a single
query.
gather takes three options:
| Option | Description |
|---|---|
start | A where filter selecting the root rows to begin traversal from |
step | A callback that receives { current } (a token for the row being visited) and returns a query with one .hopTo() call specifying which relation to follow |
maxDepth | Maximum recursive hops (default: 10). The start rows are depth 0, so 0 returns only those rows |
Inside the step callback, call hopTo(relation) to tell Jazz which reference to follow at each level.
Depth is counted from the start relation: maxDepth: 0 returns only the start rows, while
maxDepth: 1 may also return rows reached by one step hop. A zero bound never performs an
implicit first hop.
export function buildTodoLineageQuery() {
return app.todos.gather({
start: { done: false },
step: ({ current }) => app.todos.where({ id: current }).hopTo("parent"),
maxDepth: 10,
});
}This starts from all incomplete todos, then follows each todo's parentId → parent relation up to 10 levels deep, returning the full lineage.