Writing Data

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).

TierResolves whenDefault for
localPersisted to local OPFSBrowser/client
edgeAcknowledged by nearest sync serverBackend/server
globalPropagated 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");
});

On this page