Writing Data

Insert, update, and delete APIs with local-first execution, framework context hooks, and an intro to durability tiers.

Local-first writes

Ordinary mutations execute against the local database first. insert, restore, upsert, update, and delete return a MutationResult immediately. Each result contains:

  • value — The local result. insert and restore return the row; upsert, update, and delete return undefined.
  • txId — A promise for the committed transaction ID. This is the durable identity of this write; it does not mean the write has reached a server yet.
  • wait(...) — A method that waits for the mutation to reach a specific durability tier.

The local value does not mean that the mutation has reached a server.

Jazz also allows grouping writes together using transactions. Writes made through an open transaction are not individually waitable. Wait on the result returned by the transaction or its commit() call instead.

Getting a Db handle

Every framework provides a hook to access the database handle. In plain TypeScript, use the Db returned by createDb directly.

import { useDb } from "jazz-tools/react";
​
const db = useDb();

Insert, update, delete

Mutations are methods on the database handle. All three take a table as their first argument.

export async function writeTodoCrud(db: Db, todoId: string) {
db.insert(app.todos, {
title: "Write docs",
done: false,
owner_id: EXAMPLE_OWNER_ID,
projectId: EXAMPLE_PROJECT_ID,
});
db.update(app.todos, todoId, { done: true });
db.delete(app.todos, todoId);
}

Upsert with a known ID

Use upsert(...) when your app already knows the row ID and wants to create that row if it does not exist, or update it if it does. Like insert, update, and delete, it applies locally first and returns a MutationResult<void> that can be awaited for durability.

export async function upsertTodo(db: Db, importedTodoId: string) {
const write = db.upsert(app.todos, importedTodoId, {
title: "Imported task",
done: false,
});
​
await write.wait({ tier: "global" });
}

Restore a deleted row

Trying to insert, update or delete an already deleted row will fail. Use restore(...) to make a soft-deleted row visible again. restore requires providing new data for the restored row (missing fields will use schema defaults, if they exist). Deleted rows are omitted from queries by default. Add includeDeleted() when you need to find a row before restoring it.

export async function restoreDeletedTodo(db: Db, todoId: string) {
db.delete(app.todos, todoId);
​
const deletedTodo = await db.one(app.todos.where({ id: todoId }).includeDeleted());
if (!deletedTodo) throw new Error("Deleted todo not found");
​
const { value: restored } = db.restore(app.todos, todoId, {
title: "Restored task",
done: false,
owner_id: EXAMPLE_OWNER_ID,
projectId: EXAMPLE_PROJECT_ID,
});
​
return restored;
}

Partial updates and nullable fields

update(...) only modifies the keys you pass. Omitted fields are left unchanged; explicitly passing undefined also leaves a field unchanged. To clear a nullable column in TypeScript, pass null. Required fields cannot be set to null.

export function clearNullableTodoFields(db: Db, todoId: string) {
db.update(app.todos, todoId, { owner_id: null }); // clears the nullable FK
db.update(app.todos, todoId, { description: undefined }); // leaves the field unchanged
}

Editing a page of a large value

Use update(...) to replace complete primitives and, with its applyDiffs option, edit a selected page of a bytes or string column or set an existing JSON member. Replacements and diffs are one atomic update, but a column must appear in only one of them. Splice coordinates are relative to within, and multiple splices run in order against the result of the previous splice.

db.update(
app.documents,
documentId,
{ title: "Revised document" },
{
applyDiffs: {
bytes: {
within: { from: 1_000_000, to: 2_000_000 },
splices: [{ at: 4, delete: 3, insert: new Uint8Array([1, 2]) }],
},
text: {
within: { from: 4, to: 124 }, // UTF-16 coordinates
splices: [{ at: 3, delete: 1, insert: "revised" }],
},
metadata: {
edits: [{ op: "set", at: "/chapters/0/title", value: "New title" }],
},
},
},
);

For a diff-only update, pass an empty replacement object:

db.update(
app.documents,
documentId,
{},
{
applyDiffs: {
text: {
within: { from: 4, to: 124 },
splices: [{ at: 3, delete: 1, insert: "revised" }],
},
},
},
);

For byte-oriented text, use { fromUtf8, toUtf8 } in within and { atUtf8, deleteUtf8, insert } in each splice. Jazz rejects out-of-range coordinates and text boundaries that split a code point or surrogate pair. This revision does not add a page-staleness/CAS check; keep page coordinates aligned with the value your application last read. update(..., { applyDiffs }) is for existing rows only; inserts and full replacement continue to use the ordinary mutation methods.

