# FAQ



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

<Accordions type="multiple">
  <Accordion id="reset-browser-storage" title="How do I reset browser storage?">
    In browser apps using the React, Vue, Svelte, or Solid Jazz clients, open the devtools console and run:

    ```ts
    await window.__jazz.clearStorage();
    ```

    If the page has more than one live Jazz storage context, inspect the available namespaces and then choose one explicitly:

    ```ts
    window.__jazz.listLiveStorageNamespaces();
    await window.__jazz.clearStorage("my-app::alice");
    ```

    If you're working directly with a `Db` handle instead of the window helper, `await db.deleteClientStorage()` is the underlying API. See [Auth Lifecycle](/docs/auth/lifecycle#storage-reset) for how this differs from logout and local-first identity storage.

    * Browser persistent storage only.
    * If exactly one live namespace exists, `clearStorage()` uses it automatically.
    * If multiple live namespaces exist, Jazz throws and lists the available namespaces until you choose one.
    * Can be initiated from either leader or follower tabs; Jazz coordinates the reset across tabs for that namespace.
    * Deletes IndexedDB storage only. It does not clear `localStorage` local-first auth data.
    * Reopens a clean worker/runtime so the same live client remains usable after the wipe.

    This is useful when iterating on your schema during development.
  </Accordion>

  <Accordion id="undefined-vs-empty-array" title="Why does useAll return undefined?">
    `useAll` and `QuerySubscription` return `undefined` until the first response arrives from the requested tier. After that, the value is an array — empty (`[]`) if no rows match, or populated. See [The loading state](/docs/reading/queries#the-loading-state) for details.
  </Accordion>

  <Accordion id="offline-only" title="Can I use Jazz without a server?">
    Yes. Omit `serverUrl` from your config. Jazz will not try to sync: browser clients keep their data
    in local persistent storage, while other runtimes use their configured local driver.
  </Accordion>

  <Accordion id="local-first-to-external" title="Can I upgrade from local-first auth to external auth without losing data?">
    Yes. When a user signs up with an external provider, their identity carries over. See [Signing up with BetterAuth](/docs/auth/local-first-auth#signing-up-with-betterauth).
  </Accordion>

  <Accordion id="rust-migrations" title="Do Rust apps need separate migration files?">
    No. Define and publish migrations with the TypeScript tooling. The server stores the compiled
    migrations that the Rust runtime uses. See [Migrations](/docs/schemas/migrations) for details.
  </Accordion>

  <Accordion id="common-errors" title="How do I handle a rejected write?">
    Keep the result of the write and call `wait()` when your app needs to know whether the server
    accepted it. A rejected write throws `PersistedWriteRejectedError`, which provides the server's
    `code`, `reason`, and `transactionId`.

    Use `db.onMutationError()` as a fallback for rejected writes that do not have an active `wait()`
    call. The callback receives the rejection `code`, `reason`, and local transaction record. See
    [Writing data](/docs/writing/writing-data#write-durability-tiers) for an example and
    [Permissions](/docs/auth/permissions) for how the server decides whether to accept a write.
  </Accordion>
</Accordions>


# Overview



Jazz is a local-first relational database with row-level permissions, real-time sync, and offline support — no separate API layer needed. Your app reads from and writes to a local replica, and Jazz syncs it with a server in the background.

```ts
import { schema as s } from "jazz-tools";

// Define your schema
const schema = {
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
  }),
};
const app = s.defineApp(schema);

// Write — instant, works offline
db.insert(app.todos, { title: "Ship it", done: false });

// Read — reactive, stays up to date across devices
db.subscribe(app.todos.where({ done: false }), (todos) => {
  console.log(todos);
});
```

Jazz works with React, Vue, Svelte, Solid, Expo/React Native, plain TypeScript, and Rust.

(Looking for [classic Jazz docs?](https://classic.jazz.tools/docs))

Quickstart [#quickstart]

<Cards>
  <Card title="Quickstart" href="/docs/quickstart" />
</Cards>

Install [#install]

<Cards>
  <Card title="Client" href="/docs/install/client" />

  <Card title="TypeScript Server" href="/docs/install/typescript-server" />
</Cards>

Setup [#setup]

<Cards>
  <Card title="Client Setup" href="/docs/getting-started/client-setup" />

  <Card title="Server Setup" href="/docs/getting-started/server-setup" />
</Cards>

Reference [#reference]

<Cards>
  <Card title="Authentication" href="/docs/auth/authentication" />

  <Card title="Schemas" href="/docs/schemas/defining-tables" />

  <Card title="Reading Data" href="/docs/reading/queries" />

  <Card title="Writing Data" href="/docs/writing/writing-data" />

  <Card title="Permissions" href="/docs/auth/permissions" />

  <Card title="Migrations" href="/docs/schemas/migrations" />
</Cards>


# Quickstart



Scaffold a new Jazz app [#scaffold-a-new-jazz-app]

```bash title="Terminal"
pnpm create jazz
```

Pick a framework, hosting mode, and auth style when prompted. The scaffolder installs dependencies and — if you chose hosted — provisions an app on Jazz Cloud with env vars filled in for you.

What you get [#what-you-get]

* `schema.ts` — the source of truth for your data. Tables, types, and queries all flow from here. See [Schemas](/docs/schemas/defining-tables).
* `permissions.ts` — row-level access, pre-wired. See [Permissions](/docs/auth/permissions).
* A Jazz provider and a todo UI you can reshape into your own app.

Next steps [#next-steps]

* **Model your own data** — edit `schema.ts`, then use `db.insert` and `db.subscribe` from your components. See [Writing Data](/docs/writing/writing-data) and [Queries](/docs/reading/queries).
* **Add auth** — see [Authentication](/docs/auth/authentication) to go beyond an anonymous identity.
* **Install into an existing app** — see [Install → Client](/docs/install/client) or [Install → TypeScript Server](/docs/install/typescript-server).


# Authentication



A Jazz context always belongs to an account and acts as one exact identity. An identity is the pair of JWT claims `iss` (issuer) and `sub` (subject). An account is a stable ID that can admit several identities.

Create a session with configuration once:

```ts
import { createJazzSession } from "jazz-tools/client";

const jazz = await createJazzSession({
  appId: "my-app",
  serverUrl: "https://core.example",
  initial: "local-first",
});
```

The session restores a usable saved account or creates a local-first account when none exists. It owns graceful context replacement for subsequent auth commands. Local-first creation works offline once runtime assets are available.

Contexts accept an opaque `AccountHandle`, rather than raw JWTs or signing secrets. A handle fixes both the account and acting identity. The server still verifies credentials independently; copying an account ID into a JWT or object grants no access.

External authentication [#external-authentication]

Configure the Jazz core with your provider's signing keys, issuer, and audience. The provider can issue ordinary JWTs; it does not need to put a Jazz account ID or linking assertion in their claims.

Choose the operation that matches the user's intent:

| Operation                       | Result                                                                                        |
| ------------------------------- | --------------------------------------------------------------------------------------------- |
| `jazz.loginOrRegisterJWT(auth)` | Atomically select an active assignment or create the account for a fresh identity.            |
| `jazz.registerJWT(auth)`        | Create an account for a fresh external identity.                                              |
| `jazz.loginJWT(auth)`           | Select an existing, active account assignment.                                                |
| `jazz.linkJWT(auth)`            | Attach a fresh external identity to the currently selected account and select its new handle. |
| `jazz.getSnapshot().account`    | Read the locally selected handle, or `undefined`.                                             |
| `jazz.logout()`                 | Clear selection and invalidate this manager's issued credentials.                             |

Registration, login, and linking require a connection to the core. For ordinary provider signup, sign-in, and restoration, use `loginOrRegisterJWT`. The core performs one ordered decision: return the existing active assignment or create it when unassigned. Revoked identities remain rejected and assignments are never moved. Strict `loginJWT` and `registerJWT` remain available when the application needs those specific operations. An enrollment rejection preserves the previous selection. If enrollment succeeds but local persistence or client startup fails, the new selection remains; use `jazz.retry()` to retry that local work.

```ts
await jazz.loginOrRegisterJWT({
  getToken: async () => {
    const token = await getTokenFromYourProvider();
    return token;
  },
});
const { client } = jazz.getSnapshot();
```

`auth` can also be a JWT string. Use a `getToken` callback for renewable credentials. The shared account context refreshes them across all framework bindings; there is no provider-level `onJWTExpired` callback. Every refreshed token must have the same exact issuer and subject. A callback that does not settle within 30 seconds fails that attempt; its eventual result cannot replace a later credential.

Automatic provider connections [#automatic-provider-connections]

For auth-required React apps, let `JazzProvider` own the session and its provider connection:

```tsx
import { betterAuth } from "jazz-tools/client";
import { JazzProvider, useJazzAuth } from "jazz-tools/react";

function Root() {
  return (
    <JazzProvider
      appId={APP_ID}
      serverUrl={SERVER_URL}
      auth={betterAuth(authClient)}
      signedOut={<SignIn />}
    >
      <App />
    </JazzProvider>
  );
}

function SignOut() {
  const auth = useJazzAuth();
  return <button onClick={() => void auth.logout()}>Sign out</button>;
}
```

Forms only call Better Auth's `signUp` and `signIn`. Jazz follows provider hydration and session changes, atomically logs in or registers, and renders the application only when its client is ready. The provider supplies loading and retryable error UI; `signedOut` supplies your sign-in screen. Logout flushes Jazz before revoking the provider session. Failures remain in the lifecycle state and its error UI.

`useJazzAuth()` exposes the shared lifecycle and its `logout()` and `retry()` actions. React Native and Expo use their corresponding Jazz imports and native fallback controls. Svelte, Solid and Vue use the same underlying lifecycle through their framework bindings.

For WorkOS or another JWT provider, use `jwtAuth({ key, isPending, getToken, logout })`. The key identifies the provider session or exact issuer/subject, never the rotating JWT string. The logout callback signs out of the provider or clears the application's token source; the source must then report `key: null`. Jazz waits for that acknowledgement before rendering signed-out content.

Vanilla applications can own the same observable lifecycle with `createJazzApp({ appId, serverUrl, auth: betterAuth(authClient) })`, subscribe to its snapshot and call `dispose()` on teardown. Without managed auth, the convenience owner defaults to a local-first account. Explicit `initial` configuration takes precedence. Existing low-level `createJazzSession`, `connectBetterAuth` and `connectAuthProvider` remain available for custom ownership.

Linking identities [#linking-identities]

An exact identity can belong to only one account, permanently. Revocation prevents its use but does not free it for reassignment. Linking therefore accepts only a fresh identity. If the identity already belongs to a different account, the app must decide how to migrate data; Jazz does not merge accounts.

For a hybrid app, omit managed `auth` and use the framework hook’s `sessionActions`, or own a low-level session directly. Signup calls the provider and then `jazz.linkJWT(auth)` on the retained guest account; ordinary login calls `loginOrRegisterJWT`. This ensures the fresh identity is linked before any automatic account creation.

`jazz.linkJWT(auth)` handles graceful shutdown, linking outside contexts, and opening the replacement client. See [Lifecycle](/docs/auth/lifecycle) for the complete handoff and failure path.

The core orders a two-party protocol: the admitted identity authorizes an expiring nonce committed to the fresh identity, and the fresh identity authenticates acceptance. Both requests use ordinary bearer credentials. The core checks that the approver is still active and allowed to manage identities when acceptance happens.

Ownership and provenance [#ownership-and-provenance]

Public sessions and author columns expose the same structured value:

```ts
{
  account: "00000000-0000-4000-8000-000000000001",
  identity: { issuer: "https://issuer.example", subject: "alice" },
}
```

Compare `.account` for account-owned data. Comparing the whole structure also compares issuer and subject. Two linked identities share account ownership while retaining distinct authorship.

Provider claims remain under `session.claims`. They cannot override `session.user.account` or the acting identity. See [Sessions](/docs/auth/sessions) and [Permissions](/docs/auth/permissions).

Backend request contexts [#backend-request-contexts]

Create a Node owner with `await createJazzSession({ appId, app, driver, serverUrl, initial: { backendSecret } })` from `jazz-tools/backend`. Backend admission requires a live core check; the secret is private, ephemeral handle material. The ready snapshot's `client.db` performs backend work.

`await client.forRequest(request)` verifies the original bearer and resolves its active account through the core before opening an immutable requester scope. Configure JWT verification for external providers. By default, external identities must already be registered; local-first proofs can establish their deterministic founding account. Auth-required server routes can opt into `client.forRequest(request, { account: "login-or-register" })` to atomically create a fresh external assignment after verifying the bearer. The option applies only to that immutable request scope; it never changes the backend account or accepts revoked identities. Keep the default strict mode on hybrid routes where a fresh identity must be linked first.

`await client.forAccount(account)` accepts an opaque admitted user handle and re-verifies its credential. Neither method switches the shared owner. Raw session objects cannot select an arbitrary principal or account. `await client.withAttribution(account)` retains backend permissions while recording verified user authorship.


# Lifecycle



A Jazz session owns the selected account and its client. Configure it once; use session commands to link, log in, log out, or restore an account. Each underlying context remains bound to one immutable account handle.

Starting a session [#starting-a-session]

```ts
import { createJazzSession } from "jazz-tools/client";

const jazz = await createJazzSession({
  appId: "my-app",
  serverUrl: "https://core.example",
  initial: "local-first",
});
const { client, account } = jazz.getSnapshot();
```

`initial: "local-first"` restores usable saved selection or creates a local-first account when none exists. Without it, an empty selection stays signed out. External identities are never registered implicitly. Browser account storage retains local signing roots and selection; provider JWTs are not persisted.

Linking and switching accounts [#linking-and-switching-accounts]

```ts
await jazz.linkJWT({ getToken }); // Add an identity to this account.
await jazz.loginJWT({ getToken }); // Select an already registered identity.
await jazz.registerJWT({ getToken }); // Explicitly register a fresh identity.
```

For each transition Jazz hides the old client, waits for framework consumers to detach, then performs ordinary graceful shutdown with `waitForSync: true`. Only afterward does it perform the account operation, outside any context, and open the replacement client. Earlier rows retain their original issuer and subject. Linking preserves the stable account ID and its data.

A sync-barrier failure leaves the old client usable and prevents the account operation. If enrollment fails after shutdown, Jazz reopens the previous selection when possible. If enrollment succeeds but client startup fails, the new selection remains and `await jazz.retry()` retries startup without repeating enrollment. A teardown failure after sync is reported with no usable client; Jazz does not pretend a stopped context is ready.

Overlapping account-changing commands reject with a busy error instead of silently applying to a later account. Logout and close supersede in-flight work and prevent late completions from publishing a client. A remote link that already succeeded remains discoverable by a later explicit login.

Reactive state [#reactive-state]

`getSnapshot()` and `subscribe(listener)` expose stable snapshots. Status is `ready`, `signed-out`, `transitioning`, `error`, or `closed`. A client is available only in `ready`. A recoverable operation error may accompany `ready`, so the app can show it while the previous account remains usable.

```tsx
import { JazzSessionProvider, useJazzSession } from "jazz-tools/react";

function App() {
  return (
    <JazzSessionProvider
      config={{ appId, serverUrl, initial: "local-first" }}
      fallback={<AccountStatus />}
    >
      <TodoApp />
    </JazzSessionProvider>
  );
}

function AccountStatus() {
  const { status, error, retry } = useJazzSession();
  return error ? (
    <button onClick={() => void retry().catch(console.error)}>{error.message}: retry</button>
  ) : (
    <p>{status === "signed-out" ? "Sign in to continue" : "Loading…"}</p>
  );
}
```

The hook is available in children and fallback. Data consumers unmount during a transition; Jazz handles the shutdown/replacement sequence. An app can put sign-in UI in the signed-out fallback and invoke `loginJWT` or `registerJWT` there. Config is captured at mount; use a React key to replace an application's configuration. Passing an existing `session` instead of `config` leaves ownership with its caller: unmounting the provider does not close it.

For authentication coordination that must remain mounted while the data UI changes, `useJazzSessionOwner(config)` returns `{ session, error, retry }`. It owns initialization and cleanup with the same lifecycle as the configured provider. Pass the resulting session to `JazzSessionProvider`; keep provider-specific login/sign-out state above that provider. No app-owned shutdown queue or unmount timer is needed.

React Native and Expo expose the same provider/hook from their platform entrypoints. Svelte provides `JazzSessionProvider`, `getJazzSession()` (a store with bound commands), and `sessionState(session)`. Vue and Solid provide providers and reactive session adapters over the same state machine.

Logout and disposal [#logout-and-disposal]

```ts
await jazz.logout();
await signOutFromYourProvider();
// For apps that intentionally return to a fresh anonymous account:
await jazz.createLocalFirst();
```

Logout stays signed out until the app chooses another account. It does not erase the local database or revoke an identity at core. Provider cookies remain the provider's responsibility. Local-first signing roots remain available for recovery.

Call `await jazz.close()` when retiring an imperatively owned session. Ordinary close preserves local durability and works offline; it does not promise remote sync completed. Account-changing commands use the stronger sync barrier automatically.

Recovery phrases and passkeys [#recovery-phrases-and-passkeys]

```ts
import { exportLocalFirstSecret } from "jazz-tools";
import { RecoveryPhrase } from "jazz-tools/passphrase";

const secret = exportLocalFirstSecret(jazz.getSnapshot().account!);
const phrase = RecoveryPhrase.fromSecret(secret);
await jazz.restoreLocalFirst(RecoveryPhrase.toSecret(recoveredPhrase));
```

Use the same secret with `BrowserPasskeyBackup` for a passkey backup. Export requires a live local-first handle; external or logged-out handles cannot export it. Secrets never appear in snapshots. Possession of a recovery phrase allows acting as that local-first identity.

Lower-level composition [#lower-level-composition]

`createAccountManager`, opaque `AccountHandle`, and `createJazzClient` remain available for applications that intentionally own their lifecycle. The manager handles credentials and registry operations only. Contexts accept valid handles and never change identity; low-level callers must arrange graceful shutdown before linking or replacing contexts themselves. The session abstraction composes these primitives in shared TypeScript; framework adapters only observe state and acknowledge consumer cleanup.

`db.deleteClientStorage()` resets supported browser database storage without erasing retained account signing roots. It is a development reset, not account registration or linking.


# Local-first auth



A local-first account lets someone start using an app before signing up. Jazz derives its founding identity and account from a random signing root. Creating that account works offline. When it connects, the core verifies the signing proof and admits the deterministic account.

Client setup [#client-setup]

Configure a session once:

```ts
import { createJazzSession } from "jazz-tools/client";

const jazz = await createJazzSession({
  appId: "my-app",
  serverUrl: "https://core.example",
  initial: "local-first",
});
```

Session creation prepares crypto, restores usable saved selection, or creates a local-first account. Browser sessions retain signing roots in browser storage. Native hosts use an atomic, protected `AccountStore`; provider JWTs are never persisted in it. Creating a local-first account works offline once runtime assets are available.

Framework `JazzSessionProvider` and session hooks expose the same lifecycle. Account commands run outside contexts after ordinary graceful shutdown, which Jazz coordinates automatically. See [Lifecycle](/docs/auth/lifecycle). Lower-level account handles and client factories remain available when an app deliberately owns its own context lifecycle.

Backing up and restoring the secret [#backing-up-and-restoring-the-secret]

A signing root is a credential. Possession allows acting as its local-first identity. Jazz represents it as `jazz-auth-v1:` followed by 43 unpadded base64url characters encoding 32 random bytes. Export it explicitly from a live local-first handle:

```ts
import { exportLocalFirstSecret } from "jazz-tools";
import { RecoveryPhrase } from "jazz-tools/passphrase";

const secret = exportLocalFirstSecret(jazz.getSnapshot().account!);
const phrase = RecoveryPhrase.fromSecret(secret);
```

The recovery phrase encodes the same root; it is not a second password. Keep it out of logs and ordinary account-state UI. Export fails for external or logged-out handles. Signing roots remain retained after logout so explicit recovery can restore them.

Recovery passphrase [#recovery-passphrase]

Restore through the session; it owns the graceful handoff:

```ts
await jazz.restoreLocalFirst(RecoveryPhrase.toSecret(userInput));
```

Session account transitions wait for pending writes to sync before replacing the client. Ordinary `jazz.close()` preserves local durability without requiring online sync.

Passkey backup [#passkey-backup]

Browser apps can wrap the same exported root with a passkey:

```ts
import { BrowserPasskeyBackup } from "jazz-tools/passkey-backup";

const backup = new BrowserPasskeyBackup({
  appName: "My App",
  appHostname: "app.example",
});
await backup.backup(exportLocalFirstSecret(jazz.getSnapshot().account!), "My account");
```

`await backup.restore()` returns the root for `await jazz.restoreLocalFirst(secret)`. Pin the canonical production hostname when configuring a passkey backup; passkey availability and synchronization depend on the user's platform.

Linking an external provider [#linking-an-external-provider]

An external provider can issue an ordinary JWT with `iss` and `sub`. It does not need to mint Jazz-specific claims or reuse the local-first subject.

Call `await jazz.linkJWT({ getToken })`; Jazz performs graceful shutdown, linking, and client replacement. The external identity must be fresh in this application's registry. Linking requires a connection to the core and keeps the existing account ID, so account-based ownership remains valid. Earlier rows retain the exact identity that authored them.

The core never moves an already assigned identity to another account. If the provider identity already has an account, the app must choose whether to log into that account or migrate data explicitly. See [Authentication](/docs/auth/authentication) for registration versus login, and [Lifecycle](/docs/auth/lifecycle) for failure and logout handling.

Permissions by auth mode [#permissions-by-auth-mode]

Use `session.user.account` for stable account ownership. Use the exact issuer and subject when a policy deliberately distinguishes linked identities. A local-first identity is authenticated by its signing key; it is not an unauthenticated guest.

Provider claims can add application requirements, such as a verified role. They cannot replace the account assignment. Define those checks in [Permissions](/docs/auth/permissions), alongside ownership policies.


# Permissions



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

Permissions control who can read, insert, update, and delete rows. Jazz enforces them server-side using row-level policies defined in `permissions.ts`. Clients that fail a policy check have their writes rejected and their reads filtered.

Jazz decides permissions per table:

* a table with no policy declarations is open for reads, inserts, updates, and deletes
* once a table declares any policy, its policy set is closed: every omitted operation is denied

This keeps a new app usable before it introduces permissions, while making a
partial policy safe. For example, declaring `allowRead` without `allowInsert`,
`allowUpdate`, or `allowDelete` makes that table read-only.

Authoring workflow [#authoring-workflow]

Permissions are authored in TypeScript.

If we have the following schema:

```ts title="schema.ts"
const schema = {
  projects: s.table({
    name: s.string(),
    owner_id: s.uuid(),
  }),
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
    parentId: s.ref("todos").optional(),
    projectId: s.ref("projects").optional(),
    owner_id: s.uuid(),
  }),
  todoShares: s.table({
    todoId: s.ref("todos"),
    user_id: s.uuid(),
    can_read: s.boolean(),
  }),
};
```

We can define permissions in `permissions.ts`:

```ts title="permissions.ts"
import { schema as s } from "jazz-tools";
import { app } from "./schema.js";

export default s.definePermissions(app, ({ policy, session }) => {
  policy.todos.allowRead.where({ owner_id: session.user.account });
  policy.todos.allowInsert.where({ owner_id: session.user.account });
  policy.todos.allowUpdate.where({ owner_id: session.user.account });
  policy.todos.allowDelete.where({ owner_id: session.user.account });
});

```

<Callout type="info">
  Write your permissions policies in `permissions.ts` next to `schema.ts`. By default Jazz looks for both files at your project root — except in SvelteKit projects, where they should live in `src/lib/` (the standard location for shared app code).

  Run `pnpm dlx jazz-tools@alpha validate` before publishing to surface whether a table remains fully open or has omitted operations that will deny. You do not need to write a schema migration to update permissions policies. Push the updated policies by running `pnpm dlx jazz-tools@alpha deploy <appId>`.
</Callout>

Apps that do not need user-scoped filtering can leave a table policy-free, or declare all required explicit grants such as `policy.todos.allowRead.always()` and `policy.todos.allowInsert.always()`.

Basic policies [#basic-policies]

Simple conditions [#simple-conditions]

Use the policy helpers `allowRead`, `allowInsert`, `allowUpdate`, and `allowDelete` with [`.where(...)`](/docs/reference/where-operators) to restrict access based on column values.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, allOf, session }) => {
  policy.todos.allowRead.where({ owner_id: session.user.account });
  // Users cannot create todos with different owners
  policy.todos.allowInsert.where({ owner_id: session.user.account });
  // Users can update their own todos, but only if not already done
  policy.todos.allowUpdate
    .whereOld(allOf([{ owner_id: session.user.account }, { done: false }]))
    .whereNew({ owner_id: session.user.account });
  // Users can only delete their own todos
  policy.todos.allowDelete.where({ owner_id: session.user.account });
});
```

.always() [#always]

Use `.always()` when an operation should always be permitted. It is equivalent to `.where({})`.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy }) => {
  policy.todos.allowRead.always();
  policy.todos.allowInsert.always();
  policy.todos.allowUpdate.always();
  policy.todos.allowDelete.always();
});
```

.never() [#never]

Use `.never()` when an operation should be impossible. It is equivalent to
`.where(anyOf([]))`.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy }) => {
  policy.todos.allowRead.never();
  policy.todos.allowInsert.never();
  policy.todos.allowUpdate.never();
  policy.todos.allowDelete.never();
});
```

Composing policies [#composing-policies]

Combining conditions (allOf / anyOf) [#combining-conditions-allof--anyof]

Combine conditions with `allOf` (all must match) or `anyOf` (any can match).

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, allOf, anyOf, allowedTo, session }) => {
  // Users can read a todo if they own it, or if it's not done and they can read its project.
  policy.todos.allowRead.where(
    anyOf([
      { owner_id: session.user.account },
      allOf([{ done: false }, allowedTo.read("project")]),
    ]),
  );
});
```

Plain objects in a condition are row predicates. This includes tables with columns such as `type`,
`column`, or `operation` that also appear in Jazz's policy representation. Results from helpers such
as `isCreator`, `allowedTo.*`, `allOf`, and `anyOf` remain directly composable with those row
predicates.

An object such as `{ type: "True" }` is row data only when the table schema declares a `type` column.
On a table without that column, Jazz rejects the unbranded policy-shaped value rather than silently
interpreting it as either row data or policy IR. For advanced cases that need manually-authored
policy IR, use `raw(...)` to opt in explicitly:

```ts
definePermissions(app, ({ policy, raw }) => {
  policy.documents.allowRead.where(raw({ type: "True" }));
});
```

JWT session claims [#jwt-session-claims]

When external auth JWTs carry claims, `session.where(...)` lets you check them directly in permissions without mapping them onto row columns first.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
  policy.todos.allowRead.where(
    anyOf([{ owner_id: session.user.account }, session.where({ "claims.role": "manager" })]),
  );
});
```

Inherited access (allowedTo.*) [#inherited-access-allowedto]

A row can inherit its access from a related row. Use
`allowedTo.read/insert/update/delete(...)` to express that inheritance.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, allOf, allowedTo }) => {
  // Users can read a todo if it's not done, or if they can read its project.
  policy.todos.allowRead.where(anyOf([{ done: false }, allowedTo.read("project")]));
  // Users can update a todo if they can update its project and it's not done.
  policy.todos.allowUpdate
    .whereOld(allOf([allowedTo.update("project"), { done: false }]))
    .whereNew(allowedTo.update("project"));
});
```

Share-based access [#share-based-access]

Sometimes access isn't determined by ownership or a parent relationship, but by a separate "shares" table. Use `policy.<table>.exists.where(...)` to check whether a matching row exists in another table.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
  // Users can read a todo if they own it, or if someone shared it with them.
  policy.todos.allowRead.where((todo) =>
    anyOf([
      { owner_id: session.user.account },
      policy.todoShares.exists.where({
        todoId: todo.id,
        user_id: session.user.account,
        can_read: true,
      }),
    ]),
  );
});
```

The callback form `(todo) => ...` gives you access to the current row, so you can correlate it with rows in other tables.

<Accordions type="single">
  <Accordion title="Recursive inheritance">
    If a table references itself (e.g. a comment that can have sub-comments), you can inherit access recursively up to a fixed depth.

    ```ts title="permissions.ts"
    s.definePermissions(exampleApp, ({ policy, allowedTo }) => {
      // Users can read a todo if they can read its parent (follows the chain upward).
      policy.todos.allowRead.where(allowedTo.read("parent"));
      // Users can update a todo if they can update its parent, up to 5 levels deep.
      policy.todos.allowUpdate
        .whereOld(allowedTo.update("parent", { maxDepth: 5 }))
        .whereNew(allowedTo.update("parent", { maxDepth: 5 }));
    });
    ```

    `maxDepth` must be a non-negative integer. A value of `0` performs no inheritance
    hop, so the inheritance condition cannot grant access; `1` checks only the
    directly referenced row.
  </Accordion>
</Accordions>

Update policies: old row vs new row [#update-policies-old-row-vs-new-row]

Update policies can check both the row **before** the update (`.whereOld(...)`) and the row
**after** the update (`.whereNew(...)`). This is useful when you need to verify that the user had
permission to modify the original row and that the result is also valid.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, session }) => {
  // User can only update their own rows, and the result must still be owned by them
  policy.todos.allowUpdate
    .whereOld({ owner_id: session.user.account })
    .whereNew({ owner_id: session.user.account });
});
```

If you only use `.whereOld(...)`, the same condition is applied to both the old and new row. The
same applies if you only use `.whereNew(...)`. Use both when the old-row and new-row checks differ.

`exists` runs before a write is applied, so it can be used to prevent some columns from being updated.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, allOf, session }) => {
  policy.todos.allowUpdate.whereOld({ owner_id: session.user.account }).whereNew((updatedTodo) =>
    allOf([
      { owner_id: session.user.account },
      // `parentId` and `projectId` cannot be updated.
      policy.todos.exists.where({
        id: updatedTodo.id,
        parentId: updatedTodo.parentId,
        projectId: updatedTodo.projectId,
      }),
    ]),
  );
});
```

Policy enforcement and request context [#policy-enforcement-and-request-context]

Every policy is evaluated against a session. On the frontend, this is the authenticated user's session. In a backend handler, create a scoped session per request so queries run with the right identity.

```ts title="handler.ts"
export async function listTodosForRequester(req: Request, res: Response): Promise<void> {
  try {
    const requester = await client.forRequest(req);
    const rows = await requester.all(schemaApp.todos.where({ done: true }));
    res.json(rows);
  } catch {
    sendQueryError(res);
  }
}
```

See [Server Setup](/docs/getting-started/server-setup#backend-context-setup) for backend context setup details.

<Callout type="info">
  Structural-only client runtimes stay permissive locally so offline reads and writes keep working.
  Once a compiled bundle is loaded, Jazz enforces explicit grants locally too. Sync servers still
  reject violating writes, and server-scoped reads are filtered before data is sent to the client.
</Callout>

Check permissions before an action [#check-permissions-before-an-action]

Use `db.canInsert()`, `db.canRead()`, `db.canUpdate()`, or `db.canDelete()` when your app needs to
check an action before showing it to the user. Each method returns `"allowed"`, `"denied"`, or
`"unknown"`.

These results are advice, not permission enforcement. A result can become stale, and `"unknown"`
means Jazz could not give a definite answer. The authority still makes the final decision when the
read or write reaches it.

```ts title="app.ts"
export async function canReadTodo(db: Db, todoId: string) {
  return db.canRead(app.todos, todoId);
}

export async function readTodosWithDeletePermission(db: Db) {
  const todos = await db.all(app.todos.select("id", "title").orderBy("title", "asc"));
  const advice = await Promise.all(todos.map((todo) => db.canDelete(app.todos, todo.id)));
  return todos.filter((_, index) => advice[index] === "allowed");
}

export async function readEditableTodos(db: Db) {
  const todos = await db.all(app.todos.select("id", "title").orderBy("title", "asc"));
  const advice = await Promise.all(
    todos.map((todo) => db.canUpdate(app.todos, todo.id, { title: todo.title })),
  );
  return todos.filter((_, index) => advice[index] === "allowed");
}

export async function canCreateTodo(db: Db, title: string) {
  return db.canInsert(app.todos, { title, done: false });
}
```

Magic columns [#magic-columns]

Jazz exposes a small set of system-provided magic columns at query time. They do not exist in your
schema, and they are omitted from `select("*")`, so opt in explicitly when you want them.

Edit metadata columns [#edit-metadata-columns]

Jazz also tracks row authorship and timestamps automatically:

* `$createdBy` — the Jazz principal that created the row
* `$createdAt` — when the row was first created
* `$updatedBy` — the Jazz principal that last updated the row
* `$updatedAt` — when the row was last updated

Jazz always tracks this metadata; select the columns explicitly to include them in query results. Backend writes that are not attributed to a user have an internal system identity and a null account.

Authorship-based policies [#authorship-based-policies]

You can use these edit metadata magic columns directly in `permissions.ts`. This is useful for simple
"creator can read/edit/delete their own rows" policies without adding explicit `owner_id` columns.

For the most common case, Jazz also exposes `policy.<table>.managedByCreator()` and `isCreator` as
shorthand for the same `$createdBy === session.user` condition. `session.user` is the
structured `{ account, identity: { issuer, subject } }` author. Public `session.claims` also exposes the complete verified
JWT payload, including the raw `iss` and `sub`, but those inspectable fields never replace the
canonical author identity. You can still write the raw `$createdBy` comparison yourself whenever
you want the explicit form. To share ownership across linked identities, compare `$createdBy.account` with `session.user.account` instead; whole-author equality distinguishes the identities.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy }) => {
  // Sugar for applying `$createdBy === session.user` to read/insert/update/delete.
  policy.todos.managedByCreator();
});
```

When you want to reuse the same check inside a larger rule, compose `isCreator` directly:

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, isCreator }) => {
  // The same creator condition can still be composed with other rules.
  policy.todos.allowRead.where(anyOf([isCreator, { done: true }]));
});
```

For more dynamic sharing or ownership models, prefer explicit tables and relations rather than
encoding extra meaning into authorship alone.

Testing permissions [#testing-permissions]

Jazz provides utilities to test permissions in isolation without testing your whole app's logic.
See [Testing permissions](/docs/recipes/testing#testing-permissions).


# Sessions



A context's session describes its account and the exact identity currently acting for it. Linking selects a new handle for a new context; it never mutates the identity of an existing context.

Get the session and canonical user identity [#get-the-session-and-canonical-user-identity]

Public sessions expose a structured author:

```ts
const author = {
  account: "00000000-0000-4000-8000-000000000001",
  identity: { issuer: "https://issuer.example", subject: "alice" },
};
```

This illustrates `session.user`; session objects are read-only. Account ownership compares `session.user.account`. Exact authorship compares the entire structure, including issuer and subject.

System-authored rows [#system-authored-rows]

System writes use the same author structure, with a reserved account and issuer:

```ts
const systemAuthor = {
  account: "00000000-0000-0000-0000-000000000000",
  identity: {
    issuer: "urn:jazz:system",
    subject: "00000000-0000-4000-8000-000000000042", // originating node UUID
  },
};
```

`SYSTEM_ACCOUNT_ID` and `SYSTEM_ISSUER` are also exported from `jazz-tools`.

The subject records where the write originated and survives replication and
reload. Comparing accounts groups all system authors; comparing the complete
structure distinguishes their originating nodes. Neither the reserved account
nor issuer can be registered or linked to an external identity. This metadata
does not grant permission to bypass policies.

Row authors always have a non-null UUID account. Anonymous readers have a null
session account. Their account-ownership policy bindings are absent, so ownership
checks fail closed.
Use an explicit anonymous-access policy when that access is intended.

Observe the current session [#observe-the-current-session]

| Binding              | Read the current session                                            |
| -------------------- | ------------------------------------------------------------------- |
| React / React Native | `const session = useSession()`                                      |
| Vue                  | `const session = useSession()`; read `session.value`                |
| Solid                | `const session = useSession()`; read `session()`                    |
| Svelte               | `const session = getSession()`; read `session.current`              |
| TypeScript           | `db.getAuthState().session`; subscribe with `db.onAuthChanged(...)` |

Framework hooks must run inside the corresponding Jazz provider or context. These session APIs observe the open context. `useJazzSession()` observes the owning lifecycle: selected account, transition state, errors, and commands. The lower-level `useAccountState(accounts)` observes only an account manager.

Read rows for the current user [#read-rows-for-the-current-user]

Declare a UUID column for account ownership:

```ts
import { schema as s } from "jazz-tools";

const app = s.defineApp({
  todos: s.table({ title: s.string(), owner_id: s.uuid() }),
});
```

Filter with the account ID:

```ts
const session = db.getAuthState().session;
const accountId = session?.user.account;
const todos = accountId ? await db.all(app.todos.where({ owner_id: accountId })) : [];
```

Linked identities share this account ID. The issuer and subject may differ between their contexts.

Insert a user-owned row [#insert-a-user-owned-row]

```ts
if (accountId) {
  db.insert(app.todos, { title: "First task", owner_id: accountId });
}
```

The application chooses ownership columns. Jazz independently stamps `$createdBy` and `$updatedBy` with the structured author, along with edit timestamps. Assigning `owner_id` does not change authorship.

Client-side filters do not enforce access control. Define the corresponding server policy:

```ts
const permissions = s.definePermissions(app, ({ policy, session }) => {
  policy.todos.allowRead.where({ owner_id: session.user.account });
  policy.todos.allowInsert.where({ owner_id: session.user.account });
});
```

Declaring those policies leaves update and delete denied until explicitly granted. See [Permissions](/docs/auth/permissions).

Session claims and authorship [#session-claims-and-authorship]

`session.claims` contains the provider's decoded claims. A claim named `user` remains under `session.claims.user`; it cannot shadow `session.user`. Claims do not supply the authoritative account assignment. The core verifies the bearer and resolves the registry assignment separately.

`session.user` is not a profile-row reference, display name, or scalar subject string. Use a UUID account column for application ownership or a matching structured row column when storing exact authorship. Do not serialize the author into a string merely to compare it with native author columns.

Attribution without impersonation [#attribution-without-impersonation]

Backend request contexts have two distinct uses:

* `await client.forRequest(request)` verifies the bearer and registry assignment, then evaluates the requester's row policies and stamps that requester as author.
* `await client.forAccount(account)` verifies an opaque admitted user handle and returns the same immutable user policy scope without switching the shared owner.
* `await client.withAttribution(account)` and `await client.withAttributionForRequest(request)` record verified user authorship while retaining backend authority. Use it only in trusted backend code after the relevant authorization decision.

A copied account ID or provider claim is not authorization. Ordinary clients create contexts from opaque account handles; the server verifies their credentials independently.

See [Authentication](/docs/auth/authentication) for preparing handles and [Lifecycle](/docs/auth/lifecycle) for closing, linking, logout, and recovery.


# Branches



Jazz provides the storage and query mechanics for branches, but it does not prescribe what a
branch is. Your app can use branches for drafts, scenarios, environments, or any other parallel
view of the same objects.

The app owns branch names, lifecycle, parent relationships, and merge UI. Jazz owns two narrow
mechanisms:

* **Branch columns** form the coordinates that separate row history.
* **Branch views** read a head coordinate, optionally falling back to a live or frozen base.

This means branch columns remain normal data. A branch can be as small as a string such as `"main"`
or `"draft"`. When an app needs richer semantics, the column can instead reference an ordinary row.

<Callout type="info">
  Branch views are part of the new Jazz core API. Framework-specific branch conveniences are still
  being filled in; the examples below use the current typed schema, read, and mutation APIs.
</Callout>

Start with a string column [#start-with-a-string-column]

The smallest useful model adds a normal string column to the table and lists it in `branchBy`:

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  documents: s
    .table({
      branch: s.string(),
      title: s.string(),
    })
    .branchBy("branch"),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

`branch` is still an ordinary application column. `branchBy` additionally tells Jazz to keep each
value's row history separate and to use it when composing branch views.

For a table with one branch column, pass that column's ordinary value directly:

```ts
const documents = await db.all(app.documents.orderBy("title", "asc"), {
  branch: "draft",
  base: "main",
});

const unsubscribe = db.subscribe(app.documents, (documents) => renderDocuments(documents), {
  branch: "draft",
  base: "main",
});
```

For every row, Jazz selects the draft's current content when present and otherwise falls back to
main. Draft deletions hide inherited rows. Filters, joins, includes, permissions, and indices all
consume that effective view; fallback is not a late union of two query result sets.

The same row UUID identifies the application object across branches. Its content may differ in each
branch-local row. Results expose the selected head coordinate, including for content inherited from
the base, so application code sees one consistent effective branch.

Model richer branches with references [#model-richer-branches-with-references]

A string is enough when branch names and relationships live elsewhere in the application. When a
branch needs queryable metadata, model it as an ordinary row and make the branch column a reference.

```ts title="schema.ts"
const schema = {
  branches: s.table({
    workspace_id: s.uuid(),
    name: s.string(),
    base_branch_id: s.ref("branches").optional(),
    base_snapshot_ref: s.json().optional(),
    status: s.enum(["open", "approved", "archived"]),
  }),
  documents: s
    .table({
      branch_id: s.ref("branches"),
      title: s.string(),
    })
    .branchBy("branch_id"),
};
```

Jazz does not assign meaning to the branch row's `base_branch_id`, `base_snapshot_ref`, or `status`.
The app reads the row, resolves its base, and supplies the reference IDs to the same read-side API:

```ts
const branchRow = await db.one(app.branches.where({ id: draftBranchId }));
if (!branchRow?.base_branch_id) throw new Error("Branch has no base");

const documents = await db.all(app.documents, {
  branch: branchRow.id,
  base: branchRow.base_branch_id,
});
```

This keeps review, archival, nested drafts, environment promotion, and base selection in userland.

Because `branch_id` is an ordinary column, branch access does not need a separate authorization
system. A policy can compare it directly or follow its reference to application-owned data. For
example, an app can allow writes only when:

```text
document.branch_id -> branches.id
branches.workspace_id -> workspace_memberships.workspace_id
workspace_memberships.user_id == session.user.account
branches.status == "open"
```

Missing branch or membership rows are missing policy evidence and fail closed. Jazz does not add an
implicit requirement that a branch row exists, nor does it impose built-in open, closed, owner, or
parent semantics.

Transactions may contain writes to several branch coordinates and shared tables. They retain one
atomic fate: if one write is invalid or unauthorized, the whole transaction is rejected.

Use compound branch coordinates [#use-compound-branch-coordinates]

Most apps need only one branch column. When a branch is qualified by multiple dimensions, declare
them with the array form:

```ts
const schema = {
  documents: s
    .table({
      workspace_id: s.ref("workspaces"),
      branch_id: s.ref("branches"),
      title: s.string(),
    })
    .branchBy(["workspace_id", "branch_id"]),
};
```

Compound selectors name every dimension explicitly:

```ts
const documents = await db.all(app.documents, {
  branch: {
    workspace_id: workspaceId,
    branch_id: draftBranchId,
  },
  base: {
    workspace_id: workspaceId,
    branch_id: mainBranchId,
  },
});
```

The qualified form also works for generic code targeting a single-column table:

```ts
await db.all(app.documents, {
  branch: { branch_id: draftBranchId },
  base: { branch_id: mainBranchId },
});
```

Write to the branch [#write-to-the-branch]

An insert targets one exact branch selector and includes the matching ordinary column value:

```ts
const { value: document } = db.insert(
  app.documents,
  { branch: "draft", title: "Draft title" },
  { branch: "draft" },
);
```

Updates and deletes accept a branch view. If the visible row currently comes from main, Jazz copies
it into the draft before applying the change:

```ts
db.update(
  app.documents,
  existingDocumentId,
  { title: "Reworked in the draft" },
  { branch: "draft", base: "main" },
);

db.delete(app.documents, inheritedDocumentId, {
  branch: "draft",
  base: "main",
});
```

The main branch-local row remains unchanged. A deletion is branch-qualified too, so deleting the
object from the draft does not delete it from main.

Branch-column rules [#branch-column-rules]

Every `branchBy` entry must name a non-null, key-encodable ordinary column. Branch columns are
immutable after insertion. When the same branch-column name appears in multiple tables, it must have
the same type in every table.

There is no separate branch declaration, binding, or stable branch identity. Branch selectors use
the ordinary column names. A schema migration may rename a branch column because normal column
lineage preserves its physical identity.

Tables may use different subsets of the same named branch columns. For example, documents might
branch by `workspace_id` and `branch_id`, memberships only by `workspace_id`, and users by neither.
An unbranched table is shared by every branch view.

<Callout type="warn" title="Branch columns are coordinates">
  To move an object between branch values, write the destination branch-local row and remove the
  source branch-local row explicitly. Both writes may be in one transaction.
</Callout>

Use a frozen base [#use-a-frozen-base]

If a draft should keep seeing main exactly as it was at a particular point, pass a snapshot
reference with the resolved base:

```ts
if (!branchRow.base_branch_id || !branchRow.base_snapshot_ref) {
  throw new Error("Branch has no frozen base");
}

const frozenDraft = {
  branch: branchRow.id,
  base: [branchRow.base_branch_id, branchRow.base_snapshot_ref],
} as const;

const documents = await db.all(app.documents, frozenDraft);
```

Jazz applies that cut consistently to base rows and to base data reached through joins and
permissions. The app decides which snapshot to use.

Merging stays a userland operation [#merging-stays-a-userland-operation]

A merge is a high-level helper that reads authorized source and target views, calculates ordinary
target writes, and emits one transaction. The core does not need a branch lifecycle event or a
special merge commit type to admit those writes.

That leaves the app in control of merge strategy and UX while Jazz transaction metadata can retain
precise contribution provenance. Concurrent offline merges are still subject to Jazz's normal
mergeable transaction semantics; branch views do not introduce a distributed exactly-once or
distributed uniqueness guarantee.

See the [branching project planner example](https://github.com/garden-co/jazz/tree/main/examples/branching-project-planner-ts)
for a complete userland model with scenario rows, reference-based authorization, a live base, and
copy-on-write editing.


# How Sync Works



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

Traditional apps send queries to a remote server, which queries a database and returns data.

This creates a few familiar problems:

* the data is immediately stale
* both the client and the server must be online
* your app's performance depends on the speed of each network hop

Techniques like websockets, client-side caching, and optimistic updates help, but they add
complexity without changing the basic request/response shape.

Jazz solves these problems with **query subscriptions** backed by a local replica.

Queries drive everything [#queries-drive-everything]

When your app [subscribes to a query](/docs/reading/queries) (say, all todos where
[`done = false`](/docs/reading/filters-and-sorting)), Jazz sends that query subscription upstream.
The server evaluates the query against its own current relational state, finds the matching rows,
and sends them back. The client only ever sees rows it has asked for (and
[has permission to read](/docs/auth/permissions)).

The set of active queries on a client defines the rows it can see and will keep receiving updates
for.

The server keeps you subscribed [#the-server-keeps-you-subscribed]

When you register a query subscription with the server, it remembers.

The server keeps a live query graph for that subscription. When local writes, remote replay, or
schema/policy changes affect the result, it re-settles only the changed parts of the query and
pushes the relevant row updates downstream.

That means:

* if a new row matches your query, it is pushed automatically
* if a matching row changes and no longer fits, it stops appearing in the subscription result
* clients update their local replicas from deltas instead of from full snapshots every time

What happens offline [#what-happens-offline]

Reads always come from local storage.

In the browser, Jazz uses a dedicated `SharedWorker` reading from data stored locally in
IndexedDB. Multiple tabs connect directly to that one durable runtime through message ports; there
is no tab leader election.

Even without a network connection, your app can still read data it already has locally, whether
that data was synced earlier or written locally on the device.

[Writes](/docs/writing/writing-data) behave the same way: they are applied locally immediately so the
UI updates without waiting on a round-trip. Jazz queues the corresponding row-version updates for
upstream sync. When the client reconnects, queued writes are sent and active query subscriptions are
replayed automatically.

Infrastructure tiers [#infrastructure-tiers]

Jazz sync runs across three tiers:

<TierSyncDiagram />

**Local** is the first tier on the client itself. In browser persistent mode, a dedicated worker
hosts that local durable copy in IndexedDB so it can respond immediately while updates from higher tiers
stream in.

**Edge** is the first server hop after the client. In cloud configurations it is usually a nearby
node. Edge servers hold the data needed to serve the queries currently flowing through them.

**Global** is the global reconciliation tier. Edge servers reconcile through global, which is how
updates eventually spread to every subscribed client.

How data flows [#how-data-flows]

Writes flow **upward**:

```text
app -> local -> edge -> global
```

As a write flows through the network, each tier can confirm that it has durably received it. That
is what durability tiers are built on.

Reads flow **downward on demand**. When you create a query subscription, it is forwarded upward.
Each tier registers the subscription with the next tier and asks for the rows needed to satisfy it.
That allows the system to replicate only the data that has actually been requested.

Lower tiers have lower latency, but writes have further to travel before every other client can see
them. As a rough guide:

* waiting for the global core is only necessary if you want the strongest cross-region visibility
* waiting for edge is useful when you want to know data has left the user's device
* the default local tier is right for most local-first interactions

<Callout type="info" title="Sync is automatic">
  Sync happens whenever a node is online. Writes keep propagating upward even if your promise
  resolves at the local tier. Reads similarly propagate downward as each tier registers the query
  with the next one up. If higher tiers have newer rows, they stream down automatically.
</Callout>

Consistency model [#consistency-model]

Every write in Jazz produces a new **row version**. That row version is stored locally, can be
replicated upward, and contributes to the current visible state for that row.

Because sync is local-first, different tiers can temporarily disagree:

* your local tier may already have a newer row version than edge
* one edge may have data another edge has not fetched yet
* another client may have its own concurrent local write

All of that still converges. Row versions propagate upward, are durably stored at higher tiers, and
flow back down to every subscribed client that needs them.

When clients write concurrently to the same field of the same row, Jazz uses
**last-writer-wins (LWW)** with deterministic hybrid logical clock ordering to decide the current
visible result. Even row versions that lose that race remain in row history, so the system keeps
enough information to reconcile deterministically and to support richer history-aware behaviour
later.

<Accordions type="single">
  <Accordion id="hybrid-logical-clock" title="What is a hybrid logical clock?">
    A hybrid logical clock gives each write a timestamp made from the device's clock time and a counter.
    The counter moves the timestamp forwards when several writes happen in the same millisecond or when
    the device's clock has fallen behind an earlier write.

    If two devices still produce the same timestamp, Jazz uses the ID of the writing node as a final
    tie-breaker. Once replicas have received the same updates, they choose the same winner regardless of
    the order in which the updates arrived.
  </Accordion>
</Accordions>

See it in action [#see-it-in-action]

The [examples reference](/docs/reference/examples) shows complete applications that exercise
query subscriptions, real-time sync, and conflict-friendly collaboration.


# Local-First Data Model



Jazz embeds a database on each device and syncs to the server in the background.

How Jazz stores data [#how-jazz-stores-data]

As covered in [How Sync Works](/docs/concepts/how-sync-works), traditional apps wait on a network
round-trip for every read and write. Jazz eliminates that entirely by embedding a subset of the
database on your users' devices.

[Reads](/docs/reading/queries) are immediate from local storage. [Writes](/docs/writing/writing-data)
(`insert`, `update`, `delete`) are also applied locally immediately, with no network round-trip.
The sync layer picks those changes up in the background and propagates them whenever a client is
online.

This has a practical consequence: there is no difference between "optimistic" and "real" state.
The local write *is* the state. There is no single always-online source of truth. Instead, every
client that has received the same row-history updates converges on the same current result.

The only loading moment [#the-only-loading-moment]

Devices cannot show data they do not have. Each newly run query has an initial loading period until
its first result settles. If the matching data is already stored locally, that result can be
available immediately. Otherwise, Jazz needs to wait until the server sends the data to the local embedded
database. Once stored, that data can be read while offline in future.

<Callout type="info" title="Durability Tiers">
  If you need to ensure data is fully up-to-date before displaying it, you can opt in to waiting for
  a higher [durability tier](/docs/reference/durability-tiers).
</Callout>

Tables, row versions, and visible state [#tables-row-versions-and-visible-state]

Jazz stays table-first all the way down.

Each application table still behaves like a table, but the engine also tracks a little extra
information for each logical row:

* a stable row identity
* a current visible state used for ordinary reads
* a retained history of row versions over time

The easiest picture is:

```text
todos
  visible: current answer for each row in a branch
  history: row versions over time for that same row
```

That is why the app-facing API can stay simple while the runtime still has enough information for
replay, reconnect, and conflict resolution.

Row history [#row-history]

Every write to a row creates a new **row version**.

That version records:

* the new row values
* which earlier version(s) it came from
* engine-managed metadata such as branch, delete state, and durability state

Physically, that row version is still one flat stored row: user columns plus reserved `_jazz_*`
columns in the same binary row format. So the runtime keeps a row-local history graph rather than
just overwriting the row in place.

Concurrent edits [#concurrent-edits]

When only one device is editing a row, the history is effectively linear. When multiple peers edit
the same row concurrently, several row versions can exist at once and later be reconciled into a
single current visible result.

You can think of it like this:

<Graph
  eyebrow="Row version history"
  description={
  <>
    One device edits linearly. Concurrent edits branch into separate row versions, then reconcile
    into a single current visible result.
  </>
}
  direction="LR"
  converge
  grid={{ gap: "1.25rem 3rem" }}
  nodes={[
  { id: "v1", rank: 0, label: "v1" },
  { id: "v2", rank: 1, label: "v2" },
  { id: "a3", rank: 2, order: 0, label: "a3" },
  { id: "b3", rank: 2, order: 1, label: "b3" },
  { id: "m4", rank: 3, label: "m4" },
]}
  edges={[
  { from: "v1", to: "v2" },
  { from: "v2", to: "a3" },
  { from: "v2", to: "b3" },
  { from: "a3", to: "m4" },
  { from: "b3", to: "m4" },
]}
/>

The important point is not the exact shape of the graph. The important point is that Jazz preserves
enough row history to converge deterministically after peers reconnect.

Conflict resolution [#conflict-resolution]

When concurrent writes touch the same field of the same row, Jazz resolves the visible result with
[last-writer-wins (LWW)](/docs/concepts/how-sync-works#consistency-model). Jazz uses a deterministic
[hybrid logical clock](/docs/concepts/how-sync-works#hybrid-logical-clock) ordering to choose the
winning row version for the conflicting field.

If two peers update different fields, both changes can still be preserved in the resulting visible
row state. Even row versions that lose out in the current visible result remain in row history, so
no local-first reconciliation information is discarded.

<Callout type="warn" title="Beware of unexpected results">
  Jazz can resolve structural conflicts for you, but it cannot fully understand the meaning of your
  data. If Alice renames a to-do while Bob marks it complete, both changes may be preserved even if
  the new title changes what the task means. You still need application-level judgment about what
  kinds of concurrent editing should be allowed.
</Callout>

How this differs from traditional apps [#how-this-differs-from-traditional-apps]

|                        | Traditional                                | Jazz                                                               |
| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------ |
| **Read path**          | HTTP request, wait for response            | Read from local storage                                            |
| **Write path**         | HTTP request, wait for confirmation        | Immediate local persistence, background sync                       |
| **Optimistic updates** | Manually implemented, must handle rollback | Not needed, the local write is authoritative                       |
| **Offline support**    | Bespoke queueing and retry logic           | Reads and writes continue to work against the local database       |
| **Loading states**     | Every network call                         | Only on first connection                                           |
| **Source of truth**    | Single authoritative database              | Every client with the same row-history updates sees the same state |
| **Conflict handling**  | Server rejects or last-request-wins        | Automatic visible-state reconciliation with retained history       |


# Client Setup



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import { Callout } from "fumadocs-ui/components/callout";
import CreateJazzClientReference from "../../partials/create-jazz-client-reference.mdx";

Configure one session [#configure-one-session]

A Jazz session owns account selection and its current client. Give it the app configuration once, with `initial: "local-first"` to restore a saved account or start offline without signup. Its commands handle graceful shutdown and context replacement when authentication changes. See [Lifecycle](/docs/auth/lifecycle).

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function LocalFirstAuthApp({ config }: { config: JazzSessionConfig }) {
      return (
        <JazzSessionProvider config={{ ...config, initial: "local-first" }}>
          <TodoApp />
        </JazzSessionProvider>
      );
    }
    ```

    `useJazzSession()` exposes account state and bound auth commands in both the app and provider fallback.
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import type { JazzSession, JazzClient } from "jazz-tools/vue";
    import { JazzSessionProvider } from "jazz-tools/vue";
    // Configure once with await createJazzSession({ appId, serverUrl, initial: "local-first" }).
    defineProps<{ session: JazzSession<JazzClient> }>();
    </script>
    <template>
      <JazzSessionProvider :session="session"><slot /></JazzSessionProvider>
    </template>

    ```

    Use `useJazzSession()` to observe the shared session in Vue.
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import type { Snippet } from "svelte";
      import type { JazzSessionConfig } from "jazz-tools/svelte";
      import { JazzSessionProvider } from "jazz-tools/svelte";
      let { config, children }: { config: JazzSessionConfig; children: Snippet } = $props();
    </script>
    <JazzSessionProvider config={{ ...config, initial: "local-first" }}>{@render children()}</JazzSessionProvider>
    ```

    Use `getJazzSession()` for a Svelte store with account state and bound commands.
  </Tab>

  <Tab value="Solid">
    ```tsx title="App.tsx"
    import { type ParentProps } from "solid-js";
    import type { JazzSession } from "jazz-tools/solid";
    import type { JazzClient } from "jazz-tools/client";
    import { JazzSessionProvider } from "jazz-tools/solid";

    // Configure once with await createJazzSession({ appId, serverUrl, initial: "local-first" }).
    export function AuthLocalfirst(props: ParentProps<{ session: JazzSession<JazzClient> }>) {
      return (
        <JazzSessionProvider session={props.session} fallback={<p>Loading...</p>}>
          {props.children}
        </JazzSessionProvider>
      );
    }

    ```

    Use `useJazzSession()` for Solid session state and bound commands.
  </Tab>

  <Tab value="TypeScript">
    ```ts title="app.ts"
    export async function createLocalFirstSession() {
      return createJazzSession({
        appId: "my-app",
        serverUrl: "https://core.example",
        initial: "local-first",
      });
    }
    ```
  </Tab>
</Tabs>

The browser account store retains the local signing root and selection. Provider JWTs are refreshed through `getToken` and are never persisted in this store.

React Native and Expo [#react-native-and-expo]

React Native and Expo use the same session API with platform-specific runtime and account storage adapters:

```tsx
import { JazzSessionProvider } from "jazz-tools/expo";

function App() {
  return (
    <JazzSessionProvider config={{ appId, serverUrl, initial: "local-first" }}>
      <TodoApp />
    </JazzSessionProvider>
  );
}
```

Install `jazz-rn` directly in the application alongside `jazz-tools`, add its
Expo config plugin, and build the native app. Expo Go cannot load the relay. A
bare React Native host uses the same direct dependency and New Architecture,
with an OS-protected account store. The [canonical Expo
scaffold](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-expo)
shows the complete lifecycle and platform configuration. The alpha has not yet
proven two physical JSI runtimes attached to one relay.

<Callout type="info">
  For a local-only context, pass `{ ...config, serverUrl: undefined }`. The handle keeps its registry authority; writes stay locally durable. A later context using the matching server URL can synchronize them.
</Callout>

<Callout type="warn">
  The local-first root is that identity's signing credential. If it is lost, the account needs
  another linked identity or a recovery backup to regain access. Plan for recovery with [backup and
  restore](/docs/auth/local-first-auth#backing-up-and-restoring-the-secret).
</Callout>

<Accordions type="single">
  <Accordion title="Runtime assets not loading?">
    Some bundlers and runtimes cannot automatically resolve Jazz's Wasm and worker assets. See [Runtime source overrides](#runtime-source-overrides) below.
  </Accordion>
</Accordions>

Client config options [#client-config-options]

<CreateJazzClientReference />

Provide external credentials to `accounts.registerJWT`, `accounts.loginJWT`, or `accounts.linkJWT` through `getToken`. Contexts receive only the resulting handle; raw JWTs, cookie-session mirrors, and signing roots are not public context configuration.

Storage driver [#storage-driver]

Browser clients default to `driver: { type: "persistent" }`, which stores data locally for offline support. Set `driver: { type: "memory" }` to keep data in memory only. Memory storage is also usable locally, but its data disappears when the context closes.

When you supply `driver: { type: "persistent", dbName }`, the name is a logical base. Jazz derives the physical root from registry, app, environment, and account. Linked identities share that account's durable root while opening distinct live authorization sessions. A root whose recorded scope does not match is rejected.

Browser worker failures [#browser-worker-failures]

After the shared worker acknowledges startup, runtime initialisation has a five-minute grace period. If it does not become ready, startup reports an error instead of leaving the client pending indefinitely. Suspended tabs receive fresh grace when they resume.

While control requests are waiting for worker replies, Jazz probes the connection every 30 seconds and allows 30 seconds for a response. These are connection-health checks, not query or storage-operation timeouts: a responsive worker can continue a long operation, and idle connections are not continuously probed.

An unresponsive-worker error does not prove that an outstanding operation failed. It may already have completed. Jazz does not automatically retry operations with an unknown outcome or clear IndexedDB. Use the existing [client and session lifecycle](/docs/auth/lifecycle) to recover, and establish a mutation's outcome before retrying it. Terminal shutdown reports the causal failure without waiting indefinitely for an unresponsive worker; it does not claim that an unacknowledged durable handoff succeeded.

An intentional storage reset releases the old foreground identity without returning it to the erased store. `deleteClientStorage()` waits for a fresh identity before the same application client can be used again. If shutdown starts during that acquisition, Jazz retires the unused identity instead. Inspector attachments are session-scoped: after a reset, open a new attachment rather than reusing the old one.

Browser storage eviction [#browser-storage-eviction]

`{ type: "persistent" }` writes to [IndexedDB](https://developer.mozilla.org/docs/Web/API/IndexedDB_API). By default browsers treat this as **best-effort** storage — under disk pressure, or after long inactivity, the browser can clear it without warning. For a local-first app that means a returning user can find their identity (the local-first secret) and any data owned only by that user wiped.

The trade-offs are:

* **Best-effort (default).** No prompt, no friction. Acceptable when the server holds a copy of every row the user cares about, or when the app gracefully reseeds from sync. Anything that exists only on this device is at risk.
* **Persistent.** Storage is only cleared by an explicit user action (clearing site data, uninstalling). Required for true offline-first apps and for any data that doesn't round-trip through a server. Browsers gate this behind [`navigator.storage.persist()`](https://developer.mozilla.org/docs/Web/API/StorageManager/persist), which may show a prompt or grant silently based on engagement heuristics.

To request persistent storage, call it once after the user has signed in or interacted enough that a prompt makes sense:

```ts
if (navigator.storage?.persist) {
  const granted = await navigator.storage.persist();
  if (!granted) {
    // Fall back: rely on sync, warn the user, or retry later.
  }
}
```

Check `navigator.storage.persisted()` on subsequent loads to see whether the grant is still in effect. Pair this with the [backup and restore](/docs/auth/local-first-auth#backing-up-and-restoring-the-secret) flow so the local-first secret can be recovered if storage is ever cleared.

Dev plugins [#dev-plugins]

Jazz ships bundler plugins that remove boilerplate in development. They start a local Jazz dev server, watch `schema.ts` and `permissions.ts`, auto-push both on change, and inject the app ID and server URL as framework-appropriate env vars for the account-manager bootstrap. Prepare a handle using those values, then pass `{ appId, serverUrl, account }` to the client.

<Tabs groupId="jazz-framework" items={["React (Vite)", "Vue (Vite)", "Svelte (Vite)", "Solid (Vite)", "SvelteKit", "Next.js"]} persist updateAnchor>
  <Tab value="React (Vite)">
    ```ts title="vite.config.ts"
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import { jazzPlugin } from "jazz-tools/dev/vite";

    export default defineConfig({
      plugins: [react(), jazzPlugin()],
    });
    ```

    Injects `VITE_JAZZ_APP_ID` and `VITE_JAZZ_SERVER_URL` into `import.meta.env`.
  </Tab>

  <Tab value="Vue (Vite)">
    ```ts title="vite.config.ts"
    import { defineConfig } from "vite";
    import vue from "@vitejs/plugin-vue";
    import { jazzPlugin } from "jazz-tools/dev/vite";

    export default defineConfig({
      plugins: [vue(), jazzPlugin()],
    });
    ```

    Injects `VITE_JAZZ_APP_ID` and `VITE_JAZZ_SERVER_URL` into `import.meta.env`.
  </Tab>

  <Tab value="Svelte (Vite)">
    ```ts title="vite.config.ts"
    import { defineConfig } from "vite";
    import { svelte } from "@sveltejs/vite-plugin-svelte";
    import { jazzPlugin } from "jazz-tools/dev/vite";

    export default defineConfig({
      plugins: [svelte(), jazzPlugin()],
    });
    ```

    Injects `VITE_JAZZ_APP_ID` and `VITE_JAZZ_SERVER_URL` into `import.meta.env`, matching this guide's Vite app bootstrap.
  </Tab>

  <Tab value="Solid (Vite)">
    ```ts title="vite.config.ts"
    import { defineConfig } from "vite";
    import solid from "vite-plugin-solid";
    import { jazzPlugin } from "jazz-tools/dev/vite";

    export default defineConfig({
      plugins: [solid(), jazzPlugin()],
    });
    ```

    Injects `VITE_JAZZ_APP_ID` and `VITE_JAZZ_SERVER_URL` into `import.meta.env`.
  </Tab>

  <Tab value="SvelteKit">
    ```ts title="vite.config.ts"
    import { sveltekit } from "@sveltejs/kit/vite";
    import { jazzSvelteKit } from "jazz-tools/dev/sveltekit";
    import { defineConfig } from "vite";

    export default defineConfig({
      plugins: [sveltekit(), jazzSvelteKit()],
    });
    ```

    Defaults `schemaDir` to `src/lib/`. Injects `PUBLIC_JAZZ_APP_ID` and `PUBLIC_JAZZ_SERVER_URL`.
  </Tab>

  <Tab value="Next.js">
    ```ts title="next.config.ts"
    import { withJazz } from "jazz-tools/dev/next";

    export default withJazz({});
    ```

    Injects `NEXT_PUBLIC_JAZZ_APP_ID` and `NEXT_PUBLIC_JAZZ_SERVER_URL`. Only runs in the Next.js dev phase; production builds are untouched.
  </Tab>
</Tabs>

On first run the plugin generates an app ID, persists it to `.env`, and starts a local Jazz server under `node_modules/.cache/jazz-dev-server/`. Every subsequent run reuses both.

Env var names [#env-var-names]

Each plugin writes the same two values (`appId`, `serverUrl`) under the prefix its bundler uses to expose env vars to the client bundle. SvelteKit's `PUBLIC_*` prefix also covers Svelte+Vite via `jazzSvelteKit`. Secrets are always unprefixed — they're server-only: `JAZZ_ADMIN_SECRET` and `BACKEND_SECRET`.

| Bundler   | App ID                    | Server URL                    |
| --------- | ------------------------- | ----------------------------- |
| Vite      | `VITE_JAZZ_APP_ID`        | `VITE_JAZZ_SERVER_URL`        |
| Next.js   | `NEXT_PUBLIC_JAZZ_APP_ID` | `NEXT_PUBLIC_JAZZ_SERVER_URL` |
| SvelteKit | `PUBLIC_JAZZ_APP_ID`      | `PUBLIC_JAZZ_SERVER_URL`      |

Jazz Cloud sync URL: `https://v2.sync.jazz.tools/`. For self-hosted deployments, point the server URL at your own host.

Plugin options [#plugin-options]

All plugins accept the same base options:

| Option        | Description                                                                                                                                                                                   |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `server`      | `true` (default) starts an embedded local server. `false` disables managed-server startup. A URL string connects to an existing server. An object configures the embedded server (see below). |
| `schemaDir`   | Directory containing `schema.ts` and `permissions.ts`. Defaults to the project root, or `src/lib/` for SvelteKit.                                                                             |
| `appId`       | Override the app ID. Defaults to the value in `.env`, otherwise a generated UUID persisted on first run.                                                                                      |
| `adminSecret` | Required when `server` is a URL. For the embedded server, used as its admin secret unless `server.adminSecret` is set; otherwise a random secret is generated.                                |

When `server` is an object, the embedded server accepts: `port` (default: random), `appId`, `adminSecret`, `dataDir` (default: `node_modules/.cache/jazz-dev-server`), `inMemory`, `allowLocalFirstAuth`, and `jwksUrl`. See [Server Setup](/docs/getting-started/server-setup) for the full semantics.

Connecting to an existing server [#connecting-to-an-existing-server]

Pass a URL to point the plugin at a server you already run — hosted cloud, staging, or a shared self-hosted instance. You must also provide `adminSecret` (or set `JAZZ_ADMIN_SECRET`) so the plugin can push schema and permissions.

```ts title="vite.config.ts"
jazzPlugin({
  server: "https://my-jazz-server.example.com",
  adminSecret: process.env.JAZZ_ADMIN_SECRET,
  appId: process.env.VITE_JAZZ_APP_ID,
});
```

What auto-pushes [#what-auto-pushes]

On startup and on every change to `schema.ts` or `permissions.ts`, the plugin publishes the current structural schema and permissions bundle to the server. Structural schema push works without an admin secret in development, so a bare `schema.ts` edit is enough to see clients pick up the new shape. Permission pushes use the resolved server admin secret (from `server.adminSecret`, root `adminSecret`, or a generated embedded-server secret).

<Callout type="warn">
  Auto-push covers the development loop. For production — or any change that needs a schema migration — run `pnpm dlx jazz-tools@alpha deploy <appId>` to publish the migration, the new schema, and the current permissions in one step. See [Migrations](/docs/schemas/migrations).
</Callout>

Runtime source overrides [#runtime-source-overrides]

Use `runtimeSources` when Jazz can't automatically find its internal assets. This usually only happens with non-standard bundler configurations or on edge runtimes like Cloudflare Workers.

| Field                            | Use it when                                                                 |
| -------------------------------- | --------------------------------------------------------------------------- |
| `runtimeSources.baseUrl`         | Jazz runtime assets are served from a shared base path like `/assets/jazz/` |
| `runtimeSources.wasmUrl`         | The Wasm file has an explicit public URL                                    |
| `runtimeSources.brokerWorkerUrl` | The browser broker worker has an explicit public URL                        |
| `runtimeSources.wasmVersion`     | An immutable deployed build version for configured browser asset URLs       |
| `runtimeSources.wasmSource`      | Your runtime gives you Wasm bytes directly                                  |
| `runtimeSources.wasmModule`      | Your runtime gives you a precompiled `WebAssembly.Module`                   |

Jazz resolves these in this order:

1. `runtimeSources.wasmModule`
2. `runtimeSources.wasmSource`
3. `runtimeSources.wasmUrl` / `runtimeSources.brokerWorkerUrl`
4. `runtimeSources.baseUrl`
5. built-in zero-config fallback

Browser asset overrides [#browser-asset-overrides]

The prepared config is the same across frameworks. Plain TypeScript passes it directly to `createDb`. Prepare the account manager with the same runtime asset overrides.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    // Prepare this handle with the same runtimeSources and registry authority.
    export function AppWithRuntimeSources({ account }: { account: AccountHandle }) {
      return (
        <JazzProvider
          config={{
            appId: "my-app",
            account,
            serverUrl: "https://my-jazz-server.example.com",
            runtimeSources: {
              baseUrl: "/assets/jazz/",
              wasmVersion: "2026-08-25", // Change this for every deployed asset build.
            },
          }}
          fallback={<p>Loading...</p>}
        >
          {/* Your app's main component */}
          <TodoList />
        </JazzProvider>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import { computed } from "vue";
    import type { AccountHandle } from "jazz-tools";
    import { JazzProvider } from "jazz-tools/vue";

    const props = defineProps<{ account: AccountHandle }>();
    // Prepare the handle with these same runtimeSources.
    const config = computed(() => ({
      account: props.account,
      appId: "my-app",
      serverUrl: "https://my-jazz-server.example.com",
      runtimeSources: {
        baseUrl: "/assets/jazz/",
        wasmVersion: "2026-08-25", // Change this for every deployed asset build.
      },
    }));
    </script>

    <template>
      <JazzProvider :config="config">
        <!-- ... -->
      </JazzProvider>
    </template>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import type { Snippet } from "svelte";
      import type { AccountHandle } from "jazz-tools";
      import { JazzSvelteProvider } from "jazz-tools/svelte";
      // Prepare the handle with these same runtimeSources.
      let { account, children }: { account: AccountHandle; children: Snippet } = $props();
      const config = $derived({
        appId: "my-app", serverUrl: "https://my-jazz-server.example.com", account,
        runtimeSources: { baseUrl: "/assets/jazz/", wasmVersion: "2026-08-25" },
      });
    </script>
    <JazzSvelteProvider {config}>{@render children()}</JazzSvelteProvider>
    ```
  </Tab>

  <Tab value="Solid">
    ```tsx title="App.tsx"
    import { type ParentProps } from "solid-js";
    import type { AccountHandle } from "jazz-tools";
    import { JazzProvider } from "jazz-tools/solid";

    export function RuntimeConfigExample(props: ParentProps<{ account: AccountHandle }>) {
      return (
        <JazzProvider
          config={{
            appId: "my-app",
            account: props.account, // Prepare with these same runtimeSources.
            serverUrl: "https://my-jazz-server.example.com",
            runtimeSources: {
              baseUrl: "/assets/jazz/",
              wasmVersion: "2026-08-25", // Change this for every deployed asset build.
            },
          }}
        >
          {props.children}
        </JazzProvider>
      );
    }

    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="app.ts"
    const config = {
      appId: "my-app",
      serverUrl: "https://my-jazz-server.example.com",
      runtimeSources: {
        wasmUrl: "/static/jazz/jazz_wasm_bg.wasm",
        wasmVersion: "2026-08-25", // Change this for every deployed asset build.
      },
    };
    const accounts = await createAccountManager(config);
    const account = accounts.getLoggedIn() ?? accounts.createLocalFirst();
    const db = await createDb({ ...config, account });
    ```
  </Tab>
</Tabs>

Use `baseUrl` when both assets live together under one public directory. Use explicit `wasmUrl` /
`brokerWorkerUrl` when your app serves them from different places.

For browser URL overrides, also set `wasmVersion` to an immutable value that changes whenever
either the Wasm or broker-worker build changes (for example, your deployment's asset manifest
version). Jazz adds that value to both asset URLs, so a rolling deployment cannot reuse a
long-lived SharedWorker with bytes fetched from an older build at the same URL. Your asset host
must serve these URLs with query strings intact.

Edge-style Wasm setup [#edge-style-wasm-setup]

Some runtimes (like Cloudflare Workers) don't load Jazz the same way a browser does. In those cases, provide the Wasm module or bytes directly.

```ts title="worker.ts"
// Prepare this request's account handle before opening its context.
export function openRequestDb(account: AccountHandle) {
  return createDb({
    appId: "my-app",
    account,
    runtimeSources: { wasmModule: jazzWasmModule },
  });
}
```

If your platform hands you raw bytes instead of a compiled module, use `runtimeSources.wasmSource`:

```ts title="worker.ts"
import { createDb, type AccountHandle } from "jazz-tools";

// The request's account manager prepared this handle before context creation.
export function openRequestDb(account: AccountHandle, wasmBytes: Uint8Array) {
  return createDb({
    appId: "my-app",
    account,
    runtimeSources: { wasmSource: wasmBytes },
  });
}
```

See the [Cloudflare Wrangler example](https://github.com/garden-co/jazz/tree/main/examples/cloudflare-worker-runtime-ts) for a complete `workerd` setup.


# Server Setup



import { Callout } from "fumadocs-ui/components/callout";

Hosted database server [#hosted-database-server]

Your app ID namespaces your data for storage and sync. Click below to generate one on the Jazz hosted cloud — you'll also get the secrets you need to deploy permissions later. Generated apps are unclaimed until you claim them in the dashboard, and unclaimed apps are automatically deleted after 14 days.

<GenerateAppId />

Self-hosted database server [#self-hosted-database-server]

```bash title="Terminal"
export JAZZ_APP_ID="replace-with-your-app-id"
export JAZZ_ADMIN_SECRET="replace-with-admin-secret"

npx jazz-tools@alpha server "$JAZZ_APP_ID" \
  --port 1625 \
  --data-dir ./data \
  --admin-secret "$JAZZ_ADMIN_SECRET"
```

`jazz-tools@alpha server <APP_ID>` currently supports:

| Option                                  | Purpose                                                                                                                                                      | Environment variable          | Default                   |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | ------------------------- |
| `<APP_ID>` (positional)                 | App namespace identifier (required)                                                                                                                          | -                             | -                         |
| `-p, --port <PORT>`                     | Listen port                                                                                                                                                  | -                             | `1625`                    |
| `-d, --data-dir <DATA_DIR>`             | Persistent storage directory                                                                                                                                 | -                             | `./data`                  |
| `--in-memory`                           | Use in-memory storage instead of files; data is lost when the process exits                                                                                  | -                             | off                       |
| `--jwks-url <JWKS_URL>`                 | JWKS endpoint for external JWT validation                                                                                                                    | `JAZZ_JWKS_URL`               | unset                     |
| `--jwt-public-key <JWT_PUBLIC_KEY>`     | Single JWK JSON object or PEM public key for external JWT validation. Accepts inline contents or a path to a key file.                                       | `JAZZ_JWT_PUBLIC_KEY`         | unset                     |
| `--auth-cookie-name <AUTH_COOKIE_NAME>` | Cookie name to read for browser authentication during WebSocket upgrades                                                                                     | `JAZZ_AUTH_COOKIE_NAME`       | unset                     |
| `--allow-local-first-auth`              | Allow local-first auth (`Authorization: Bearer <self-signed Jazz JWT>`)                                                                                      | `JAZZ_ALLOW_LOCAL_FIRST_AUTH` | see `NODE_ENV` note below |
| `--backend-secret <BACKEND_SECRET>`     | Enable backend session impersonation                                                                                                                         | `JAZZ_BACKEND_SECRET`         | unset                     |
| `--admin-secret <ADMIN_SECRET>`         | Required for `deploy`, `migrations push`, schema catalogue reads, and edge upstream sync. In development mode, structural schema auto-sync works without it. | `JAZZ_ADMIN_SECRET`           | unset                     |
| `--upstream-url <UPSTREAM_URL>`         | Run as an edge server connected to the upstream core server. Requires `--admin-secret`.                                                                      | `JAZZ_UPSTREAM_URL`           | unset                     |
| `--shutdown-timeout-secs <SECONDS>`     | Graceful shutdown network-drain timeout in seconds                                                                                                           | `JAZZ_SHUTDOWN_TIMEOUT_SECS`  | `30`                      |

Local-first auth is enabled by default in development and requires `--allow-local-first-auth` in production. External JWT auth requires either `--jwks-url` or `--jwt-public-key`, but not both.
Edge mode is enabled by `--upstream-url`; when set, provide `--admin-secret` or `JAZZ_ADMIN_SECRET`. The edge uses that admin secret for its upstream WebSocket connection.

For forwarded catalogue responses, an edge server uses a 67,108,864-byte (64 MiB)
default limit for `GET /schemas`. You can override it with
`JAZZ_CATALOGUE_LIST_RESPONSE_LIMIT_BYTES`, using a positive decimal `usize`
value; startup rejects zero, malformed, overflowing, or otherwise
allocation-invalid values. This setting is read only when `--upstream-url` is
configured, affects only forwarded `GET /schemas`, and requires a restart.
Every other forwarded catalogue response has a fixed 8 MiB limit. Invalid values
are ignored when starting a direct core server without an upstream URL, so this
edge-only setting does not change core startup or local catalogue behaviour.
The edge forwarding client is HTTP/1-only and does not follow redirects. Its
transport safety is exercised with a raw HTTP/1.1 authority (including tiny
body writes) and an HTTP/2 connection-preface rejection; these are the local
equivalent of asserting HTTP/1 ALPN selection without requiring test
certificates.

Cookie-based WebSocket auth is enabled with `--auth-cookie-name` or `JAZZ_AUTH_COOKIE_NAME`. When no
explicit auth credential is supplied, the sync server reads that named cookie and validates the JWT
it contains. If your app uses a separate application session cookie, resolve it in your own app server
and obtain an admitted account handle from its JWT. Pass that handle to `await client.forAccount(account)`;
`forRequest` accepts bearer headers and does not parse application session cookies.

If you prefer to start the database server programmatically, you can use `startLocalJazzServer` from `jazz-tools/dev`.
It expects similar arguments as the CLI.

Backend context setup [#backend-context-setup]

A TypeScript backend creates one `createJazzSession` owner from `jazz-tools/backend`, with `appId`, `app`, `permissions`, `driver`, and `serverUrl` configured once. Select backend authority with `initial: { backendSecret }` or later with `await session.becomeBackend({ backendSecret })`. For authentication and account linking, see [Authentication](/docs/auth/authentication).

Backend admission requires a reachable Jazz server: the Node host sends `POST /apps/{app}/backend/admit` with `X-Jazz-Backend-Secret`, and proceeds only after that server validates it. An edge validates its backend transport credential; account registration and linking still use the core registry. This also applies to a memory driver; backend initialization is not an offline privilege grant. Browser and React Native hosts reject backend selection.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts title="main.ts"
    const session = await createJazzSession({
      appId,
      app: schemaApp,
      permissions,
      driver: { type: "persistent", dataPath: dbPath },
      serverUrl,
      initial: { backendSecret },
      jwksUrl,
      jwtPublicKey,
      allowLocalFirstAuth,
      env: "dev",
    });
    const snapshot = session.getSnapshot();
    if (snapshot.status !== "ready" || !snapshot.client) {
      throw snapshot.error ?? new Error("Backend session is not ready");
    }
    const client = snapshot.client;
    const db = client.db;
    ```
  </Tab>

  <Tab value="Rust">
    ```rs title="main.rs"
    let context = AppContext {
        app_id: AppId::from_name(&app_id),
        client_id: None,
        schema,
        server_url,
        data_dir: PathBuf::from(data_dir),
        storage: ClientStorage::Persistent,
        storage_factory: Some(std::sync::Arc::new(
            jazz_storage_rocksdb::RocksDbStorageFactory,
        )),
        jwt_token: None,
        backend_secret: None,
        admin_secret: None,
    };
    ```
  </Tab>
</Tabs>

Backend identity pattern [#backend-identity-pattern]

The ready snapshot exposes `client.db` for backend-owned work. Backend accounts use the reserved nil account UUID and identity `{ issuer: "urn:jazz:system", subject: nodeUUID }`, where `nodeUUID` is the actual originating native node. Copying that identity does not grant authority. The secret stays in private handle material and is never serialized into snapshots or account preferences.

* `client.db` has backend permissions and records SYSTEM node provenance.
* `await client.forRequest(req)` verifies the caller's bearer and active core account assignment, then returns an immutable database scope with that user's policies and authorship.
* `await client.forAccount(account)` performs the same verification from an opaque admitted user account handle.
* `await client.withAttribution(account)` and `await client.withAttributionForRequest(req)` keep backend permissions while recording the verified user's authorship. Use them after the application authorizes the operation.

Request scopes do not change the shared session's selected account, so concurrent requests retain separate policy contexts. Do not switch the shared owner to serve individual requests. When the application deliberately transitions the owner from backend to a user account, the old client shuts down and the replacement opens an ordinary native runtime without backend privilege. The shared lifecycle handles detach, sync, shutdown, retry, and logout; logout invalidates every issued handle. Backend selection is ephemeral across process restarts, while retained local-first signing roots remain available.

`forRequest` reads standard HTTP headers from Express, Hono, Fastify, or a Web Fetch API `Request`. Configure `jwksUrl` or `jwtPublicKey` on `createJazzSession(...)` for external IdP tokens, but not both. Without either, it accepts only Jazz local-first tokens; `allowLocalFirstAuth: false` disables those too. Application cookies must first be resolved to an admitted account by your auth integration.

The TypeScript backend's `jwksUrl` must use HTTPS, except for development HTTP
with a WHATWG-canonical hostname of `localhost`, `[::1]`, or an IPv4 address in
`127.0.0.0/8`. For example, `http://localhost:3000/api/auth/jwks` is allowed;
`http://localhost.:3000/api/auth/jwks` (the trailing-dot spelling) and
`http://keys.example/api/auth/jwks` are rejected before fetching keys.
Every redirect is rejected, including redirects to HTTPS. Use the provider's
final JWKS URL directly. This policy applies to TypeScript request authentication,
not the separate Rust server's `--jwks-url` verifier.

Attribution without impersonation [#attribution-without-impersonation]

Use `await client.withAttribution(account)` or `await client.withAttributionForRequest(req)` to retain backend access while recording verified user provenance. Raw session objects and issuer/subject strings are not public authority inputs. See [Sessions > Attribution without impersonation](/docs/auth/sessions#attribution-without-impersonation).

Per-request user-scoped client [#per-request-user-scoped-client]

Pass `req` to run queries as the authenticated user, with all permission policies applied.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts title="request-context.ts"
    export async function listTodosForRequester(req: Request, res: Response): Promise<void> {
      try {
        const requester = await client.forRequest(req);
        const rows = await requester.all(schemaApp.todos.where({ done: true }));
        res.json(rows);
      } catch {
        sendQueryError(res);
      }
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs title="request-context.rs"
    pub async fn list_todos_for_request(
        headers: &HeaderMap,
        client: &JazzClient,
    ) -> Result<usize, StatusCode> {
        let user_client = client.for_session(requester_session_from_headers(headers)?);
        let query = Query::from("todos");
        let rows = user_client
            .query(query, None)
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>


# Client



import { Callout } from "fumadocs-ui/components/callout";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import QuickstartSchemaTypesSummary from "../../partials/schema-types-summary.mdx";

Create a project [#create-a-project]

Start with a fresh app. If you already have one, skip to [Install](#install).

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```bash title="Terminal"
    pnpm create vite my-jazz-app --template react-ts
    cd my-jazz-app
    pnpm install
    ```
  </Tab>

  <Tab value="Vue">
    ```bash title="Terminal"
    pnpm create vite my-jazz-app --template vue-ts
    cd my-jazz-app
    pnpm install
    ```
  </Tab>

  <Tab value="Svelte">
    ```bash title="Terminal"
    pnpm create vite my-jazz-app --template svelte-ts
    cd my-jazz-app
    pnpm install
    ```

    <Callout type="info">
      This quickstart uses Svelte 5 features (`$state`, `$props`, `$derived`, snippets). Make sure you're on Svelte 5 or later.
    </Callout>
  </Tab>

  <Tab value="Solid">
    ```bash title="Terminal"
    pnpm create vite my-jazz-app --template solid-ts
    cd my-jazz-app
    pnpm install
    ```
  </Tab>

  <Tab value="TypeScript">
    ```bash title="Terminal"
    mkdir my-jazz-app && cd my-jazz-app
    pnpm init
    pnpm add vite typescript
    ```
  </Tab>
</Tabs>

Install [#install]

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    `bash title="Terminal" pnpm add jazz-tools@alpha `
  </Tab>

  <Tab value="Vue">
    `bash title="Terminal" pnpm add jazz-tools@alpha `
  </Tab>

  <Tab value="Svelte">
    `bash title="Terminal" pnpm add jazz-tools@alpha `
  </Tab>

  <Tab value="Solid">
    `bash title="Terminal" pnpm add jazz-tools@alpha `
  </Tab>

  <Tab value="TypeScript">
    `bash title="Terminal" pnpm add jazz-tools@alpha `
  </Tab>
</Tabs>

<Callout type="warn">
  React Native and Expo are supported as an alpha through `jazz-tools/react-native` and the direct
  `jazz-rn` native dependency. Use a matching development or release build: Expo Go is unsupported.
  The [canonical Expo
  scaffold](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-expo)
  prepares an `AccountHandle` through `jazz-tools/expo`, opens an effect-owned `createJazzClient`,
  and supplies it to `JazzClientProvider`. See the [React Native install
  guide](https://github.com/garden-co/jazz/tree/main/crates/jazz-rn#readme) for the required New
  Architecture, Expo plugin, and bare-host setup.
</Callout>

Get an app ID [#get-an-app-id]

Your app ID namespaces your data for storage and sync. Click below to generate one on [Jazz Cloud](https://v2.dashboard.jazz.tools) (sync URL: `https://v2.sync.jazz.tools/`) — you'll also get the secrets you need to deploy permissions later. Generated apps are unclaimed until you claim them in the dashboard, and unclaimed apps are automatically deleted after 14 days.

<GenerateAppId />

Define your schema [#define-your-schema]

Create `schema.ts` at the root of your project (or `src/lib/schema.ts` for SvelteKit). This is the source of truth for your data model.

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  projects: s.table({
    name: s.string(),
  }),
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
    description: s.string().optional(),
    parentId: s.ref("todos").optional(),
    projectId: s.ref("projects").optional(),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);

export type Todo = s.RowOf<typeof app.todos>;
export type TodoQueryBuilder = ReturnType<typeof app.todos.limit>;
```

<QuickstartSchemaTypesSummary />

[Learn more about schemas, optional validation, and migrations](/docs/schemas/defining-tables).

Set up your app [#set-up-your-app]

Create the basic structure for your app with Jazz and a to-do list.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="src/App.tsx"
    import type { AccountHandle } from "jazz-tools";
    import { JazzProvider } from "jazz-tools/react";
    import { TodoList } from "./TodoList.js";

    // Prepare the account outside the context with createAccountManager.
    export default function App({ account }: { account: AccountHandle }) {
      return (
        <JazzProvider
          config={{
            appId: "<your-app-id>",
            account,
          }}
        >
          <h1>Todos</h1>
          <TodoList />
        </JazzProvider>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="src/App.vue"
    <script setup lang="ts">
    import type { DbConfig } from "jazz-tools";
    import { JazzProvider } from "jazz-tools/vue";
    import TodoList from "./TodoList.vue";

    defineProps<{ config: DbConfig }>();
    </script>

    <template>
      <JazzProvider :config="config">
        <h1>Todos</h1>
        <TodoList />

        <template #fallback>
          <p>Loading...</p>
        </template>
      </JazzProvider>
    </template>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="src/App.svelte"
    <script lang="ts">
      import type { DbConfig } from "jazz-tools";
      import { JazzSvelteProvider } from 'jazz-tools/svelte';
      import TodoList from './TodoList.svelte';

      let { config }: { config: DbConfig } = $props();
    </script>

    <JazzSvelteProvider {config}>
      {#snippet children()}
        <h1>Todos</h1>
        <TodoList />
      {/snippet}
      {#snippet fallback()}
        <p>Loading...</p>
      {/snippet}
    </JazzSvelteProvider>
    ```
  </Tab>

  <Tab value="Solid">
    ```tsx title="src/App.tsx"
    import { JazzProvider, type JazzAppConfig } from "jazz-tools/solid";
    import { TodoList } from "./TodoList.js";

    export function App(props: { config: JazzAppConfig }) {
      return (
        <JazzProvider {...props.config}>
          <h1>Todos</h1>
          <TodoList />
        </JazzProvider>
      );
    }

    ```
  </Tab>

  <Tab value="TypeScript">
    ```html title="index.html"
    <!doctype html>
    <html>
      <body>
        <ul id="todos"></ul>
        <script type="module" src="./src/main.ts"></script>
      </body>
    </html>

    ```

    Create `src/main.ts` and initialise the database:

    ```ts title="src/main.ts"
    import { createAccountManager, createDb } from "jazz-tools";
    import { app } from "../schema.js";
    import { renderTodoItem } from "./TodoItem.js";

    const appId = "<your-app-id>";
    const config = { appId, serverUrl: "https://core.example" };
    const accounts = await createAccountManager(config);
    const account = accounts.getLoggedIn() ?? accounts.createLocalFirst();
    const db = await createDb({ ...config, account });
    // use db.shutdown() to clean up when finished
    ```
  </Tab>
</Tabs>

<Accordions type="single">
  <Accordion title="Client Config">
    `appId` identifies your app for storage and sync. Use the UUID you generated
    above — not a human-readable name — so your local data is already
    correctly namespaced when you add a server. For all client config options and runtime source
    overrides, see [Client Setup](/docs/getting-started/client-setup#client-config-options).
  </Accordion>
</Accordions>

Add a to-do [#add-a-to-do]

Use `db.insert` to create a new row.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="src/AddTodo.tsx"
    import { useState } from "react";
    import { useDb } from "jazz-tools/react";
    import { app } from "../schema.js";

    export function AddTodo() {
      const db = useDb();
      const [title, setTitle] = useState("");

      return (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            db.insert(app.todos, { title, done: false });
            setTitle("");
          }}
        >
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="What needs to be done?"
          />
          <button type="submit">Add</button>
        </form>
      );
    }

    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="src/AddTodo.vue"
    <script setup lang="ts">
    import { ref } from "vue";
    import { useDb } from "jazz-tools/vue";
    import { app } from "../schema.js";

    const db = useDb();
    const title = ref("");

    function addTodo() {
      db.insert(app.todos, { title: title.value, done: false });
      title.value = "";
    }
    </script>

    <template>
      <form @submit.prevent="addTodo">
        <input v-model="title" type="text" placeholder="What needs to be done?" />
        <button type="submit">Add</button>
      </form>
    </template>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="src/AddTodo.svelte"
    <script lang="ts">
      import { getDb } from 'jazz-tools/svelte';
      import { app } from '../schema.js';

      const db = getDb();
      let title = $state('');
    </script>

    <form onsubmit={(e) => {
      e.preventDefault();
      db.insert(app.todos, { title, done: false });
      title = '';
    }}>
      <input type="text" bind:value={title} placeholder="What needs to be done?" />
      <button type="submit">Add</button>
    </form>

    ```
  </Tab>

  <Tab value="Solid">
    ```tsx title="src/AddTodo.tsx"
    import { createSignal } from "solid-js";
    import { useDb } from "jazz-tools/solid";
    import { app } from "../schema.js";

    export function QuickstartAdd() {
      const db = useDb();
      const [title, setTitle] = createSignal("");

      function addTodo() {
        db().insert(app.todos, { title: title(), done: false });
        setTitle("");
      }

      return (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            addTodo();
          }}
        >
          <input
            value={title()}
            onInput={(e) => setTitle(e.currentTarget.value)}
            type="text"
            placeholder="What needs to be done?"
          />
          <button type="submit">Add</button>
        </form>
      );
    }

    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="src/main.ts"
    const form = document.createElement("form");
    const input = Object.assign(document.createElement("input"), {
      placeholder: "What needs to be done?",
    });
    form.append(input, Object.assign(document.createElement("button"), { textContent: "Add" }));
    form.onsubmit = (e) => {
      e.preventDefault();
      db.insert(app.todos, { title: input.value, done: false });
      input.value = "";
    };
    document.body.append(form);
    ```
  </Tab>
</Tabs>

Display and edit a to-do [#display-and-edit-a-to-do]

Display a to-do with toggle and delete controls.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="src/TodoItem.tsx"
    import { useDb, useAll } from "jazz-tools/react";
    import { app } from "../schema.js";

    export function TodoItem({ id }: { id: string }) {
      const db = useDb();
      const { data: todos = [] } = useAll(app.todos.where({ id }).limit(1));
      const [todo] = todos;

      if (!todo) return null;

      return (
        <li className={todo.done ? "done" : ""}>
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => db.update(app.todos, id, { done: !todo.done })}
          />
          <span>{todo.title}</span>
          <button onClick={() => db.delete(app.todos, id)}>&times;</button>
        </li>
      );
    }

    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="src/TodoItem.vue"
    <script setup lang="ts">
    import { computed } from "vue";
    import { useDb, useAll } from "jazz-tools/vue";
    import { app } from "../schema.js";

    const props = defineProps<{ id: string }>();

    const db = useDb();
    const { data: todos } = useAll(() => app.todos.where({ id: props.id }).limit(1));
    const todo = computed(() => todos.value?.[0]);
    </script>

    <template>
      <li v-if="todo" :class="{ done: todo.done }">
        <input
          type="checkbox"
          :checked="todo.done"
          @change="db.update(app.todos, props.id, { done: !todo.done })"
        />
        <span>{{ todo.title }}</span>
        <button @click="db.delete(app.todos, props.id)">&times;</button>
      </li>
    </template>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="src/TodoItem.svelte"
    <script lang="ts">
      import { getDb, QuerySubscription } from 'jazz-tools/svelte';
      import { app } from '../schema.js';

      const { id }: { id: string } = $props();

      const db = getDb();
      const todos = new QuerySubscription(() => app.todos.where({ id }).limit(1));
      const todo = $derived(todos.current?.[0]);
    </script>

    {#if todo}
      <li class={todo.done ? 'done' : ''}>
        <input
          type="checkbox"
          checked={todo.done}
          onchange={() => db.update(app.todos, id, { done: !todo.done })}
        />
        <span>{todo.title}</span>
        <button onclick={() => db.delete(app.todos, id)}>&times;</button>
      </li>
    {/if}

    ```
  </Tab>

  <Tab value="Solid">
    ```tsx title="src/TodoItem.tsx"
    import { Show } from "solid-js";
    import { useAll, useDb } from "jazz-tools/solid";
    import { app } from "../schema.js";

    export function QuickstartItem(props: { id: string }) {
      const db = useDb();
      const todos = useAll(() => ({ query: app.todos.where({ id: props.id }).limit(1) }));
      const todo = () => todos.data?.[0];

      return (
        <Show when={todo()}>
          {(item) => (
            <li classList={{ done: item().done }}>
              <input
                type="checkbox"
                checked={item().done}
                onChange={() => db().update(app.todos, props.id, { done: !item().done })}
              />
              <span>{item().title}</span>
              <button onClick={() => db().delete(app.todos, props.id)}>&times;</button>
            </li>
          )}
        </Show>
      );
    }

    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="src/TodoItem.ts"
    import type { Db } from "jazz-tools";
    import { app as schemaApp, type Todo } from "../schema.js";

    export function renderTodoItem(todo: Todo, db: Db, app: typeof schemaApp) {
      const li = Object.assign(document.createElement("li"), {
        textContent: todo.title,
      });

      const toggle = Object.assign(document.createElement("input"), {
        type: "checkbox",
        checked: todo.done,
        onchange: () => db.update(app.todos, todo.id, { done: !todo.done }),
      });

      const remove = Object.assign(document.createElement("button"), {
        textContent: "\u00d7",
        onclick: () => db.delete(app.todos, todo.id),
      });

      li.prepend(toggle);
      li.append(remove);
      return li;
    }

    ```
  </Tab>
</Tabs>

Full mutation API: [Writing Data](/docs/writing/writing-data).

<Accordions type="single">
  <Accordion title="Local writes">
    With the default persistent browser driver, Jazz saves writes locally and updates your UI
    immediately — even while offline.
  </Accordion>
</Accordions>

List to-dos [#list-to-dos]

Subscribe to a query to get real-time updates as data changes, regardless of where the change originates.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="src/TodoList.tsx"
    import { useAll } from "jazz-tools/react";
    import { app } from "../schema.js";
    import { TodoItem } from "./TodoItem.js";
    import { AddTodo } from "./AddTodo.js";

    export function TodoList() {
      const { data: todos = [] } = useAll(app.todos);

      return (
        <>
          <ul>
            {todos.map((todo) => (
              <TodoItem key={todo.id} id={todo.id} />
            ))}
          </ul>
          <AddTodo />
        </>
      );
    }

    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="src/TodoList.vue"
    <script setup lang="ts">
    import { useAll } from "jazz-tools/vue";
    import { app } from "../schema.js";
    import TodoItem from "./TodoItem.vue";
    import AddTodo from "./AddTodo.vue";

    const { data: todos } = useAll(app.todos);
    </script>

    <template>
      <ul>
        <TodoItem v-for="todo in todos ?? []" :key="todo.id" :id="todo.id" />
      </ul>
      <AddTodo />
    </template>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="src/TodoList.svelte"
    <script lang="ts">
      import { QuerySubscription } from 'jazz-tools/svelte';
      import { app } from '../schema.js';
      import TodoItem from './TodoItem.svelte';
      import AddTodo from './AddTodo.svelte';

      const todos = new QuerySubscription(app.todos);
    </script>

    <ul>
      {#each todos.current ?? [] as todo (todo.id)}
        <TodoItem id={todo.id} />
      {/each}
    </ul>
    <AddTodo />


    ```
  </Tab>

  <Tab value="Solid">
    ```tsx title="src/TodoList.tsx"
    import { For } from "solid-js";
    import { useAll } from "jazz-tools/solid";
    import { app } from "../schema.js";
    import { QuickstartItem } from "./quickstart-item.js";
    import { QuickstartAdd } from "./quickstart-add.js";

    export function QuickstartList() {
      const todos = useAll(() => ({ query: app.todos }));

      return (
        <>
          <ul>
            <For each={todos.data ?? []}>{(todo) => <QuickstartItem id={todo.id} />}</For>
          </ul>
          <QuickstartAdd />
        </>
      );
    }

    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="src/main.ts"
    const list = document.getElementById("todos")!;

    db.subscribe(app.todos, (todos) => {
      list.replaceChildren(...todos.map((todo) => renderTodoItem(todo, db, app)));
    });
    ```

    The callback receives a `{ all, delta }` object — `all` is the current full result set, and `delta` is an array of row-level changes (each with a `kind`: added, removed, or updated).
  </Tab>
</Tabs>

Framework hooks return `undefined` while loading, then an array of matching rows. For filtering, sorting, and pagination, see [Queries](/docs/reading/queries).

Enable sync [#enable-sync]

So far, your to-do list only works locally. To sync across devices, you need permissions published to the server.

<Accordions type="single">
  <Accordion title="Didn't generate your app ID above? Generate it now!">
    <GenerateAppId />
  </Accordion>
</Accordions>

Add permissions [#add-permissions]

The server rejects all reads and writes unless you define [permissions](/docs/auth/permissions). For this quickstart, allow everything:

```ts title="permissions.ts"
import { schema as s } from "jazz-tools";
import { app } from "./schema.js";

export default s.definePermissions(app, ({ policy }) => {
  policy.todos.allowRead.always();
  policy.todos.allowInsert.always();
  policy.todos.allowUpdate.always();
  policy.todos.allowDelete.always();
});
```

Then publish the permissions bundle using the app ID and admin secret from above:

<DeployCommand />

Connect the client [#connect-the-client]

Update your client config to add `serverUrl` — Jazz Cloud is at `https://v2.sync.jazz.tools/`. If you generated an app ID above, these values are already filled in:

<CloudConfig />

Clients pick up the schema from the server when they connect. If you update `schema.ts` you need to [create and push a migration to the new schema](/docs/schemas/migrations) so that clients can understand existing data with the new schema.

Permissions can be updated without a schema migration by re-running `pnpm dlx jazz-tools@alpha deploy`.

For self-hosted deployments, see [Server Setup](/docs/getting-started/server-setup).

Next steps [#next-steps]

* [Authentication](/docs/auth/authentication) — local-first and external JWT auth
* [Permissions](/docs/auth/permissions) — row-level access policies
* [Queries](/docs/reading/queries) — filtering, sorting, pagination, and relations
* [Durability tiers](/docs/writing/writing-data#write-durability-tiers) — control when writes are confirmed

Example apps [#example-apps]

* [Todo app (React)](https://github.com/garden-co/jazz2/tree/main/examples/docs/todo-client-localfirst-react) — the app you just built, as a complete project
* [Todo app (Solid)](https://github.com/garden-co/jazz2/tree/main/examples/docs/todo-client-localfirst-solid) — the same app built with `jazz-tools/solid`


# TypeScript Server



import { Callout } from "fumadocs-ui/components/callout";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import SchemaSetup from "../../partials/schema-setup.mdx";
import ServerAuthConfig from "../../partials/server-auth-config.mdx";
import QuickstartSchemaTypesSummary from "../../partials/schema-types-summary.mdx";

Create a project [#create-a-project]

Start with a fresh project. If you already have one, skip to [Install](#install).

```bash title="Terminal"
mkdir my-jazz-app && cd my-jazz-app
pnpm init
```

Install [#install]

`jazz-napi` is the native runtime for Jazz on Node.js. It bundles the query engine, storage, and sync layer as a Rust binary via NAPI. `jazz-tools` detects it automatically at runtime, but it must be listed as an explicit dependency.

```bash title="Terminal"
pnpm add jazz-tools@alpha jazz-napi@alpha hono @hono/node-server
pnpm add -D typescript tsx
```

Define your schema [#define-your-schema]

Create `schema.ts` at the root of your project (or `src/lib/schema.ts` for SvelteKit). This is the source of truth for your data model.

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  projects: s.table({
    name: s.string(),
  }),
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
    description: s.string().optional(),
    parentId: s.ref("todos").optional(),
    projectId: s.ref("projects").optional(),
    owner_id: s.uuid(),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);

```

<QuickstartSchemaTypesSummary />

Validate schema [#validate-schema]

<SchemaSetup />

[Learn more about schemas, optional validation, and migrations](/docs/schemas/defining-tables).

Add permissions [#add-permissions]

A table with no [permission](/docs/auth/permissions) declarations is open to reads and writes. Once a table declares any policy, omitted operations are denied. For this quickstart, explicitly allow all four operations:

```ts title="permissions.ts"
import { schema as s } from "jazz-tools";
import { app } from "./schema.js";

export default s.definePermissions(app, ({ policy }) => {
  policy.todos.allowRead.always();
  policy.todos.allowInsert.always();
  policy.todos.allowUpdate.always();
  policy.todos.allowDelete.always();
});
```

Set up your server [#set-up-your-server]

Generate an app ID:

```bash title="Terminal"
pnpm dlx jazz-tools@alpha create app
# outputs a UUID like: 019d0ba1-519a-7e01-b0eb-0059ee898e4d
export JAZZ_APP_ID=019d0ba1-519a-7e01-b0eb-0059ee898e4d
```

Create `src/index.ts`. This quickstart puts everything in one file; a real app would split across multiple modules.

```ts title="src/index.ts"
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { createJazzSession } from "jazz-tools/backend";
import { app as schemaApp } from "../schema.js";
import permissions from "../permissions.js";

const session = await createJazzSession({
  appId: process.env.JAZZ_APP_ID ?? "todo-server-ts",
  app: schemaApp,
  permissions,
  driver: { type: "persistent", dataPath: "./data/jazz.db" },
  serverUrl: process.env.JAZZ_SERVER_URL!,
  initial: { backendSecret: process.env.JAZZ_BACKEND_SECRET! },
  jwksUrl: process.env.JAZZ_JWKS_URL,
  jwtPublicKey: process.env.JAZZ_JWT_PUBLIC_KEY,
  allowLocalFirstAuth: process.env.JAZZ_ALLOW_LOCAL_FIRST_AUTH !== "false",
});

const snapshot = session.getSnapshot();
if (snapshot.status !== "ready" || !snapshot.client) {
  throw snapshot.error ?? new Error("Backend session is not ready");
}
const client = snapshot.client;
const api = new Hono();
```

* `appId` identifies the app namespace for storage and sync.
* `app` is your typed schema export.
* `permissions` is the server-side policy bundle.
* `serverUrl` identifies the required reachable core; `initial: { backendSecret }` admits the backend account before opening its native client.
* `jwksUrl` verifies external JWTs inside `await client.forRequest(req)`. Without it, the backend only accepts Jazz local-first tokens unless you set `allowLocalFirstAuth: false`. It does not currently accept anonymous bearer tokens.
* `dataPath` controls where local server state persists.

For this TypeScript backend, use a direct HTTPS `jwksUrl`. HTTP is allowed for
development only when the URL's WHATWG-canonical hostname is `localhost`,
`[::1]`, or an IPv4 address in `127.0.0.0/8` (for example,
`http://127.0.0.1:3000/api/auth/jwks`). HTTP with the trailing-dot spelling
`localhost.`, other schemes, and remote HTTP are rejected before fetching keys.
The backend rejects every redirect, even to another HTTPS URL, so configure your
provider's final JWKS URL rather than a redirecting URL.

Each route handler awaits `client.forRequest(c.req)` to get a database handle with [permissions](/docs/auth/permissions) scoped to the request.
For server-owned work, use the ready snapshot's `client.db`. Backend admission requires the core
even with a memory driver; keep the secret server-side and provide it through `initial` or `session.becomeBackend(...)`. [Server Setup](/docs/getting-started/server-setup)
covers those patterns in more detail.

<Accordions type="single">
  <Accordion title="What does the driver do?">
    The `persistent` driver stores data on disk through the native `jazz-napi` runtime. In the
    current Node.js setup that means SQLite-backed local persistence. `dataPath` is the directory
    where that local database lives.

    There is also a `memory` driver, which does not persist data. To use it, set a `serverUrl` pointing to an upstream peer that *can* persist the data.
  </Accordion>
</Accordions>

Add a to-do [#add-a-to-do]

Add each of the following snippets to `src/index.ts`, below the setup code.

Use `db.insert` to create a new row.

```ts title="src/index.ts"
api.post("/api/todos", async (c) => {
  const db = await client.forRequest(c.req);
  const session = db.getAuthState().session;
  if (!session?.user.account) return c.json({ error: "Account required" }, 401);
  const { title } = await c.req.json();

  const { value: todo } = db.insert(schemaApp.todos, {
    title,
    done: false,
    owner_id: session.user.account,
  });

  return c.json(todo, 201);
});
```

Update and delete to-dos [#update-and-delete-to-dos]

Use `db.update` and `db.delete` to modify existing rows.

```ts title="src/index.ts"
api.patch("/api/todos/:id", async (c) => {
  const db = await client.forRequest(c.req);
  const { id } = c.req.param();
  const { done } = await c.req.json();
  db.update(schemaApp.todos, id, { done });
  return c.json({ ok: true });
});

api.delete("/api/todos/:id", async (c) => {
  const db = await client.forRequest(c.req);
  const { id } = c.req.param();
  db.delete(schemaApp.todos, id);
  return c.json({ ok: true });
});
```

Full mutation API: [Writing Data](/docs/writing/writing-data).

List to-dos [#list-to-dos]

Use `db.all` to query rows. The query builder supports filtering, sorting, and pagination.

```ts title="src/index.ts"
api.get("/api/todos", async (c) => {
  const db = await client.forRequest(c.req);
  const todos = await db.all(
    schemaApp.todos.where({ done: false }).orderBy("title", "asc").limit(100),
  );
  return c.json(todos);
});
```

Full query API: [Reading Data](/docs/reading/queries).

Run it [#run-it]

Start the server at the bottom of `src/index.ts`:

```ts title="src/index.ts"
serve({ fetch: api.fetch, port: 3000 }, (info) => {
  console.log(`Server running on http://localhost:${info.port}`);
});
```

```bash title="Terminal"
npx tsx src/index.ts
```

Try it out with a self-signed dev token:

```bash title="Terminal"
TOKEN=$(node -e 'const { mintLocalFirstToken } = require("jazz-napi"); const seed = Buffer.alloc(32, 7).toString("base64url"); console.log(mintLocalFirstToken(seed, process.env.JAZZ_APP_ID ?? "todo-server-ts", 3600));')

curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"title": "Buy milk"}'

curl http://localhost:3000/api/todos \
  -H "Authorization: Bearer $TOKEN"

curl -X PATCH http://localhost:3000/api/todos/<id> \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"done": true}'

curl -X DELETE http://localhost:3000/api/todos/<id> \
  -H "Authorization: Bearer $TOKEN"
```

<Callout type="info">
  `forRequest` verifies the caller's bearer token inside the backend context. The token above is a
  Jazz self-signed dev token, which works because `allowLocalFirstAuth` defaults to `true`. If you
  want to accept external JWTs from your auth provider, also set `jwksUrl` so the backend can verify
  them via JWKS.
</Callout>

Authentication [#authentication]

<ServerAuthConfig />

Next steps [#next-steps]

* [Authentication](/docs/auth/authentication) — identity providers, JWKS, and session resolution
* [Permissions](/docs/auth/permissions) — row-level access policies
* [Queries](/docs/reading/queries) — filtering, sorting, pagination, and relations
* [Server Setup](/docs/getting-started/server-setup) — hosting, sync, and deployment


# Filters, Sorting & Pagination



import WhereOperatorsTable from "../../partials/where-operators-table.mdx";

Filters [#filters]

Use `where(...)` to filter rows. Pass the columns you want to match on — all conditions are combined with `AND`. OR filters are not supported in queries; use multiple queries if you need disjoint result sets.

<WhereOperatorsTable />

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosWithFilters(db: Db) {
      return db.all(app.todos.where({ done: false, title: { contains: "docs" } }));
    }
    ```

    ```ts
    export async function readTodosWithWhereOperators(db: Db) {
      await db.all(app.todos.where({ done: false }));
      await db.all(app.todos.where({ title: { contains: "milk" } }));
      await db.all(app.todos.where({ projectId: { ne: EXAMPLE_PROJECT_ID } }));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_with_filters(client: &JazzClient) -> jazz::tools::Result<usize> {
        let query = Query::from("todos").filter(eq(col("done"), lit(false)));

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Sorting [#sorting]

Sort results with `orderBy(...)`. Pass a column name and optionally `"asc"` or `"desc"`.

<Callout type="warn">
  Always sort **before** paginating. Unsorted items may not appear on the same page across all
  queries.
</Callout>

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosSortedByTitle(db: Db) {
      return db.all(app.todos.where({ done: false }).orderBy("title", "asc"));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_sorted(client: &JazzClient) -> jazz::tools::Result<usize> {
        let query = Query::from("todos")
            .filter(eq(col("done"), lit(false)))
            .order_by("title", OrderDirection::Asc);

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Pagination [#pagination]

`limit(n)` caps the number of rows returned; `offset(n)` skips that many rows. Combine them with a
deterministic order. If the first sort column is not unique, add `orderBy("id")` as the final
tie-breaker so page boundaries are stable.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodoPage(db: Db, page: number, pageSize = 20) {
      const offset = Math.max(0, (page - 1) * pageSize);
      return db.all(
        app.todos
          .where({ done: false })
          .orderBy("title", "asc")
          .orderBy("id", "asc")
          .limit(pageSize)
          .offset(offset),
      );
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todo_page(
        client: &JazzClient,
        page_size: usize,
        page: usize,
    ) -> jazz::tools::Result<usize> {
        let offset = page.saturating_sub(1) * page_size;
        let query = Query::from("todos")
            .filter(eq(col("done"), lit(false)))
            .order_by("title", OrderDirection::Asc)
            .order_by("id", OrderDirection::Asc)
            .limit(page_size)
            .offset(offset);

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>


# Includes & Relations



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

Includes [#includes]

Use `include(...)` to load related rows as nested objects in a single query result. Pass `true` to resolve a relation, or a nested object to follow multi-hop references.

Rust has deliberately separate query forms. Use `flat_join(...)` when you need fields from both
tables in one flat result: it is an inner join, so each matching pair is an output occurrence and a
root row can produce more than one result. Use `ArraySubquery` when you need a named nested array
on each root result. Rust does not turn a forward reference into the same nullable nested-object
shape that TypeScript `include(...)` does.

Including a relation adds the resolved object alongside the foreign-key column — it does not replace it. For example, `include({ project: true })` gives you both `projectId` (the FK string) and `project` (the resolved row).

```ts title="schema.ts"
projects: s.table({
  name: s.string(),
}),
todos: s.table({
  title: s.string(),
  done: s.boolean(),
  priority: s.int().optional(),
  description: s.string().optional(),
  owner_id: s.uuid().optional(),
  parentId: s.ref("todos").optional(),
  projectId: s.ref("projects").optional(),
}),
```

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosWithIncludes(db: Db) {
      return db.all(
        app.todos.where({ done: false }).include({ project: true, parent: { project: true } }),
      );
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_with_project(client: &JazzClient) -> jazz::tools::Result<usize> {
        let query = Query::from("todos")
            .filter(eq(col("done"), lit(false)))
            .flat_join("projects", "todos.project_id", "projects.id");

        let rows = client.query_results(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Reverse relations [#reverse-relations]

When table A has a ref column pointing to table B, Jazz auto-derives a reverse relation on B that returns all matching A rows as an array. The naming convention is `{sourceTable}Via{RelationName}` — for example, if `todos` has `projectId: s.ref("projects")`,
then `projects` gets a `todosViaProject` reverse relation.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readProjectsWithTodos(db: Db) {
      return db.all(app.projects.include({ todosViaProject: app.todos.where({ done: false }) }));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub fn build_projects_with_todos_query() -> Query {
        Query::from("projects").array_subquery(
            ArraySubquery::new("todos_via_project", "todos", "project_id", "id")
                .filter(eq(col("done"), lit(false))),
        )
    }
    ```
  </Tab>
</Tabs>

In TypeScript, you can chain `.where()`, `.select()`, `.orderBy()` and other query methods on the
included reverse relation. In Rust, configure the `ArraySubquery` itself with `.filter(...)`,
`.select(...)`, `.order_by(...)`, `.limit(...)`, and `.offset(...)` before passing it to
`.array_subquery(...)`.

Rust also has `join_via(...)`, `join_via_column(...)`, and `join_via_row_id(...)` for an
existential relation check: they keep a root row only when a matching related row exists, without
adding that related row to the result. Use `flat_join(...)` or `ArraySubquery` when the related
data must be returned.

Select [#select]

Use `select(...)` to narrow a row to `id` plus the columns you pick. You can combine it with `include(...)`, and select within included rows too.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodoTitlesWithSelectedProject(db: Db) {
      return db.all(
        app.todos
          .select("title")
          .where({ done: false })
          .include({ project: app.projects.select("name") }),
      );
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todo_titles(client: &JazzClient) -> jazz::tools::Result<usize> {
        let query = Query::from("todos").select(["title", "done"]);

        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Partial large values [#partial-large-values]

For a bytes, text, or JSON column, the object form of `select(...)` returns
just the requested primitive slice or JSON subtree. The range end is exclusive.
Text coordinates use JavaScript UTF-16 code units by default, so they match
`String.prototype.slice`; use `fromUtf8` and `toUtf8` when interoperating with
a byte-oriented protocol. Jazz rejects a range that would split a UTF-8 code
point or UTF-16 surrogate pair rather than rounding it.

```ts
const [document] = await db.all(
  app.documents.where({ id: documentId }).select({
    bytes: { from: 1_000_000, to: 2_000_000 },
    text: { from: 4, to: 124 },
    utf8Text: { fromUtf8: 4, toUtf8: 67 },
    metadata: { at: "/chapters/0/title" },
  }),
);

// document.bytes is Uint8Array; text and utf8Text are strings; metadata is
// the decoded JSON value at that RFC 6901 pointer.
```

The object form is schema-aware: byte ranges are for `bytes`, text ranges are
for `string`, and `{ at }` is for `json`. Selecting a field by name continues
to return the complete primitive.

Partial object selections currently apply to the root query only. An included
relation may select complete fields by name, but rejects object-form partial
selections until Jazz can carry terminal-specific demand through relation
evaluation ([#2090](https://github.com/garden-co/jazz/issues/2090)).

Until exact chunk demand reaches every query terminal, Jazz may materialize the
selected carrier column before applying the requested slice. It never needs to
materialize unselected columns; exact chunk-demand propagation is tracked in
[#2090](https://github.com/garden-co/jazz/issues/2090).

Missing references [#missing-references]

Jazz is distributed and supports offline edits, so a referenced row won't always be available locally — it might not have synced yet, or another peer might have deleted it.

When you don't load a reference, the FK column contains its raw value (the UUID string) and the row always appears in results.

When you load a forward reference with TypeScript `include`, the source row still appears if the
target can't be resolved — for example, because it has not synced yet, was deleted, or is
hidden by permissions. The included field is `null`. A nullable, unset reference also produces
`null`.

Rust's `flat_join(...)` is an inner join, so it filters out source rows without a matching target.
An `ArraySubquery` is optional by default: its parent still appears with an empty array when no
readable child row matches.

Requiring includes [#requiring-includes]

Use `.requireIncludes()` when every included, non-nullable forward reference must resolve. It drops
a source row if one of those required targets is unavailable.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    const requiredReferences = s.defineApp({
      customers: s.table({ name: s.string() }),
      orders: s.table({ customerId: s.ref("customers") }),
    });

    export async function readOrdersWithRequiredCustomer(db: Db) {
      return db.all(requiredReferences.orders.include({ customer: true }).requireIncludes());
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub fn build_todos_with_required_project() -> Query {
        Query::from("todos")
            .filter(eq(col("done"), lit(false)))
            .array_subquery(
                ArraySubquery::new("project", "projects", "id", "project_id")
                    .requirement(ArraySubqueryRequirement::AtLeastOne),
            )
    }
    ```
  </Tab>
</Tabs>

`requireIncludes()` only filters non-nullable forward references (FK → row). Nullable forward
references and reverse relations (`todosViaOwner`, etc.) are unaffected. When used inside a nested
`include`, it applies at that nesting level only.

In Rust, set the corresponding correlated `ArraySubquery` to
`.requirement(ArraySubqueryRequirement::AtLeastOne)`. That likewise filters the parent when no
readable related row matches. This is explicit because the normal Rust `ArraySubquery` behavior is
to keep the parent and return an empty array.

Magic columns [#magic-columns]

Jazz exposes computed columns for permission introspection (`$canRead`, `$canEdit`, `$canDelete`) and edit metadata (`$createdBy`, `$createdAt`, `$updatedBy`, `$updatedAt`). See [Magic columns](/docs/reading/queries#magic-columns) for details and examples.

Recursive queries with gather and hopTo [#recursive-queries-with-gather-and-hopto]

If your data has self-referencing relations (e.g. a todo with a `parent` that points to another
todo), use `gather(...)` to walk the graph recursively and collect all reachable rows in a single
query.

`gather` takes three options:

| Option     | Description                                                                                                                                                 |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `start`    | A `where` filter selecting the root rows to begin traversal from                                                                                            |
| `step`     | A callback that receives `{ current }` (a token for the row being visited) and returns a query with one `.hopTo()` call specifying which relation to follow |
| `maxDepth` | Maximum recursive hops (default: `10`). The start rows are depth `0`, so `0` returns only those rows                                                        |

Inside the `step` callback, call `hopTo(relation)` to tell Jazz which reference to follow at each level.

Depth is counted from the start relation: `maxDepth: 0` returns only the start rows, while
`maxDepth: 1` may also return rows reached by one `step` hop. A zero bound never performs an
implicit first hop.

```ts
export function buildTodoLineageQuery() {
  return app.todos.gather({
    start: { done: false },
    step: ({ current }) => app.todos.where({ id: current }).hopTo("parent"),
    maxDepth: 10,
  });
}
```

This starts from all incomplete todos, then follows each todo's `parentId` → `parent` relation up to 10 levels deep, returning the full lineage.


# Queries



import DurabilityTiersTable from "../../partials/durability-tiers-table.mdx";

One-shot queries [#one-shot-queries]

A one-shot query runs once against the database without subscribing to changes.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosOneshot(db: Db) {
      return db.all(app.todos.where({ done: false }));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_oneshot(client: &JazzClient) -> jazz::tools::Result<usize> {
        let query = Query::from("todos");
        let rows = client.query(query, None).await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

Subscriptions [#subscriptions]

Subscribe to a query to receive updates whenever the underlying data changes.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export function subscribeTodos(db: Db, onCount: (count: number) => void) {
      return db.subscribe(app.todos.where({ done: false }), (todos) => onCount(todos.length));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn subscribe_todos(
        client: &JazzClient,
    ) -> jazz::tools::Result<jazz::tools::SubscriptionStream> {
        let query = Query::from("todos");
        client.subscribe(query).await
    }
    ```
  </Tab>
</Tabs>

Composing queries [#composing-queries]

Queries are immutable and chainable. Each method returns a new query, so you can store a base and reuse it for different views without side effects.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // Store a base query and reuse it for different views.
    const openTodos = app.todos.where({ done: false });

    const byNewest = openTodos.orderBy("id", "desc");
    const byTitle = openTodos.orderBy("title", "asc").limit(20);
    const urgent = openTodos.where({ title: { contains: "urgent" } });
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub fn composing_queries() {
        // Build two views from the same base conditions.
        let by_title = Query::from("todos")
            .filter(eq(col("done"), lit(false)))
            .order_by("title", OrderDirection::Asc)
            .limit(20);
        let by_newest = Query::from("todos")
            .filter(eq(col("done"), lit(false)))
            .order_by("id", OrderDirection::Desc);

        let _ = (by_title, by_newest);
    }
    ```
  </Tab>
</Tabs>

See [Filters & Sorting](/docs/reading/filters-and-sorting) for the full list of `where` operators, `orderBy`, `limit`, and `offset`.

Read durability [#read-durability]

Queries and subscriptions return results as soon as they are available locally by default. Local reads are effectively instant, and remote updates stream in as they arrive, which is normally a good default. When you need a stronger guarantee for the first result, pass a `tier` option to fetch data from a different tier before returning.

<DurabilityTiersTable />

For background on how data flows between tiers, see [How Sync Works](/docs/concepts/how-sync-works#infrastructure-tiers).

Choosing a tier [#choosing-a-tier]

Pass a read choice when you need to control whether the first result may use local
knowledge — for example, when a user has just navigated to a page and a
stale local snapshot would mislead them.

* `"local"` — Local storage only. The default on browsers and clients. Fastest, but reflects only what this device has already synced.
* `"edge"` — Wait for the nearest sync server to respond. The default on backends and servers. A good middle ground when you want confirmation that the query has fetched data from the network.
* `"global"` — Wait for the central server. Use when you need a globally-consistent snapshot, accepting the extra round-trip latency.

New APIs also expose `"local-first"`, `"remote"`, and
`"remote-if-possible"`. The last option falls back to local only after an
explicit app disconnect; it never treats a timeout, error, or slow remote request
as offline. The legacy values above remain supported for reads and continue to be
the write-durability values used by `wait({ tier })`.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export async function readTodosAtEdgeDurability(db: Db) {
      return db.all(app.todos.where({ done: false }), { tier: ReadTier.Remote });
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    pub async fn read_todos_at_edge_durability(client: &JazzClient) -> jazz::tools::Result<usize> {
        let query = Query::from("todos");
        let rows = client
            .query(query, Some(DurabilityTier::EdgeServer))
            .await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

<Callout type="warn" title="Subscriptions only gate the first result">
  The read tier gates the **first** delivery of a subscription only. After the initial snapshot
  arrives at the requested tier, subsequent updates are delivered as they reach the local node,
  regardless of which tier they've propagated to. For example, a subscription with `tier: "global"`
  guarantees a globally-consistent initial snapshot, but later incremental updates from other
  clients may arrive through edge tiers before being globally available **even if the durability of
  the write is set to `"global"`**.
</Callout>

Own writes [#own-writes]

The read tier also determines how a subscription treats your own local writes:

* `"local-first"` shows pending local writes using locally known data.
* Online `"remote-if-possible"` shows pending edits/deletes to rows already in remote scope, plus matching new inserts. Existing out-of-scope rows and cached related rows are not pulled in merely because of a pending edit. After an explicit disconnect, it uses local knowledge instead.
* `"remote"` has no pending overlay: writes appear when reflected in the server's query scope.

See [Durability Tiers](/docs/reference/durability-tiers) for the full reference, including which APIs accept these options and how they compose.

Magic columns [#magic-columns]

You can select and filter on Jazz's magic columns just like other columns. They are omitted from
`select("*")`, so opt in explicitly when you want them.

Permission introspection columns [#permission-introspection-columns]

* `$canRead` — whether the current session can read the row
* `$canEdit` — whether the current session can update the row
* `$canDelete` — whether the current session can delete the row
* Without a session, all three return `null`

Edit metadata columns [#edit-metadata-columns]

* `$createdBy` — the Jazz principal that created the row
* `$createdAt` — when the row was first created
* `$updatedBy` — the Jazz principal that last updated the row
* `$updatedAt` — when the row was last updated

These are useful for showing authorship, when a row last changed, or building "my created items"
views without storing duplicate ownership fields.

```ts
export async function readTodoEditMetadata(db: Db, author: RowAuthor, updatedSinceMs: number) {
  return db.all(
    app.todos
      .where({
        $createdBy: author,
        $updatedAt: { gt: updatedSinceMs },
      })
      .select("title", "$createdBy", "$createdAt", "$updatedBy", "$updatedAt"),
  );
}
```

See [Permissions](/docs/auth/permissions) for policy examples using `$createdBy` and the other
magic columns.

Framework hooks [#framework-hooks]

Each framework has a reactive binding that re-renders when query results change. See [Framework Patterns](/docs/reference/framework-patterns#query-subscriptions) for side-by-side examples.

| Framework  | API                            | Notes                                                 |
| ---------- | ------------------------------ | ----------------------------------------------------- |
| React/Expo | `useAll(query)`                | Returns `{ data, isLoading, error }`                  |
| Vue        | `useAll(query)`                | Returns `{ data, isLoading, error }` refs             |
| Svelte     | `new QuerySubscription(query)` | Exposes reactive `.current` / `.isLoading` / `.error` |
| Solid      | `useAll(() => ({ query }))`    | Returns `{ data, isLoading, error }`                  |

The loading state [#the-loading-state]

`useAll`'s `data` field (or Vue's `data` ref and Svelte's `QuerySubscription.current`) are
`undefined` until the first result arrives from the requested tier. After that, the value is an
array — empty (`[]`) if no rows match, or populated with the requested data.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid"]} persist updateAnchor>
  <Tab value="React">
    ```tsx
    const allTodos = useAll(app.todos);
    // `allTodos.data` is `undefined` while loading the first result (and `allTodos.isLoading` is `true`).
    // `allTodos.data` is `[]` when loaded but empty
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    <script setup lang="ts">
    import { useAll } from "jazz-tools/vue";
    import { app } from "../schema.js";

    const { data: todos } = useAll(app.todos);
    // undefined = not yet connected; [] = connected, no rows; [...] = rows present
    </script>

    <template>
      <p v-if="todos === undefined">Connecting…</p>
      <ul v-else>
        <li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
      </ul>
    </template>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    <script lang="ts">
      import { QuerySubscription } from 'jazz-tools/svelte';
      import { app } from '../schema.js';

      const todos = new QuerySubscription(app.todos);
      // .current: undefined = not yet connected; [] = connected, no rows; [...] = rows present
    </script>

    {#if todos.current === undefined}
      <p>Connecting…</p>
    {:else}
      <ul>
        {#each todos.current as todo}
          <li>{todo.title}</li>
        {/each}
      </ul>
    {/if}
    ```
  </Tab>

  <Tab value="Solid">
    ```tsx
    import { For, Show } from "solid-js";
    import { useAll } from "jazz-tools/solid";
    import { app } from "../schema.js";

    export function LiveQueryExample() {
      const todos = useAll(() => ({ query: app.todos }));
      return (
        <Show when={todos.data !== undefined} fallback={<p>Connecting...</p>}>
          <ul>
            <For each={todos.data ?? []}>{(todo) => <li>{todo.title}</li>}</For>
          </ul>
        </Show>
      );
    }

    ```
  </Tab>
</Tabs>

<Callout type="info">
  You're unlikely to see `data: undefined` in practice unless you're awaiting a more [durable
  tier](#read-durability). Local storage reads are effectively instant and resolve to `[]` if no
  data exists yet.
</Callout>

Fine-grained updates [#fine-grained-updates]

Vue's `useAll`, Svelte's `QuerySubscription`, and Solid's `useAll` reconcile new query results into the existing reactive array in place rather than swapping the reference. When an upstream change touches a single row, only that row's affected fields write into the reactive proxy, so `$effect` (Svelte), Solid computations/effects, and `watch` / template bindings (Vue) only re-fire for components that depend on the fields that actually changed.

In practice this means:

* Adding, removing, or reordering rows updates the array structure only — untouched row objects keep their identity, so `{#each items as item (item.id)}` and `<TransitionGroup>` keyed renders are stable.
* Editing a single field on one row writes only that field — sibling rows do not re-render, and per-row components that read other fields stay quiet.
* Vue's `useAll` returns `{ data, isLoading, error }`, where `data` is a deep `Ref` (not a `shallowRef`), so per-field reactivity composes with the rest of your component tree without manual unwrapping.
* React and Solid return `{ data, isLoading, error }`; React returns a fresh `data` array each tick, while Solid's store keeps fine-grained dependency tracking for fields that changed.

React's granularity benefit comes from React's diffing, not from in-place reconciliation.

Conditional queries [#conditional-queries]

Pass `undefined` instead of a query to skip evaluation. This is useful when building dynamic queries.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid"]} persist updateAnchor>
  <Tab value="React">
    ```tsx
    const [filter, setFilter] = useState<string | null>(null);
    const { data: filtered } = useAll(
      filter ? app.todos.where({ title: { contains: filter } }) : undefined,
    );
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    <script setup lang="ts">
    import { ref, computed } from "vue";
    import { useAll } from "jazz-tools/vue";
    import { app } from "../schema.js";

    const filter = ref<string | null>(null);
    const query = computed(() =>
      filter.value ? app.todos.where({ title: { contains: filter.value } }) : undefined,
    );
    const { data: filtered } = useAll(query);
    </script>

    <template>
      <input v-model="filter" placeholder="Filter by title" />
      <ul v-if="filtered">
        <li v-for="todo in filtered" :key="todo.id">{{ todo.title }}</li>
      </ul>
    </template>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    <script lang="ts">
      import { QuerySubscription } from 'jazz-tools/svelte';
      import { app } from '../schema.js';

      let filter = $state<string | null>(null);

      const filtered = new QuerySubscription(
        () => filter ? app.todos.where({ title: { contains: filter } }) : undefined,
      );
    </script>

    <input bind:value={filter} placeholder="Filter by title" />
    {#if filtered.current}
      <ul>
        {#each filtered.current as todo}
          <li>{todo.title}</li>
        {/each}
      </ul>
    {/if}
    ```
  </Tab>

  <Tab value="Solid">
    ```tsx
    import { For, Show, createMemo, createSignal } from "solid-js";
    import { useAll } from "jazz-tools/solid";
    import { app } from "../schema.js";

    export function ConditionalQueryExample() {
      const [filter, setFilter] = createSignal<string | null>(null);
      const query = createMemo(() =>
        filter() ? app.todos.where({ title: { contains: filter()! } }) : undefined,
      );
      const filtered = useAll(() => ({ query: query() }));

      return (
        <>
          <input
            value={filter() ?? ""}
            onInput={(e) => setFilter(e.currentTarget.value || null)}
            placeholder="Filter by title"
          />
          <Show when={filtered.data}>
            <ul>
              <For each={filtered.data ?? []}>{(todo) => <li>{todo.title}</li>}</For>
            </ul>
          </Show>
        </>
      );
    }

    ```
  </Tab>
</Tabs>

React Suspense and Transitions [#react-suspense-and-transitions]

`useAllSuspense` is a React-specific variant that suspends instead of returning a loading state, keeping the previous result visible while the next one loads.

```tsx title="App.tsx"
export function ConcurrentTodoList() {
  const db = useDb();
  const [title, setTitle] = useState("");
  const [filterTitle, setFilterTitle] = useState("");
  const [showDoneOnly, setShowDoneOnly] = useState(false);
  const [page, setPage] = useState(0);
  const [isPending, startTransition] = useTransition();
  const deferredFilterTitle = useDeferredValue(filterTitle);

  let query = app.todos
    .orderBy("id", "desc")
    .limit(25)
    .offset(page * 25);

  if (deferredFilterTitle.trim()) {
    query = query.where({ title: { contains: deferredFilterTitle.trim() } });
  }
  if (showDoneOnly) {
    query = query.where({ done: true });
  }

  const isLoading = isPending || deferredFilterTitle !== filterTitle;

  function updatePage(nextPage: number) {
    startTransition(() => {
      setPage(nextPage);
    });
  }

  function handleFilterChange(e: React.ChangeEvent<HTMLInputElement>) {
    setFilterTitle(e.target.value);
    startTransition(() => {
      setPage(0);
    });
  }

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const trimmedTitle = title.trim();

    if (!trimmedTitle) {
      return;
    }

    await db.insert(app.todos, { title: trimmedTitle, done: false });
    setTitle("");
  }

  return (
    <>
      <form onSubmit={(e) => void handleSubmit(e)}>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="What needs to be done?"
          required
        />
        <button type="submit">Add</button>
      </form>

      <div>
        <input
          type="text"
          value={filterTitle}
          onChange={handleFilterChange}
          placeholder="Filter by title (contains)"
          aria-label="Filter by title"
        />
        <label>
          <input
            type="checkbox"
            checked={showDoneOnly}
            onChange={(e) => setShowDoneOnly(e.target.checked)}
          />
          Done only
        </label>
      </div>

      <Suspense fallback={<p>Loading todos...</p>}>
        <div style={{ opacity: isLoading ? 0.5 : 1, transition: "opacity 0.2s" }}>
          <ConcurrentTodoResults query={query} page={page} onPageChange={updatePage} />
        </div>
      </Suspense>
    </>
  );
}
```


# Testing



As you develop your Jazz application, you might find yourself needing to test functionality relating to the database, including sync and offline behaviour.

Testing a standalone Jazz DB [#testing-a-standalone-jazz-db]

If your application uses a Jazz `Db` instance directly, you can simply create a test database using the regular `createDb` function
exposed by the `jazz-tools` package.

Databases created this way are easy to set up and pretty lightweight. You can even create an in-memory Db (with `driver: { type: "memory" }`)
so that results do not persist across test runs.

Keep in mind this approach is limited, though. Db instances that are not connected to a server cannot enforce permissions.

For UI tests you can also use a simple in-memory DB configuration without a server, but it's generally more useful to spin up
a test server for your app (see the [next section](/docs/recipes/testing#testing-jazz-dbs-connected-to-a-sync-server)).

Testing permissions [#testing-permissions]

Jazz provides utilities to test permissions in isolation without testing your whole app's logic.

The `createPolicyTestApp` helper from `jazz-tools/testing` starts an isolated local Jazz server, publishes your app schema
and permissions, and gives you session-scoped database clients for assertions.

```typescript title="permissions.test.ts" custom="noscroll"
import { createPolicyTestApp, type PolicyTestApp } from "jazz-tools/testing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { app } from "../schema.js";
import permissions from "../permissions.js";

let testApp: PolicyTestApp;

beforeEach(async () => {
  testApp = await createPolicyTestApp(app, permissions, expect);
});

afterEach(async () => {
  await testApp.shutdown();
});

describe("todo permissions", () => {
  it("allows owners to update their own todos", async () => {
    const aliceAccount = crypto.randomUUID();
    const bobAccount = crypto.randomUUID();
    const todo = await testApp.seed((db) => {
      const { value } = db.insert(app.todos, {
        title: "Buy milk",
        ownerId: aliceAccount,
      });
      return value;
    });

    const alice = testApp.as({
      issuer: "https://auth.example.test",
      user_id: "alice",
      account_id: aliceAccount,
      claims: {},
      authMode: "external",
    });
    const bob = testApp.as({
      issuer: "https://auth.example.test",
      user_id: "bob",
      account_id: bobAccount,
      claims: {},
      authMode: "external",
    });

    await alice
      .update(app.todos, todo.id, {
        title: "Buy oat milk",
      })
      .wait({ tier: "edge" });

    await bob.expectDenied((db) =>
      db.update(app.todos, todo.id, {
        title: "Buy orange juice",
      }),
    );
  });
});
```

`createPolicyTestApp` takes the app created with `defineApp(...)`, the
permissions object created with `definePermissions(...)`, and your test runner's `expect` function.

The returned `PolicyTestApp` provides:

* `testApp.seed(fn)` — run setup writes as an admin, bypassing policy checks, and wait for them to reach the server
* `testApp.as(session)` — create a `TestDb` client scoped to a specific session
* `testDb.expectAllowed(fn)` — check that a write can be staged locally, then roll it back; this does not prove that the server will accept it
* `testDb.expectDenied(fn)` — wait for the server to reject a write because of a policy
* `testApp.shutdown()` — stop the local client and server

For read policies, assert on returned rows directly. Denied reads are filtered out rather than
throwing.

```typescript
const bob = testApp.as({
  user_id: "bob",
  claims: {},
  authMode: "local-first",
});

await expect(bob.all(app.todos.where({ id: privateTodo.id }))).resolves.toEqual([]);
```

If your policies depend on JWT claims, put those values in `session.claims` using the same claim
names your policy reads.

```typescript
const invitedUser = testApp.as({
  user_id: "bob",
  claims: { join_code: "invite-123" },
  authMode: "local-first",
});
```

Testing Jazz DBs connected to a sync server [#testing-jazz-dbs-connected-to-a-sync-server]

In many cases, it's more useful to test a Jazz database that's connected to a sync server. Jazz provides
utilities to set up a local sync server for tests, available in `jazz-tools/testing`:

* use `startLocalJazzServer` to create a test sync server
* use `deploy` to publish your app's schema and permissions to the server

Once the server is created, you can connect client databases to it using `createDb` with the server's URL and appId.

If your app uses external JWT auth, `startTestJwtIssuer` starts a local JWKS endpoint and mints signed JWTs for test users.

```typescript custom="noscroll"
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createAccountManager, createDb, type Db } from "jazz-tools";
import {
  startLocalJazzServer,
  startTestJwtIssuer,
  type LocalJazzServerHandle,
  type TestJwtIssuerHandle,
} from "jazz-tools/testing";

let issuer: TestJwtIssuerHandle;
let server: LocalJazzServerHandle;
let db: Db | undefined;

beforeEach(async () => {
  issuer = await startTestJwtIssuer();
  server = await startLocalJazzServer({
    inMemory: true,
    jwksUrl: issuer.jwksUrl,
  });
});

afterEach(async () => {
  await db?.shutdown();
  await server.stop();
  await issuer.stop();
});

describe("external auth", () => {
  it("connects with a signed test JWT", async () => {
    const jwtToken = issuer.jwtForUser("alice", {
      role: "admin",
    });

    let stored: string | null = null;
    const accounts = await createAccountManager({
      appId: server.appId,
      serverUrl: server.url,
      store: {
        read: async () => stored,
        update: async (transform) => {
          stored = transform(stored);
        },
      },
    });
    const account = await accounts.registerJWT(jwtToken);
    db = await createDb({
      appId: server.appId,
      serverUrl: server.url,
      account,
      driver: { type: "memory" },
    });

    expect(db.getAuthState().session).toMatchObject({
      user: { account: account.id, identity: { subject: "alice" } },
      authMode: "external",
    });
  });
});
```

UI tests [#ui-tests]

For browser or framework tests, start one local sync server for the test project and mount your app against it.
This lets your framework's bindings create their own Jazz clients connected to the server.

You can often do this in a single place (e.g. using Vitest's [globalsetup](https://vitest.dev/config/globalsetup)).

```typescript title="tests/browser/global-setup.ts"
import { deploy, startLocalJazzServer, type LocalJazzServerHandle } from "jazz-tools/testing";
import permissions from "../../permissions.js";
import { app } from "../../schema.js";
import { ADMIN_SECRET, APP_ID, TEST_PORT } from "./test-constants.js";

let server: LocalJazzServerHandle;

export async function setup(): Promise<void> {
  server = await startLocalJazzServer({
    appId: APP_ID,
    port: TEST_PORT,
    adminSecret: ADMIN_SECRET,
  });

  await deploy({
    serverUrl: server.url,
    appId: server.appId,
    adminSecret: server.adminSecret,
    schema: app,
    permissions,
  });
}

export async function teardown(): Promise<void> {
  await server.stop();
}
```

If you need to isolate data across tests, you can use different `dbName`s when creating the client database.
For local-first auth in browser tests, create one account handle per logical user and reuse that handle when testing reloads. Prepare accounts before mounting the UI, outside any Jazz context:

```tsx
import { createAccountManager } from "jazz-tools";
import { JazzProvider } from "jazz-tools/react";
import type { ReactNode } from "react";

const accounts = await createAccountManager({
  appId: APP_ID,
  serverUrl: `http://127.0.0.1:${TEST_PORT}`,
});
const account = accounts.createLocalFirst();
const dbName = crypto.randomUUID();

function TestApp({ children }: { children: ReactNode }) {
  return (
    <JazzProvider
      config={{
        appId: APP_ID,
        serverUrl: `http://127.0.0.1:${TEST_PORT}`,
        account,
        driver: { type: "persistent", dbName },
      }}
    >
      {children}
    </JazzProvider>
  );
}
```

Testing sync [#testing-sync]

Having multiple client databases connected to the same sync server also lets you test scenarios that require data
to be synced across client databases, such as sharing content across users. The utilities presented until now are
usually enough for this type of tests, but we also provide a few additional utilities for advanced scenarios.

Testing offline behavior [#testing-offline-behavior]

Simulating offline clients can be useful to test conflict resolution works correctly when multiple users make changes while being offline.
Call `db.disconnect()` to temporarily stop syncing that client with its configured Jazz server, and `db.reconnect()` to resume syncing.

While disconnected, writes will only be performed locally and reads can only retrieve local data.
After `reconnect()`, pending writes are sent to the server and writes missed from other clients are received.

Testing migrations [#testing-migrations]

`deploy` can also be used to publish [migrations](/docs/schemas/migrations). When the server already has a schema, `deploy` can push
a migration between the current server schema and the new schema. This makes it possible to test clients with different schemas.

```typescript custom="noscroll"
import { createDb, schema as s, type Db } from "jazz-tools";
import { deploy, startLocalJazzServer } from "jazz-tools/testing";
import { describe, expect, it, vi } from "vitest";

const oldSchema = {
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
  }),
};

const newSchema = {
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
    tags: s.array(s.string()).default([]),
  }),
};

const oldApp = s.defineApp(oldSchema);
const newApp = s.defineApp(newSchema);

const oldPermissions = s.definePermissions(oldApp, ({ policy }) => [
  policy.todos.allowRead.always(),
  policy.todos.allowInsert.always(),
]);

const newPermissions = s.definePermissions(newApp, ({ policy }) => [
  policy.todos.allowRead.always(),
  policy.todos.allowInsert.always(),
]);

describe("migrations", () => {
  it("lets old-schema clients write rows new-schema clients can read", async () => {
    const server = await startLocalJazzServer({
      inMemory: true,
    });
    const { appId, adminSecret, url: serverUrl } = server;
    let oldDb: Db | undefined;
    let newDb: Db | undefined;

    const migration = s.defineMigration({
      fromHash: await oldApp.schemaHash,
      toHash: await newApp.schemaHash,
      from: oldSchema,
      to: newSchema,
      migrate: {
        todos: {
          tags: s.add.array({ of: s.string(), default: [] }),
        },
      },
    });

    await deploy({
      serverUrl,
      appId,
      adminSecret,
      schema: oldApp,
      permissions: oldPermissions,
    });

    await deploy({
      serverUrl,
      appId,
      adminSecret,
      schema: newApp,
      permissions: newPermissions,
      migration,
    });

    oldDb = await createDb({
      appId,
      serverUrl,
      adminSecret,
      driver: { type: "memory" },
    });
    newDb = await createDb({
      appId,
      serverUrl,
      adminSecret,
      driver: { type: "memory" },
    });

    const created = await oldDb
      .insert(oldApp.todos, {
        title: "written through old schema",
        done: false,
      })
      .wait({ tier: "edge" });

    await vi.waitFor(async () => {
      const rows = await newDb.all(newApp.todos.where({ id: { eq: created.id } }), {
        tier: "edge",
      });

      expect(rows).toEqual([
        {
          id: created.id,
          title: "written through old schema",
          done: false,
          tags: [],
        },
      ]);
    });
  });
});
```


# Agent skills



`create-jazz` installs a concise Jazz skill bundle at `.agents/skills/jazz/` in every new app.
Compatible coding agents discover it from the project, then load only the reference relevant to the
current task: application data, schemas and permissions, authentication, or testing.

The bundle deliberately links to the canonical public documentation instead of packaging a copied
API reference. It tells an agent to treat the installed `jazz-tools` version and the app's existing
types as authoritative when they differ from current docs.

For an existing app, copy `.agents/skills/jazz/` from a freshly scaffolded Jazz project. Keep the
bundle project-local so it can be reviewed and updated with the application.


# Durability Tiers



import DurabilityTiersTable from "../../partials/durability-tiers-table.mdx";

<DurabilityTiersTable />

For background on how data flows between tiers, see [How Sync Works](/docs/concepts/how-sync-works#infrastructure-tiers).

Write tiers [#write-tiers]

Mutations such as `insert`, `upsert`, `update`, `delete`, and `restore` apply locally with no
durability guarantee and return a `MutationResult`. Transaction helpers resolve to the same kind of
result. Call `.wait({ tier: ... })` when you need confirmation that the mutation reached a specific
durability tier.

See [Writing Data](/docs/writing/writing-data#write-durability-tiers) for detailed guidance on which tier to use and code examples.

Mutation errors [#mutation-errors]

Jazz throws errors it can detect locally as soon as you call the mutation. The server may reject the
change later, e.g. if the user does not have permissions. The change can appear locally before that
rejection arrives. If this happens, `.wait(...)` rejects and Jazz removes the local change.

If you are not waiting with `.wait(...)`, use `db.onMutationError(listener)` to handle a rejection.
Transactions use `.wait(...)` and report errors in the same way as individual mutations.

Read tiers [#read-tiers]

Read choices control which data a query uses and when its **first result** is delivered.
They are intentionally separate from write durability.

New applications should use the product read choices `"local-first"`,
`"remote"`, and `"remote-if-possible"`:

* `"local-first"` uses cached local knowledge and shows pending local writes immediately, while still syncing.
* `"remote"` uses the server's current query scope, without pending local writes. It waits while offline.
* `"remote-if-possible"` prioritizes remote data, but falls back to local data if the app explicitly calls `db.disconnect()` (a timeout, connection error, or slow response never triggers that fallback).
  Online, pending edits/deletes to in-scope rows and matching new inserts appear before server approval. Editing an existing out-of-scope row does not bring it into the result; cached related rows are not pulled in automatically.

A one-shot keeps its initial choice. A `"remote-if-possible"` subscription follows
confirmed disconnect/reconnect transitions in both directions, waiting for a fresh
remote scope on reconnect. Losing remote access does not erase cached data:
local-first and offline fallback may still show it. An actual synced deletion,
however, hides the row from local reads too.

The older read values `"local"`, `"edge"`, and `"global"` remain accepted
with their existing semantics during migration. They are legacy read controls only;
write durability and `wait({ tier })` continue to use those values.

These options apply to:

* `db.all(query, options?)`
* `db.one(query, options?)`
* `db.subscribe(query, callback, options?)`
* `useAll(query, options?)` / `useAllSuspense(query, options?)` (React/Expo)
* `new QuerySubscription(query, options?)` (Svelte)
* `useAll(query, options?)` (Vue)
* `useAll(() => ({ query, options }))` (Solid)

The React Native/Expo alpha uses these same read and write durability tiers
after opening its account-handle client with `createJazzClient`. Its native
relay owns persistence and upstream connectivity; do not substitute a browser
storage driver or a JavaScript-side SQLite path.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    const todosAtEdgeDurability = useAll(app.todos, { tier: "edge" });
    ```
  </Tab>

  <Tab value="Vue">
    ```ts title="App.vue"
    export function subscribeTodosAtEdge(db: Db, onCount: (count: number) => void) {
      return db.subscribe(app.todos.where({ done: false }), (todos) => onCount(todos.length), {
        tier: ReadTier.Remote,
      });
    }
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    const todosAtEdgeDurability = new QuerySubscription(app.todos, { tier: 'edge' });
    ```
  </Tab>

  <Tab value="Solid">
    ```ts title="App.tsx"
    export function subscribeTodosAtEdge(db: Db, onCount: (count: number) => void) {
      return db.subscribe(app.todos.where({ done: false }), (todos) => onCount(todos.length), {
        tier: ReadTier.Remote,
      });
    }
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="app.ts"
    export async function readTodosAtEdgeDurability(db: Db) {
      return db.all(app.todos.where({ done: false }), { tier: ReadTier.Remote });
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs title="main.rs"
    pub async fn read_todos_at_edge_durability(client: &JazzClient) -> jazz::tools::Result<usize> {
        let query = Query::from("todos");
        let rows = client
            .query(query, Some(DurabilityTier::EdgeServer))
            .await?;
        Ok(rows.len())
    }
    ```
  </Tab>
</Tabs>

For most queries and subscriptions, omitting a tier is the right choice: Jazz delivers results from local storage immediately and streams in remote updates as they arrive. Reserve explicit tiers for cases where eventual consistency is not acceptable.

<Callout type="warn">
  The read tier gates the **first** delivery of a subscription only. After the initial snapshot
  arrives at the requested tier, subsequent updates are delivered as they reach the local node,
  regardless of which tier they've propagated to. For example, a subscription with `tier: "global"`
  guarantees a globally-consistent initial snapshot, but later incremental updates from other
  clients may arrive through edge tiers before being globally available **even if the durability of
  the write is set to 'global'**.
</Callout>


# Examples



The `examples/` folder in the Jazz monorepo contains runnable apps that each
highlight a different facet of Jazz — auth strategies, runtimes,
framework bindings, server-side usage, or a specific product pattern. Use this
table to jump straight to the example that covers the technique you need.

<Callout type="info">
  If you just want a bare-bones skeleton to build on, the [`starters/`](https://github.com/garden-co/jazz/tree/main/starters) folder ships minimal templates for Next.js, React, and SvelteKit, each in `localfirst`, `betterauth`, and `hybrid` flavours. You can also scaffold a new app from one of these templates with:

  ```bash
  npm create jazz
  ```

  Reach for the `examples/` apps below when you want to see a specific pattern worked through end-to-end.
</Callout>

At a glance [#at-a-glance]

| Example                                                                                                             | Stack                     | Uniquely demonstrates                                                                                                                                                                                                                                                                   |
| ------------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [auth-betterauth-chat](https://github.com/garden-co/jazz/tree/main/examples/auth-betterauth-chat)                   | Next.js + Better Auth     | Better Auth tables stored in Jazz via `jazz-tools/better-auth-adapter`; Better Auth's `jwt` plugin issues the ES256 tokens and JWKS that the sync server verifies; demonstrates exposing user attributes (e.g. the `admin` plugin's `role`) as JWT claims that drive Jazz authorization |
| [auth-simple-chat](https://github.com/garden-co/jazz/tree/main/examples/auth-simple-chat)                           | React + Vite + Express    | Bring-your-own JWT auth server: local Express issuing ES256 tokens, JWKS verified by the sync server, `session.claims.role` driving UI gating                                                                                                                                           |
| [auth-workos-chat](https://github.com/garden-co/jazz/tree/main/examples/auth-workos-chat)                           | React + Vite + WorkOS     | Hosted OAuth/SSO with no local auth server — sync server points at the WorkOS JWKS, `getAccessToken()` is passed straight to `JazzProvider`                                                                                                                                             |
| [chat-react](https://github.com/garden-co/jazz/tree/main/examples/chat-react)                                       | React + Vite              | Public vs private rooms with invite links via ephemeral `session.claims.join_code`; emoji reactions; collaborative drawing canvases; binary file attachments                                                                                                                            |
| [cloudflare-worker-runtime-ts](https://github.com/garden-co/jazz/tree/main/examples/cloudflare-worker-runtime-ts)   | Cloudflare Workers        | Booting Jazz inside Workers by passing a precompiled `WebAssembly.Module` via `runtimeSources.wasmModule` — no browser asset URLs                                                                                                                                                       |
| [moon-lander-react](https://github.com/garden-co/jazz/tree/main/examples/moon-lander-react)                         | React + Vite              | Multiplayer game state — player positions, fuel deposits, inventory, and chat — synced through Jazz with no custom networking code                                                                                                                                                      |
| [nextjs-csr-ssr](https://github.com/garden-co/jazz/tree/main/examples/nextjs-csr-ssr)                               | Next.js (App Router)      | The canonical SSR/RSC story: a Server Component reading via `jazz-tools/backend` alongside a Client Component using `jazz-tools/react` hooks, wired by `withJazz`                                                                                                                       |
| [todo-client-localfirst-expo](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-expo)     | Expo (React Native)       | Supported alpha app: a direct New-Architecture `jazz-rn` dependency, Expo SecureStore-backed `AccountHandle`, and effect-owned `createJazzClient` persistent relay. Requires a matching development/release build; Expo Go cannot load the native module.                               |
| [todo-client-localfirst-react](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-react)   | React + Vite              | Fully client-side React app with local-first auth using a locally generated secret; `useAll` with composable `where()`, `useDb` writes, IndexedDB persistence, and the development Inspector overlay                                                                                    |
| [todo-client-localfirst-svelte](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-svelte) | Svelte 5 + Vite           | The Svelte bindings: `JazzSvelteProvider`, `QuerySubscription` live queries, `getDb` writes                                                                                                                                                                                             |
| [todo-client-localfirst-solid](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-solid)   | Solid + Vite              | The Solid bindings: `JazzProvider`, `useAll` live queries, `useDb` writes                                                                                                                                                                                                               |
| [todo-client-localfirst-ts](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-ts)         | Vanilla TypeScript + Vite | The low-level client API with no framework bindings: `createDb`, `db.subscribe`, `db.onAuthChanged`, `createAccountManager`                                                                                                                                                             |
| [todo-client-localfirst-vue](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-vue)       | Vue + Vite                | The Vue bindings: `JazzProvider`, `useAll` live queries, and `useDb` writes                                                                                                                                                                                                             |
| [todo-server-rs](https://github.com/garden-co/jazz/tree/main/examples/todo-server-rs)                               | Rust (axum) + `jazz`      | Using Jazz directly from Rust as the embedded database for an axum REST + SSE service — no browser, no WASM, no Node bindings                                                                                                                                                           |
| [todo-server-ts](https://github.com/garden-co/jazz/tree/main/examples/todo-server-ts)                               | Node + Express            | Jazz as a server-side backend via `jazz-tools/backend` and NAPI SQLite storage; `await client.forRequest(request)` for per-user policy; SSE live snapshots; `wait({ tier })` durability                                                                                                 |
| [world-tour](https://github.com/garden-co/jazz/tree/main/examples/world-tour)                                       | Vue + Vite + MapLibre GL  | The Vue bindings end-to-end in a non-trivial app (tour management with maps)                                                                                                                                                                                                            |

How the examples group together [#how-the-examples-group-together]

* **The `todo-client-localfirst-*` family** (`-react`, `-svelte`, `-solid`, `-vue`, `-ts`)
  all implement the same schema and feature set, so the diff between them is the
  framework binding. They cover the local-first baseline: local-first identity,
  reactive queries, synchronous writes, IndexedDB persistence, optional server sync.
  See also [Framework Patterns](/docs/reference/framework-patterns).
* **The `auth-*-chat` family** (`auth-betterauth-chat`, `auth-simple-chat`,
  `auth-workos-chat`) all implement a role-gated chat against three different
  JWT-issuing auth setups, so the diff between them is the auth integration.
* **The server-side examples** (`todo-server-ts`, `todo-server-rs`,
  `nextjs-csr-ssr`, `cloudflare-worker-runtime-ts`) show Jazz used as the
  database from a server runtime — Node, Rust, Next.js App Router, and
  Cloudflare Workers respectively.
* **The product-shaped examples** (`chat-react`, `moon-lander-react`,
  `world-tour`) are full apps that lean on
  Jazz for a specific real-world pattern: real-time chat with invites and
  canvases, multiplayer game state, and Vue + maps.


# Framework Patterns



Jazz provides framework-specific bindings for React/Expo, Vue, Svelte, and Solid. This page
is a side-by-side reference — see [Reading Data](/docs/reading/queries),
[Writing Data](/docs/writing/writing-data), and [Sessions](/docs/auth/sessions) for
full details.

API equivalents [#api-equivalents]

| Concept            | React / Expo                     | Vue                               | Svelte                          | Solid                         |
| ------------------ | -------------------------------- | --------------------------------- | ------------------------------- | ----------------------------- |
| Provider           | `<JazzProvider config={config}>` | `<JazzProvider :config="config">` | `<JazzSvelteProvider {config}>` | `<JazzProvider config={...}>` |
| Query subscription | `useAll(query)`                  | `useAll(query)`                   | `new QuerySubscription(query)`  | `useAll(() => ({ query }))`   |
| DB access          | `useDb()`                        | `useDb()`                         | `getDb()`                       | `useDb()`                     |
| Session            | `useSession()`                   | `useSession()`                    | `getSession()`                  | `useSession()`                |
| Client creation    | `createJazzClient(config)`       | `createJazzClient(config)`        | `createJazzClient(config)`      | `createSolidJazzClient(...)`  |

Provider setup [#provider-setup]

Wrap your app in a provider to make the database available to every component.
Svelte callers that create their own client can use `JazzSvelteClientProvider`
instead; it accepts a client or client promise and leaves shutdown to the caller.

In Solid, use `JazzClientProvider` instead when you already own a client created with
`createSolidJazzClient`. `JazzClientProvider` does not shut down caller-owned clients.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function ProviderExample({ config }: { config: DbConfig }) {
      return (
        <JazzProvider config={config} fallback={<p>Loading...</p>}>
          <YourApp />
        </JazzProvider>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import type { DbConfig } from "jazz-tools";
    import { JazzProvider } from "jazz-tools/vue";
    // Prepare a handle outside this context with createAccountManager.
    defineProps<{ config: DbConfig }>();
    </script>
    <template>
      <JazzProvider :config="config"><slot /></JazzProvider>
    </template>

    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import type { Snippet } from "svelte";
      import type { DbConfig } from "jazz-tools";
      import { JazzSvelteProvider } from "jazz-tools/svelte";
      // Prepare a handle outside this context with createAccountManager.
      let { config, children }: { config: DbConfig; children: Snippet } = $props();
    </script>
    <JazzSvelteProvider {config}>{@render children()}</JazzSvelteProvider>
    ```
  </Tab>

  <Tab value="Solid">
    ```tsx title="App.tsx"
    import { type ParentProps } from "solid-js";
    import type { DbConfig } from "jazz-tools";
    import { JazzProvider } from "jazz-tools/solid";

    // Prepare a handle outside this context with createAccountManager.
    export function ProviderExample(props: ParentProps<{ config: DbConfig }>) {
      return (
        <JazzProvider config={props.config} fallback={<p>Loading...</p>}>
          {props.children}
        </JazzProvider>
      );
    }

    ```
  </Tab>
</Tabs>

Vue's `JazzProvider` creates and shuts down its client. If you already have a client, pass it
to `JazzClientProvider` instead; you remain responsible for shutting that client down.

Query subscriptions [#query-subscriptions]

Subscribe to query results reactively. See [Reading Data](/docs/reading/queries) for the full API.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function LiveQueryExample() {
      const { data: todos, isLoading, error } = useAll(app.todos.where({ done: false }));

      if (isLoading) return <p>Loading...</p>;
      if (error) return <p>Something went wrong!</p>;

      return (
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      );
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```vue title="App.vue"
    <script setup lang="ts">
    import { useAll } from "jazz-tools/vue";
    import { app } from "../schema.js";

    const { data: todos } = useAll(app.todos.where({ done: false }));
    </script>

    <template>
      <li v-for="todo in todos ?? []" :key="todo.id">{{ todo.title }}</li>
    </template>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte title="App.svelte"
    <script lang="ts">
      import { QuerySubscription } from 'jazz-tools/svelte';
      import { app } from '../schema.js';

      const todos = new QuerySubscription(
        app.todos.where({ done: false }),
      );
    </script>

    {#each todos.current ?? [] as todo}
      <li>{todo.title}</li>
    {/each}
    ```
  </Tab>

  <Tab value="Solid">
    ```tsx title="App.tsx"
    import { For } from "solid-js";
    import { useAll } from "jazz-tools/solid";
    import { app } from "../schema.js";

    export function QuerySubscriptionExample() {
      const todos = useAll(() => ({ query: app.todos.where({ done: false }) }));
      return <For each={todos.data ?? []}>{(todo) => <li>{todo.title}</li>}</For>;
    }

    ```
  </Tab>
</Tabs>

The `?? []` guard handles the `undefined` (not yet connected) case. See
[Reading data: the loading state](/docs/reading/queries#the-loading-state) for patterns that depend on this signal.

In Vue, Svelte, and Solid, the binding reconciles updates into the existing reactive array in place, so only fields that actually changed trigger re-renders. See [Fine-grained updates](/docs/reading/queries#fine-grained-updates) for the full behaviour.

Accessing the database for writes [#accessing-the-database-for-writes]

Get a handle to the database for inserts, updates, and deletes. See [Writing Data](/docs/writing/writing-data) for the full API.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function DbAccessExample() {
      // Must be called at component top level (rules of hooks)
      const db = useDb();

      async function addTodo(title: string) {
        await db.insert(app.todos, { title, done: false });
      }

      void addTodo;
      return null;
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```ts title="DbSessionExamples.vue"
    // Must be called inside setup() or <script setup>
    const db = useDb();

    async function addTodo(title: string) {
      await db.insert(app.todos, { title, done: false });
    }
    ```
  </Tab>

  <Tab value="Svelte">
    ```ts title="DbSessionExamples.svelte"
    // Callable anywhere — component, store, or utility module
    const db = getDb();

    async function addTodo(title: string) {
      await db.insert(app.todos, { title, done: false });
    }
    ```
  </Tab>

  <Tab value="Solid">
    ```tsx title="DbSessionExamples.tsx"
    import { useDb, useSession } from "jazz-tools/solid";
    import { app } from "../schema.js";

    export function DbSessionExamples() {
      const db = useDb();
      const session = useSession();

      async function addTodo(title: string) {
        await db().insert(app.todos, { title, done: false });
      }

      void addTodo;
      void session;
      return null;
    }

    ```
  </Tab>
</Tabs>

Session/user identity [#sessionuser-identity]

Access the current user's session. See [Sessions](/docs/auth/sessions) for details on authentication modes and identity linking.

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid"]} persist updateAnchor>
  <Tab value="React">
    ```tsx title="App.tsx"
    export function SessionExample() {
      const session = useSession(); // { user: { account, identity }, ... } | null

      void session;
      return null;
    }
    ```
  </Tab>

  <Tab value="Vue">
    ```ts title="DbSessionExamples.vue"
    const session = useSession(); // ComputedRef<Session | null>
    ```
  </Tab>

  <Tab value="Svelte">
    ```ts title="DbSessionExamples.svelte"
    const session = getSession(); // { current: Session | null }
    ```
  </Tab>

  <Tab value="Solid">
    ```tsx title="DbSessionExamples.tsx"
    import { useDb, useSession } from "jazz-tools/solid";
    import { app } from "../schema.js";

    export function DbSessionExamples() {
      const db = useDb();
      const session = useSession();

      async function addTodo(title: string) {
        await db().insert(app.todos, { title, done: false });
      }

      void addTodo;
      void session;
      return null;
    }

    ```
  </Tab>
</Tabs>


# Inspector



The [Jazz Inspector](https://v2.inspector.jazz.tools/) is a standalone client for inspecting a Jazz
app through any reachable sync server. It is not tied to Jazz Cloud: if you have a server URL, app
ID, and admin credential, you can connect to that app's sync server directly.

<Callout type="warn" title="Admin access only">
  The inspector uses `adminSecret`, not an end-user session token. On the sync connection, that
  authenticates the client as the backend, so normal permission policies are bypassed. Treat it like
  production infrastructure access and never expose it to end users.
</Callout>

Connecting [#connecting]

Open [v2.inspector.jazz.tools](https://v2.inspector.jazz.tools/) and enter:

* **Server URL** — base URL of the sync server
* **App ID** — Jazz app namespace to inspect
* **Admin secret** — app admin credential
* **Env** — logical environment such as `dev`, `staging`, or `prod`
* **Branch** — legacy connection namespace, usually `main` (this does not select a
  branch view)

After connecting, choose the published schema hash you want to inspect. The inspector stores the
connection locally so you can reopen it without re-entering everything each time.

Features [#features]

Data Explorer [#data-explorer]

Browse every table in the selected schema, inspect rows reactively, sort columns, and add typed
filters. Relation cells link to the referenced table, so you can follow relations without manually
rebuilding the query.

The explorer also supports admin writes: edit cells inline, open a row sidebar for full-row edits,
insert new rows, and delete existing ones.

Schema and permissions view [#schema-and-permissions-view]

Each table includes a schema view showing the stored structural schema for that table. In standalone
mode, the same page also shows the currently published sync-server permissions for that table, which
is useful when you want to confirm what policy bundle the server is enforcing.

Subscriptions [#subscriptions]

The Subscriptions tab shows active subscriptions from the inspected page runtime and active
server-managed subscriptions from the connected sync server. You can inspect the table, propagation
mode, branch-view options, and compiled query JSON, then jump straight into the matching table view in Data
Explorer.

Schema switching [#schema-switching]

If an app has multiple published schema hashes, the inspector lets you switch between them without
reconnecting. This is useful when checking migrations, comparing stored shapes, or debugging data
that was authored under an older schema.

Development overlay [#development-overlay]

The Vite and SvelteKit development integrations can serve an in-app inspector overlay that opens the
embedded inspector against the running app's local runtime. The overlay is enabled by default during
development and can be disabled with the integration's `inspector: false` option.

For remote or self-hosted sync servers, the hosted client at
[v2.inspector.jazz.tools](https://v2.inspector.jazz.tools/) is the easiest way to inspect the server
directly.


# Advanced Internals



This page describes Jazz's internal architecture. You do not need any of this to use Jazz, but it
is helpful if you are debugging, reasoning about performance, or understanding why the system
behaves the way it does.

Data model [#data-model]

Raw tables plus engine-managed fields [#raw-tables-plus-engine-managed-fields]

Jazz stays table-first all the way down.

Your schema defines normal application columns such as `title`, `done`, and `projectId`. Under the
hood, the engine also tracks a small set of reserved `_jazz_*` columns that explain how each row
behaves over time, such as:

* a stable row id
* the branch view the row belongs to
* the current row-version id
* ancestry pointers to earlier row versions
* visibility state
* confirmed durability tier
* delete markers
* engine/user metadata

The important physical fact is that Jazz stores one flat `row_format` row containing both the user
columns and the reserved engine columns. Some Rust types still expose the user-column slice
separately for convenience, but that is just a decoded view rather than a different storage model.

Visible entries and row histories [#visible-entries-and-row-histories]

Each logical row has two important storage shapes behind it:

* a **visible entry** for current reads
* a **row history** containing every stored row version

Ordinary queries read the visible entry first. History is what makes replay, reconnect, branching,
and future historical queries possible.

The simplest picture is:

```text
todos
  visible: (branch, row_id) -> current winner for that branch view
  history: (row_id, version_id) -> row versions over time
```

This is why Jazz can feel like "just tables" at the app layer while still keeping rich local-first
history underneath.

Both storage shapes are flat rows:

* history rows use reserved `_jazz_*` columns plus the user columns
* visible rows use a slightly larger `_jazz_*` prefix plus the same user columns

Indexing [#indexing]

By default, **every column on every table is indexed**. This keeps `where`, `orderBy`, and
join lookups fast on any column without you having to think about it, at the cost of one
index entry per column per row. The `_id` index for each table doubles as the authoritative
row manifest, so discovering all rows in a table is just an `_id` index scan.

Sometimes you'll want to optimise for write performance or storage cost instead. Use
`indexOnly()` to specify which columns in the table need indexes; only the columns you
specify will be indexed.

<Callout type="warn" title="Avoid overuse">
  `table.indexOnly(["title", "done"])` does **not** mean "add indexes on `title` and `done`". It
  means "**drop** the indexes on every other column on this table". Read it as *"index only these"*.
  `indexOnly` is an optimisation that you're unlikely to need to begin with, and if not used
  correctly, can significantly impact the performance of your app, especially on reads.
</Callout>

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  todos: s
    .table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      activityLog: s.string().optional(),
    })
    .indexOnly(["title", "done"]),
};
```

In this example, `where({ title: ... })` and `where({ done: ... })` are still index-backed.
A query that filters on `description` or `activityLog` works, but falls back to scanning
every row in the table.

Use `indexOnly` when you're seeing slow writes and:

* The column holds large or rarely-queried data (long text, serialised metadata, audit logs).
* The table is write-heavy and the per-column index cost is showing up in your performance data.

Deletion [#deletion]

The user-facing `delete` API performs a **soft delete**. The row is preserved in
history, but it disappears from ordinary live queries.

Internally, the current visible state leaves the live `_id` index and can still be addressed
through deleted-row paths such as `_id_deleted`.

A **hard delete** mode also exists at the storage layer, but it is not currently exposed as the
normal app-facing API.

Row history and truncation [#row-history-and-truncation]

Row history is append-only by default. Every write creates a new row version and keeps older
versions available for replay and reconciliation.

There is a low-level truncation path that can drop older ancestry while preserving the current
visible state, but it is not a normal application-facing feature yet.

Monotonic direct-write ordering [#monotonic-direct-write-ordering]

Each runtime instance maintains a small monotonic clock for direct writes. New row versions created
by that runtime get strictly increasing local timestamps, which makes deterministic last-writer-wins
ordering straightforward within a single device or process.

Merge strategies [#merge-strategies]

By default, Jazz adopts a per-column last-writer-wins strategy to resolve concurrent edits. If two
clients update the same column of the same row simultaneously, Jazz's deterministic
[hybrid logical clock](/docs/concepts/how-sync-works#hybrid-logical-clock) ordering chooses the
winner.

This is a sensible default, but can cause some unexpected behaviour with certain types of data. For example,
imagine a voting app. Alice and Bob both read the current `voteCount` value as 2. They each want to increment
the value. If they simultaneously write 3, then the new value will be 3, even though they actually each wanted
to increment by one.

Counters [#counters]

Use `.merge("counter")` on an integer or bigint column to keep deltas additive instead:

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  votes: s.table({
    proposal: s.string(),
    voteCount: s.int().merge("counter"),
    totalVotingPower: s.bigint().merge("counter"),
  }),
};
```

With `merge("counter")`, every `update(...)` on the column is recorded as a delta from the
value the writer was looking at. When concurrent edits meet, the deltas are summed: Alice's
`+1` and Bob's `+1` both apply, and the `voteCount` correctly lands on `4`.

Counter merges are useful for things like:

* A shared score or vote tally.
* An inventory level being incremented and decremented from multiple devices.
* Any other counter where you care about preserving every increment rather than which device
  wrote last.

<Callout type="warn">
  `merge("counter")` is only valid on **non-nullable integer or bigint columns**. Calling it on a
  string, a nullable integer (`s.int().optional()`), a nullable bigint (`s.bigint().optional()`), or
  any other type throws at schema construction time.
</Callout>

Grow-only sets [#grow-only-sets]

Counters solve concurrent numbers; arrays have the same problem with concurrent membership. Under
last-writer-wins, if Alice adds `"urgent"` to a `tags` array while Bob concurrently adds
`"blocked"`, one write clobbers the whole array and a tag is silently lost.

Use `.merge("g-set")` to make an array column a grow-only set instead — concurrent writes converge
to the union of every replica's elements:

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  documents: s.table({
    title: s.string(),
    tags: s.array(s.string()).merge("g-set"),
  }),
};
```

When concurrent edits meet, the merged array is the union of all contributed elements, deduplicated
and sorted into a canonical order so every replica converges on a byte-identical result. Alice's
`"urgent"` and Bob's `"blocked"` both survive. An element written by one replica is never dropped by
a concurrent write from another that never saw it.

Grow-only sets are useful for things like:

* Accumulating tags, labels, or category membership.
* Append-only logs of participants, contributors, or seen IDs.
* Any collection where you care about keeping every element rather than which device wrote last.

<Callout type="warn">
  `merge("g-set")` is **grow-only**: there is no element removal. It is only valid on **non-nullable
  array columns**; calling it on any other type, or on a nullable array (`s.array(...).optional()`),
  throws at schema construction time.
</Callout>

Cold start [#cold-start]

On startup, Jazz loads indices first rather than eagerly decoding every row. Row content is then
loaded on demand as queries reference it.

The result is that cold-start cost is much closer to "index size" than "total stored data size."

Browser architecture [#browser-architecture]

Core browser runtime [#core-browser-runtime]

In the browser, Jazz uses the direct Rust/WASM core for reads, writes, subscriptions, and sync. With
`driver: { type: "memory" }`, the database runs in the main thread and syncs to the configured
server over the core WebSocket protocol.

With `driver: { type: "persistent" }`, Jazz opens one directly durable SharedWorker runtime per
database namespace. The worker owns the IndexedDB page store and the server connection; each tab
communicates with that runtime through a message port. There is no tab-leader election, follower
handoff, or per-tab durable database. Jazz does use one origin-wide Web Lock per physical database
namespace to prevent retry generations or separately loaded worker assets from concurrently opening
the same IndexedDB root. If that lock is unavailable or already held by another worker realm, the
new realm fails closed instead of risking two durable owners.

`driver.dbName` (and `dbName`) selects a **logical base**, not a physical IndexedDB namespace.
Jazz derives the physical IndexedDB and SharedWorker namespace from that base plus app,
environment, and canonical authentication scope. Thus accounts can coexist on one browser:
reopening the same base as Alice selects Alice's prior cache, while Bob selects Bob's. The derived
names and durable metadata contain no credential, token, secret, or arbitrary claims.

On its first open Jazz also pins the exact non-secret logical owner (app, environment, and
authentication scope) next to the page-store manifest. This is defense in depth against a
derivation bug or low-level attempt to open the wrong physical root: that attempt fails before
pages change. `db.deleteClientStorage()` / logout with `wipeData` destroys only the current scoped
namespace; it does not erase another account's cache under the same logical base. The durable owner
is neither the foreground replica/node ID nor a credential; those remain separate identities.

Calling `db.disconnect()` in one tab explicitly takes that **whole persistent namespace** offline:
the worker has one upstream connection, so every attached tab uses local-first fallback for
`"remote-if-possible"` reads and remote reads wait until any tab calls `db.reconnect()`. This is
intentional; disconnecting only one tab while continuing to sync its writes through the shared
worker would give that tab an incoherent offline contract.

The main thread keeps a responsive client-side peer while the SharedWorker commits durable state in
the background.

With `driver: { type: "memory" }`, the worker and IndexedDB are skipped entirely, and the main-thread
runtime syncs directly with the server.

IndexedDB crash safety [#indexeddb-crash-safety]

The IndexedDB page store commits a complete page generation and its metadata in one IndexedDB
transaction. A failed or interrupted write therefore leaves the previous committed generation
available on reopen.

React Native [#react-native]

React Native uses its native adapter rather than browser workers or IndexedDB.

Query engine [#query-engine]

Execution pipeline [#execution-pipeline]

Queries compile into a graph of processing nodes:

```text
IndexScan → [Union] → Materialize → [PolicyFilter]
  → [ArraySubquery] → [Filter] → [Sort] → [LimitOffset]
  → [Project] → Output
```

Nodes in brackets are only present when the query requires them. The graph processes deltas
incrementally, which means that when data changes, only dirty nodes re-evaluate. That is what makes
live subscriptions efficient: a single row change does not require re-running the whole query.

Materialization [#materialization]

`Materialize` is where candidate row ids turn back into rows.

It typically:

1. looks up the visible entry for the relevant branch
2. falls back to row history only when the query needs an older settled winner
3. decodes or reprojects the flat row, dropping the reserved engine columns before returning app-facing values
4. emits row-level deltas to the downstream graph

This is why the visible region matters so much: most current reads never need to reconstruct a row
from full history.

One-shot queries [#one-shot-queries]

`db.all()` and `db.one()` are implemented as "create a temporary subscription, wait for the first
durability-qualified snapshot, then auto-unsubscribe." They share the same reactive machinery as
live subscriptions, which is why they participate in durability-tier gating and lens transforms.

Include performance [#include-performance]

Initial setup and materialisation still scale with the size of the relation returned by an
`include()` or array subquery. Once the query is maintained, a child-row change is routed
incrementally to the affected result. A canonical scale canary checks that the allocation cost of
one maintained relation change does not grow with the accumulated relation size.

Sync protocol [#sync-protocol]

Transport [#transport]

Jazz uses a single WebSocket sync transport plus a small HTTP surface for health and admin reads.

* **Sync**: `GET /apps/<appId>/ws` upgrades to a WebSocket carrying the typed sync protocol.
* **Admin**: `GET /apps/<appId>/schemas`, `GET /apps/<appId>/schema/:hash`, and
  `POST /apps/<appId>/admin/...` handle schema and permissions publication/read flows.
* **Health**: `GET /health`.

Client identity [#client-identity]

Each client generates and persists a stable `ClientId`. On reconnect with the same id, the server
can treat it as the same logical peer rather than as a brand-new client with no prior state.

Reconnection [#reconnection]

The TypeScript client uses exponential backoff with jitter. On reconnect, active query
subscriptions are replayed as anti-entropy: the server re-evaluates them and resends any rows the
client still needs.

Trust model and client roles [#trust-model-and-client-roles]

Sync is asymmetric:

* **Upward** (client -> server): row versions, row-state changes, and catalogue updates are pushed
  toward trusted servers
* **Downward** (server -> client): only rows matching the client's active query subscriptions are
  sent

Each client connection has a **role** that determines how writes are routed:

| Role    | Write handling                                                    |
| ------- | ----------------------------------------------------------------- |
| `User`  | Writes queued for permission policy evaluation before apply       |
| `Admin` | Writes applied directly, no permission check                      |
| `Peer`  | Writes applied directly, used for trusted runtime-to-runtime sync |

Frontend clients usually authenticate as `User`. Backend services with a backend secret authenticate
as `Admin` or `Peer`.

Schema evolution [#schema-evolution]

Lenses [#lenses]

Migrations in Jazz produce **lenses** — bidirectional transformations between schema versions. When
`jazz-tools migrations create` diffs two schemas, it generates a lens with declarative operations
such as adding, removing, or renaming columns and tables.

At query time, Jazz can use lens paths to read older stored data through the current schema. At
write time, it projects updates through the lens path while retaining the row's physical schema
identity.

Catalogue sync [#catalogue-sync]

Schemas and lenses travel through a separate catalogue lane, not through the normal user-row
history path. Clients publish catalogue entries, servers discover them lazily, and query execution
uses that catalogue state to resolve schema context on demand.

Durability signals [#durability-signals]

Jazz separates two durability questions:

| Signal                  | Gates                                | Question it answers                        |
| ----------------------- | ------------------------------------ | ------------------------------------------ |
| `QuerySettled`          | First read delivery                  | "Has the query result settled at tier T?"  |
| Write tier confirmation | `.wait({ tier })` promise completion | "Has this write been confirmed at tier T?" |

Both use the same tier lattice (`local` \< `edge` \< `global`), but they answer different
questions. A query's first callback is held until `QuerySettled` reaches the requested tier. A
`.wait({ tier })` promise resolves when the requested tier confirms the write.

<Callout type="warn">
  The read durability tier only gates the **first** delivery of a subscription. After the initial
  snapshot arrives at the requested tier, later updates are delivered as they reach the local node.
  That means `tier: "global"` gives you a globally settled first snapshot, not globally gated
  delivery forever after.
</Callout>


# Local-first auth internals



[Local-first auth](/docs/auth/local-first-auth) separates a signing identity from the account that owns application data. A local-first account can be created offline; adding an external identity requires the core registry.

Identity and account derivation [#identity-and-account-derivation]

The client retains a 32-byte secret, represented as `jazz-auth-v1:<43 unpadded base64url characters>`. Native crypto derives an Ed25519 signing key using the `jazz-auth-sign-v1` domain separator. Its public key determines the local identity's UUID subject in the `jazz-auth-key-v1` namespace.

The identity is `(urn:jazz:local-first, subject)`. Its founder account is a separate, application-scoped UUIDv5: the namespace is the normalized app UUID, and the name is `jazz-account-founder-v1` followed by a zero byte and the subject. Therefore the same recovery secret restores the same identity and the same account within an application. A new secret creates a different identity and founder account.

`createAccountManager` prepares native crypto and retained selection. After preparation, `createLocalFirst()` synchronously returns an opaque `AccountHandle`. A context waits for the handle's recovery material to be durably retained before using its credentials. Browser, React Native, and Expo adapters provide host storage; the account-selection logic is shared.

Self-signed authentication [#self-signed-authentication]

Local-first JWTs use Ed25519, `iss: "urn:jazz:local-first"`, the derived identity subject, an application audience, and the embedded `jazz_pub_key`. The server verifies the signature, checks the audience and token lifetime, and confirms that the public key derives the claimed subject.

This proves control of the identity without an external JWKS provider. The deterministic founder rule lets core admit that identity's account without a competing registration choice. It does not let the caller choose an arbitrary account ID.

The core registry records the exact identity-to-account assignment. External identities require explicit registration or linking; merely verifying an external JWT does not create an account.

Linking a provider identity [#linking-a-provider-identity]

The provider keeps its normal issuer and subject. It does not rewrite its user ID, accept a Jazz-specific signup proof, or mint custom linking claims.

The app first gracefully closes its current context with `shutdown({ waitForSync: true })`. If synchronization fails, it keeps that context and retries later. Once closed, it calls `accounts.linkJWT({ getToken })` outside any context, then opens a new context using the returned handle.

The core linking protocol uses a nonce and two authenticated proofs: a currently permitted identity authorizes linking a particular target identity, and that target proves control through its ordinary JWT. The ordered registry accepts the link only if the target identity is unassigned. An identity already assigned to another account cannot be moved or merged, including after revocation.

Existing rows retain their original structured authorship. Their account IDs stay unchanged, while new writes record the linked provider identity. Policies comparing `.account` keep ownership stable; comparisons of the entire author also distinguish the acting identity.

If linking fails after the old context was closed, the app may reopen the account still selected by the manager. The manager performs no database shutdown or upload scheduling itself. See [Local-first auth](/docs/auth/local-first-auth) for the application flow.


# WHERE Operators



import WhereOperatorsTable from "../../partials/where-operators-table.mdx";

Operator reference by column type [#operator-reference-by-column-type]

<WhereOperatorsTable />

Examples [#examples]

Equality and inequality [#equality-and-inequality]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // Exact match (shorthand — no operator object needed)
    const incompleteTodos = await db.all(app.todos.where({ done: false }));

    // Not equal
    const nonDraftTodos = await db.all(app.todos.where({ title: { ne: "Draft" } }));

    // One of a set
    const selectedTodos = await db.all(app.todos.where({ id: { in: [todoIdA, todoIdB] } }));
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    // Exact match
    let query = Query::from("todos").filter(eq(col("done"), lit(false)));
    let incomplete_todos = client.query(query, None).await?;

    // Not equal
    let query = Query::from("todos").filter(ne(col("title"), lit("Draft")));
    let non_draft_todos = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Numeric comparisons [#numeric-comparisons]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;

    const recentTodos = await db.all(app.todos.where({ $createdAt: { gt: oneWeekAgo } }));
    const highPriority = await db.all(app.todos.where({ priority: { gte: 3 } }));
    const lowPriority = await db.all(app.todos.where({ priority: { lt: 10 } }));
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis() as u64;
    let one_week_ago = now_ms - 7 * 24 * 60 * 60 * 1000;

    let query = Query::from("todos").filter(gt(col("$createdAt"), lit(one_week_ago)));
    let recent_todos = client.query(query, None).await?;

    let query = Query::from("todos").filter(gte(col("priority"), lit(3)));
    let high_priority = client.query(query, None).await?;

    let query = Query::from("todos").filter(lt(col("priority"), lit(10)));
    let low_priority = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

String contains [#string-contains]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // Substring match (case-sensitive)
    const matches = await db.all(app.todos.where({ title: { contains: searchTerm } }));
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    // Substring match (case-sensitive)
    let query = Query::from("todos").filter(contains(col("title"), lit(search_term)));
    let matches = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Null checks on optional references [#null-checks-on-optional-references]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // Rows where the optional ref is not set
    const unlinkedTodos = await db.all(app.todos.where({ parentId: { isNull: true } }));

    // Rows where it is set
    const linkedTodos = await db.all(app.todos.where({ parentId: { isNull: false } }));
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    // Rows where the optional ref is not set
    let query = Query::from("todos").filter(is_null(col("parent")));
    let unlinked_todos = client.query(query, None).await?;

    // Rows where it is set
    let query = Query::from("todos").filter(not(is_null(col("parent"))));
    let linked_todos = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Multiple conditions (AND) [#multiple-conditions-and]

All predicates passed to `where(...)` / chained `filter_*` calls are AND-combined:

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    // done AND assigned to a project
    const doneWithProject = await db.all(
      app.todos.where({
        done: true,
        projectId: { isNull: false },
      }),
    );
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    // Multiple filter calls are AND-combined
    let query = Query::from("todos")
        .filter(eq(col("done"), lit(true)))
        .filter(not(is_null(col("project"))));
    let done_with_project = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Combining with ordering and limits [#combining-with-ordering-and-limits]

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    const recentIncomplete = await db.all(
      app.todos.where({ done: false }).orderBy("$createdAt", "asc").limit(50),
    );
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    let query = Query::from("todos")
        .filter(eq(col("done"), lit(false)))
        .order_by("$createdAt", OrderDirection::Asc)
        .limit(50);
    let recent_incomplete = client.query(query, None).await?;
    ```
  </Tab>
</Tabs>

Live subscriptions with WHERE [#live-subscriptions-with-where]

`useAll` and query subscriptions accept the same query builders as `db.all`. The subscription stays active and updates whenever any row enters or exits the filter:

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    export function subscribeOpenTodos(db: Db, onChange: (todos: unknown[]) => void) {
      return db.subscribe(app.todos.where({ done: false }), (todos) => onChange(todos));
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    let query = Query::from("todos").filter(eq(col("done"), lit(false)));
    let pending = client.subscribe(query).await?;
    ```
  </Tab>
</Tabs>

For reactive framework bindings (`useAll` in React/Vue/Solid, `QuerySubscription` in Svelte), see [Framework Patterns](/docs/reference/framework-patterns#query-subscriptions).


# Column Types



import AvailableColumnTypes from "../../partials/available-column-types.mdx";

Any column can be made nullable by chaining `.optional()`.

<AvailableColumnTypes />

Transformed Columns [#transformed-columns]

> **Experimental:** Transformed columns are an early TypeScript API and may change before the stable release.

Any normal column definer can be transformed with `.transform({ from, to })`. The database still stores the column using the underlying SQL type, while the TypeScript API exposes the transformed type on rows, inserts, and updates.

Use `from` to convert stored values into the value your app reads. Use `to` to convert app values back into the stored column value before inserts and updates.

```ts
import { schema as s } from "jazz-tools";

type Priority = "low" | "medium" | "high";

const schema = {
  tasks: s.table({
    title: s.string(),
    priority: s.int().transform<Priority>({
      from: (score) => (score >= 8 ? "high" : score >= 4 ? "medium" : "low"),
      to: (priority) => ({ low: 1, medium: 5, high: 10 })[priority],
    }),
  }),
};
```

With this schema, `priority` is stored as an `INTEGER`, but TypeScript treats it as `Priority` when reading and writing rows:

```ts
db.insert(app.tasks, {
  title: "Write launch notes",
  priority: "high",
});

db.update(app.tasks, task.id, {
  priority: "medium",
});

const task = await db.one(app.tasks.where({ id: task.id }));
task?.priority; // "low" | "medium" | "high"
```

Filters still use the stored column value, because arbitrary transforms cannot be translated into SQL predicates:

```ts
await db.all(app.tasks.where({ priority: { gte: 8 } }));
```

Transforms are TypeScript-client behavior. They do not change the generated SQL schema, migrations, permissions, indexes, or values stored on disk.


# Defining Tables



import SchemaSetup from "../../partials/schema-setup.mdx";

Project layout [#project-layout]

A Jazz project has a small set of files at the app root:

```text
app-root/
├── schema.ts            # Structural schema — tables and columns
├── permissions.ts       # Optional row-level policies
└── migrations/          # Reviewed migration edges
    └── 20260331-add-description-aaa-bbb.ts
```

`schema.ts` is the source of truth for your data model. `permissions.ts` is optional and must be a
separate file. The `migrations/` directory holds reviewed migration stubs — see
[Migrations](/docs/schemas/migrations) for the full workflow.

Table definitions [#table-definitions]

Tables are defined in `schema.ts` using the Jazz DSL. Each `s.table(...)` call registers a table and `s.ref(...)` defines typed relations between them.

```ts title="schema.ts"
projects: s.table({
  name: s.string(),
}),
todos: s.table({
  title: s.string(),
  done: s.boolean(),
  priority: s.int().optional(),
  description: s.string().optional(),
  owner_id: s.uuid().optional(),
  parentId: s.ref("todos").optional(),
  projectId: s.ref("projects").optional(),
}),
```

<Callout type="warn">
  `s.ref()` columns must be named with an `Id` or `_id` suffix (for example `projectId` or
  `owner_id`). For `s.array(s.ref())`, use an `Ids` or `_ids` suffix instead. The runtime enforces
  this convention and will throw if a ref column name does not match.
</Callout>

Validate locally [#validate-locally]

<SchemaSetup />

When you change your schema on a shared app, create and push a migration. See [Migrations](/docs/schemas/migrations) for details.

If you need to clear local browser data after a schema change, see [Auth Lifecycle](/docs/auth/lifecycle#storage-reset).

Exporting the app [#exporting-the-app]

`s.defineApp(schema)` converts your schema definition into a typed `app` object. This is what you
pass to queries, mutations, and subscriptions throughout your application code.

```ts title="schema.ts"
type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);

export type Todo = s.RowOf<typeof app.todos>;
```

The `app` object has one typed table handle per table (e.g. `app.todos`, `app.projects`). Table handles are query builders — you chain `.where()`, `.include()`, `.orderBy()` and other methods directly on them.

Type helpers [#type-helpers]

Extract precise TypeScript types from any table handle:

| Helper                         | Returns                                                     |
| ------------------------------ | ----------------------------------------------------------- |
| `s.RowOf<typeof app.todos>`    | The row type (all columns, `id` included)                   |
| `s.InsertOf<typeof app.todos>` | The insert shape (no `id`, respects optionals and defaults) |
| `s.WhereOf<typeof app.todos>`  | The `where(...)` input shape for that table                 |

Very Large Schemas [#very-large-schemas]

For most apps, `s.defineApp(schema)` is the right export: it gives you one typed table handle for
each table in the schema, and Jazz uses the same schema for runtime validation, migrations, query
planning, and TypeScript inference.

Very large apps can hit a different tradeoff. The runtime schema may need to contain hundreds of
tables, while a given feature area only works with a much smaller subset. Because typed relations
and reverse relations are derived from the app schema, asking TypeScript to understand the whole
graph can make editor and build performance worse than the code you are writing actually needs.

Use `s.defineSliceableApp(schema)` when you want one complete runtime schema but smaller typed app
surfaces:

```ts title="schema.ts"
import { schema as s } from "jazz-tools";

const schema = {
  accounts: s.table({
    name: s.string(),
  }),
  workspaces: s.table({
    name: s.string(),
    accountId: s.ref("accounts"),
  }),
  catalog_items: s.table({
    title: s.string(),
    workspaceId: s.ref("workspaces"),
  }),
  orders: s.table({
    number: s.string(),
    catalogItemId: s.ref("catalog_items"),
    buyerId: s.ref("users"),
  }),
  shipments: s.table({
    trackingCode: s.string(),
    orderId: s.ref("orders"),
  }),
  users: s.table({
    name: s.string(),
  }),
  support_tickets: s.table({
    workspaceId: s.ref("workspaces"),
    requesterId: s.ref("users"),
  }),
};

const sliceableApp = s.defineSliceableApp(schema);

export const commerceApp = sliceableApp.slice(
  "accounts",
  "workspaces",
  "catalog_items",
  "orders",
  "shipments",
);
export const supportApp = sliceableApp.slice("accounts", "workspaces", "support_tickets");
```

Each slice returns a normal typed `App` surface for only the selected tables:

```ts
await db.all(commerceApp.orders.include({ catalogItem: true }));
await db.all(commerceApp.catalog_items.include({ ordersViaCatalogItem: true }));
```

Refs to tables inside the slice become typed relations and includes. Refs to tables outside the slice
remain valid scalar ID columns:

```ts
type Order = s.RowOf<typeof commerceApp.orders>;
// Order["catalogItemId"] is string, and commerceApp.orders.include({ catalogItem: true }) is typed.
// Order["buyerId"] is string, but there is no typed `buyer` include unless `users` is in the slice.
```

Reverse relations are also derived only from the current slice. In the example above,
`commerceApp.catalog_items` has `ordersViaCatalogItem`, while `supportApp.workspaces` has
`support_ticketsViaWorkspace`.

All slices share the complete runtime schema:

```ts
commerceApp.wasmSchema === sliceableApp.wasmSchema;
supportApp.wasmSchema === sliceableApp.wasmSchema;
```

That means schema hashing, migrations, permissions, runtime validation, query planning, inserts,
updates, and row transforms still see the full schema. The slice only limits the TypeScript app
graph you ask the compiler to expand.


# Migrations



<Callout type="info" title="You can skip this first">
  If you are trying to get your first app running, you can skip this page and return later.
</Callout>

Why Jazz migrations are different [#why-jazz-migrations-are-different]

Most migration systems are one-way: rewrite every row to the new shape, then cut over. That assumes you can stop the world long enough to upgrade — which doesn't hold when your clients are local-first, frequently offline, and updating their app on their own schedule.

Jazz keeps every schema version usable and translates rows between them on read and write. Clients on different versions stay interoperable, and nothing on disk is rewritten when you ship a new schema.

Schemas and lenses [#schemas-and-lenses]

Every unique version of your `schema.ts` has a hash which can be used to refer to it. When creating migrations, you describe the changes required to move between two schema versions. This is known as a 'lens'. Fetching all intermediate lenses allows clients with any published schema version to read data created with any other published schema version.

<LensDiagram />

Rows retain the physical schema identity under which they were written. A read in another compatible
schema uses lenses to project those rows into the requested shape. Non-adjacent reads compose lenses in sequence to bridge multiple schema
versions.

In practice, this lets you:

* Ship a schema change without waiting for every user to update their app.
* Roll out platform-by-platform (mobile, desktop, web) on independent cadences.
* Accept writes from clients that have been offline since before the new schema landed.

Workflow [#workflow]

1. If this is the first migration you are creating, run:

   ```bash
   pnpm dlx jazz-tools@alpha migrations create
   ```

   This creates an initial snapshot of your schema in `migrations/snapshots/`. No migration file is created yet because there is no previous schema to diff against.

2. **Edit `schema.ts`** — change the data shape as needed.

3. **Validate locally** — optionally run `pnpm dlx jazz-tools@alpha validate` to surface
   any policy diagnostics without publishing. `deploy` runs the same checks; `validate` is most
   useful as a fast pre-publish sanity check or in CI.

4. **Create a migration stub for the updated schema** — run:

   ```bash
   pnpm dlx jazz-tools@alpha migrations create --name <your-migration-name>
   ```

   By default, Jazz diffs the latest committed snapshot in `migrations/snapshots/` against the
   current schema and writes a stub migration file into `migrations/`. It also saves a snapshot of the generated schema.

5. **Review and customise** — the migration, if needed (see below).

6. **Publish** — push the migration to the server:

   ```bash
   pnpm dlx jazz-tools@alpha migrations push <appId> <fromHash> <toHash>
   ```

   If you also want to publish the current schema, the migration and permissions in one step, you can run:

   ```bash
   pnpm dlx jazz-tools@alpha deploy <appId>
   ```

   `deploy` walks through the publish pipeline in one go:

   1. Publishes the current schema if the server does not already have it.
   2. If your previous `permissions.ts` was tied to an older schema hash, asks the server whether
      it already has a migration path between the two hashes. If not, pushes the local migration
      file that closes the gap (and fails with a helpful message if you haven't created one yet).
   3. Publishes the current permissions, attached to the current schema hash.

Permission-only changes in `permissions.ts` don't need a migration but still need to be deployed:
`pnpm dlx jazz-tools@alpha deploy <appId>`. See [Permissions](/docs/auth/permissions) for details.

The migration file [#the-migration-file]

The generated stub describes the diff as declarative operations which carry enough information to run in either direction. That
is how older clients can still read data written under a newer schema: the same operations replay
in reverse.

<Callout type="warn">
  If the diff contains ambiguities (e.g. a column was removed and a same-typed column was added,
  which could be a rename), the generated lens is marked as a **draft**. Draft lenses will fail at
  startup if they are in the path to a live schema. You need to review the draft lens and resolve
  the ambiguity before publishing.
</Callout>

Generated stub [#generated-stub]

Here's a generated stub for adding a `description` column:

```ts title="migrations/20260318-unnamed-a01f5c72ec47-311995e9a178.ts"
import { schema as s } from "jazz-tools";

export default s.defineMigration({
  migrate: {
    todos: {
      description: s.add.string({ default: null }),
    },
  },
  fromHash: "a01f5c72ec47",
  toHash: "311995e9a178",
  from: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
  to: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
});

```

Customising defaults [#customising-defaults]

Review generated defaults before you publish. For example, you might replace a nullable default with a domain-specific value:

```ts title="migrations/20260318-add-description-a01f5c72ec47-311995e9a178.ts"
import { schema as s } from "jazz-tools";

// Example of editing a generated migration stub.
export default s.defineMigration({
  migrate: {
    todos: {
      description: s.add.string({ default: "No description" }),
    },
  },
  fromHash: "a01f5c72ec47",
  toHash: "311995e9a178",
  from: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
  to: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
});

```

Backwards defaults [#backwards-defaults]

When a newer schema drops a column that older clients still expect, define a backwards default so the lens can supply a value for those clients:

```ts title="migrations/20260318-drop-legacy-priority-311995e9a178-73b65d082ab8.ts"
import { schema as s } from "jazz-tools";

// Example: dropping a column with a backwards default.
// Clients still on the older schema continue seeing legacy_priority.
export default s.defineMigration({
  migrate: {
    todos: {
      legacy_priority: s.drop.int({ backwardsDefault: 0 }),
    },
  },
  fromHash: "311995e9a178",
  toHash: "73b65d082ab8",
  from: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
      legacy_priority: s.int(),
    }),
  },
  to: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
});

```

Migrating historical schemas [#migrating-historical-schemas]

Jazz does not require you to create a migration for every schema change. You can just use the app and create new data.
Existing data will still be stored in the database, but you will not be able to read it until you create a migration.

This is particularly useful when you are iterating on a feature that is not yet ready to be released.

The Jazz server will detect when there are rows that are not reachable from the current schema. It will log a warning and suggest you create a migration.

To do so, you'll need to **create the migration using explicit to/from schema hashes**:

```bash
pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash>
pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash> --toHash <toHash>
```

`--toHash` defaults to the current local schema. When a requested hash is not already saved locally, Jazz resolves it from the
server and saves a snapshot in `migrations/snapshots/`.

Inspecting the local schema hash [#inspecting-the-local-schema-hash]

`pnpm dlx jazz-tools@alpha schema hash` prints the hash of the local `schema.ts` without contacting a server or writing a snapshot, giving you a simple way to compare your local schema against what's deployed.

```bash
pnpm dlx jazz-tools@alpha schema hash
```

Exporting the compiled schema [#exporting-the-compiled-schema]

`pnpm dlx jazz-tools@alpha schema export` prints the compiled structural schema as JSON to stdout. It
also saves a snapshot of the schema in the local snapshot directory.

```bash
pnpm dlx jazz-tools@alpha schema export
pnpm dlx jazz-tools@alpha schema export --schema-dir ./packages/app
pnpm dlx jazz-tools@alpha schema export <appId> --schema-hash <hash> --server-url http://localhost:4200 --admin-secret <secret>
```

Without `--schema-hash`, Jazz exports the current local `schema.ts`. With `--schema-hash`, it
loads the schema from the local snapshot folder or, if missing, from the server.
`--schema-dir` and `--schema-hash` are mutually exclusive.

Server-backed commands require the app id so Jazz can resolve app-scoped routes like
`/apps/<appId>/schema/:hash`.

| Flag                   | Default             | Description                                                     |
| ---------------------- | ------------------- | --------------------------------------------------------------- |
| `--schema-dir <path>`  | current directory   | Path to app root containing `schema.ts`                         |
| `--schema-hash <hash>` | none                | Export a stored structural schema by hash                       |
| `--migrations-dir <p>` | `./migrations`      | Path to migrations directory and snapshot folder                |
| `--server-url <url>`   | `JAZZ_SERVER_URL`   | Server URL used when `--schema-hash` is not available locally   |
| `--admin-secret <sec>` | `JAZZ_ADMIN_SECRET` | Admin secret used when `--schema-hash` is not available locally |

Migration flags [#migration-flags]

`migrations create` uses flags rather than positional hashes. When it needs to resolve missing
schema hashes from a server, pass `<appId>` as the leading positional argument.

| Flag                   | Default             | Description                                      |
| ---------------------- | ------------------- | ------------------------------------------------ |
| `--schema-dir <path>`  | current directory   | Path to app root containing `schema.ts`          |
| `--migrations-dir <p>` | `./migrations`      | Path to migrations directory and snapshot folder |
| `--server-url <url>`   | `JAZZ_SERVER_URL`   | Server URL used when resolving missing schema    |
| `--admin-secret <sec>` | `JAZZ_ADMIN_SECRET` | Admin secret used when resolving missing schema  |
| `--fromHash <hash>`    | latest snapshot     | Optional source schema hash                      |
| `--toHash <hash>`      | current schema      | Optional target schema hash                      |
| `--name <name>`        | `unnamed`           | Optional migration filename label                |

Next steps [#next-steps]

* [Defining Tables](/docs/schemas/defining-tables) — table and column definitions
* [Column Types](/docs/schemas/column-types) — full list of available column types


# Transactions



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

A transaction groups related writes under one commit. Reads made through the transaction can see
its staged writes, while code outside the transaction cannot see them until it commits.

Jazz has two transaction types. The difference is how they handle concurrent changes:

* A mergeable transaction commits locally, even when the device is offline. When it syncs, Jazz
  combines it with concurrent work using the normal conflict rules. Jazz does not reject it just
  because another client changed the same data.
* An exclusive transaction uses a stable view of the data. Before accepting it, the authority checks
  whether another write made a conflicting change. If so, it rejects the whole transaction
  and Jazz removes its local changes. Use this when accepting both changes would break a rule.

Most applications should start with a mergeable transaction.

<Accordions type="single">
  <Accordion title="What's the authority?">
    The authority is the part of Jazz that accepts or rejects writes. It checks permissions and other
    rules before accepting them. For an exclusive transaction, it also checks that the data read by the
    transaction has not changed in a conflicting way.

    For most apps, the authority is the server at the `global` durability tier. This is why an exclusive
    transaction cannot be accepted while the device is offline. For an app without a sync server, the
    authority runs locally instead.
  </Accordion>
</Accordions>

Choose a transaction type [#choose-a-transaction-type]

| Use                 | Mergeable transaction                           | Exclusive transaction                                                  |
| ------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
| Best for            | Grouping normal local-first writes              | Enforcing an invariant across reads and writes                         |
| Concurrent work     | Merges using Jazz's normal conflict rules       | The authority validates the transaction as one serialisable unit       |
| TypeScript callback | `db.transaction(...)`                           | `db.exclusiveTransaction(...)`                                         |
| TypeScript wait     | `wait({ tier: "local" \| "edge" \| "global" })` | `wait()`                                                               |
| Offline             | Can commit locally and sync later               | Can be staged, but cannot be accepted until the authority is reachable |

You do not need a transaction for every write. `insert`, `update`, `upsert`, `delete` and `restore`
already apply locally first and sync in the background.

<Callout type="info" title="One commit does not mean one UI update">
  Jazz sends every transaction upstream as one unit, and the authority decides that unit as a whole.
  Other clients can still receive rows from a mergeable transaction in separate updates. An
  exclusive transaction is shown atomically within each subscription view.
</Callout>

Use a callback for normal work [#use-a-callback-for-normal-work]

The callback form commits automatically when the callback finishes. In TypeScript, Jazz rolls the
transaction back if the callback throws or returns a rejected promise. In Rust, it leaves the
transaction uncommitted if the callback returns `Err`.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts title="app.ts"
    export async function groupTodoWrites(db: Db, existingTodoId: string) {
      const result = await db.transaction(async (tx) => {
        const created = tx.insert(app.todos, {
          title: "Write transaction docs",
          done: false,
          owner_id: EXAMPLE_OWNER_ID,
          projectId: EXAMPLE_PROJECT_ID,
        });

        tx.update(app.todos, existingTodoId, { done: true });

        const staged = await tx.one(app.todos.where({ id: created.id }));
        if (!staged) throw new Error("Staged todo not found");

        return staged.id;
      });

      await result.wait({ tier: "edge" });
      return result.value;
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs title="app.rs"
    pub fn group_todo_writes(
        db: &Db<RocksDbStorage>,
        existing_todo_id: RowUuid,
    ) -> Result<RowUuid, jazz::db::Error> {
        let (created_id, _transaction_id) = db.transaction(|tx| {
            let created_id = tx.insert("todos", transaction_todo_values("Write transaction docs"))?;
            tx.update(
                "todos",
                existing_todo_id,
                BTreeMap::from([("done".to_string(), DbValue::Bool(true))]),
            )?;

            let _staged = tx.read("todos", created_id)?;

            Ok(created_id)
        })?;

        Ok(created_id)
    }
    ```
  </Tab>
</Tabs>

The TypeScript callback returns the new row ID. Jazz exposes that value as `result.value`, and
`result.wait(...)` resolves to the same value. In Rust, `Db::transaction` returns the callback value
and the committed transaction ID as a tuple.

Inside a TypeScript transaction:

| Method                                      | Returns                                                   |
| ------------------------------------------- | --------------------------------------------------------- |
| `insert(...)`, `restore(...)`               | The staged row                                            |
| `update(...)`, `upsert(...)`, `delete(...)` | `void`                                                    |
| `all(...)`                                  | A promise for all matching rows, including staged changes |
| `one(...)`                                  | A promise for the first matching row, or `null`           |

Use the `tx` object for every read and write that belongs to the transaction. A query made through
the outer `db` does not read the transaction's staged changes.

Know what each await means [#know-what-each-await-means]

In TypeScript, committing and waiting for durability are separate steps:

| Code                                 | What it proves                                                                     |
| ------------------------------------ | ---------------------------------------------------------------------------------- |
| `await db.transaction(...)`          | The callback finished and Jazz created the committed mergeable transaction locally |
| `result.value`                       | The value returned by the callback                                                 |
| `await result.txId`                  | The committed transaction has an ID                                                |
| `await result.wait({ tier })`        | The mergeable transaction reached that durability tier, or was rejected            |
| `await db.exclusiveTransaction(...)` | Jazz created the committed exclusive transaction locally                           |
| `await result.wait()`                | The authority accepted the exclusive transaction, or rejected it                   |

If your next action depends on server acceptance, keep the result and call `wait(...)`. Awaiting
`db.transaction(...)` by itself is not enough.

See [Durability Tiers](/docs/reference/durability-tiers) for the meaning of `local`, `edge` and
`global`.

Handle errors [#handle-errors]

A transaction can fail at three points:

1. While the callback runs, a write can throw immediately or an awaited read can reject, for
   example because its input is invalid.
2. The callback or local commit can fail. Jazz rolls back a callback transaction and rejects
   `db.transaction(...)` or `db.exclusiveTransaction(...)`.
3. The authority can reject a committed transaction later. In TypeScript, `wait(...)` then rejects
   with `PersistedWriteRejectedError` and Jazz removes the rejected local changes.

```ts title="app.ts"
export async function completeTodoInTransaction(db: Db, todoId: string) {
  try {
    const result = await db.transaction((tx) => {
      tx.update(app.todos, todoId, { done: true });
    });

    await result.wait({ tier: "edge" });
  } catch (error) {
    if (error instanceof PersistedWriteRejectedError) {
      console.error(error.code, error.reason);
      return;
    }

    throw error;
  }
}
```

Use `db.onMutationError(...)` as a fallback for later rejections when your code does not keep and
wait on the transaction result.

Use an exclusive transaction to reject conflicts [#use-an-exclusive-transaction-to-reject-conflicts]

An exclusive transaction reads from a stable transaction snapshot. The authority checks the reads
and writes, then accepts or rejects the transaction as one serialisable unit. This is useful when a
write is valid only if the data you just read has not changed in a conflicting transaction.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts title="app.ts"
    export async function finishTodoExclusively(db: Db, todoId: string) {
      const result = await db.exclusiveTransaction(async (tx) => {
        const todo = await tx.one(app.todos.where({ id: todoId }));
        if (!todo) throw new Error("Todo not found");

        tx.update(app.todos, todo.id, { done: true });
        return todo.id;
      });

      return result.wait();
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs title="app.rs"
    pub fn finish_todo_exclusively(
        db: &Db<RocksDbStorage>,
        todo_id: RowUuid,
    ) -> Result<(), jazz::db::Error> {
        let tx = db.exclusive_tx()?;
        let _todo = tx.read("todos", todo_id)?;

        tx.update(
            "todos",
            todo_id,
            BTreeMap::from([("done".to_string(), DbValue::Bool(true))]),
        )?;
        let _transaction_id = tx.commit()?;
        Ok(())
    }
    ```
  </Tab>
</Tabs>

In TypeScript, `wait()` takes no durability tier for an exclusive transaction. It resolves if the
authority accepts the transaction and rejects if the authority rejects it.

The Rust example uses the embedded `jazz::db::Db` API. An owning `ExclusiveTx` abandons the open
transaction if it is dropped before `commit()` succeeds.

Manage a transaction yourself only when needed [#manage-a-transaction-yourself-only-when-needed]

Use `beginTransaction()` when the work cannot fit in one callback. You then own the transaction and
must call `commit()` or `rollback()`.

```ts title="app.ts"
export async function stageTodoAcrossSteps(db: Db, shouldCancel: boolean) {
  const tx = db.beginTransaction();

  tx.insert(app.todos, {
    title: "Review staged changes",
    done: false,
    owner_id: EXAMPLE_OWNER_ID,
    projectId: EXAMPLE_PROJECT_ID,
  });

  if (shouldCancel) {
    await tx.rollback();
    return;
  }

  const result = await tx.commit();
  await result.wait({ tier: "edge" });
}
```

For an exclusive transaction, use `beginExclusiveTransaction()` instead. Call `wait()` on the
commit result to wait for the authority to accept or reject the transaction. You do not choose a
durability tier, it is always settled by the authority.

If a mergeable transaction has no writes, `commit()` throws. Call `rollback()` instead.


# Writing Data



Local-first writes [#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](#write-durability-tiers).

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

Jazz also allows grouping writes together using [transactions](#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 [#getting-a-db-handle]

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

<Tabs groupId="jazz-framework" items={["React", "Vue", "Svelte", "Solid", "TypeScript"]} persist updateAnchor>
  <Tab value="React">
    ```tsx
    import { useDb } from "jazz-tools/react";

    const db = useDb();
    ```
  </Tab>

  <Tab value="Vue">
    ```vue
    <script setup lang="ts">
    import { useDb } from "jazz-tools/vue";

    const db = useDb();
    </script>
    ```
  </Tab>

  <Tab value="Svelte">
    ```svelte
    <script lang="ts">
      import { getDb } from 'jazz-tools/svelte';

      const db = getDb();
    </script>
    ```
  </Tab>

  <Tab value="Solid">
    ```tsx
    import { useDb } from "jazz-tools/solid";

    export function GetDbExample() {
      const db = useDb();
      void db;
      return null;
    }

    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    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 });
    ```
  </Tab>
</Tabs>

Insert, update, delete [#insert-update-delete]

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

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    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);
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    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(())
    }
    ```
  </Tab>
</Tabs>

Upsert with a known ID [#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.

```ts
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 [#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.

```ts
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 [#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`.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    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
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    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(())
    }
    ```
  </Tab>
</Tabs>

Editing a page of a large value [#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.

```ts
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:

```ts
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 [#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           |

<WriteTierDiagram />

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.

<Tabs groupId="jazz-environment" items={["TypeScript", "Rust"]} persist updateAnchor>
  <Tab value="TypeScript">
    ```ts
    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" });
    }
    ```
  </Tab>

  <Tab value="Rust">
    ```rs
    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)
    }
    ```
  </Tab>
</Tabs>

See [Durability Tiers](/docs/reference/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](/docs/auth/lifecycle#storage-reset).

Handling mutation errors [#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`.

```ts
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.

```ts
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 [#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](/docs/writing/transactions) for examples, error handling and the differences
between commit and authority acceptance.


# Group permissions



This recipe shows how to build a workspace where users have different levels of access depending on their role. The same pattern applies to any group-like concept — teams, projects, channels, organisations.

Roles at a glance [#roles-at-a-glance]

| Role          | Read | Create | Edit own | Edit any | Manage members |
| ------------- | ---- | ------ | -------- | -------- | -------------- |
| `reader`      | ✓    |        |          |          |                |
| `contributor` | ✓    | ✓      | ✓        |          |                |
| `writer`      | ✓    | ✓      | ✓        | ✓        |                |
| `admin`       | ✓    | ✓      | ✓        | ✓        | ✓              |

Schema [#schema]

Three tables: the workspace itself, a members join table that records each user's role, and the documents that belong to the workspace.

```ts title="schema.ts"
const schema = {
  workspaces: s.table({
    name: s.string(),
  }),
  workspaceMembers: s.table({
    workspaceId: s.ref("workspaces"),
    user_id: s.uuid(),
    role: s.enum("reader", "writer", "contributor", "admin"),
  }),
  documents: s.table({
    title: s.string(),
    content: s.string(),
    workspaceId: s.ref("workspaces"),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

Permissions [#permissions]

```ts title="permissions.ts"
type Role = "reader" | "writer" | "contributor" | "admin";

s.definePermissions(app, ({ policy, session, anyOf, allOf }) => {
  // Re-usable helpers to improve readability.
  const isMember = (workspaceId: RowRefValue) =>
    policy.workspaceMembers.exists.where({ workspaceId, user_id: session.user.account });

  const hasRole = (workspaceId: RowRefValue, role: Role) =>
    policy.workspaceMembers.exists.where({ workspaceId, user_id: session.user.account, role });

  const isAdmin = (workspaceId: RowRefValue) => hasRole(workspaceId, "admin");

  // --- documents ---

  policy.documents.allowRead.where((doc) => isMember(doc.workspaceId));

  policy.documents.allowInsert.where((doc) =>
    anyOf([
      hasRole(doc.workspaceId, "writer"),
      hasRole(doc.workspaceId, "contributor"),
      hasRole(doc.workspaceId, "admin"),
    ]),
  );

  // Writers and admins can edit any document; contributors can only edit their own
  policy.documents.allowUpdate.where((doc) =>
    anyOf([
      hasRole(doc.workspaceId, "writer"),
      hasRole(doc.workspaceId, "admin"),
      allOf([
        { "$createdBy.account": session.user.account },
        hasRole(doc.workspaceId, "contributor"),
      ]),
    ]),
  );

  // Writers and admins can delete any document; contributors can delete their own
  policy.documents.allowDelete.where((doc) =>
    anyOf([
      hasRole(doc.workspaceId, "writer"),
      isAdmin(doc.workspaceId),
      allOf([
        { "$createdBy.account": session.user.account },
        hasRole(doc.workspaceId, "contributor"),
      ]),
    ]),
  );

  // --- workspaces ---

  policy.workspaces.allowRead.where((workspace) => isMember(workspace.id));
  policy.workspaces.allowInsert.always();
  policy.workspaces.allowUpdate.where((workspace) => isAdmin(workspace.id));
  policy.workspaces.allowDelete.where((workspace) => isAdmin(workspace.id));

  // --- workspaceMembers ---

  policy.workspaceMembers.allowRead.where((member) => isMember(member.workspaceId));

  // Admins can add members; workspace creators can bootstrap themselves as the first admin
  policy.workspaceMembers.allowInsert.where((member) =>
    anyOf([
      isAdmin(member.workspaceId),
      allOf([
        { user_id: session.user.account, role: "admin" },
        policy.workspaces.exists.where({
          id: member.workspaceId,
          "$createdBy.account": session.user.account,
        }),
      ]),
    ]),
  );

  policy.workspaceMembers.allowUpdate.where((member) => isAdmin(member.workspaceId));

  // Admins can remove any member; members can leave on their own
  policy.workspaceMembers.allowDelete.where((member) =>
    anyOf([isAdmin(member.workspaceId), { user_id: session.user.account }]),
  );
});
```

* **Contributor** edit access uses `allOf` to require both `"$createdBy.account": session.user.account` (the row belongs to this author) and a matching contributor membership. Writers and admins bypass the creator check entirely.
* **Bootstrap insert**: the second branch of `allowInsert` lets the workspace creator add themselves as the first admin — otherwise `isAdmin` would block everyone, since there are no members yet.
* **Leave on your own**: members can delete their own membership row regardless of role. Admins can remove anyone.

See [Permissions](/docs/auth/permissions) for more on `exists.where`, `anyOf`, `allOf`, and `$createdBy`.

Creating a workspace [#creating-a-workspace]

```ts
export async function createWorkspace(
  db: ReturnType<typeof useDb>,
  name: string,
  creatorId: string,
) {
  const { value: workspace } = await db.insert(app.workspaces, { name });
  // Add the creator as admin immediately so they can manage the workspace
  db.insert(app.workspaceMembers, {
    workspaceId: workspace.id,
    user_id: creatorId,
    role: "admin",
  });
  return workspace;
}
```

Managing members [#managing-members]

Adding a member [#adding-a-member]

```ts
export async function addMember(
  db: ReturnType<typeof useDb>,
  workspaceId: string,
  userId: string,
  role: "reader" | "writer" | "contributor" | "admin",
) {
  await db.insert(app.workspaceMembers, { workspaceId, user_id: userId, role });
}
```

Listing members [#listing-members]

```tsx title="WorkspaceMembers.tsx"
export function WorkspaceMembers({ workspaceId }: { workspaceId: string }) {
  const { data: members = [], isLoading } = useAll(app.workspaceMembers.where({ workspaceId }));

  if (isLoading) return <p>Loading…</p>;

  return (
    <ul>
      {members.map((member) => (
        <li key={member.id}>
          {member.user_id} — {member.role}
        </li>
      ))}
    </ul>
  );
}
```

Changing a member's role [#changing-a-members-role]

```ts
export async function changeRole(
  db: ReturnType<typeof useDb>,
  memberId: string,
  newRole: "reader" | "contributor" | "writer" | "admin",
) {
  await db.update(app.workspaceMembers, memberId, { role: newRole });
}
```

Removing a member [#removing-a-member]

```ts
export async function removeMember(
  db: ReturnType<typeof useDb>,
  workspaceId: string,
  userId: string,
) {
  const member = await db.one(app.workspaceMembers.where({ workspaceId, user_id: userId }));
  if (member) await db.delete(app.workspaceMembers, member.id);
}
```

Querying documents [#querying-documents]

```tsx title="WorkspaceDocuments.tsx"
export function WorkspaceDocuments({ workspaceId }: { workspaceId: string }) {
  const { data: docs, isLoading, error } = useAll(app.documents.where({ workspaceId }));

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Something went wrong!</p>;

  return (
    <ul>
      {docs.map((doc) => (
        <li key={doc.id}>{doc.title}</li>
      ))}
    </ul>
  );
}
```


# Invite links



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

Invite links are a pattern that let a resource owner share a URL that grants access to anyone who
has it without knowing the recipient's identity in advance. The URL carries the resource ID plus a
secret that the owner stores in a private invite row.

When an invitee opens the URL, your backend validates the code and inserts a membership row for
their already-authenticated session. From that point on, access flows through the normal membership
rule.

This recipe deliberately keeps redemption on a backend. A link is a bearer capability: it must be
checked at the same authority that performs the membership write. Do not model the code as a
client-supplied session claim or authorize the write from a one-off read; that would let the read
and write run under different credentials.

Schema [#schema]

Keep invite codes in their own table, not on the resource row. Jazz permissions are row-level, so a
`joinCode` on the chat would be visible to every chat member. This table has no client read rule;
only the backend checks its code. The members table records which invite each user used to join, so
you can audit or revoke individual memberships later.

```ts title="schema.ts"
const schema = {
  chats: s.table({}),
  chatMembers: s.table({
    chatId: s.ref("chats"),
    user_id: s.uuid(),
    inviteId: s.string().optional(),
  }),
  chatInvites: s.table({
    chatId: s.ref("chats"),
    code: s.string(),
    singleUse: s.boolean(),
  }),
};
```

Permissions [#permissions]

A user can read a chat once they have a membership row. A chat creator can bootstrap their own
membership and create or revoke invite links, but no client can write a membership for somebody
else. The only way for an invitee to join is the server route below. The server has a
[backend identity](/docs/getting-started/server-setup#backend-identity-pattern) and inserts the row
on their behalf after validating the join code.

```ts title="permissions.ts"
s.definePermissions(app, ({ policy, allOf, anyOf, session }) => {
  policy.chats.allowRead.where((chat) =>
    policy.chatMembers.exists.where({ chatId: chat.id, user_id: session.user.account }),
  );
  policy.chats.allowInsert.always();

  // Users can read their own membership row; chat creators can read every
  // member of their chats.
  policy.chatMembers.allowRead.where((member) =>
    anyOf([
      { user_id: session.user.account },
      policy.chats.exists.where({ id: member.chatId, "$createdBy.account": session.user.account }),
    ]),
  );

  // The creator can insert their own membership in their own chat. Everyone
  // else must come through the server route, which writes with backend
  // privileges.
  policy.chatMembers.allowInsert.where((member) =>
    allOf([
      { user_id: session.user.account },
      policy.chats.exists.where({ id: member.chatId, "$createdBy.account": session.user.account }),
    ]),
  );

  // Users can leave; chat creators can remove any member.
  policy.chatMembers.allowDelete.where((member) =>
    anyOf([
      { user_id: session.user.account },
      policy.chats.exists.where({ id: member.chatId, "$createdBy.account": session.user.account }),
    ]),
  );

  // Invite codes are bearer capabilities. They never sync back down to a client.
  policy.chatInvites.allowRead.never();
  policy.chatInvites.allowInsert.where((invite) =>
    policy.chats.exists.where({ id: invite.chatId, "$createdBy.account": session.user.account }),
  );
  policy.chatInvites.allowDelete.where((invite) =>
    policy.chats.exists.where({ id: invite.chatId, "$createdBy.account": session.user.account }),
  );
});
```

See [Permissions](/docs/auth/permissions) for more on `exists.where`, `allOf`, and `anyOf`, and [Session identity and authorship](/docs/auth/sessions#session-identity-and-authorship) for how the backend stamps the user as the row's author while keeping backend permissions.

Generating a link [#generating-a-link]

The creator inserts the chat, their own membership, and a private invite row client-side. These
writes are allowed by the permission rules above — the chat row is open to insert, and
the membership and invite rules let the creator bootstrap resources that match their
`$createdBy`. The code goes only in the invite row and URL.

```ts title="createInviteLink.ts"
export function createInviteLink(
  db: ReturnType<typeof useDb>,
  accountId: string,
  { singleUse = false }: { singleUse?: boolean } = {},
): string {
  const joinCode = crypto.randomUUID();

  const { value: chat } = db.insert(app.chats, {});

  db.insert(app.chatMembers, { chatId: chat.id, user_id: accountId });
  db.insert(app.chatInvites, { chatId: chat.id, code: joinCode, singleUse });

  return `${window.location.origin}/#/invite/${chat.id}/${joinCode}`;
}
```

<Callout type="warn">
  The code lives in the URL fragment (after `#`) to keep it out of server access logs, CDN logs, and
  `Referer` headers. Don't pass the code as a route parameter or query string — those
  land in server logs.
</Callout>

Accepting an invite [#accepting-an-invite]

The invitee opens the link while signed in. The client posts the chat ID and code to a server route.
The server uses one exclusive transaction to read the private invite, check whether the user is
already a member, insert their membership, and consume a single-use invite. The route is
idempotent — it skips the insert if the user already has a membership, so re-opening the
link or retrying after a transient error won't create duplicate rows.

Server route [#server-route]

The route reads and writes as the backend: the invitee has no read access to the chat until they're
a member, and the `chatMembers.allowInsert` rule only admits the chat's creator, so they can't
insert their own membership directly. The ready backend client verifies the bearer with `await client.withAttributionForRequest(request)`,
which keeps backend permissions while stamping the membership's edit metadata to the verified
caller. An opaque admitted account handle can instead be passed to `await client.withAttribution(account)`.

The transaction is what makes redemption correct under concurrency. A multi-use invite stays in
the table, so it is deliberately replayable by anyone holding the link. A single-use invite is
deleted in the same exclusive transaction as the membership insert: two simultaneous redeems
cannot both succeed. If the authority rejects the transaction because another redemption won the
race, report that failure to the client and let it retry only if that remains appropriate for your
UI.

The `client` referenced below is the ready backend Jazz client for your app — see [Backend context setup](/docs/getting-started/server-setup#backend-context-setup) for how to create one.

```ts title="api/invite/redeem.ts"
export async function POST(req: Request): Promise<Response> {
  const requester = await client.forRequest(req);
  const user = requester.getAuthState().session?.user.account;
  if (!user) return new Response("Account required", { status: 401 });

  const { chatId, code } = (await req.json()) as { chatId: string; code: string };

  // Preserve the verified caller as author while using backend permissions.
  const backendDb = await client.withAttributionForRequest(req);
  const result = await backendDb.exclusiveTransaction(async (tx) => {
    // Checking membership first keeps re-opening a successfully redeemed link idempotent,
    // even after a single-use invite has been consumed.
    const existing = await tx.one(app.chatMembers.where({ chatId, user_id: user }));
    if (existing) return "already-member" as const;

    const invite = await tx.one(app.chatInvites.where({ chatId, code }));
    if (!invite) return "invalid" as const;

    tx.insert(app.chatMembers, { chatId, user_id: user, inviteId: invite.id });
    if (invite.singleUse) tx.delete(app.chatInvites, invite.id);
    return "joined" as const;
  });

  // Exclusive transactions settle at the authority, so wait() takes no tier.
  await result.wait();
  if (result.value === "invalid") return new Response("invalid invite", { status: 400 });

  return Response.json({ ok: true });
}
```

<Accordions type="single">
  <Accordion title="Single-use, expiring, or role-scoped codes">
    Pass `{ singleUse: true }` when creating a link to consume it atomically on successful redemption.
    For richer requirements (expiry, codes that grant specific roles, audit metadata), add those fields
    to the invite table and check them in the same exclusive transaction.

    The example uses the full `crypto.randomUUID()` value. Keep at least that much entropy for a link
    that is not single-use or expiring.
  </Accordion>
</Accordions>

Client handler [#client-handler]

Route `/#/invite/:chatId/:code` to a component that calls the server route and then redirects.

```tsx title="InviteHandler.tsx"
export function InviteHandler({ chatId, code }: { chatId: string; code: string }) {
  const handled = useRef(false);

  useEffect(() => {
    if (handled.current) return;
    handled.current = true;

    fetch("/api/invite/redeem", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ chatId, code }),
    })
      .then((res) => {
        if (!res.ok) throw new Error(`redeem failed: ${res.status}`);
        navigate(`/chat/${chatId}`);
      })
      .catch((err) => {
        console.error("failed to join", err);
        handled.current = false;
      });
  }, [chatId, code]);

  return <p>Joining…</p>;
}
```

Once the route returns, the user's subscription picks the chat up via the membership rule, and access persists for as long as the membership row exists.

Revoking access [#revoking-access]

Delete a member's row to revoke their access immediately. The server stops syncing the resource to
them as soon as the row is gone.

```ts title="revokeMember.ts"
export function revokeMember(db: ReturnType<typeof useDb>, memberId: string) {
  db.delete(app.chatMembers, memberId);
}
```

An invite link is a bearer capability: deleting a membership alone does not stop somebody who kept
the link from redeeming it again. Delete or rotate the private invite row too when you need to
invalidate the link for future redeemers.


# Shared access between users



After [user-owned data](/docs/recipes/access-control/user-owned-data), the next pattern you're likely to need is sharing. This recipe shows how to let one user grant another access to specific rows using a shares table.

Schema [#schema]

Add a `todoShares` table that records which user has access to which todo.

```ts title="schema.ts"
const schema = {
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
  }),
  todoShares: s.table({
    todoId: s.ref("todos"),
    user_id: s.uuid(),
    can_edit: s.boolean(),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

Permissions [#permissions]

The creator can do everything. Share recipients get read access, and optionally edit access via `can_edit`.

```ts title="permissions.ts"
s.definePermissions(app, ({ policy, anyOf, session }) => {
  policy.todos.allowRead.where((todo) =>
    anyOf([
      { "$createdBy.account": session.user.account },
      policy.todoShares.exists.where({
        todoId: todo.id,
        user_id: session.user.account,
      }),
    ]),
  );

  policy.todos.allowInsert.always();

  policy.todos.allowUpdate.where((todo) =>
    anyOf([
      { "$createdBy.account": session.user.account },
      policy.todoShares.exists.where({
        todoId: todo.id,
        user_id: session.user.account,
        can_edit: true,
      }),
    ]),
  );

  policy.todos.allowDelete.where({ "$createdBy.account": session.user.account });

  // Only the todo creator can manage shares
  policy.todoShares.allowInsert.where((share) =>
    policy.todos.exists.where({
      id: share.todoId,
      "$createdBy.account": session.user.account,
    }),
  );
  policy.todoShares.allowRead.where({ user_id: session.user.account });
  policy.todoShares.allowDelete.where((share) =>
    policy.todos.exists.where({
      id: share.todoId,
      "$createdBy.account": session.user.account,
    }),
  );
});
```

See [Permissions](/docs/auth/permissions) for more on `exists.where`, `anyOf`, and `allOf`.

Granting access [#granting-access]

To share a todo, insert a row into `todoShares`.

```ts
export function shareTodo(
  db: ReturnType<typeof useDb>,
  todoId: string,
  recipientAccountId: string,
) {
  db.insert(app.todoShares, {
    todoId,
    user_id: recipientAccountId,
    can_edit: false,
  });
}
```

Querying shared items [#querying-shared-items]

```tsx title="SharedWithMe.tsx"
export function SharedWithMe() {
  const session = useSession();
  const {
    data: shares,
    isLoading,
    error,
  } = useAll(
    session?.user.account
      ? app.todoShares.where({ user_id: session.user.account }).include({ todo: true })
      : undefined,
  );

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Something went wrong!</p>;

  return (
    <ul>
      {shares?.map((share) =>
        share.todo ? (
          <li key={share.id}>
            {share.todo.title}
            {share.can_edit ? " (can edit)" : " (read-only)"}
          </li>
        ) : null,
      )}
    </ul>
  );
}
```

Revoking access [#revoking-access]

Delete the share row to revoke access. The server will stop syncing the todo to the former recipient.

```ts
export function unshareTodo(db: ReturnType<typeof useDb>, shareId: string) {
  db.delete(app.todoShares, shareId);
}
```


# User-owned data



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

This recipe covers building an app where users own their own data and no-one else can see it, covering schema, permissions, querying, and inserting.

Schema [#schema]

There's no need to add an explicit owner column — Jazz tracks who created each row automatically via `$createdBy`.

```ts title="schema.ts"
const schema = {
  todos: s.table({
    title: s.string(),
    done: s.boolean(),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

See [Defining tables](/docs/schemas/defining-tables) for the full schema DSL.

Permissions [#permissions]

Match `$createdBy.account` to `session.user.account` to validate whether the current user is the one who created the data. Because `$createdBy` is set automatically, we can declare insert explicitly with `.always()`.

```ts title="permissions.ts"
s.definePermissions(app, ({ policy, session }) => {
  policy.todos.allowRead.where({ "$createdBy.account": session.user.account });
  policy.todos.allowInsert.always();
  policy.todos.allowUpdate.where({ "$createdBy.account": session.user.account });
  policy.todos.allowDelete.where({ "$createdBy.account": session.user.account });
});
```

These rules are enforced on the server. See [Permissions](/docs/auth/permissions) for combinators, `allowedTo`, and more complex options.

Querying [#querying]

The table's permissions already scope results to the current user, so queries don't need a separate owner filter.

```tsx title="MyTodos.tsx"
export function MyTodos() {
  const { data: todos, isLoading, error } = useAll(app.todos.where({ done: false }));

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Something went wrong!</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}
```

See [Queries](/docs/reading/queries) for subscriptions, one-shot queries, and durability tiers.

Inserting [#inserting]

`$createdBy` is set automatically on insert, so we don't need to set an owner.

```tsx title="AddTodo.tsx"
export function AddTodo() {
  const db = useDb();

  function handleAdd(title: string) {
    db.insert(app.todos, { title, done: false });
  }

  return <button onClick={() => handleAdd("Buy milk")}>Add</button>;
}
```

<Accordions type="single">
  <Accordion title="Durable inserts">
    Use `db.insert(...).wait({ tier: "..." })` if you need confirmation that the write reached a specific [durability tier](/docs/reference/durability-tiers).
  </Accordion>

  <Accordion title="Explicit owners for transferable ownership">
    If ownership can be transferred after creation, use an explicit `owner_id` column instead of `$createdBy`.

    ```ts title="schema.ts"
    const schemaExplicit = {
      todos: s.table({
        title: s.string(),
        done: s.boolean(),
        owner_id: s.uuid(),
      }),
    };

    type ExplicitAppSchema = s.Schema<typeof schemaExplicit>;
    export const explicitApp: s.App<ExplicitAppSchema> = s.defineApp(schemaExplicit);
    ```

    ```ts title="permissions.ts"
    s.definePermissions(explicitApp, ({ policy, session }) => {
      policy.todos.allowRead.where({ owner_id: session.user.account });
      policy.todos.allowInsert.always();
      policy.todos.allowUpdate.whereOld({ owner_id: session.user.account });
      policy.todos.allowDelete.where({ owner_id: session.user.account });
    });
    ```

    `allowUpdate.whereOld(...)` checks the row before the update, so the current owner can rewrite `owner_id` to transfer the row. Using `.where(...)` instead would also enforce the condition on the post-update row and block transfers. See [Permissions](/docs/auth/permissions) for `whereOld`/`whereNew` semantics.

    `allowInsert.always()` lets any user insert a row with any `owner_id`, including someone else's. That's the right default if you want users to be able to assign rows to others on creation; otherwise, narrow it to `.where({ owner_id: session.user.account })` so clients can only create rows they own.
  </Accordion>
</Accordions>


# Auth provider integration



Jazz supports external JWT-based authentication for production use. This recipe walks through connecting a provider to Jazz, with examples for [Better Auth](https://www.better-auth.com/) (self-hosted) and [WorkOS](https://workos.com/) (managed service).

How it works [#how-it-works]

<Sequence
  eyebrow="Provider sign-in"
  description="An external JWT provider connecting to a Jazz server."
  participants={[
  { id: "browser", label: "Browser" },
  { id: "auth", label: "Auth provider" },
  { id: "jazz", label: "Jazz server", createAtStep: 2 },
]}
  steps={[
  { kind: "message", from: "browser", to: "auth", text: "Sign in" },
  { kind: "message", from: "auth", to: "browser", text: "JWT token", line: "dashed" },
  { kind: "message", from: "browser", to: "jazz", text: "Register or log in with JWT" },
  { kind: "message", from: "jazz", to: "auth", text: "Fetch JWKS" },
  { kind: "message", from: "auth", to: "jazz", text: "Public keys", line: "dashed" },
  { kind: "message", from: "jazz", to: "jazz", text: "Verify JWT and resolve account" },
  { kind: "message", from: "jazz", to: "browser", text: "Account handle ready", line: "dashed" },
]}
/>

1. The user signs in with your auth provider and gets a JWT.
2. The shared provider connection calls `loginOrRegisterJWT({ getToken })`.
3. The Jazz server validates the JWT signature against the provider's JWKS endpoint.
4. Core atomically resolves the exact `(iss, sub)` assignment or creates its account when fresh. The Jazz session opens the account's client. `session.user` contains the account ID and exact acting identity; provider claims remain under `session.claims`. Revoked assignments remain rejected; login-or-register never reassigns an identity.

If you're unfamiliar with JWTs and JWKS, see the [explainer on the Authentication page](/docs/auth/authentication#external-auth-for-production).

Provider setup [#provider-setup]

<Tabs groupId="auth-provider" persist updateAnchor items={["Better Auth", "WorkOS"]}>
  <Tab value="Better Auth">
    [Better Auth](https://www.better-auth.com/) is a self-hosted auth framework. You run the server yourself and enable the `jwt` plugin, which exposes a JWKS endpoint and issues signed JWTs.

    Server [#server]

    ```ts title="src/lib/auth.ts"
    export const auth = betterAuth({
      // your database, email config, etc.
      plugins: [
        jwt({
          jwks: {
            keyPairConfig: { alg: "ES256" },
          },
          jwt: {
            issuer: "https://your-app.example.com",
            definePayload: ({ user }) => ({ role: (user as { role?: string }).role ?? "" }),
          },
        }),
      ],
    });
    ```

    This exposes a JWKS endpoint at `/api/auth/jwks` (or wherever you mount Better Auth's handler).

    Client [#client]

    Create the auth client with the `jwtClient` plugin so you can request JWT tokens.

    ```ts title="src/lib/auth-client.ts"
    export const authClient = createAuthClient({
      plugins: [jwtClient()],
    });
    ```

    Connecting to Jazz [#connecting-to-jazz]

    Pass `auth={betterAuth(authClient)}` to `JazzProvider`. Forms only call Better Auth signup or sign-in; Jazz owns startup, account changes and error recovery. Use `useJazzAuth().logout()` to flush Jazz before revoking the provider session.

    ```tsx title="App.tsx"
    export function App() {
      return (
        <JazzProvider
          appId="my-app"
          serverUrl="wss://your-jazz-server.example.com"
          auth={betterAuth(authClient)}
          signedOut={<p>Sign in to continue.</p>}
        >
          <YourApp />
        </JazzProvider>
      );
    }
    // Forms only call authClient.signUp / signIn. Jazz follows automatically.
    ```

    Jazz server configuration [#jazz-server-configuration]

    Point the Jazz server at your Better Auth JWKS endpoint:

    ```bash
    pnpm dlx jazz-tools@alpha server <APP_ID> --jwks-url https://your-app.example.com/api/auth/jwks
    ```

    For a full working example, see the [Better Auth chat example](https://github.com/garden-co/jazz2/tree/main/examples/auth-betterauth-chat).

    <Callout type="info">
      If your users start unauthenticated and sign up later, see [Local-first auth](/docs/auth/local-first-auth#signing-up-with-betterauth) for how to preserve their identity across the transition.
    </Callout>
  </Tab>

  <Tab value="WorkOS">
    [WorkOS](https://workos.com/) is a managed auth service — no server-side auth code needed. Wrap your app with `AuthKitProvider`, get the access token, and pass it to Jazz.

    ```tsx title="App.tsx"
    function JazzWithWorkOS() {
      const { user, isLoading, getAccessToken, signIn, signOut } = useAuth();
      return (
        <JazzProvider
          appId="my-app"
          serverUrl="wss://your-jazz-server.example.com"
          auth={jwtAuth({
            key: user?.id ?? null,
            isPending: isLoading,
            getToken: () => getAccessToken({ forceRefresh: true }),
            logout: () => signOut(),
          })}
          signedOut={<button onClick={() => void signIn()}>Sign in</button>}
        >
          <YourApp />
        </JazzProvider>
      );
    }
    export function App() {
      return (
        <AuthKitProvider clientId="client_01ABC...">
          <JazzWithWorkOS />
        </AuthKitProvider>
      );
    }
    ```

    Jazz server configuration [#jazz-server-configuration-1]

    Point the Jazz server at the WorkOS JWKS endpoint:

    ```bash
    pnpm dlx jazz-tools@alpha server <APP_ID> --jwks-url https://api.workos.com/sso/jwks/client_01ABC...
    ```

    For a full working example, see the [WorkOS chat example](https://github.com/garden-co/jazz2/tree/main/examples/auth-workos-chat).
  </Tab>
</Tabs>

See [Server setup](/docs/getting-started/server-setup) for the full set of server flags.

Using JWT claims in permissions [#using-jwt-claims-in-permissions]

Your auth provider's JWT may include custom claims (roles, organisation IDs, etc.). Access them in permissions via `session.where(...)`.

```ts title="permissions.ts"
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
  policy.todos.allowRead.where(
    anyOf([{ owner_id: session.user.account }, session.where({ "claims.role": "manager" })]),
  );
});
```

See [Permissions](/docs/auth/permissions) for the full claims API.

Other providers [#other-providers]

Any provider that issues JWTs and exposes a JWKS endpoint will work. The key pattern is always the same: get a JWT from your provider, pass it to Jazz.

| Provider    | JWKS endpoint                                                                               | `sub` claim format |
| ----------- | ------------------------------------------------------------------------------------------- | ------------------ |
| Better Auth | `<baseURL>/api/auth/jwks`                                                                   | User ID            |
| WorkOS      | `https://api.workos.com/sso/jwks/<clientId>`                                                | `user_<id>`        |
| Clerk       | `https://<app>.clerk.accounts.dev/.well-known/jwks.json`                                    | `user_<id>`        |
| Auth0       | `https://<tenant>.auth0.com/.well-known/jwks.json`                                          | `auth0\|<id>`      |
| Firebase    | `https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com` | Firebase UID       |

Jazz records the exact JWT `iss` and `sub` pair as the acting identity. Providers keep their existing subjects. Use `linkJWT` to associate a fresh identity with an existing account; use application rows for profile data.

Full working examples: [Better Auth chat](https://github.com/garden-co/jazz2/tree/main/examples/auth-betterauth-chat), [WorkOS chat](https://github.com/garden-co/jazz2/tree/main/examples/auth-workos-chat).


# Better Auth Adapter



The Jazz Better Auth adapter lets Better Auth store its tables in Jazz. For general Better Auth setup (route handlers, client, plugins), see the [Better Auth documentation](https://www.better-auth.com/docs).

Schema Workflow [#schema-workflow]

Better Auth tables live in a generated `schema-better-auth/` module that your app schema spreads into its own table map. This keeps Better Auth's tables in the same Jazz app as your own data, so a single backend context handles both.

Start with shared Better Auth options. The JWT plugin supplies the bearer tokens used in the account enrollment example below; it also adds a JWKS table to the generated schema.

```ts title="auth-options.ts"
import { jwt } from "better-auth/plugins";

export const authOptions = {
  baseURL: process.env.BETTER_AUTH_URL!,
  secret: process.env.BETTER_AUTH_SECRET!,
  emailAndPassword: { enabled: true },
  plugins: [jwt()],
};
```

Set `BETTER_AUTH_URL` to your application server's origin and `BETTER_AUTH_SECRET` to a private Better Auth secret. Keep plugins, additional fields, and model customizations in these shared options so generation and runtime use the same configuration.

Create a separate configuration for generation. It does not import your app schema or open a Jazz session, so it works before either the generated file or the sync server exists. The CLI only requests schema generation; the database callback is deliberately unavailable.

```ts title="auth-generate.ts"
import { betterAuth } from "better-auth";
import { schema as s } from "jazz-tools";
import { jazzAdapter } from "jazz-tools/better-auth-adapter";
import { authOptions } from "./auth-options";

export const auth = betterAuth({
  ...authOptions,
  database: jazzAdapter({
    db: async () => {
      throw new Error("The generation config cannot query the database");
    },
    schema: s.defineApp({}).wasmSchema,
  }),
});
```

Generate with the current [Better Auth CLI](https://www.better-auth.com/docs/concepts/cli):

```bash
npx auth@latest generate \
  --config ./auth-generate.ts \
  --output ./schema-better-auth/schema.ts \
  --yes
```

`--yes` writes the output without an interactive confirmation. Review the generated diff when regenerating an existing module.

Import the generated tables in your app's `schema.ts` and merge them into the app definition:

```ts title="schema.ts"
import { schema as s } from "jazz-tools";
import { schema as betterauthSchema } from "./schema-better-auth/schema";

const schema = {
  ...betterauthSchema,
  messages: s.table({
    author_name: s.string(),
    chat_id: s.string(),
    text: s.string(),
    sent_at: s.timestamp(),
  }),
  // ...your own tables
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

The generated `schema-better-auth/schema.ts` also ships a `permissions` export that denies all operations on the Better Auth tables. Merge it with your own permissions so regular client sessions can't read or write Better Auth rows — the adapter itself uses the admitted backend client's `db`, which has backend permissions.

```ts title="permissions.ts"
import { definePermissions } from "jazz-tools/permissions";
import { permissions as betterAuthPermissions } from "./schema-better-auth/schema";
import { app } from "./schema";

const appPermissions = definePermissions(app, ({ policy }) => {
  // Define policies for your own tables.
  policy.messages.allowRead.always();
});

export default {
  ...betterAuthPermissions,
  ...appPermissions,
};
```

After adding both files, validate the merged schema and permissions:

```bash
pnpm dlx jazz-tools@alpha validate
```

Database Adapter [#database-adapter]

Create a server-side [Jazz session](/docs/getting-started/server-setup#backend-context-setup), then pass it to `jazzAdapter(...)` as the `database` in your Better Auth config. Point both `db` and `schema` at the merged `app` — not at the generated module directly — so Better Auth and your own tables share a single Jazz app.

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { createJazzSession, type JazzSessionConfig } from "jazz-tools/backend";
import { jazzAdapter } from "jazz-tools/better-auth-adapter";
import { app } from "./schema";
import permissions from "./permissions";
import { authOptions } from "./auth-options";

export const jazzConfig = {
  app,
  permissions,
  appId: process.env.APP_ID!,
  driver: { type: "memory" },
  serverUrl: process.env.SYNC_SERVER_URL!,
  env: process.env.NODE_ENV === "production" ? "prod" : "dev",
  jwksUrl: `${authOptions.baseURL}/api/auth/jwks`,
  jwtIssuer: authOptions.baseURL,
  jwtAudience: authOptions.baseURL,
} satisfies JazzSessionConfig;

const session = await createJazzSession({
  ...jazzConfig,
  initial: { backendSecret: process.env.BACKEND_SECRET! },
});

const snapshot = session.getSnapshot();
if (snapshot.status !== "ready" || !snapshot.client) {
  throw snapshot.error ?? new Error("Backend session is not ready");
}
export const client = snapshot.client;

export const auth = betterAuth({
  ...authOptions,
  database: jazzAdapter({
    db: async () => client.db,
    schema: app.wasmSchema,
  }),
});
```

| Option      | Description                                                                                                     |
| ----------- | --------------------------------------------------------------------------------------------------------------- |
| `db`        | A function returning `Db` or `Promise<Db>` from the ready backend client. Use the merged app, not `authSchema`. |
| `schema`    | Typically `app.wasmSchema` from your merged schema. A raw `s.App` is also accepted.                             |
| `debugLogs` | Better Auth adapter debug logging controls.                                                                     |
| `usePlural` | Whether Better Auth model names should use plural table names.                                                  |
| `prefix`    | Table name prefix (defaults to `"better_auth_"`).                                                               |

<Callout title="Leave Better Auth joins disabled">
  The Jazz adapter does not support Better Auth experimental joins yet, so do not enable
  `experimental.joins`.
</Callout>

Connect Better Auth sessions to Jazz accounts [#connect-better-auth-sessions-to-jazz-accounts]

The adapter stores Better Auth users, password credentials, and application sessions. For browser apps, pass `auth={betterAuth(authClient)}` to the framework's `JazzProvider`; import the descriptor from `jazz-tools/client`. The provider owns the Jazz session, loading and error recovery. Vanilla apps can use `createJazzApp` with the same descriptor. Better Auth signup and login then automatically create or resolve the Jazz account; there is no second enrollment action. The application session cookie is also not a Jazz bearer token: `client.forRequest()` reads an `Authorization: Bearer` header.

Mount `auth.handler` using your framework's [Better Auth integration](https://www.better-auth.com/docs/installation). The [JWT plugin](https://www.better-auth.com/docs/plugins/jwt) exposes `/api/auth/token` for a signed-in user and `/api/auth/jwks` for verification. Configure the Jazz core with that reachable JWKS URL and the same issuer and audience as the backend's `jazzConfig`. For a self-hosted core, use `--jwks-url`, `--jwt-issuer`, and `--jwt-audience`; see [Server Setup](/docs/getting-started/server-setup). Set `BETTER_AUTH_URL` without a trailing slash in this example.

For a server-only app, resolve the Better Auth cookie and opt into account creation
on the immutable request scope. The shared backend client keeps its own identity;
no extra Jazz session or client is created for the user:

```ts title="messages.ts"
import { auth, client } from "./auth";
import { app } from "./schema";

export async function messages(request: Request) {
  const signedIn = await auth.api.getSession({ headers: request.headers });
  if (!signedIn) return new Response("Sign in first", { status: 401 });
  const { token } = await auth.api.getToken({ headers: request.headers });
  const headers = new Headers(request.headers);
  headers.set("Authorization", `Bearer ${token}`);
  const bearerRequest = new Request(request, { headers });
  const db = await client.forRequest(bearerRequest, { account: "login-or-register" });
  return Response.json(await db.all(app.messages, { tier: "global" }));
}
```

The core atomically resolves an active assignment or creates one for a fresh
identity. Invalid and revoked credentials are rejected. Use the default strict
request mode for a hybrid app where signup must link the fresh provider identity
to an existing guest account first. See [Authentication](/docs/auth/authentication).

Browser clients with an automatic connection can instead send a JWT from
`/api/auth/token` as a bearer. Data routes then use the default strict scope:

```ts title="messages.ts"
import { auth, client } from "./auth";
import { app } from "./schema";

export async function messages(request: Request) {
  const db = await client.forRequest(request);
  return Response.json(await db.all(app.messages, { tier: "global" }));
}

// A cookie-based route can ask Better Auth for the JWT server-side instead.
export async function messagesFromCookie(request: Request) {
  const signedIn = await auth.api.getSession({ headers: request.headers });
  if (!signedIn) return new Response("Sign in first", { status: 401 });
  const { token } = await auth.api.getToken({ headers: request.headers });
  const bearerRequest = new Request(request.url, {
    headers: { Authorization: `Bearer ${token}` },
  });
  return messages(bearerRequest);
}
```

These scopes retain the verified user's policies and authorship without switching the shared backend owner. For account-owned rows, compare ownership against `session.user.account` in permissions; a Better Auth user ID or a JWT claim is not an admitted Jazz account UUID. If your application already retains an opaque admitted account handle, `await client.forAccount(account)` provides the same scope. Never accept a raw account ID from a request as authority.

Publishing to a sync server [#publishing-to-a-sync-server]

Because Better Auth tables are merged into your app schema, they ride on the same deploy as everything else — no separate push for `schema-better-auth/`. Publish schema, permissions, and migrations through the normal workflow:

```bash
pnpm dlx jazz-tools@alpha deploy <appId>
```

See [Migrations](/docs/schemas/migrations) for the full deploy flow and how schema changes produce migration edges.

Compatibility [#compatibility]

The adapter is currently aligned with Better Auth `1.7.1`.

| Plugin/Feature        | Compatibility |
| --------------------- | :-----------: |
| Email & Password auth |       ✅       |
| Social Provider auth  |       ✅       |
| Email OTP             |       ✅       |

<Callout title="Compatibility scope">
  This section reflects the adapter behavior currently covered by this repo's Better Auth
  integration and tests. If you add plugins that introduce extra tables or custom schema fields,
  regenerate `schema-better-auth/schema.ts` and re-run the Jazz schema workflow.
</Callout>


# Nested data with permission inheritance



This recipe shows how to model a simple hierarchy, inherit permissions from parent rows, and query/insert at each level.

Schema [#schema]

A simple schema with three tables linked by foreign keys. A project has tasks, and tasks have comments.

```ts title="schema.ts"
const schema = {
  projects: s.table({
    name: s.string(),
  }),
  tasks: s.table({
    title: s.string(),
    done: s.boolean(),
    projectId: s.ref("projects"),
  }),
  comments: s.table({
    body: s.string(),
    taskId: s.ref("tasks"),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

Inherited permissions [#inherited-permissions]

Use `allowedTo` to inherit access from the parent row. If you can read a project, you can read its tasks, and if you can read a task, you can read its comments.

```ts title="permissions.ts"
s.definePermissions(app, ({ policy, allowedTo, session }) => {
  // Projects: only the creator
  policy.projects.allowRead.where({ "$createdBy.account": session.user.account });
  policy.projects.allowInsert.always();
  policy.projects.allowUpdate.where({ "$createdBy.account": session.user.account });
  policy.projects.allowDelete.where({ "$createdBy.account": session.user.account });

  // Tasks: inherit from project
  policy.tasks.allowRead.where(allowedTo.read("projectId"));
  policy.tasks.allowInsert.where(allowedTo.read("projectId"));
  policy.tasks.allowUpdate.where(allowedTo.update("projectId"));
  policy.tasks.allowDelete.where(allowedTo.delete("projectId"));

  // Comments: inherit from task
  policy.comments.allowRead.where(allowedTo.read("taskId"));
  policy.comments.allowInsert.where(allowedTo.read("taskId"));
  policy.comments.allowUpdate.where({ "$createdBy.account": session.user.account });
  policy.comments.allowDelete.where({ "$createdBy.account": session.user.account });
});
```

The `allowedTo.read("projectId")` argument is the FK column name. See [Permissions](/docs/auth/permissions) for `maxDepth` and recursive inheritance.

<Callout type="info">
  Projects use `$createdBy` instead of an explicit `owner_id` column. Jazz tracks who created each
  row automatically, so you can reference it in permissions without adding a column to your schema.
  Anyone can insert a `project`, and it will automatically be created with the appropriate
  `$createdBy` information.
</Callout>

Querying [#querying]

```tsx title="ProjectTasks.tsx"
export function ProjectTasks({ projectId }: { projectId: string }) {
  const {
    data: tasks,
    isLoading,
    error,
  } = useAll(app.tasks.where({ projectId }).orderBy("$createdAt", "desc"));

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Something went wrong!</p>;

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.title}</li>
      ))}
    </ul>
  );
}
```

See [Includes and relations](/docs/reading/includes-and-relations) for `include()`, reverse relations, and `select()`.

Inserting [#inserting]

Insert from the top down — create the project first, then tasks referencing it.

```tsx title="CreateProject.tsx"
export function CreateProject() {
  const db = useDb();
  const session = useSession();

  async function handleCreate() {
    const { value: project } = await db.insert(app.projects, {
      name: "Website redesign",
    });

    await db.insert(app.tasks, {
      title: "Design homepage",
      done: false,
      projectId: project.id,
    });
  }

  return <button onClick={handleCreate}>New project</button>;
}
```

Each insert executes locally and syncs in the background. Because permissions inherit downward, anyone who can access the project automatically gets access to its tasks and comments.


# Real-time collaborative list



import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

Every client subscribes to the same data and sees changes as they happen. This recipe shows the pattern end-to-end.

Schema [#schema]

A shared project with collaboratively-edited tasks.

```ts title="schema.ts"
const schema = {
  projects: s.table({
    name: s.string(),
  }),
  tasks: s.table({
    title: s.string(),
    done: s.boolean(),
    assignee_id: s.uuid().optional(),
    projectId: s.ref("projects"),
  }),
  projectMembers: s.table({
    projectId: s.ref("projects"),
    user_id: s.uuid(),
  }),
};

type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);
```

Permissions [#permissions]

Project members can read and write tasks. The creator can manage membership. Tasks inherit access from their project via `allowedTo`.

```ts title="permissions.ts"
s.definePermissions(app, ({ policy, anyOf, allowedTo, session }) => {
  // Projects: creator and members
  policy.projects.allowRead.where((project) =>
    anyOf([
      { "$createdBy.account": session.user.account },
      policy.projectMembers.exists.where({
        projectId: project.id,
        user_id: session.user.account,
      }),
    ]),
  );
  policy.projects.allowInsert.always();
  policy.projects.allowUpdate.where({ "$createdBy.account": session.user.account });

  // Tasks: inherit from project
  policy.tasks.allowRead.where(allowedTo.read("projectId"));
  policy.tasks.allowInsert.where(allowedTo.read("projectId"));
  policy.tasks.allowUpdate.where(allowedTo.read("projectId"));

  // Members: only the creator can manage
  policy.projectMembers.allowInsert.where((member) =>
    policy.projects.exists.where({
      id: member.projectId,
      "$createdBy.account": session.user.account,
    }),
  );
  policy.projectMembers.allowRead.where((member) =>
    anyOf([
      policy.projects.exists.where({
        id: member.projectId,
        "$createdBy.account": session.user.account,
      }),
      { user_id: session.user.account },
    ]),
  );
});
```

Subscribing to shared data [#subscribing-to-shared-data]

When multiple clients subscribe to the same query, they all see each other's changes in real-time.

```tsx title="ProjectTasks.tsx"
export function ProjectTasks({ projectId }: { projectId: string }) {
  const db = useDb();
  const {
    data: tasks,
    isLoading,
    error,
  } = useAll(app.tasks.where({ projectId, done: false }).orderBy("$createdAt", "desc"));

  function addTask(title: string) {
    db.insert(app.tasks, { title, done: false, projectId });
  }

  function completeTask(taskId: string) {
    db.update(app.tasks, taskId, { done: true });
  }

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Something went wrong!</p>;

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          <button onClick={() => completeTask(task.id)}>Done</button>
          {task.title}
        </li>
      ))}
    </ul>
  );
}
```

When Alice inserts a task it appears in her UI instantly, syncs to the server, and the server pushes it to Bob — whose `useAll` subscription re-renders automatically.

<Accordions type="single">
  <Accordion title="How sync and conflicts work">
    When two users edit the same row concurrently, Jazz uses last-writer-wins (LWW) per column. Each column resolves independently, so if Alice updates `title` while Bob updates `done`, both changes are preserved. If they both update `title`, Jazz's deterministic [hybrid logical clock](/docs/concepts/how-sync-works#hybrid-logical-clock) ordering decides the winner.

    For most applications this is the right default. See [How sync works](/docs/concepts/how-sync-works) for more detail.
  </Accordion>
</Accordions>
