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);}
export const writeTodoCrud = Effect.fn("writeTodoCrud")(function* (todoId: string) {const jazz = yield* Jazz;yield* jazz.insert(app.todos, {title: "Write docs",done: false,owner_id: EXAMPLE_OWNER_ID,projectId: EXAMPLE_PROJECT_ID,});yield* jazz.update(app.todos, todoId, { done: true });yield* jazz.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: "global" });}
export const upsertTodo = Effect.fn("upsertTodo")(function* (importedTodoId: string) {const jazz = yield* Jazz;yield* jazz.upsert(app.todos,importedTodoId,{ title: "Imported task", done: false },{ wait: "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;}
export const restoreDeletedTodo = Effect.fn("restoreDeletedTodo")(function* (todoId: string) {const jazz = yield* Jazz;yield* jazz.delete(app.todos, todoId);const deletedTodo = yield* jazz.one(app.todos.where({ id: todoId }).includeDeleted());if (Option.isNone(deletedTodo)) return yield* new TodoNotFound({ id: todoId });return yield* jazz.restore(app.todos, todoId, {title: "Restored task",done: false,owner_id: EXAMPLE_OWNER_ID,projectId: EXAMPLE_PROJECT_ID,});});
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 FKdb.update(app.todos, todoId, { description: undefined }); // leaves the field unchanged}
export const clearNullableTodoFields = Effect.fn("clearNullableTodoFields")(function* (todoId: string,) {const jazz = yield* Jazz;yield* jazz.update(app.todos, todoId, { owner_id: null }); // clears the nullable FKyield* jazz.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 nullclient.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 coordinatessplices: [{ 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 endsplices: [{ 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:
| Tier | Resolves when |
|---|---|
local | Saved by the local persistence driver |
global | Authorized 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.
Authorized and durably accepted by Core.
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" });}
export const writeTodoWithDurabilityTiers = Effect.gen(function* () {const jazz = yield* Jazz;const { id } = yield* jazz.insert(app.todos,{title: "Write docs with durability tier",done: false,owner_id: EXAMPLE_OWNER_ID,projectId: EXAMPLE_PROJECT_ID,},{ wait: "global" },);yield* jazz.update(app.todos, id, { done: true }, { wait: "global" });yield* jazz.delete(app.todos, id, { wait: "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;}}
export const insertTodoAndWait = Effect.gen(function* () {const jazz = yield* Jazz;const row = yield* jazz.insert(app.todos,{title: "Ship review fixes",done: false,owner_id: EXAMPLE_OWNER_ID,projectId: EXAMPLE_PROJECT_ID,},{ wait: "global" },);yield* Effect.log(row.id);}).pipe(// The server rejected the write; it has already been undone locally.Effect.catchTag("JazzWriteRejected", (rejected) =>Effect.logError(rejected.code, rejected.reason),),);
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);});}
// Rejections of writes nobody waited for. Fork this for the app's lifetime.export const logMutationErrors = Effect.gen(function* () {const jazz = yield* Jazz;yield* jazz.mutationErrors.pipe(Stream.runForEach((event) => Effect.logError("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.