Reading Data

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

schema.ts
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:

OptionDescription
startA where filter selecting the root rows to begin traversal from
stepA callback that receives { current } (a token for the row being visited) and returns a query with one .hopTo() call specifying which relation to follow
maxDepthMaximum 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 parentIdparent relation up to 10 levels deep, returning the full lineage.

On this page