Transactions
Group related reads and writes into one mergeable or exclusive transaction.
A transaction groups related writes under one commit. Reads made through the transaction can see its staged writes, while code outside the transaction cannot see them until it commits.
Jazz has two transaction types. The difference is how they handle concurrent changes:
- A mergeable transaction commits locally, even when the device is offline. When it syncs, Jazz combines it with concurrent work using the normal conflict rules. Jazz does not reject it just because another client changed the same data.
- An exclusive transaction uses a stable view of the data. Before accepting it, the authority checks whether another write made a conflicting change. If so, it rejects the whole transaction and Jazz removes its local changes. Use this when accepting both changes would break a rule.
Most applications should start with a mergeable transaction.
Choose a transaction type
| Use | Mergeable transaction | Exclusive transaction |
|---|---|---|
| Best for | Grouping normal local-first writes | Enforcing an invariant across reads and writes |
| Concurrent work | Merges using Jazz's normal conflict rules | The authority validates the transaction as one serialisable unit |
| TypeScript callback | db.transaction(...) | db.exclusiveTransaction(...) |
| TypeScript wait | wait({ tier: "local" | "edge" | "global" }) | wait() |
| Offline | Can commit locally and sync later | Can be staged, but cannot be accepted until the authority is reachable |
You do not need a transaction for every write. insert, update, upsert, delete and restore
already apply locally first and sync in the background.
One commit does not mean one UI update
Jazz sends every transaction upstream as one unit, and the authority decides that unit as a whole. Other clients can still receive rows from a mergeable transaction in separate updates. An exclusive transaction is shown atomically within each subscription view.
Use a callback for normal work
The callback form commits automatically when the callback finishes. In TypeScript, Jazz rolls the
transaction back if the callback throws or returns a rejected promise. In Rust, it leaves the
transaction uncommitted if the callback returns Err.
export async function groupTodoWrites(db: Db, existingTodoId: string) {
const result = await db.transaction(async (tx) => {
const created = tx.insert(app.todos, {
title: "Write transaction docs",
done: false,
owner_id: EXAMPLE_OWNER_ID,
projectId: EXAMPLE_PROJECT_ID,
});
tx.update(app.todos, existingTodoId, { done: true });
const staged = await tx.one(app.todos.where({ id: created.id }));
if (!staged) throw new Error("Staged todo not found");
return staged.id;
});
await result.wait({ tier: "edge" });
return result.value;
}pub fn group_todo_writes(
db: &Db<RocksDbStorage>,
existing_todo_id: RowUuid,
) -> Result<RowUuid, jazz::db::Error> {
let (created_id, _transaction_id) = db.transaction(|tx| {
let created_id = tx.insert("todos", transaction_todo_values("Write transaction docs"))?;
tx.update(
"todos",
existing_todo_id,
BTreeMap::from([("done".to_string(), DbValue::Bool(true))]),
)?;
let _staged = tx.read("todos", created_id)?;
Ok(created_id)
})?;
Ok(created_id)
}The TypeScript callback returns the new row ID. Jazz exposes that value as result.value, and
result.wait(...) resolves to the same value. In Rust, Db::transaction returns the callback value
and the committed transaction ID as a tuple.
Inside a TypeScript transaction:
| Method | Returns |
|---|---|
insert(...), restore(...) | The staged row |
update(...), upsert(...), delete(...) | void |
all(...) | A promise for all matching rows, including staged changes |
one(...) | A promise for the first matching row, or null |
Use the tx object for every read and write that belongs to the transaction. A query made through
the outer db does not read the transaction's staged changes.
Know what each await means
In TypeScript, committing and waiting for durability are separate steps:
| Code | What it proves |
|---|---|
await db.transaction(...) | The callback finished and Jazz created the committed mergeable transaction locally |
result.value | The value returned by the callback |
await result.txId | The committed transaction has an ID |
await result.wait({ tier }) | The mergeable transaction reached that durability tier, or was rejected |
await db.exclusiveTransaction(...) | Jazz created the committed exclusive transaction locally |
await result.wait() | The authority accepted the exclusive transaction, or rejected it |
If your next action depends on server acceptance, keep the result and call wait(...). Awaiting
db.transaction(...) by itself is not enough.
See Durability Tiers for the meaning of local, edge and
global.
Handle errors
A transaction can fail at three points:
- While the callback runs, a write can throw immediately or an awaited read can reject, for example because its input is invalid.
- The callback or local commit can fail. Jazz rolls back a callback transaction and rejects
db.transaction(...)ordb.exclusiveTransaction(...). - The authority can reject a committed transaction later. In TypeScript,
wait(...)then rejects withPersistedWriteRejectedErrorand Jazz removes the rejected local changes.
export async function completeTodoInTransaction(db: Db, todoId: string) {
try {
const result = await db.transaction((tx) => {
tx.update(app.todos, todoId, { done: true });
});
await result.wait({ tier: "edge" });
} catch (error) {
if (error instanceof PersistedWriteRejectedError) {
console.error(error.code, error.reason);
return;
}
throw error;
}
}Use db.onMutationError(...) as a fallback for later rejections when your code does not keep and
wait on the transaction result.
Use an exclusive transaction to reject conflicts
An exclusive transaction reads from a stable transaction snapshot. The authority checks the reads and writes, then accepts or rejects the transaction as one serialisable unit. This is useful when a write is valid only if the data you just read has not changed in a conflicting transaction.
export async function finishTodoExclusively(db: Db, todoId: string) {
const result = await db.exclusiveTransaction(async (tx) => {
const todo = await tx.one(app.todos.where({ id: todoId }));
if (!todo) throw new Error("Todo not found");
tx.update(app.todos, todo.id, { done: true });
return todo.id;
});
return result.wait();
}pub fn finish_todo_exclusively(
db: &Db<RocksDbStorage>,
todo_id: RowUuid,
) -> Result<(), jazz::db::Error> {
let tx = db.exclusive_tx()?;
let _todo = tx.read("todos", todo_id)?;
tx.update(
"todos",
todo_id,
BTreeMap::from([("done".to_string(), DbValue::Bool(true))]),
)?;
let _transaction_id = tx.commit()?;
Ok(())
}In TypeScript, wait() takes no durability tier for an exclusive transaction. It resolves if the
authority accepts the transaction and rejects if the authority rejects it.
The Rust example uses the embedded jazz::db::Db API. An owning ExclusiveTx abandons the open
transaction if it is dropped before commit() succeeds.
Manage a transaction yourself only when needed
Use beginTransaction() when the work cannot fit in one callback. You then own the transaction and
must call commit() or rollback().
export async function stageTodoAcrossSteps(db: Db, shouldCancel: boolean) {
const tx = db.beginTransaction();
tx.insert(app.todos, {
title: "Review staged changes",
done: false,
owner_id: EXAMPLE_OWNER_ID,
projectId: EXAMPLE_PROJECT_ID,
});
if (shouldCancel) {
await tx.rollback();
return;
}
const result = await tx.commit();
await result.wait({ tier: "edge" });
}For an exclusive transaction, use beginExclusiveTransaction() instead. Call wait() on the
commit result to wait for the authority to accept or reject the transaction. You do not choose a
durability tier, it is always settled by the authority.
If a mergeable transaction has no writes, commit() throws. Call rollback() instead.