applyDiffs also works inside a transaction. Coordinates are relative to the value the transaction sees, including its own earlier writes, so an append after an append in the same transaction lands after both, and every diff commits together with the transaction's other writes:

await db.transaction(async (tx) => {
tx.update(
app.documents,
documentId,
{ editedAt: new Date() },
{
applyDiffs: {
text: {
within: { from: length, to: length }, // an empty page at the end
splices: [{ at: 0, delete: 0, insert: nextTokens }],
},
},
},
);
});

Only the pages a diff touches are rewritten, so streaming many small appends into a long value stays cheap however long it grows.

Write durability tiers

For ordinary mutations and mergeable transactions, wait({ tier }) controls which confirmation the caller waits for:

TierResolves when
localSaved by the local persistence driver
globalAuthorized and durably accepted by Core

Each write picks a fresh colour. Choose how durable it must be before it's confirmed — it lands on this device at once, then syncs to Core and then to the other device whenever the network is available; the promise resolves once it reaches the tier you picked. Turn on aeroplane mode to watch writes queue on the device and flush when you reconnect. Local confirms after local persistence, even offline.

Wait for

Authorized and durably accepted by Core.

#146aff
StatusReady
Local-onlyall synced
Core
accepts writes
Other device
receives updates

Offline, only local can resolve. A global wait stays pending until the device reconnects and Core accepts or rejects the queued write. Both choices apply the initial mutation locally and upload it when connected.

export async function writeTodoWithDurabilityTiers(db: Db) {
const { id } = await db
.insert(app.todos, {
title: "Write docs with durability tier",
done: false,
owner_id: EXAMPLE_OWNER_ID,
projectId: EXAMPLE_PROJECT_ID,
})
.wait({ tier: "global" });
​
await db.update(app.todos, id, { done: true }).wait({ tier: "global" });
await db.delete(app.todos, id).wait({ tier: "global" });
}

See Durability Tiers for the full reference, including read durability, data flow between tiers, and consistency semantics.

Need to clear local data during development? See Auth Lifecycle.

Handling mutation errors

A mutation can fail when you call it or while you wait:

  • If Jazz cannot apply the mutation locally, for example because the input is invalid, the mutation call throws immediately.
  • If a server later rejects the mutation, an active wait(...) call rejects with PersistedWriteRejectedError.
export async function insertTodoAndWait(db: Db) {
const pending = db.insert(app.todos, {
title: "Ship review fixes",
done: false,
owner_id: EXAMPLE_OWNER_ID,
projectId: EXAMPLE_PROJECT_ID,
});
​
console.log(await pending.txId);
​
try {
const row = await pending.wait({ tier: "global" });
console.log(row.id);
} catch (error) {
if (error instanceof PersistedWriteRejectedError) {
console.error(error.code, error.reason);
return;
}
​
throw error;
}
}

Use db.onMutationError(...) as a fallback for rejected mutations without an active wait(...). This includes mutations you did not wait for and mutations whose wait finished before a later rejection arrived.

export function listenForMutationErrors(db: Db) {
return db.onMutationError((event) => {
console.error("DB mutation failed:", event.code, event.reason);
});
}

onMutationError returns an unsubscribe function. Without a listener, Jazz logs the rejection and keeps it so that a listener registered later can receive it.

On a server, every Db from one createJazzContext (db(), asBackend(), forRequest(), forSession()) shares one runtime, so onMutationError is context-wide. A listener sees rejections of writes made through any of them, for every session, so a per-request handler must match events against its own transaction IDs. Rejections that arrive before any listener exists, such as writes that were still in flight when the process stopped and are settled on restart, go to whichever Db registers a listener first. Jazz keeps up to 256 of them and reports how many it dropped beyond that. A dropped or shut-down Db releases its listeners. context.db() returns a new Db on each call, so keep a reference to the Db you register on, for example a long-lived const db = context.db(). A listener on a Db you don't keep stops firing once that Db is garbage-collected.

Transactions

Use a transaction when several reads and writes must share one commit. Use a mergeable transaction for normal local-first work. Use an exclusive transaction only when the authority must check the whole operation against one stable view of the data.

See Transactions for examples, error handling and the differences between commit and authority acceptance.