Version Control
Jazz provides built-in version control through branching and merging, allowing multiple users to work on the same resource in isolation and merge their changes when they are ready.
This enables the design of new editing workflows where users (or agents!) can create branches, make changes, and merge them back to the main version.
Important: Version control is currently unstable and we may ship breaking changes in patch releases.
Working with branches
Creating Branches
To create a branch, use the unstable_branch
option when loading a CoValue:
const branch = await Project.load(projectId, { unstable_branch: { name: "feature-branch" } });
You can also create a branch via CoState
:
const branch = new CoState(Project, projectId, { unstable_branch: { name: "feature-branch" } });
You can also include nested CoValues in your branch by using a resolve
query.
You are in control of how nested CoValues are included in your branch. When you specify the CoValue to branch, any nested CoValues specified in a resolve
query will also be branched. Nested CoValues not specified in your resolve query will not be branched.
In order to access branched nested CoValues, you should access them in the same way you would normally access a deeply loaded property, and all operations will work within the branch context.
In case you create a separate reference to a nested CoValue (for example by loading it by its ID), or you use .$jazz.ensureLoaded()
or .$jazz.subscribe()
, you will need to specify the branch you wish to load.
Making Changes
Once you have a branch, you can make changes just as you would with the original CoValue:
<script lang="ts"> import { CoState } from 'jazz-tools/svelte'; import { Project } from './schema.js'; let projectId = $state<string>(); let currentBranchName = $state<string>('main'); const project = new CoState(Project, () => projectId, () => ({ resolve: { tasks: { $each: true } }, unstable_branch: currentBranchName === 'main' ? undefined : { name: currentBranchName } })); function handleTitleChange(e: Event) { const target = e.target as HTMLInputElement; // Won't be visible on main until merged project.current?.$jazz.set("title", target.value); } function handleTaskTitleChange(index: number, e: Event) { const target = e.target as HTMLInputElement; const task = project.current?.tasks[index]; // The task is also part of the branch because we used the `resolve` option // with `tasks: { $each: true }` // so the changes won't be visible on main until merged task?.$jazz.set("title", target.value); } </script> <form> <!-- Edit form fields --> <input type="text" value={project.current?.title || ''} oninput={handleTitleChange} /> {#each project.current?.tasks || [] as task, index} <input type="text" value={task.title || ''} oninput={(e) => handleTaskTitleChange(index, e)} /> {/each} </form>
Account & Group
Branching does not bring isolation on Account and Group CoValues.
This means that, adding a member on a branched Group will also add the member to the main Group.
const branch = await Project.load(projectId, { unstable_branch: { name: "feature-branch" } }); branch.$jazz.owner.addMember(member, "writer"); // Will also add the member to the main Group
On account only root and profile replacements are not affected by the branching.
const me = useAccount(MyAccount, { resolve: { root: true }, unstable_branch: { name: "feature-branch" } }); me.$jazz.set("root", { value: "Feature Branch" }); // Will also modify the main account me.root.$jazz.set("value", "Feature Branch"); // This only modifies the branch
Merging Branches
There are two ways to merge a branch in Jazz, each with different characteristics:
1. Merge loaded values
This method merges all the values that are currently loaded inside the branch. It happens synchronously and there is no possibility of errors because the values are already loaded.
async function handleSave() { // Merge all currently loaded values in the branch branch.$jazz.unstable_merge(); router.navigate("/"); }
This approach is recommended when you can co-locate the merge operation with the branch load, keeping at a glance what the merge operation will affect.
Important: The merge operation will only affect values loaded in the current subscription scope. Values loaded via ensureLoaded
or subscribe
will not be affected.
2. Merge with resolve query
This is a shortcut for loading a value and calling branch.$jazz.unstable_merge()
on it and will fail if the load isn't possible due to permission errors or network issues.
async function handleSave() { // Merge the branch changes back to main await Project.unstable_merge(projectId, { resolve: { tasks: { $each: true } }, branch: { name: "feature-branch" } }); router.navigate("/"); }
This approach is recommended for more complex merge operations where it's not possible to co-locate the merge with the branch load.
Best Practices
When using version control with Jazz, always be exhaustive when defining the resolve query to keep the depth of the branch under control and ensure that the merge covers all the branched values.
The mechanism that Jazz uses to automatically load accessed values should be avoided with branching, as it might lead to cases where merge won't reach all the branch changes.
All the changes made to the branch will be merged into the main CoValue, preserving both author and timestamp.
The merge is idempotent, so you can merge the same branch multiple times, the result will always depend on the branch changes and loading state.
The merge operation cascades down to the CoValue's children, but not to its parents. So if you call unstable_merge()
on a task, only the changes to the task and their children will be merged:
async function handleTaskSave(index: number) { const task = project.tasks[index]; // Only the changes to the task will be merged task?.$jazz.unstable_merge(); }
Conflict Resolution
When conflicts occur (the same field is modified in both the branch and main), Jazz uses a "last writer wins" strategy:
// Branch modifies priority to "high" branch.$jazz.applyDiff({ priority: "high" }); // Meanwhile, main modifies priority to "urgent" originalProject.$jazz.applyDiff({ priority: "urgent" }); // Merge the branch branch.$jazz.unstable_merge(); // Main's value ("urgent") wins because it was written later console.log(originalProject.priority); // "urgent"
Private branches
When the owner is not specified, the branch has the same permissions as the main values.
You can also create a private branch by providing a group owner.
// Create a private group for the branch const privateGroup = Group.create(); const privateBranch = useCoState(Project, projectId, { unstable_branch: { name: "private-edit", owner: privateGroup } }); // Only members of privateGroup can see the branch content // The sync server cannot read the branch content
You can use private branches both to make the changes to the branches "private" until merged, or to give controlled write access to a group of users.
Only users with both write access to the main branch and read access to the private branch have the rights to merge the branch.
Important: Branch names are scoped to their owner. The same branch name with different owners creates completely separate branches. For example, a branch named "feature-branch" owned by User A is completely different from a branch named "feature-branch" owned by User B.
Branch Identification
You can get the current branch information from the $jazz
field.
const branch = await Project.load(projectId, { unstable_branch: { name: "feature-branch" } }); console.log(branch.$jazz.id); // Branch ID is the same as source console.log(branch.$jazz.branchName); // "feature-branch" console.log(branch.$jazz.isBranched); // true