Branches

Learn how to model your app's own branches with ordinary columns and head-over-base views.

Jazz provides the storage and query mechanics for branches, but it does not prescribe what a branch is. Your app can use branches for drafts, scenarios, environments, or any other parallel view of the same objects.

The app owns branch names, lifecycle, parent relationships, and merge UI. Jazz owns two narrow mechanisms:

  • Branch columns form the coordinates that separate row history.
  • Branch views read a head coordinate, optionally falling back to a live or frozen base.

This means branch columns remain normal data. A branch can be as small as a string such as "main" or "draft". When an app needs richer semantics, the column can instead reference an ordinary row.

Note

Branch views are part of the new Jazz core API. Framework-specific branch conveniences are still being filled in; the examples below use the current typed schema, read, and mutation APIs.

Start with a string column

The smallest useful model adds a normal string column to the table and lists it in branchBy:

schema.ts
import { schema as s } from "jazz-tools";
import { migrations } from "./migrations";
​
const schema = {
documents: s
.table(
{
branch: s.string(),
title: s.string(),
},
{},
)
.branchBy("branch"),
};
​
type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema, { migrations });

branch is still an ordinary application column. branchBy additionally tells Jazz to keep each value's row history separate and to use it when composing branch views.

For a table with one branch column, pass that column's ordinary value directly:

const documents = await db.all(app.documents.orderBy("title", "asc"), {
branch: "draft",
base: "main",
});
​
const unsubscribe = db.subscribe(app.documents, (documents) => renderDocuments(documents), {
branch: "draft",
base: "main",
});

For every row, Jazz selects the draft's current content when present and otherwise falls back to main. Draft deletions hide inherited rows. Filters, joins, includes, permissions, and indices all consume that effective view; fallback is not a late union of two query result sets.

The same row UUID identifies the application object across branches. Its content may differ in each branch-local row. Results expose the selected head coordinate, including for content inherited from the base, so application code sees one consistent effective branch.

Model richer branches with references

A string is enough when branch names and relationships live elsewhere in the application. When a branch needs queryable metadata, model it as an ordinary row and make the branch column a reference.

schema.ts
const schema = {
branches: s.table(
{
workspace_id: s.uuid(),
name: s.string(),
base_branch_id: s.uuid().optional(),
base_snapshot_ref: s.string().optional(),
status: s.enum(["open", "approved", "archived"]),
},
{
baseBranch: s.rel("branches", "base_branch_id"),
derivedBranches: s.reverse("branches", "baseBranch"),
documents: s.reverse("documents", "branch"),
},
),
documents: s
.table(
{
branch_id: s.uuid(),
title: s.string(),
},
{ branch: s.rel("branches", "branch_id") },
)
.branchBy("branch_id"),
};

Jazz does not assign meaning to the branch row's base_branch_id, base_snapshot_ref, or status. The app reads the row, resolves its base, and supplies the reference IDs to the same read-side API:

const branchRow = await db.one(app.branches.where({ id: draftBranchId }));
if (!branchRow?.base_branch_id) throw new Error("Branch has no base");
​
const documents = await db.all(app.documents, {
branch: branchRow.id,
base: branchRow.base_branch_id,
});

This keeps review, archival, nested drafts, environment promotion, and base selection in userland.

Because branch_id is an ordinary column, branch access does not need a separate authorization system. A policy can compare it directly or follow its reference to application-owned data. For example, an app can allow writes only when:

document.branch_id -> branches.id
branches.workspace_id -> workspace_memberships.workspace_id
workspace_memberships.user_id == session.user
branches.status == "open"

Missing branch or membership rows are missing policy evidence and fail closed. Jazz does not add an implicit requirement that a branch row exists, nor does it impose built-in open, closed, owner, or parent semantics.

Transactions may contain writes to several branch coordinates and shared tables. They retain one atomic fate: if one write is invalid or unauthorized, the whole transaction is rejected.

Use compound branch coordinates

Most apps need only one branch column. When a branch is qualified by multiple dimensions, declare them with the array form:

const schema = {
documents: s
.table(
{
workspace_id: s.uuid(),
branch_id: s.uuid(),
title: s.string(),
},
{ workspace: s.rel("workspaces", "workspace_id"), branch: s.rel("branches", "branch_id") },
)
.branchBy(["workspace_id", "branch_id"]),
};

Compound selectors name every dimension explicitly:

const documents = await db.all(app.documents, {
branch: {
workspace_id: workspaceId,
branch_id: draftBranchId,
},
base: {
workspace_id: workspaceId,
branch_id: mainBranchId,
},
});

The qualified form also works for generic code targeting a single-column table:

await db.all(app.documents, {
branch: { branch_id: draftBranchId },
base: { branch_id: mainBranchId },
});

Write to the branch

An insert targets one exact branch selector and includes the matching ordinary column value:

const { value: document } = db.insert(
app.documents,
{ branch: "draft", title: "Draft title" },
{ branch: "draft" },
);

Updates and deletes accept a branch view. If the visible row currently comes from main, Jazz copies it into the draft before applying the change:

db.update(
app.documents,
existingDocumentId,
{ title: "Reworked in the draft" },
{ branch: "draft", base: "main" },
);
​
db.delete(app.documents, inheritedDocumentId, {
branch: "draft",
base: "main",
});

The main branch-local row remains unchanged. A deletion is branch-qualified too, so deleting the object from the draft does not delete it from main.

Branch-column rules

Every branchBy entry must name a non-null, key-encodable ordinary column. Branch columns are immutable after insertion. When the same branch-column name appears in multiple tables, it must have the same type in every table.

There is no separate branch declaration, binding, or stable branch identity. Branch selectors use the ordinary column names. A schema migration may rename a branch column because normal column lineage preserves its physical identity.

Tables may use different subsets of the same named branch columns. For example, documents might branch by workspace_id and branch_id, memberships only by workspace_id, and users by neither. An unbranched table is shared by every branch view.

Use a frozen base

Capture a snapshot when a draft should keep seeing its base at a fixed point. Store the reference in a string column alongside the base branch ID:

import { parseSnapshotRef, serializeSnapshotRef } from "jazz-tools";
​
// Earlier queries or mutations have attached this app's schema to db.
const captured = await db.captureSnapshot();
await db
.update(app.branches, branchRow.id, {
base_branch_id: mainBranch.id,
base_snapshot_ref: serializeSnapshotRef(captured),
})
.wait({ tier: "local" });

After reopening the same dataset, read the branch row and restore its saved reference:

if (!branchRow.base_branch_id || !branchRow.base_snapshot_ref) {
throw new Error("Branch has no frozen base");
}
​
const frozenDraft = {
branch: branchRow.id,
base: [branchRow.base_branch_id, parseSnapshotRef(branchRow.base_snapshot_ref)],
} as const;
​
const documents = await db.all(app.documents, frozenDraft);

The frozen draft keeps the captured base values as main changes. Draft edits remain visible above the frozen base and leave main untouched. Jazz applies the cut consistently to base rows and base data reached through joins and permissions.

Capture includes earlier locally committed writes, including writes awaiting server acceptance. It excludes changes staged in an uncommitted transaction and writes admitted after capture. Capture works offline and does not wait for the server. To capture after a particular write has been accepted, await that write's wait({ tier: "global" }) before capturing; other local pending writes are still included.

On React Native, snapshot capture requires a native build with capture support. Older compatible native builds continue to support existing operations; calling captureSnapshot() on them throws UnsupportedSnapshotError (code: "unsupported_snapshot"). Rebuild the native app to use capture.

The persisted string uses the versioned jazz-snapshot-v1 format. Use the public parser and serializer so malformed or unsupported references are rejected. The reference contains no data, grants no permission and does not retain history. Restore it against the same dataset with its captured history intact. Reopening a persistent local-only store can reuse its pending frontier; another device or a partial synced cache may need authority coverage. If Jazz cannot certify the exact history, a new read reports an unavailable-history error (code: "not_observed") instead of using a live base. An already-open frozen subscription keeps its captured base values: later base writes, server acceptance and rejection do not change that materialised snapshot. Its draft head remains live. A new or reopened read may fail if rejection has removed history needed to rebuild the captured base; the reference itself does not retain that history.

Merging stays a userland operation

A merge is a high-level helper that reads authorized source and target views, calculates ordinary target writes, and emits one transaction. The core does not need a branch lifecycle event or a special merge commit type to admit those writes.

That leaves the app in control of merge strategy and UX while Jazz transaction metadata can retain precise contribution provenance. Concurrent offline merges are still subject to Jazz's normal mergeable transaction semantics; branch views do not introduce a distributed exactly-once or distributed uniqueness guarantee.

See the branching project planner example for scenario rows, reference-based authorization, live and frozen bases, persisted snapshot restoration, and copy-on-write editing.