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.insertandrestorereturn the row;upsert,update, anddeletereturnundefined.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();<script setup lang="ts">
import { useDb } from "jazz-tools/vue";
const db = useDb();
</script><script lang="ts">
import { getDb } from 'jazz-tools/svelte';
const db = getDb();
</script>import { useDb } from "jazz-tools/solid";
export function GetDbExample() {
const db = useDb();
void db;
return null;
}import { createAccountManager, createDb } from "jazz-tools";
const config = { appId: "my-app", serverUrl: "https://core.example", env: "dev" };
const accounts = await createAccountManager(config);
const account = accounts.getLoggedIn() ?? accounts.createLocalFirst();
const db = await createDb({ ...config, account });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);
}pub async fn write_todo_crud(
client: &JazzClient,
existing_id: ObjectId,
) -> jazz::tools::Result<()> {
let values = todo_values("Write docs", "");
let _new_row = client.insert("todos", values)?;
client.update(
existing_id,
vec![("done".to_string(), Value::Boolean(true))],
)?;
client.delete(existing_id)?;
Ok(())
}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: "edge" });
}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
}pub async fn clear_nullable_fields(
client: &JazzClient,
todo_id: ObjectId,
) -> jazz::tools::Result<()> {
// Set a nullable column to null
client.update(todo_id, vec![("owner_id".to_string(), Value::Null)])?;
// Only the specified columns are changed; omitted columns are left as-is.
Ok(())
}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.
Write durability tiers
For ordinary mutations and mergeable transactions, the tier passed to wait({ tier }) controls how
far the mutation must propagate before the promise resolves: locally on the client (local), the
nearest edge server (edge), or the global core (global).
| Tier | Resolves when |
|---|---|
local | Persisted to local durable storage |
edge | Acknowledged by nearest sync server |
global | Propagated to global core |
Pick a tier
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 up through the tiers and across 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 always confirms instantly, even offline.
Durable on a regional sync server. Survives the device going offline.
Offline, only local can resolve. edge and global waits stay pending until the device
reconnects and the queued write either propagates upstream or is rejected. Choosing a higher tier
changes when the promise settles; it does not delay the initial local mutation.
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: "edge" });
await db.update(app.todos, id, { done: true }).wait({ tier: "global" });
await db.delete(app.todos, id).wait({ tier: "global" });
}pub async fn write_todo_with_default_durability(
client: &JazzClient,
) -> jazz::tools::Result<ObjectId> {
let (id, _row_values, _transaction_id) = client.insert(
"todos",
todo_values("Write docs with default durability behavior", ""),
)?;
// Rust currently does not expose per-write durability tier arguments.
// Writes apply locally first, then sync asynchronously to higher tiers.
Ok(id)
}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 withPersistedWriteRejectedError.
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.
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.