Writing Data

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

UseMergeable transactionExclusive transaction
Best forGrouping normal local-first writesEnforcing an invariant across reads and writes
Concurrent workMerges using Jazz's normal conflict rulesThe authority validates the transaction as one serialisable unit
TypeScript callbackdb.transaction(...)db.exclusiveTransaction(...)
TypeScript waitwait({ tier: "local" | "edge" | "global" })wait()
OfflineCan commit locally and sync laterCan 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.

app.ts
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;
}
app.rs
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:

MethodReturns
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:

CodeWhat it proves
await db.transaction(...)The callback finished and Jazz created the committed mergeable transaction locally
result.valueThe value returned by the callback
await result.txIdThe 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:

  1. While the callback runs, a write can throw immediately or an awaited read can reject, for example because its input is invalid.
  2. The callback or local commit can fail. Jazz rolls back a callback transaction and rejects db.transaction(...) or db.exclusiveTransaction(...).
  3. The authority can reject a committed transaction later. In TypeScript, wait(...) then rejects with PersistedWriteRejectedError and Jazz removes the rejected local changes.
app.ts
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.

app.ts
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();
}
app.rs
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().

app.ts
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.

On this page