Writing Data
Insert, update, and delete APIs with local-first execution, framework context hooks, and an intro to durability tiers.
Local-first writes
All writes execute against the local database first. insert, update, and delete return write handles immediately. Call .wait({ tier: ... }) on those handles when you need confirmation that the write reached a specific durability tier.
Jazz also allows grouping writes together using batches and transactions.
Writes made through an open transaction or batch are not individually waitable. Call .wait({ tier }) on the returned write result instead.
For browser Blob, File, or ReadableStream uploads, use db.createFileFromBlob(...)
or db.createFileFromStream(...) and store the returned file ID on your own row.
See Files & Blobs for more details.
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 { createDb } from "jazz-tools";
const db = await createDb({
appId: "my-app",
env: "dev",
userBranch: "main",
});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.create("todos", values).await?;
client
.update(
existing_id,
vec![("done".to_string(), Value::Boolean(true))],
)
.await?;
client.delete(existing_id).await?;
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 write handle that can be awaited for durability.
const write = db.upsert(
app.todos,
{
title: "Imported task",
done: false,
},
{ id: importedTodoId },
);
await write.wait({ tier: "edge" });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)])
.await?;
// Only the specified columns are changed; omitted columns are left as-is.
Ok(())
}Write durability tiers
When using insert(...).wait({ tier }), update(...).wait({ tier }), or delete(...).wait({ tier }), the 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 | Default for |
|---|---|---|
local | Persisted to local OPFS | Browser/client |
edge | Acknowledged by nearest sync server | Backend/server |
global | Propagated to global core | — |
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) = client
.create(
"todos",
todo_values("Write docs with default durability behavior", ""),
)
.await?;
// 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.
wait({ tier }) resolves when the batch reaches the requested durability tier. If the batch is rejected, it rejects with PersistedWriteRejectedError instead of hanging.
const pending = db.insert(app.todos, {
title: "Ship review fixes",
done: false,
});
console.log(pending.batchId);
try {
const row = await pending.wait({ tier: "global" });
console.log(row.id);
} catch (error) {
if (error instanceof PersistedWriteRejectedError) {
console.error(error.code, error.reason);
}
}You can also set a global onMutationError handler that will be notified anytime a write that was not explicitly awaited fails:
db.onMutationError((error) => {
console.error("DB mutation failed: ", error);
});Transactions and batches
Use a transaction when several writes should settle together after an authority validates the sealed batch:
const result = await db.transaction(async (tx) => {
tx.insert(app.todos, { title: "Draft copy", done: false });
const stagedDrafts = await tx.all(app.todos.where({ done: false }));
tx.update(app.todos, todoId, { done: true });
});
await result.wait({ tier: "edge" });
console.log(result.batchId);The changes made as part of the transaction are scoped to it, and will only be globally visible once it's committed and accepted by the authority.
You can also use batches when you want to group several ordinary writes and commit them together:
const result = db.batch((batch) => {
batch.insert(app.todos, { title: "Grouped write", done: false });
batch.update(app.todos, todoId, { done: true });
});
await result.wait({ tier: "edge" });
console.log(result.batchId);Transactions and batches can read its own local writes through all(...) and one(...) before commit.
When using transaction and batch, changes are automatically committed once the callback finishes running and,
if an error is thrown inside the callback, the open transaction or batch is rolled back instead of committed.
Alternatively, you can use beginTransaction and beginBatch to get a transaction or batch object, respectively.
This can be useful when making writes across multiple contexts. In this case however, you need to commit or rollback
the transaction/batch explicitly.
let transaction: DbTransaction;
beforeEach(() => {
transaction = db.beginTransaction();
});
afterEach(() => {
// Writes performed in tests will be rolled back at the end,
// preventing tests from interfering with each other
transaction.rollback();
});
it("tasks have a title", () => {
const task = transaction.insert(todos, { title: "Test task", done: false });
expect(task.title).toEqual("Test task");
});