jazz-tools 2.0.0-alpha.49 → 2.0.0-alpha.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/bin/docs-index.db +0 -0
  2. package/bin/docs-index.txt +1653 -634
  3. package/bin/native/jazz-tools-darwin-arm64 +0 -0
  4. package/bin/native/jazz-tools-darwin-x64 +0 -0
  5. package/bin/native/jazz-tools-linux-arm64 +0 -0
  6. package/bin/native/jazz-tools-linux-x64 +0 -0
  7. package/dist/better-auth-adapter/index.test.js +5 -5
  8. package/dist/better-auth-adapter/index.test.js.map +1 -1
  9. package/dist/cli.js +13 -3
  10. package/dist/cli.js.map +1 -1
  11. package/dist/cli.test.js +20 -1
  12. package/dist/cli.test.js.map +1 -1
  13. package/dist/dev/dev-server.d.ts +0 -3
  14. package/dist/dev/dev-server.d.ts.map +1 -1
  15. package/dist/dev/dev-server.js +0 -3
  16. package/dist/dev/dev-server.js.map +1 -1
  17. package/dist/dev/dev-server.test.js +1 -0
  18. package/dist/dev/dev-server.test.js.map +1 -1
  19. package/dist/dev/managed-runtime.d.ts.map +1 -1
  20. package/dist/dev/managed-runtime.js +4 -4
  21. package/dist/dev/managed-runtime.js.map +1 -1
  22. package/dist/dev/managed-runtime.test.js +41 -0
  23. package/dist/dev/managed-runtime.test.js.map +1 -1
  24. package/dist/dev/sveltekit.d.ts +37 -7
  25. package/dist/dev/sveltekit.d.ts.map +1 -1
  26. package/dist/dev/sveltekit.js +103 -77
  27. package/dist/dev/sveltekit.js.map +1 -1
  28. package/dist/dev/sveltekit.test.js +38 -18
  29. package/dist/dev/sveltekit.test.js.map +1 -1
  30. package/dist/dev/vite.d.ts +0 -3
  31. package/dist/dev/vite.d.ts.map +1 -1
  32. package/dist/dev/vite.js.map +1 -1
  33. package/dist/dev-tools/extension-panel.d.ts.map +1 -1
  34. package/dist/dev-tools/extension-panel.js +0 -12
  35. package/dist/dev-tools/extension-panel.js.map +1 -1
  36. package/dist/mcp/backend-naive.d.ts.map +1 -1
  37. package/dist/mcp/backend-naive.js +22 -3
  38. package/dist/mcp/backend-naive.js.map +1 -1
  39. package/dist/mcp/backend-naive.test.js +9 -6
  40. package/dist/mcp/backend-naive.test.js.map +1 -1
  41. package/dist/mcp/backend-sqlite.js +2 -2
  42. package/dist/mcp/backend-sqlite.js.map +1 -1
  43. package/dist/mcp/build-index.d.ts +2 -38
  44. package/dist/mcp/build-index.d.ts.map +1 -1
  45. package/dist/mcp/build-index.js +5 -145
  46. package/dist/mcp/build-index.js.map +1 -1
  47. package/dist/mcp/fallback-no-sqlite.test.d.ts +2 -0
  48. package/dist/mcp/fallback-no-sqlite.test.d.ts.map +1 -0
  49. package/dist/mcp/fallback-no-sqlite.test.js +135 -0
  50. package/dist/mcp/fallback-no-sqlite.test.js.map +1 -0
  51. package/dist/mcp/parse.d.ts +39 -0
  52. package/dist/mcp/parse.d.ts.map +1 -0
  53. package/dist/mcp/parse.js +154 -0
  54. package/dist/mcp/parse.js.map +1 -0
  55. package/dist/permissions/index.d.ts +1 -1
  56. package/dist/permissions/index.d.ts.map +1 -1
  57. package/dist/permissions/index.js +13 -3
  58. package/dist/permissions/index.js.map +1 -1
  59. package/dist/permissions/index.test.js +40 -0
  60. package/dist/permissions/index.test.js.map +1 -1
  61. package/dist/react-native/db.test.js +3 -0
  62. package/dist/react-native/db.test.js.map +1 -1
  63. package/dist/react-native/jazz-rn-runtime-adapter.d.ts +3 -8
  64. package/dist/react-native/jazz-rn-runtime-adapter.d.ts.map +1 -1
  65. package/dist/react-native/jazz-rn-runtime-adapter.js +3 -27
  66. package/dist/react-native/jazz-rn-runtime-adapter.js.map +1 -1
  67. package/dist/react-native/jazz-rn-runtime-adapter.test.js +11 -38
  68. package/dist/react-native/jazz-rn-runtime-adapter.test.js.map +1 -1
  69. package/dist/runtime/auth-secret-store.d.ts +2 -1
  70. package/dist/runtime/auth-secret-store.d.ts.map +1 -1
  71. package/dist/runtime/auth-secret-store.js +16 -7
  72. package/dist/runtime/auth-secret-store.js.map +1 -1
  73. package/dist/runtime/auth-secret-store.test.js +14 -0
  74. package/dist/runtime/auth-secret-store.test.js.map +1 -1
  75. package/dist/runtime/client-tests/for-request.test.js +2 -89
  76. package/dist/runtime/client-tests/for-request.test.js.map +1 -1
  77. package/dist/runtime/client-tests/support.d.ts +1 -2
  78. package/dist/runtime/client-tests/support.d.ts.map +1 -1
  79. package/dist/runtime/client-tests/support.js +1 -2
  80. package/dist/runtime/client-tests/support.js.map +1 -1
  81. package/dist/runtime/client.d.ts +8 -142
  82. package/dist/runtime/client.d.ts.map +1 -1
  83. package/dist/runtime/client.js +19 -569
  84. package/dist/runtime/client.js.map +1 -1
  85. package/dist/runtime/client.mutations.test.js +1 -52
  86. package/dist/runtime/client.mutations.test.js.map +1 -1
  87. package/dist/runtime/client.test.js +1 -46
  88. package/dist/runtime/client.test.js.map +1 -1
  89. package/dist/runtime/context.d.ts +1 -1
  90. package/dist/runtime/db-runtime-module.d.ts +0 -2
  91. package/dist/runtime/db-runtime-module.d.ts.map +1 -1
  92. package/dist/runtime/db-runtime-module.js.map +1 -1
  93. package/dist/runtime/db.d.ts +11 -10
  94. package/dist/runtime/db.d.ts.map +1 -1
  95. package/dist/runtime/db.js +56 -205
  96. package/dist/runtime/db.js.map +1 -1
  97. package/dist/runtime/db.persisted.test.js +0 -1
  98. package/dist/runtime/db.persisted.test.js.map +1 -1
  99. package/dist/runtime/db.transaction.test.js +4 -76
  100. package/dist/runtime/db.transaction.test.js.map +1 -1
  101. package/dist/runtime/db.transport.test.js +4 -4
  102. package/dist/runtime/db.transport.test.js.map +1 -1
  103. package/dist/runtime/index.d.ts +1 -1
  104. package/dist/runtime/index.d.ts.map +1 -1
  105. package/dist/runtime/index.js +1 -1
  106. package/dist/runtime/index.js.map +1 -1
  107. package/dist/runtime/napi.sqlite-compat.test.js +35 -17
  108. package/dist/runtime/napi.sqlite-compat.test.js.map +1 -1
  109. package/dist/runtime/permissions.repro.test.js +85 -0
  110. package/dist/runtime/permissions.repro.test.js.map +1 -1
  111. package/dist/runtime/sync-telemetry.js +49 -24
  112. package/dist/runtime/sync-telemetry.js.map +1 -1
  113. package/dist/runtime/sync-telemetry.test.js +16 -4
  114. package/dist/runtime/sync-telemetry.test.js.map +1 -1
  115. package/dist/runtime/wasm-runtime-module.d.ts +1 -1
  116. package/dist/runtime/wasm-runtime-module.d.ts.map +1 -1
  117. package/dist/runtime/wasm-runtime-module.js +2 -3
  118. package/dist/runtime/wasm-runtime-module.js.map +1 -1
  119. package/dist/runtime/worker-bridge.d.ts +1 -5
  120. package/dist/runtime/worker-bridge.d.ts.map +1 -1
  121. package/dist/runtime/worker-bridge.js +0 -16
  122. package/dist/runtime/worker-bridge.js.map +1 -1
  123. package/dist/svelte/index.d.ts +1 -0
  124. package/dist/svelte/index.d.ts.map +1 -1
  125. package/dist/svelte/index.js +1 -0
  126. package/dist/svelte/local-first-auth.svelte.d.ts +49 -0
  127. package/dist/svelte/local-first-auth.svelte.d.ts.map +1 -0
  128. package/dist/svelte/local-first-auth.svelte.js +115 -0
  129. package/dist/svelte/local-first-auth.svelte.test.js +292 -0
  130. package/dist/svelte/rune-patterns.svelte.test.js +12 -13
  131. package/dist/testing/index.test.js +2 -2
  132. package/dist/testing/index.test.js.map +1 -1
  133. package/dist/typed-app.d.ts +3 -3
  134. package/dist/typed-app.d.ts.map +1 -1
  135. package/dist/typed-app.js +5 -0
  136. package/dist/typed-app.js.map +1 -1
  137. package/dist/vue/index.d.ts +1 -0
  138. package/dist/vue/index.d.ts.map +1 -1
  139. package/dist/vue/index.js +1 -0
  140. package/dist/vue/index.js.map +1 -1
  141. package/dist/vue/use-local-first-auth.d.ts +52 -0
  142. package/dist/vue/use-local-first-auth.d.ts.map +1 -0
  143. package/dist/vue/use-local-first-auth.js +123 -0
  144. package/dist/vue/use-local-first-auth.js.map +1 -0
  145. package/dist/vue/use-local-first-auth.test.d.ts +2 -0
  146. package/dist/vue/use-local-first-auth.test.d.ts.map +1 -0
  147. package/dist/vue/use-local-first-auth.test.js +237 -0
  148. package/dist/vue/use-local-first-auth.test.js.map +1 -0
  149. package/package.json +6 -6
@@ -121,7 +121,26 @@ the same principal:
121
121
  Recreate `Db` or `JazzProvider` when the authenticated user changes.
122
122
 
123
123
  If you change the `config` passed to `JazzProvider`, Jazz recreates the client. That is the
124
- recommended path for login, logout, and any principal change.
124
+ recommended path for login, logout, and any principal change. See
125
+ [Lifecycle](/docs/auth/lifecycle) for logout, storage reset, and local-first identity storage.
126
+
127
+ ### Cookie-based auth
128
+
129
+ Use `cookieSession` only when your app authenticates sync with an HttpOnly cookie instead of a JS-readable bearer token. In that setup, the cookie is the real transport credential, while `cookieSession` mirrors the current Jazz session into the client so local permission checks, `useSession()`, and `db.getAuthState()` know which user is active.
130
+
131
+ ```ts
132
+ const db = await createDb({
133
+ appId: "my-app",
134
+ serverUrl: "https://sync.example.com",
135
+ cookieSession: {
136
+ user_id: "user_123",
137
+ claims: { role: "member" },
138
+ authMode: "external",
139
+ },
140
+ });
141
+ ```
142
+
143
+ You must choose between `cookieSession` and `secret`/`jwtToken`. If the same user is signed in, but their session changes (e.g. new claims, refreshed cookie), call `db.updateCookieSession(nextSession)`. To sign in, sign out, or authenticate as a different user, recreate `Db` or `JazzProvider` with a new config.
125
144
 
126
145
  ### Reacting to expiry and unauthenticated responses
127
146
 
@@ -216,7 +235,7 @@ When the server receives a request, it tries each auth method in priority order
216
235
 
217
236
  Backend impersonation always takes precedence, so a backend service can reliably impersonate users even if the request also carries a JWT.
218
237
 
219
- On TypeScript backends, `await createJazzContext(...).forRequest(req)` follows the same rule set. Configure `jwksUrl` or `jwtPublicKey` on the backend context to verify external JWTs there too, but not both.
238
+ On TypeScript backends, `await createJazzContext(...).forRequest(req)` follows the same rule set. Configure `jwksUrl` or `jwtPublicKey` on the backend context to verify external JWTs there too, but not both. Cookie-based app auth is resolved by your app server, not by the Jazz CLI server; once you have a Jazz session from a cookie, use `context.forSession(session)`.
220
239
 
221
240
  ### Upgrading to external auth
222
241
 
@@ -227,53 +246,245 @@ Users on local-first auth can sign up with an external provider and keep the sam
227
246
  - [Sessions](/docs/auth/sessions) — read the current user and scope queries to their identity
228
247
  - [Permissions](/docs/auth/permissions) — define row-level access policies
229
248
 
249
+ ===PAGE:auth/lifecycle===
250
+ TITLE:Lifecycle
251
+ DESCRIPTION:"Client auth lifecycle: local-first identity storage, sign-in and sign-out, storage reset, and upgrading to external auth."
252
+
253
+ Jazz clients have two pieces of local state that are easy to confuse:
254
+
255
+ - **Identity secret** — the local-first auth secret. In browser apps this is usually stored
256
+ by `BrowserAuthSecretStore` in `localStorage`; in Expo it is stored by `ExpoAuthSecretStore` in
257
+ secure storage.
258
+ - **Database storage** — the local relational database. Browser persistent clients store this
259
+ in OPFS; React Native uses native storage; memory drivers keep it only for the life of the process.
260
+
261
+ Clearing one does not automatically clear the other. That separation is intentional: a development
262
+ storage reset should not silently destroy a user's identity, and signing out of an auth provider
263
+ should not necessarily delete offline data.
264
+
265
+ ## Local-first identity storage
266
+
267
+ Local-first auth derives the user's Jazz identity from a 32-byte secret. The same secret always
268
+ produces the same Jazz user ID, so preserving the secret preserves identity across app restarts.
269
+
270
+ ```ts
271
+ const secret = await BrowserAuthSecretStore.getOrCreateSecret();
272
+
273
+ const db = await createDb({
274
+ appId: "my-app",
275
+ secret,
276
+ });
277
+ ```
278
+
279
+ `useLocalFirstAuth()` wraps this storage for React and Expo apps. Its `signOut()` method clears the
280
+ stored secret, which means the next login without a restored secret creates a different Jazz
281
+ identity.
282
+
283
+ The local-first secret is the user's identity. If it is lost, rows owned only by that identity may
284
+ become inaccessible unless the user restores the same secret from a recovery passphrase, passkey
285
+ backup, or linked external account. The secret should be protected carefully: anyone with the
286
+ secret can authenticate as the user.
287
+
288
+ Use [Local-first auth](/docs/auth/local-first-auth#backing-up-and-restoring-the-secret) to add
289
+ recovery before shipping flows that clear or replace a local-first secret.
290
+
291
+ ## Switching users
292
+
293
+ Recreate the
294
+ client with a new auth config whenever the active principal changes:
295
+
296
+ ```tsx
297
+
298
+ ```
299
+
300
+ For the same external user, `db.updateAuthToken(freshJwt)` is fine for refreshing an expiring
301
+ bearer token. Do not use `db.updateAuthToken(null)` to sign out or switch users on a live `Db`. For cookie-based auth, `db.updateCookieSession(nextSession)` updates the mirrored
302
+ session for the same user while the HttpOnly cookie remains the transport credential.
303
+
304
+ ## Logout
305
+
306
+ Use `db.logout()` when you are done with a `Db` instance during logout or switching users. It
307
+ shuts down subscriptions, workers, and cached runtime clients. Your auth provider is still
308
+ responsible for clearing its own token or cookie, and your app should recreate `Db` or
309
+ `JazzProvider` with the next auth config.
310
+
311
+ ```ts
312
+ await db.logout();
313
+ ```
314
+
315
+ In browser persistent mode, pass `wipeData: true` to also clear the local OPFS database namespace
316
+ before shutdown:
317
+
318
+ ```ts
319
+ await db.logout({ wipeData: true });
320
+ ```
321
+
322
+ `wipeData` clears the local database for this browser storage namespace. It does not clear
323
+ local-first auth secrets from `localStorage`, Expo secure storage, or your external provider's
324
+ cookies/tokens.
325
+
326
+ ## Storage reset
327
+
328
+ For development tools that only need to clear browser database storage without treating it as
329
+ logout, call:
330
+
331
+ ```ts
332
+ await db.deleteClientStorage();
333
+ ```
334
+
335
+ This API is only available for browser worker-backed persistent storage. It clears OPFS database
336
+ files and coordinates across tabs, but intentionally leaves local-first auth secrets alone.
337
+
338
+ Need a console-only reset while debugging? See [How do I reset browser storage?](/docs/faq#reset-browser-storage).
339
+
340
+ ## Upgrading to external auth
341
+
342
+ Users can start with local-first auth and later sign up with an external provider while keeping the
343
+ same Jazz identity. The upgrade flow is:
344
+
345
+ 1. Start the app with a local-first `secret`.
346
+ 2. Before sign-up, call `db.getLocalFirstIdentityProof(...)` to prove ownership of that identity.
347
+ 3. Verify that proof on your server.
348
+ 4. Create or link the external account so its future JWTs use the same Jazz user ID as `sub`.
349
+ 5. Recreate `Db` or `JazzProvider` with `jwtToken` or `cookieSession` for the linked external account.
350
+
351
+ After the external account is linked, keep the local-first secret backed up until you are confident
352
+ the external provider can recover the same Jazz user ID. See
353
+ [Signing up with BetterAuth](/docs/auth/local-first-auth#signing-up-with-betterauth) for the full
354
+ proof-token flow.
355
+
356
+ ## Related APIs
357
+
358
+ | API | Use it for |
359
+ | --------------------------------- | ---------------------------------------------------------------- |
360
+ | `BrowserAuthSecretStore` | Browser local-first secret storage |
361
+ | `ExpoAuthSecretStore` | Expo secure local-first secret storage |
362
+ | `useLocalFirstAuth()` | React/Expo hook for loading, replacing, and clearing the secret |
363
+ | `db.updateAuthToken(jwt)` | Refreshing a bearer JWT for the same principal |
364
+ | `db.updateCookieSession(session)` | Updating a mirrored cookie-backed session for the same principal |
365
+ | `db.logout()` | Shutting down a client during logout or principal switch |
366
+ | `db.logout({ wipeData: true })` | Logout plus browser OPFS database wipe |
367
+ | `db.deleteClientStorage()` | Development-only browser OPFS storage reset |
368
+
230
369
  ===PAGE:auth/local-first-auth===
231
370
  TITLE:Local-first auth
232
371
  DESCRIPTION:"Authenticate users without a server using self-signed tokens, and optionally upgrade to an external provider while preserving identity."
233
372
 
234
- Local-first auth lets users start using your app immediately no sign-up, no server round-trip. Jazz generates a stable identity from a secret stored on the device. Without a recovery mechanism, that identity lives only on the device if the secret is lost, it's gone. Linking to an external provider at sign-up is one way to make the identity durable and recoverable.
373
+ Local-first auth lets users start using your app immediately without signing up. Jazz generates a secret on the client, derives a stable account ID from it, and uses self-signed tokens to prove the user owns that account. The secret itself effectively _is_ the account.
374
+
375
+ This secret (and therefore the account) lives wherever the user uses the app. To log in from other devices, the user needs to use the same secret.
376
+
377
+ | Mode | Identity source | Server needed? | Best for |
378
+ | ------------- | ---------------------- | -------------- | ------------------------------------------------------ |
379
+ | `local-first` | Keypair held by client | No | Production offline-first apps, try-before-signup flows |
380
+ | `external` | JWT from auth provider | Yes | Production apps with real user accounts |
381
+
382
+ ## When to use local-first auth
383
+
384
+ If you want users to be able to start immediately, local-first ([client setup](#client-setup)) is a great option, but any user who clears site data (intentionally or otherwise) loses their account.
385
+
386
+ You can add a [recovery passphrase](#recovery-passphrase) or [passkey backup](#passkey-backup) which allows users to more easily log in on multiple devices or recover their accounts if they delete them. However, these are often less familiar and can add UX friction.
387
+
388
+ A good middle ground is starting users on local-first so they can play around immediately, but let them upgrade to a managed account when they want to, for example using Better Auth (see [signing up with BetterAuth](#signing-up-with-betterauth)).
389
+
390
+ If you don't want a try-before-signup flow at all, skip local-first entirely and use [external auth](/docs/auth/authentication) from day one.
235
391
 
236
- | Mode | Identity source | Server needed? | Best for |
237
- | ------------- | ----------------------- | -------------- | ------------------------------------------------------ |
238
- | `local-first` | Secret stored on device | No | Production offline-first apps, try-before-signup flows |
239
- | `external` | JWT from auth provider | Yes | Production apps with real user accounts |
392
+ You can also combine modes: let local-first users read and experiment, but require an upgrade before any sensitive action — see [Permissions by auth mode](#permissions-by-auth-mode).
240
393
 
241
394
  ## Client setup
242
395
 
243
- Use `useLocalFirstAuth()` to manage the user's secret. On first load it generates a new identity; on subsequent loads it reuses the stored one. It also exposes `login` and `signOut` for switching or clearing the local identity.
396
+ Fetch or create a secret and pass it to your Jazz client. Jazz stores the secret in the browser (or equivalent on native) so subsequent loads reuse the same account.
244
397
 
245
- ```tsx
398
+ ```tsx
246
399
 
247
- function App() {
248
400
  const { secret, isLoading } = useLocalFirstAuth();
249
401
 
250
- if (isLoading || !secret) return <p>Loading…</p>;
402
+ if (isLoading || !secret) return null;
251
403
 
252
404
  return (
253
405
 
254
406
  );
255
407
  }
408
+ ```
409
+ `useLocalFirstAuth()` also exposes `login` and `signOut` for switching or clearing accounts.
410
+
411
+ ```vue
412
+ <script setup lang="ts">
413
+
414
+ const client = ref | null>(null);
415
+
416
+ onMounted(async () => {
417
+ const secret = await BrowserAuthSecretStore.getOrCreateSecret();
418
+ client.value = createJazzClient({
419
+ appId: "my-app",
420
+ secret,
421
+ });
422
+ });
423
+ </script>
424
+
425
+ <template>
426
+
427
+ <slot />
428
+
429
+ </template>
430
+
431
+ ```
432
+
433
+ ```svelte
434
+
435
+ <script lang="ts">
436
+ import { BrowserAuthSecretStore, createJazzClient, JazzSvelteProvider } from 'jazz-tools/svelte';
437
+ import type { Snippet } from 'svelte';
438
+
439
+ let { children }: { children: Snippet } = $props();
440
+
441
+ const client = BrowserAuthSecretStore.getOrCreateSecret().then((secret) =>
442
+ createJazzClient({ appId: 'my-app', secret }),
443
+ );
444
+ </script>
445
+
446
+ {@render children()}
447
+
448
+ ```
449
+
450
+ ```ts
451
+
452
+ const secret = await BrowserAuthSecretStore.getOrCreateSecret({ appId: "my-app" });
453
+
454
+ return createDb({
455
+ appId: "my-app",
456
+ secret,
457
+ });
458
+ }
256
459
  ```
257
460
 
258
- The secret is the user's identity. If it's lost (e.g. `localStorage` cleared), the identity is
259
- gone and any data owned by that user becomes inaccessible unless you restore it from a recovery
260
- passphrase, a passkey backup, or an external provider you linked earlier.
461
+ Lose the secret and you lose the account. If it's cleared (e.g. `localStorage` wiped), the account
462
+ and any data owned by it become inaccessible unless the user restores from a recovery passphrase,
463
+ a passkey backup, or an external provider they linked earlier.
464
+
465
+ For how the secret relates to local database storage, logout, and user switching, see
466
+ [Auth Lifecycle](/docs/auth/lifecycle).
261
467
 
262
468
  ## Backing up and restoring the secret
263
469
 
264
470
  You can keep local-first auth fully serverless and still make it recoverable. Jazz ships two backup
265
- helpers for the same 32-byte secret:
471
+ helpers:
266
472
 
267
- | Method | Platforms | Best for | Trade-offs |
268
- | --------------------------- | ------------- | ---------------------------------------------- | ---------------------------------------------------- |
269
- | `jazz-tools/passphrase` | Browser, Expo | Manual backup users can carry anywhere | User must safely store a 24-word recovery passphrase |
270
- | `jazz-tools/passkey-backup` | Browser only | Fast recovery on devices that support passkeys | Requires WebAuthn and a stable relying-party ID |
473
+ | Method | Platforms | Best for | Trade-offs |
474
+ | --------------------------- | ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- |
475
+ | `jazz-tools/passphrase` | Browser, Expo | Manual backup users can carry anywhere | User must securely store a 24-word recovery passphrase, and UI has to convey the weight of this |
476
+ | `jazz-tools/passkey-backup` | Browser only | Fast recovery on devices that support passkeys | Passkey sync is platform-bounded |
271
477
 
272
478
  ### Recovery passphrase
273
479
 
274
- `jazz-tools/passphrase` converts the local-first secret into a 24-word English recovery
275
- passphrase. Restoring that passphrase produces the exact same secret, so the user keeps the same
276
- Jazz identity.
480
+ `jazz-tools/passphrase` encodes the secret as a 24-word English passphrase, similar to
481
+ a crypto-wallet seed phrase. Decoding the passphrase produces the exact same secret, so the user
482
+ keeps the same Jazz account.
483
+
484
+ The 24 words are just the secret encoded differently, not a password layered over it. There's no
485
+ hashing, no key-wrapping, no challenge-response: whoever sees the passphrase can sign in as the
486
+ user. This poses a difficult UX challenge: the phrase is too long for most people to remember, and
487
+ the risk of insecure storage is high.
277
488
 
278
489
  ```tsx
279
490
 
@@ -289,18 +500,48 @@ Jazz identity.
289
500
  }
290
501
  ```
291
502
 
292
- ```tsx
503
+ ```ts
293
504
 
294
- isLoading: boolean;
295
- recoveryPhrase: string | null;
296
- } {
297
- const { secret, isLoading } = useLocalFirstAuth();
505
+ const isLoading = ref(true);
506
+ const recoveryPhrase = ref<string | null>(null);
507
+
508
+ onMounted(async () => {
509
+ const secret = await BrowserAuthSecretStore.loadSecret();
510
+ recoveryPhrase.value = secret ? RecoveryPhrase.fromSecret(secret) : null;
511
+ isLoading.value = false;
512
+ });
513
+
514
+ return { isLoading, recoveryPhrase };
515
+ }
516
+ ```
517
+
518
+ ```ts
519
+
520
+ let isLoading = $state(true);
521
+ let recoveryPhrase = $state<string | null>(null);
522
+
523
+ onMount(async () => {
524
+ const secret = await BrowserAuthSecretStore.loadSecret();
525
+ recoveryPhrase = secret ? RecoveryPhrase.fromSecret(secret) : null;
526
+ isLoading = false;
527
+ });
298
528
 
299
529
  return {
300
- isLoading,
301
- recoveryPhrase: secret ? RecoveryPhrase.fromSecret(secret) : null,
530
+ get isLoading() {
531
+ return isLoading;
532
+ },
533
+ get recoveryPhrase() {
534
+ return recoveryPhrase;
535
+ },
302
536
  };
303
537
  }
538
+ ```
539
+
540
+ ```ts
541
+
542
+ const secret = await BrowserAuthSecretStore.loadSecret();
543
+ return secret ? RecoveryPhrase.fromSecret(secret) : null;
544
+ }
304
545
  ```
305
546
 
306
547
  To restore, decode the user-provided passphrase back into a secret and hand it back to your
@@ -317,33 +558,71 @@ local-first auth flow:
317
558
  }
318
559
  ```
319
560
 
320
- ```tsx
321
-
322
- const { login } = useLocalFirstAuth();
561
+ ```ts
323
562
 
324
563
  return async (userInput: string) => {
325
564
  const restoredSecret = RecoveryPhrase.toSecret(userInput);
326
- await login(restoredSecret);
565
+ await BrowserAuthSecretStore.saveSecret(restoredSecret);
566
+ // Reload so the mounted JazzProvider picks up the restored secret.
567
+ location.reload();
327
568
  };
328
569
  }
329
570
  ```
330
571
 
331
- The parser accepts upper-case input and extra whitespace. Invalid input throws
572
+ ```ts
573
+
574
+ const restoredSecret = RecoveryPhrase.toSecret(userInput);
575
+ await BrowserAuthSecretStore.saveSecret(restoredSecret);
576
+ // Reload so the mounted JazzSvelteProvider picks up the restored secret.
577
+ location.reload();
578
+ }
579
+ ```
580
+
581
+ ```ts
582
+
583
+ const restoredSecret = RecoveryPhrase.toSecret(userInput);
584
+ await BrowserAuthSecretStore.saveSecret(restoredSecret);
585
+ // Reload so the live Jazz client picks up the restored secret.
586
+ location.reload();
587
+ }
588
+ ```
589
+
590
+ The parser is case-insensitive and tolerant of extra whitespace between words. Invalid input throws
332
591
  `RecoveryPhraseError` with codes like `invalid-length`, `invalid-word`, and
333
592
  `invalid-checksum`.
334
593
 
335
- In React and Expo, prefer `useLocalFirstAuth()` for backup and restore so the mounted
336
- `JazzProvider` updates immediately. The lower-level `BrowserAuthSecretStore` and
337
- `ExpoAuthSecretStore` APIs are still useful outside React-based UIs.
594
+ In React and Expo, `useLocalFirstAuth().login()` updates the mounted `JazzProvider` immediately.
595
+ Other frameworks use `BrowserAuthSecretStore.saveSecret()` directly, which doesn't notify the live
596
+ client the snippets above reload the page so the new secret is picked up on the next load.
338
597
 
339
598
  ### Passkey backup
340
599
 
341
- For browser apps, `jazz-tools/passkey-backup` stores the same secret in a resident WebAuthn
342
- credential. That gives users passkey-style recovery without adding a server-side auth provider.
600
+ A passkey is a credential stored and synced by the user's browser or platform (via the WebAuthn
601
+ browser API), typically unlocked with biometrics. `jazz-tools/passkey-backup` uses this as an
602
+ encrypted at-rest store for the secret: the secret is held inside a resident WebAuthn
603
+ credential and released after a user verification prompt. This is not a full WebAuthn
604
+ authentication flow&hairsp;—&hairsp;Jazz is not challenging the key, just using the passkey as a vault for the
605
+ seed.
606
+
607
+ Passkey availability depends on where the user's passkey provider works. OS-native stores
608
+ (iCloud Keychain, Google Password Manager) have platform boundaries that are not always visible
609
+ to users. For example, a passkey created on Safari/macOS may not be available on Chrome/Windows unless the
610
+ user is using a third-party passkey manager (1Password, Bitwarden, Dashlane etc.) that spans
611
+ both. WebAuthn's cross-device flow (QR from laptop to phone) can bridge a session but requires
612
+ the original device, so it doesn't help after a loss.
613
+
614
+ Treat passkey backup as one option among several. Pair it with a recovery passphrase or a linked
615
+ provider account so the user doesn't get locked out.
343
616
 
344
617
  Create the passkey from the current local-first secret:
345
618
 
346
- ```tsx
619
+ ```tsx
620
+ const passkeyBackup = new BrowserPasskeyBackup({
621
+ appName: "My App",
622
+ // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
623
+ // which scopes passkeys per preview-deploy URL.
624
+ appHostname: "myapp.com",
625
+ });
347
626
 
348
627
  isLoading: boolean;
349
628
  backupWithPasskey: (displayName: string) => Promise<void>;
@@ -363,10 +642,53 @@ Create the passkey from the current local-first secret:
363
642
  }
364
643
  ```
365
644
 
366
- Restore from the passkey by reading the secret back and passing it to `login(...)` from
367
- `useLocalFirstAuth()`:
645
+ ```ts
646
+ const passkeyBackup = new BrowserPasskeyBackup({
647
+ appName: "My App",
648
+ // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
649
+ // which scopes passkeys per preview-deploy URL.
650
+ appHostname: "myapp.com",
651
+ });
368
652
 
369
- ```tsx
653
+ return async (displayName: string) => {
654
+ const secret = await BrowserAuthSecretStore.loadSecret();
655
+ if (!secret) throw new Error("No local secret to back up yet");
656
+ await passkeyBackup.backup(secret, displayName);
657
+ };
658
+ }
659
+ ```
660
+
661
+ ```ts
662
+ const passkeyBackup = new BrowserPasskeyBackup({
663
+ appName: "My App",
664
+ // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
665
+ // which scopes passkeys per preview-deploy URL.
666
+ appHostname: "myapp.com",
667
+ });
668
+
669
+ const secret = await BrowserAuthSecretStore.loadSecret();
670
+ if (!secret) throw new Error("No local secret to back up yet");
671
+ await passkeyBackup.backup(secret, displayName);
672
+ }
673
+ ```
674
+
675
+ ```ts
676
+ const passkeyBackup = new BrowserPasskeyBackup({
677
+ appName: "My App",
678
+ // Pin to your canonical production hostname. If omitted, defaults to `location.hostname`,
679
+ // which scopes passkeys per preview-deploy URL.
680
+ appHostname: "myapp.com",
681
+ });
682
+
683
+ const secret = await BrowserAuthSecretStore.loadSecret();
684
+ if (!secret) throw new Error("No local secret to back up yet");
685
+ await passkeyBackup.backup(secret, displayName);
686
+ }
687
+ ```
688
+
689
+ Restore by reading the secret back from the passkey:
690
+
691
+ ```tsx
370
692
 
371
693
  const { login } = useLocalFirstAuth();
372
694
 
@@ -377,9 +699,39 @@ Restore from the passkey by reading the secret back and passing it to `login(...
377
699
  }
378
700
  ```
379
701
 
380
- Use a stable `appHostname` everywhere you want passkey recovery to work. It becomes the WebAuthn
381
- relying-party ID, so changing it creates a different passkey namespace. `backup(secret,
382
- displayName)` also needs a user-visible name for the platform passkey sheet.
702
+ ```ts
703
+
704
+ return async () => {
705
+ const restoredSecret = await passkeyBackup.restore();
706
+ await BrowserAuthSecretStore.saveSecret(restoredSecret);
707
+ location.reload();
708
+ };
709
+ }
710
+ ```
711
+
712
+ ```ts
713
+
714
+ const restoredSecret = await passkeyBackup.restore();
715
+ await BrowserAuthSecretStore.saveSecret(restoredSecret);
716
+ location.reload();
717
+ }
718
+ ```
719
+
720
+ ```ts
721
+
722
+ const restoredSecret = await passkeyBackup.restore();
723
+ await BrowserAuthSecretStore.saveSecret(restoredSecret);
724
+ location.reload();
725
+ }
726
+ ```
727
+
728
+ Passkey backup is currently browser-only. Mobile platform support is planned.
729
+
730
+ - `appHostname` becomes the WebAuthn relying-party ID which is the namespace passkeys are scoped to.
731
+ If you change it, all existing passkeys will stop working.
732
+
733
+ - The `displayName` argument to `backup(secret,
734
+ displayName)` is the user-visible name shown in the platform passkey sheet.
383
735
 
384
736
  Handle `PasskeyBackupError` in your UI to surface cases like unsupported browsers, no saved
385
737
  credential, or missing user verification. Keep a recovery passphrase or external sign-in as a
@@ -387,29 +739,23 @@ fallback if the user might lose access to their passkey provider.
387
739
 
388
740
  ## Server configuration
389
741
 
390
- Local-first auth is enabled by default in development. In production, enable it explicitly:
742
+ Local-first auth is enabled by default in the cloud, and in local dev mode. For self-hosted production setups, enable it explicitly:
391
743
 
392
744
  ```bash
393
745
  pnpm dlx jazz-tools@alpha server --allow-local-first-auth
394
746
  ```
395
747
 
396
- No JWKS endpoint is needed the token is self-contained and the server can verify it on its own.
748
+ No JWKS endpoint is needed: the token is self-contained and the server can verify it on its own.
397
749
 
398
750
  ## Signing up with BetterAuth
399
751
 
400
- Users can try your app before creating an account. When they sign up later through BetterAuth, all the data they created carries over same account, nothing lost.
752
+ Users can try your app before creating an account. When they sign up later through BetterAuth, all the data they created carries over: same Jazz account, now with an email (or OAuth identity) attached.
401
753
 
402
- >Client: db.getLocalFirstIdentityProof()
403
- Client->>BetterAuth: Sign up (email + proofToken)
404
- BetterAuth->>BetterAuth: Verify proof, create user with same ID
405
- BetterAuth-->>Client: Session + JWT
406
- Note over Client: Same user ID, now with an account
407
-
408
- `} />
754
+ The client generates a short-lived proof that it owns the current Jazz account and hands it to BetterAuth alongside the sign-up credentials. BetterAuth verifies the proof, records the Jazz account ID against the new user, and issues JWTs that keep pointing to the same ID. For the reasoning and sequence diagram, see [Upgrading to a provider account](/docs/reference/local-first-auth-internals#upgrading-to-a-provider-account).
409
755
 
410
756
  ### Generating the proof token
411
757
 
412
- Before calling your sign-up endpoint, the client generates a short-lived proof that it owns the current Jazz identity:
758
+ Before calling your sign-up endpoint, the client generates a short-lived proof that it owns the current Jazz account:
413
759
 
414
760
  ```tsx
415
761
  function SignUpButton() {
@@ -442,7 +788,7 @@ function SignUpButton() {
442
788
  }
443
789
  ```
444
790
 
445
- The `audience` should match what the server expects when verifying. The `ttlSeconds` keeps the window short — 60 seconds is enough for a sign-up round-trip.
791
+ `audience` is application-defined; the string only needs to match what the server passes when verifying. `ttlSeconds` keeps the window short — 60 seconds is usually enough for a sign-up round-trip.
446
792
 
447
793
  ### Verifying on sign-up
448
794
 
@@ -587,7 +933,9 @@ The BetterAuth example above uses a framework-specific middleware hook. With any
587
933
  3. Stores the proven Jazz user ID linked to the new user account
588
934
  4. Issues JWTs with `sub: jazzId`
589
935
 
590
- ```ts title="server.ts"
936
+ Sketched in pseudo-Express (`db.users.create` and `issueJwt` stand in for whatever your stack provides):
937
+
938
+ ```ts title="Illustrative — not a runnable example"
591
939
 
592
940
  // POST /signup
593
941
  app.post("/signup", async (req, res) => {
@@ -612,31 +960,15 @@ app.post("/signup", async (req, res) => {
612
960
 
613
961
  The `audience` string on the client and server must match. `jazz-napi` normalizes it internally, so any consistent string works.
614
962
 
615
- Jazz uses the JWT `sub` claim as `session.user_id`. If your provider keeps `sub` fixed to its own user ID, add a `/link-jazz-identity` endpoint that stores the Jazz ID and mint the JWT with `sub: <jazzId>` yourself either via a custom `getSubject` hook on your provider or by issuing the JWT directly.
616
-
617
- ## Under the hood
618
-
619
- Local-first auth uses Ed25519 cryptography to derive a stable identity from a secret:
620
-
621
- 1. A 32-byte **seed** is hashed with SHA-512 to produce an Ed25519 **signing key**.
622
- 2. The corresponding **public key** (32 bytes) is extracted.
623
- 3. A deterministic **user ID** is derived from the public key using UUIDv5 (namespace `jazz-auth-key-v1`).
624
- 4. The client mints a **self-signed JWT** with:
625
- - `alg: "EdDSA"` — Ed25519 signature
626
- - `iss: "urn:jazz:local-first"` — identifies the token as local-first
627
- - `sub: <user_id>` — the derived user ID
628
- - `jazz_pub_key: <base64url>` — the embedded public key
629
- - `aud: <app_id>` — scoped to the app
630
-
631
- The server verifies the signature using the embedded public key, re-derives the user ID, and confirms it matches `sub`. No external key store is involved — the token is fully self-contained.
632
-
633
- Same seed always produces the same user ID, across devices and time.
963
+ Jazz reads the JWT `sub` claim verbatim as `session.user_id` no custom-claim fallback — so your provider needs to emit the Jazz ID as `sub`. If it doesn't let you override `sub` (e.g. it pins it to its own user ID), you'll need to mint the JWT yourself rather than using the provider's issuer.
634
964
 
635
965
  ## Next steps
636
966
 
967
+ - [Lifecycle](/docs/auth/lifecycle) — local-first storage, logout, and upgrading auth
637
968
  - [Sessions](/docs/auth/sessions) — read the current user and scope queries to their identity
638
969
  - [Permissions](/docs/auth/permissions) — define row-level access policies
639
- - [Auth provider integration](/docs/recipes/auth-provider-integration) — set up BetterAuth or WorkOS as your JWT provider
970
+ - [Auth provider integration](/docs/recipes/auth/auth-provider-integration) — set up BetterAuth or WorkOS as your JWT provider
971
+ - [Local-first auth internals](/docs/reference/local-first-auth-internals) — token format, verification, and design rationale
640
972
 
641
973
  ===PAGE:auth/permissions===
642
974
  TITLE:Permissions
@@ -941,6 +1273,96 @@ s.definePermissions(exampleApp, ({ policy, anyOf, isCreator }) => {
941
1273
  For more dynamic sharing or ownership models, prefer explicit tables and relations rather than
942
1274
  encoding extra meaning into authorship alone.
943
1275
 
1276
+ ## Testing permissions
1277
+
1278
+ Jazz provides utilities to test permissions in isolation without testing your whole app's logic.
1279
+
1280
+ The `createPolicyTestApp` helper from `jazz-tools/testing` starts an isolated local Jazz server, publishes your app schema
1281
+ and compiled permissions, and gives you session-scoped database clients for assertions.
1282
+
1283
+ ```typescript title="permissions.test.ts"
1284
+
1285
+ let testApp: PolicyTestApp;
1286
+
1287
+ beforeEach(async () => {
1288
+ testApp = await createPolicyTestApp(app, permissions, expect);
1289
+ });
1290
+
1291
+ afterEach(async () => {
1292
+ await testApp.shutdown();
1293
+ });
1294
+
1295
+ describe("todo permissions", () => {
1296
+ it("allows owners to update their own todos", () => {
1297
+ const todo = testApp.seed((db) => {
1298
+ const { value } = db.insert(app.todos, {
1299
+ title: "Buy milk",
1300
+ ownerId: "alice",
1301
+ });
1302
+ return value;
1303
+ });
1304
+
1305
+ const alice = testApp.as({
1306
+ user_id: "alice",
1307
+ claims: {},
1308
+ authMode: "local-first",
1309
+ });
1310
+ const bob = testApp.as({
1311
+ user_id: "bob",
1312
+ claims: {},
1313
+ authMode: "local-first",
1314
+ });
1315
+
1316
+ alice.expectAllowed((db) =>
1317
+ db.update(app.todos, todo.id, {
1318
+ title: "Buy oat milk",
1319
+ }),
1320
+ );
1321
+
1322
+ bob.expectDenied((db) =>
1323
+ db.update(app.todos, todo.id, {
1324
+ title: "Buy orange juice",
1325
+ }),
1326
+ );
1327
+ });
1328
+ });
1329
+ ```
1330
+
1331
+ `createPolicyTestApp` takes the app created with `defineApp(...)`, the
1332
+ permissions object created with `definePermissions(...)`, and your test runner's `expect` function.
1333
+
1334
+ The returned `PolicyTestApp` provides:
1335
+
1336
+ - `testApp.seed(fn)` — run setup writes as an admin, bypassing policy checks
1337
+ - `testApp.as(session)` — create a `TestDb` client scoped to a specific session
1338
+ - `testDb.expectAllowed(fn)` — assert that a write does not throw a policy error
1339
+ - `testDb.expectDenied(fn)` — assert that a write is rejected by a policy
1340
+ - `testApp.shutdown()` — stop the local client and server
1341
+
1342
+ For read policies, assert on returned rows directly. Denied reads are filtered out rather than
1343
+ throwing.
1344
+
1345
+ ```typescript
1346
+ const bob = testApp.as({
1347
+ user_id: "bob",
1348
+ claims: {},
1349
+ authMode: "local-first",
1350
+ });
1351
+
1352
+ await expect(bob.all(app.todos.where({ id: privateTodo.id }))).resolves.toEqual([]);
1353
+ ```
1354
+
1355
+ If your policies depend on JWT claims, put those values in `session.claims` using the same claim
1356
+ names your policy reads.
1357
+
1358
+ ```typescript
1359
+ const invitedUser = testApp.as({
1360
+ user_id: "bob",
1361
+ claims: { join_code: "invite-123" },
1362
+ authMode: "local-first",
1363
+ });
1364
+ ```
1365
+
944
1366
  ===PAGE:auth/sessions===
945
1367
  TITLE:Sessions
946
1368
  DESCRIPTION:Reading the current session and scoping queries and inserts to the logged-in user.
@@ -1183,7 +1605,7 @@ All [reads](/docs/reading/queries) and [writes](/docs/writing/writing-data) thro
1183
1605
 
1184
1606
  When your schema changes, Jazz creates a new branch with a different schema hash (e.g. `prod-a1b2c3d4-main` for v1 and `prod-e5f6g7h8-main` for v2). At the storage level, data from different schema versions is kept separate.
1185
1607
 
1186
- At query time, Jazz automatically fetches data from all known schema versions within your current environment and user branch. It applies [migrations](/docs/schemas/migrations) to adapt older data to your current schema automatically.
1608
+ At query time, Jazz automatically fetches data from all known schema versions within your current environment and user branch. It applies [migrations](/docs/schemas/migrations) to adapt older data to your current schema automatically. See the [schemas, lenses and branches diagram](/docs/schemas/migrations#schemas-lenses-and-branches) for how schema versions, lenses and per-hash branches fit together.
1187
1609
 
1188
1610
  ## Environments and user branches are fully isolated
1189
1611
 
@@ -1454,7 +1876,7 @@ window.__jazz.listLiveStorageNamespaces();
1454
1876
  await window.__jazz.clearStorage("my-app::alice");
1455
1877
  ```
1456
1878
 
1457
- If you're working directly with a `Db` handle instead of the window helper, `await db.deleteClientStorage()` is the underlying API.
1879
+ 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.
1458
1880
 
1459
1881
  - Browser persistent storage only.
1460
1882
  - If exactly one live namespace exists, `clearStorage()` uses it automatically.
@@ -1517,7 +1939,7 @@ Every client needs an `appId` and a `secret` for [local-first auth](/docs/auth/l
1517
1939
 
1518
1940
  const secret = await BrowserAuthSecretStore.getOrCreateSecret();
1519
1941
 
1520
- const client = createJazzClient({
1942
+ const client = await createJazzClient({
1521
1943
  appId: "<your-app-id>",
1522
1944
  secret,
1523
1945
  });
@@ -1539,24 +1961,19 @@ Every client needs an `appId` and a `secret` for [local-first auth](/docs/auth/l
1539
1961
  } from 'jazz-tools/svelte';
1540
1962
  import TodoList from './TodoList.svelte';
1541
1963
 
1542
- let client = $state | null>(null);
1543
-
1544
- BrowserAuthSecretStore.getOrCreateSecret().then((secret) => {
1545
- client = createJazzClient({ appId: '<your-app-id>', secret });
1546
- });
1964
+ const client = BrowserAuthSecretStore.getOrCreateSecret().then(
1965
+ (secret) => createJazzClient({ appId: '<your-app-id>', secret })
1966
+ );
1547
1967
  </script>
1548
1968
 
1549
- {#if client}
1550
-
1551
- {#snippet children()}
1552
- <h1>Todos</h1>
1969
+ {#snippet children()}
1970
+ <h1>Todos</h1>
1553
1971
 
1554
- {/snippet}
1555
- {#snippet fallback()}
1556
- <p>Loading...</p>
1557
- {/snippet}
1972
+ {/snippet}
1973
+ {#snippet fallback()}
1974
+ <p>Loading...</p>
1975
+ {/snippet}
1558
1976
 
1559
- {/if}
1560
1977
  ```
1561
1978
 
1562
1979
  ```tsx
@@ -1581,8 +1998,6 @@ Every client needs an `appId` and a `secret` for [local-first auth](/docs/auth/l
1581
1998
 
1582
1999
  return createDb({
1583
2000
  appId: "my-app",
1584
- env: "dev",
1585
- userBranch: "main",
1586
2001
  secret,
1587
2002
  });
1588
2003
  }
@@ -1603,21 +2018,45 @@ Some bundlers and runtimes cannot automatically resolve Jazz's Wasm and worker a
1603
2018
 
1604
2019
  For React Native and Expo clients, make sure `serverUrl` points to a host your runtime can reach. For example, the Android emulator often needs `10.0.2.2` instead of `localhost`.
1605
2020
 
2021
+ Use `cookieSession` only when your app authenticates sync with an HttpOnly cookie instead of a JS-readable bearer token. In that setup, the cookie is the real transport credential, while `cookieSession` mirrors the current Jazz session into the client so local permission checks, `useSession()`, and `db.getAuthState()` know which user is active. You must choose between `cookieSession` and `secret`/`jwtToken`. Call `db.updateCookieSession(nextSession)` for cookie-backed session changes or recreate `JazzProvider` / `Db` on sign-in and sign-out.
2022
+
1606
2023
  ### Storage driver
1607
2024
 
1608
2025
  Browser clients default to `driver: { type: "persistent" }`, which stores data locally for offline support. Set `driver: { type: "memory" }` to keep data in memory only. This option requires you to set a `serverUrl` since nothing is saved locally.
1609
2026
 
1610
- ## Dev plugins
2027
+ #### Browser storage eviction
1611
2028
 
1612
- 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 so `createJazzClient({ appId, serverUrl })` picks them up without any manual wiring.
2029
+ `{ type: "persistent" }` writes to the [Origin Private File System (OPFS)](https://developer.mozilla.org/docs/Web/API/File_System_API/Origin_private_file_system). By default browsers treat this as **best-effort** storage&hairsp;—&hairsp;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.
1613
2030
 
1614
- ```ts title="vite.config.ts"
1615
- import { defineConfig } from "vite";
1616
- import react from "@vitejs/plugin-react";
1617
- import { jazzPlugin } from "jazz-tools/dev/vite";
2031
+ The trade-offs are:
1618
2032
 
1619
- export default defineConfig({
1620
- plugins: [react(), jazzPlugin()],
2033
+ - **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.
2034
+ - **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.
2035
+
2036
+ To request persistent storage, call it once after the user has signed in or interacted enough that a prompt makes sense:
2037
+
2038
+ ```ts
2039
+ if (navigator.storage?.persist) {
2040
+ const granted = await navigator.storage.persist();
2041
+ if (!granted) {
2042
+ // Fall back: rely on sync, warn the user, or retry later.
2043
+ }
2044
+ }
2045
+ ```
2046
+
2047
+ 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.
2048
+
2049
+ ## Dev plugins
2050
+
2051
+ 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 so `createJazzClient({ appId, serverUrl })` picks them up without any manual wiring.
2052
+
2053
+ ```ts title="vite.config.ts"
2054
+ import { defineConfig } from "vite";
2055
+ import react from "@vitejs/plugin-react";
2056
+ import { jazzPlugin } from "jazz-tools/dev/vite";
2057
+
2058
+ export default defineConfig({
2059
+ plugins: [react(), jazzPlugin()],
1621
2060
  });
1622
2061
  ```
1623
2062
 
@@ -1653,9 +2092,7 @@ Jazz ships bundler plugins that remove boilerplate in development. They start a
1653
2092
  import { defineConfig } from "vite";
1654
2093
 
1655
2094
  export default defineConfig({
1656
- // jazzSvelteKit must come before sveltekit so it populates
1657
- // process.env before SvelteKit captures $env/dynamic/public.
1658
- plugins: [jazzSvelteKit(), sveltekit()],
2095
+ plugins: [sveltekit(), jazzSvelteKit()],
1659
2096
  });
1660
2097
  ```
1661
2098
 
@@ -1682,6 +2119,10 @@ Jazz ships bundler plugins that remove boilerplate in development. They start a
1682
2119
 
1683
2120
  Injects `EXPO_PUBLIC_JAZZ_APP_ID` and `EXPO_PUBLIC_JAZZ_SERVER_URL` via `expoConfig.extra`. Skipped automatically when `NODE_ENV=production`.
1684
2121
 
2122
+ Expo and React Native apps still need the runtime polyfills from the quickstart. Import
2123
+ `jazz-tools/expo/polyfills` as the first line of your app entry point before any other Jazz
2124
+ import.
2125
+
1685
2126
  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.
1686
2127
 
1687
2128
  ### Env var names
@@ -1706,9 +2147,9 @@ All plugins accept the same base options:
1706
2147
  | `server` | `true` (default) starts an embedded local server. `false` disables the plugin. A URL string connects to an existing server. An object configures the embedded server (see below). |
1707
2148
  | `schemaDir` | Directory containing `schema.ts` and `permissions.ts`. Defaults to the project root, or `src/lib/` for SvelteKit. |
1708
2149
  | `appId` | Override the app ID. Defaults to the value in `.env`, otherwise a generated UUID persisted on first run. |
1709
- | `adminSecret` | Required when `server` is a URL. Ignored for the embedded server (a random secret is generated). |
2150
+ | `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. |
1710
2151
 
1711
- When `server` is an object, the embedded server accepts: `port` (default: random), `dataDir` (default: `node_modules/.cache/jazz-dev-server`), `inMemory`, `allowLocalFirstAuth`, `jwksUrl`, and the catalogue authority forwarding fields. See [Server Setup](/docs/getting-started/server-setup) for the full semantics.
2152
+ 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.
1712
2153
 
1713
2154
  ### Connecting to an existing server
1714
2155
 
@@ -1724,7 +2165,7 @@ jazzPlugin({
1724
2165
 
1725
2166
  ### What auto-pushes
1726
2167
 
1727
- 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 server's admin secret (generated for the embedded server, supplied by you for a remote server).
2168
+ 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).
1728
2169
 
1729
2170
  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).
1730
2171
 
@@ -1874,8 +2315,17 @@ npx jazz-tools@alpha server "$JAZZ_APP_ID" \
1874
2315
  | `--allow-local-first-auth` | Allow local-first auth (`Authorization: Bearer <self-signed Jazz JWT>`) | `JAZZ_ALLOW_LOCAL_FIRST_AUTH` | see `NODE_ENV` note below |
1875
2316
  | `--backend-secret ` | Enable backend session impersonation | `JAZZ_BACKEND_SECRET` | unset |
1876
2317
  | `--admin-secret ` | Required for `deploy`, `migrations push`, and schema catalogue reads. In development mode, structural schema auto-sync works without it. | `JAZZ_ADMIN_SECRET` | unset |
2318
+ | `--upstream-url ` | Run as an edge server connected to the upstream core server. Requires `--peer-secret` and `--admin-secret`. | `JAZZ_UPSTREAM_URL` | unset |
2319
+ | `--peer-secret ` | Shared server-to-server secret for edge sync. Required when `--upstream-url` is set. | `JAZZ_PEER_SECRET` | unset |
1877
2320
 
1878
2321
  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.
2322
+ Edge mode is enabled by `--upstream-url`; when set, provide both `--peer-secret` and `--admin-secret` (or `JAZZ_PEER_SECRET` and `JAZZ_ADMIN_SECRET`).
2323
+
2324
+ There is no separate Jazz server CLI flag for cookie-based auth. The sync server validates bearer
2325
+ JWTs, local-first bearer JWTs, or backend impersonation headers. If your app uses an HttpOnly cookie,
2326
+ resolve that cookie in your own app server and either exchange it for a bearer JWT before creating
2327
+ the Jazz client, or pass a mirrored `cookieSession` to the browser client while the cookie remains
2328
+ the transport credential.
1879
2329
 
1880
2330
  ## Backend context setup
1881
2331
 
@@ -1929,6 +2379,10 @@ In TypeScript backends, create the context once with both `app` and `permissions
1929
2379
 
1930
2380
  `forRequest` reads authentication from standard HTTP headers, so `req` can be any object that exposes them: Express, Hono, Fastify, or a Web Fetch API `Request` all work. Configure `jwksUrl` or `jwtPublicKey` on `createJazzContext(...)` if those bearer tokens come from an external IdP, but do not set both at once. Without either one, backend `forRequest()` only accepts Jazz self-signed tokens; set `allowLocalFirstAuth: false` to disable those too.
1931
2381
 
2382
+ For cookie-based auth on your own backend, resolve the cookie with your framework or auth provider
2383
+ first, then call `context.forSession(session)` once you have a Jazz `Session`. `forRequest(...)`
2384
+ does not parse application cookies by itself.
2385
+
1932
2386
  For embedded or local-only runtime setups where your backend is not connected to a server, use
1933
2387
  `context.db()` to get an unscoped, unauthenticated handle. This allows read/write access to the
1934
2388
  database directly, without permissions being evaluated.
@@ -1968,105 +2422,6 @@ pub async fn list_todos_for_request(
1968
2422
  }
1969
2423
  ```
1970
2424
 
1971
- ===PAGE:guides/better-auth-adapter===
1972
- TITLE:Better Auth Adapter
1973
- DESCRIPTION:Use Better Auth with Jazz as the database adapter.
1974
-
1975
- 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).
1976
-
1977
- ## Schema Workflow
1978
-
1979
- 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.
1980
-
1981
- ```bash
1982
- # Generate the Better Auth schema module into schema-better-auth/
1983
- npx @better-auth/cli@latest generate \
1984
- --config ./src/lib/auth.ts \
1985
- --output ./schema-better-auth/schema.ts
1986
-
1987
- # Validate the generated schema source alongside your own
1988
- pnpm dlx jazz-tools@alpha validate
1989
- ```
1990
-
1991
- Import the generated tables in your app's `schema.ts` and merge them into the app definition:
1992
-
1993
- ```ts title="schema.ts"
1994
-
1995
- const schema = {
1996
- ...betterauthSchema,
1997
- messages: s.table({
1998
- author_name: s.string(),
1999
- chat_id: s.string(),
2000
- text: s.string(),
2001
- sent_at: s.timestamp(),
2002
- }),
2003
- // ...your own tables
2004
- };
2005
-
2006
- type AppSchema = s.Schema<typeof schema>;
2007
-
2008
- ```
2009
-
2010
- 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 `context.asBackend()`, which authenticates with `backendSecret` and bypasses permission checks entirely.
2011
-
2012
- ## Database Adapter
2013
-
2014
- Create a server-side [Jazz context](/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.
2015
-
2016
- ```ts title="src/lib/auth.ts"
2017
-
2018
- const jazzContext = createJazzContext({
2019
- appId: process.env.APP_ID!,
2020
- driver: { type: "memory" },
2021
- serverUrl: process.env.SYNC_SERVER_URL!,
2022
- env: process.env.NODE_ENV === "production" ? "prod" : "dev",
2023
- userBranch: "main",
2024
- backendSecret: process.env.BACKEND_SECRET!,
2025
- });
2026
-
2027
- database: jazzAdapter({
2028
- db: () => jazzContext.asBackend(app),
2029
- schema: app.wasmSchema,
2030
- }),
2031
- // ...your Better Auth config
2032
- });
2033
- ```
2034
-
2035
- | Option | Description |
2036
- | ----------- | ------------------------------------------------------------------------------------------------------ |
2037
- | `db` | A function returning a `Db` handle via `context.asBackend(app)`. Use the merged app, not `authSchema`. |
2038
- | `schema` | Typically `app.wasmSchema` from your merged schema. A raw `s.App` is also accepted. |
2039
- | `debugLogs` | Better Auth adapter debug logging controls. |
2040
- | `usePlural` | Whether Better Auth model names should use plural table names. |
2041
- | `prefix` | Table name prefix (defaults to `"better_auth_"`). |
2042
-
2043
- The Jazz adapter does not support Better Auth experimental joins yet, so do not enable
2044
- `experimental.joins`.
2045
-
2046
- ### Publishing to a sync server
2047
-
2048
- 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:
2049
-
2050
- ```bash
2051
- pnpm dlx jazz-tools@alpha deploy <appId>
2052
- ```
2053
-
2054
- See [Migrations](/docs/schemas/migrations) for the full deploy flow and how schema changes produce migration edges.
2055
-
2056
- ## Compatibility
2057
-
2058
- The adapter is currently aligned with Better Auth `1.5.5`.
2059
-
2060
- | Plugin/Feature | Compatibility |
2061
- | --------------------- | :-----------: |
2062
- | Email & Password auth | ✅ |
2063
- | Social Provider auth | ✅ |
2064
- | Email OTP | ✅ |
2065
-
2066
- This section reflects the adapter behavior currently covered by this repo's Better Auth
2067
- integration and tests. If you add plugins that introduce extra tables or custom schema fields,
2068
- regenerate `schema-better-auth/schema.ts` and re-run the Jazz schema workflow.
2069
-
2070
2425
  ===PAGE:index===
2071
2426
  TITLE:Overview
2072
2427
  DESCRIPTION:The database that syncs.
@@ -2163,6 +2518,8 @@ Start with a fresh app. If you already have one, skip to [Install](#install).
2163
2518
  pnpm add -D @babel/plugin-transform-flow-strip-types
2164
2519
  ```
2165
2520
 
2521
+ `jazz-rn` is mandatory in Expo / React Native environments and must be installed alongside `jazz-tools`.
2522
+
2166
2523
  Create `index.js` at the project root, and set `"main": "./index.js"` in your `package.json`. Import polyfills as the very first line, before any other code:
2167
2524
 
2168
2525
  ```js title="index.js"
@@ -2172,9 +2529,12 @@ Start with a fresh app. If you already have one, skip to [Install](#install).
2172
2529
  registerRootComponent(App);
2173
2530
  ```
2174
2531
 
2175
- Delete the existing `babel.config.js` and replace it with `babel.config.cjs`:
2532
+ Keep this import first in the app entry point. It installs the React Native `fetch`, stream, and
2533
+ related web API shims Jazz needs before the RN runtime is loaded.
2534
+
2535
+ Update `babel.config.js` to enable the `import.meta` transform and strip Flow types:
2176
2536
 
2177
- ```js title="babel.config.cjs"
2537
+ ```js title="babel.config.js"
2178
2538
  module.exports = function (api) {
2179
2539
  api.cache(true);
2180
2540
  return {
@@ -2186,23 +2546,27 @@ Start with a fresh app. If you already have one, skip to [Install](#install).
2186
2546
  };
2187
2547
  ```
2188
2548
 
2189
- ```js title="metro.config.js"
2549
+ Replace `metro.config.js` with `metro.config.mjs` so Metro can top-level `await` the Jazz dev server (it injects `EXPO_PUBLIC_JAZZ_*` env vars that Metro inlines into your bundle):
2550
+
2551
+ ```js title="metro.config.mjs"
2190
2552
  import path from "node:path";
2191
2553
  import { fileURLToPath } from "node:url";
2192
2554
  import { createRequire } from "node:module";
2555
+ import { withJazz } from "jazz-tools/dev/expo";
2193
2556
 
2194
2557
  const require = createRequire(import.meta.url);
2195
2558
  const { getDefaultConfig } = require("expo/metro-config");
2196
2559
 
2197
- const __filename = fileURLToPath(import.meta.url);
2198
- const projectRoot = path.dirname(__filename);
2560
+ const projectRoot = path.dirname(fileURLToPath(import.meta.url));
2199
2561
 
2200
2562
  const config = getDefaultConfig(projectRoot);
2201
2563
 
2202
- config.transformer = config.transformer || {};
2203
- config.transformer.extendsBabelConfigPath = path.resolve(projectRoot, "babel.config.cjs");
2564
+ // pnpm uses symlinks for hoisted packages
2204
2565
  config.resolver.unstable_enableSymlinks = true;
2205
2566
 
2567
+ // Start the Jazz dev server and inject EXPO_PUBLIC_JAZZ_* env vars for Metro to inline.
2568
+ await withJazz({}, { schemaDir: projectRoot });
2569
+
2206
2570
  export default config;
2207
2571
  ```
2208
2572
 
@@ -3421,6 +3785,18 @@ const todos = useAll(app.todos);
3421
3785
  tier](#read-durability). Local storage reads are effectively instant and resolve to `[]` if no
3422
3786
  data exists yet.
3423
3787
 
3788
+ ### Fine-grained updates
3789
+
3790
+ Vue's `useAll` and Svelte's `QuerySubscription` 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) and `watch` / template bindings (Vue) only re-fire for components that depend on the fields that actually changed.
3791
+
3792
+ In practice this means:
3793
+
3794
+ - Adding, removing, or reordering rows updates the array structure only — untouched row objects keep their identity, so `{#each items as item (item.id)}` and `` keyed renders are stable.
3795
+ - 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.
3796
+ - Vue's `useAll` returns a deep `Ref` (not a `shallowRef`), so per-field reactivity composes with the rest of your component tree without manual unwrapping.
3797
+
3798
+ React's `useAll` follows React's own re-render model and returns a fresh array each tick; the granularity benefit there comes from React's diffing, not from in-place reconciliation.
3799
+
3424
3800
  ### Conditional queries
3425
3801
 
3426
3802
  Pass `undefined` instead of a query to skip evaluation. This is useful when building dynamic queries.
@@ -3568,407 +3944,226 @@ const filtered = useAll(query);
3568
3944
  }
3569
3945
  ```
3570
3946
 
3571
- ===PAGE:recipes/auth-provider-integration===
3572
- TITLE:"Auth provider integration"
3573
- DESCRIPTION:Connect an external auth provider to Jazz with JWT validation, with examples for Better Auth and WorkOS.
3947
+ ===PAGE:recipes/access-control/group-permissions===
3948
+ TITLE:"Group permissions"
3949
+ DESCRIPTION:Model a workspace with role-based access control using a members table and existence-based permissions.
3574
3950
 
3575
- 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).
3951
+ 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&hairsp;—&hairsp;teams, projects, channels, organisations.
3576
3952
 
3577
- ## How it works
3953
+ ## Roles at a glance
3578
3954
 
3579
- >Auth: Sign in
3580
- Auth-->>Browser: JWT token
3955
+ | Role | Read | Create | Edit own | Edit any | Manage members |
3956
+ | ------------- | ---- | ------ | -------- | -------- | -------------- |
3957
+ | `reader` | ✓ | | | | |
3958
+ | `contributor` | ✓ | ✓ | ✓ | | |
3959
+ | `writer` | ✓ | ✓ | ✓ | ✓ | |
3960
+ | `admin` | ✓ | ✓ | ✓ | ✓ | ✓ |
3581
3961
 
3582
- create participant Jazz as Jazz server
3583
- Browser->>Jazz: Connect with JWT
3584
- Jazz->>Auth: Fetch JWKS
3585
- Auth-->>Jazz: Public keys
3586
- Jazz->>Jazz: Verify JWT
3587
- Jazz-->>Browser: Session ready
3962
+ ## Schema
3588
3963
 
3589
- `} />
3964
+ Three tables: the workspace itself, a members join table that records each user's role, and the documents that belong to the workspace.
3590
3965
 
3591
- 1. The user signs in with your auth provider and gets a JWT.
3592
- 2. Your client passes that JWT to Jazz.
3593
- 3. The Jazz server validates the JWT signature against the provider's JWKS endpoint.
3594
- 4. On success, Jazz uses the JWT's `sub` claim as `session.user_id`.
3966
+ ```ts
3967
+ const schema = {
3968
+ workspaces: s.table({
3969
+ name: s.string(),
3970
+ }),
3971
+ workspaceMembers: s.table({
3972
+ workspaceId: s.ref("workspaces"),
3973
+ user_id: s.string(),
3974
+ role: s.enum(["reader", "writer", "contributor", "admin"]),
3975
+ }),
3976
+ documents: s.table({
3977
+ title: s.string(),
3978
+ content: s.string(),
3979
+ workspaceId: s.ref("workspaces"),
3980
+ }),
3981
+ };
3595
3982
 
3596
- If you're unfamiliar with JWTs and JWKS, see the [explainer on the Authentication page](/docs/auth/authentication#external-auth-for-production).
3983
+ type AppSchema = s.Schema<typeof schema>;
3597
3984
 
3598
- ## Provider setup
3985
+ ```
3599
3986
 
3600
- [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.
3987
+ ## Permissions
3601
3988
 
3602
- ### Server
3989
+ ```ts
3990
+ type Role = "reader" | "writer" | "contributor" | "admin";
3603
3991
 
3604
- ```ts
3992
+ s.definePermissions(app, ({ policy, session, anyOf, allOf }) => {
3993
+ // Re-usable helpers to improve readability
3994
+ const isMember = (workspaceId: string) =>
3995
+ policy.workspaceMembers.exists.where({ workspaceId, user_id: session.user_id });
3605
3996
 
3606
- // your database, email config, etc.
3607
- plugins: [
3608
- jwt({
3609
- jwks: {
3610
- keyPairConfig: { alg: "ES256" },
3611
- },
3612
- jwt: {
3613
- issuer: "https://your-app.example.com",
3614
- definePayload: ({ user }) => ({
3615
- claims: { role: (user as { role?: string }).role ?? "" },
3616
- }),
3617
- },
3618
- }),
3619
- ],
3620
- });
3621
- ```
3997
+ const hasRole = (workspaceId: string, role: Role) =>
3998
+ policy.workspaceMembers.exists.where({ workspaceId, user_id: session.user_id, role });
3622
3999
 
3623
- This exposes a JWKS endpoint at `/api/auth/jwks` (or wherever you mount Better Auth's handler).
4000
+ const isAdmin = (workspaceId: string) => hasRole(workspaceId, "admin");
3624
4001
 
3625
- ### Client
4002
+ // --- documents ---
3626
4003
 
3627
- Create the auth client with the `jwtClient` plugin so you can request JWT tokens.
4004
+ policy.documents.allowRead.where((doc) => isMember(doc.workspaceId));
3628
4005
 
3629
- ```ts
4006
+ policy.documents.allowInsert.where((doc) =>
4007
+ anyOf([
4008
+ hasRole(doc.workspaceId, "writer"),
4009
+ hasRole(doc.workspaceId, "contributor"),
4010
+ hasRole(doc.workspaceId, "admin"),
4011
+ ]),
4012
+ );
3630
4013
 
3631
- plugins: [jwtClient()],
3632
- });
3633
- ```
4014
+ // Writers and admins can edit any document; contributors can only edit their own
4015
+ policy.documents.allowUpdate.where((doc) =>
4016
+ anyOf([
4017
+ hasRole(doc.workspaceId, "writer"),
4018
+ hasRole(doc.workspaceId, "admin"),
4019
+ allOf([{ $createdBy: session.user_id }, hasRole(doc.workspaceId, "contributor")]),
4020
+ ]),
4021
+ );
3634
4022
 
3635
- ### Connecting to Jazz
4023
+ // Writers and admins can delete any document; contributors can delete their own
4024
+ policy.documents.allowDelete.where((doc) =>
4025
+ anyOf([
4026
+ hasRole(doc.workspaceId, "writer"),
4027
+ isAdmin(doc.workspaceId),
4028
+ allOf([{ $createdBy: session.user_id }, hasRole(doc.workspaceId, "contributor")]),
4029
+ ]),
4030
+ );
3636
4031
 
3637
- Get the JWT from Better Auth and pass it to Jazz via the `config` prop.
4032
+ // --- workspaces ---
3638
4033
 
3639
- ```tsx
4034
+ policy.workspaces.allowRead.where((workspace) => isMember(workspace.id));
4035
+ policy.workspaces.allowInsert.always();
4036
+ policy.workspaces.allowUpdate.where((workspace) => isAdmin(workspace.id));
4037
+ policy.workspaces.allowDelete.where((workspace) => isAdmin(workspace.id));
3640
4038
 
3641
- const { data: session, isPending } = authClient.useSession();
3642
- const [token, setToken] = useState<string | undefined>();
4039
+ // --- workspaceMembers ---
3643
4040
 
3644
- useEffect(() => {
3645
- if (isPending || !session?.session) {
3646
- setToken(undefined);
3647
- return;
3648
- }
4041
+ policy.workspaceMembers.allowRead.where((member) => isMember(member.workspaceId));
3649
4042
 
3650
- authClient.token().then((res) => {
3651
- if (res.error) return;
3652
- setToken(res.data.token);
3653
- });
3654
- }, [isPending, session?.session?.id]);
4043
+ // Admins can add members; workspace creators can bootstrap themselves as the first admin
4044
+ policy.workspaceMembers.allowInsert.where((member) =>
4045
+ anyOf([
4046
+ isAdmin(member.workspaceId),
4047
+ allOf([
4048
+ { user_id: session.user_id, role: "admin" },
4049
+ policy.workspaces.exists.where({ id: member.workspaceId, $createdBy: session.user_id }),
4050
+ ]),
4051
+ ]),
4052
+ );
3655
4053
 
3656
- return (
4054
+ policy.workspaceMembers.allowUpdate.where((member) => isAdmin(member.workspaceId));
3657
4055
 
4056
+ // Admins can remove any member; members can leave on their own
4057
+ policy.workspaceMembers.allowDelete.where((member) =>
4058
+ anyOf([isAdmin(member.workspaceId), { user_id: session.user_id }]),
3658
4059
  );
3659
- }
4060
+ });
3660
4061
  ```
3661
4062
 
3662
- ### Jazz server configuration
4063
+ - **Contributor** edit access uses `allOf` to require both `$createdBy: session.user_id` (the row belongs to this user) and a matching contributor membership. Writers and admins bypass the creator check entirely.
4064
+ - **Bootstrap insert**: the second branch of `allowInsert` lets the workspace creator add themselves as the first admin&hairsp;—&hairsp;otherwise `isAdmin` would block everyone, since there are no members yet.
4065
+ - **Leave on your own**: members can delete their own membership row regardless of role. Admins can remove anyone.
3663
4066
 
3664
- Point the Jazz server at your Better Auth JWKS endpoint:
4067
+ See [Permissions](/docs/auth/permissions) for more on `exists.where`, `anyOf`, `allOf`, and `$createdBy`.
3665
4068
 
3666
- ```bash
3667
- pnpm dlx jazz-tools@alpha server --jwks-url https://your-app.example.com/api/auth/jwks
3668
- ```
4069
+ ## Creating a workspace
3669
4070
 
3670
- For a full working example, see the [Better Auth chat example](https://github.com/garden-co/jazz2/tree/main/examples/auth-betterauth-chat).
4071
+ ```ts
3671
4072
 
3672
- 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.
4073
+ const workspace = db.insert(app.workspaces, { name });
4074
+ // Add the creator as admin immediately so they can manage the workspace
4075
+ db.insert(app.workspaceMembers, {
4076
+ workspaceId: workspace.id,
4077
+ user_id: creatorId,
4078
+ role: "admin",
4079
+ });
4080
+ return workspace;
4081
+ }
4082
+ ```
3673
4083
 
3674
- [WorkOS](https://workos.com/) is a managed auth service&hairsp;—&hairsp;no server-side auth code needed. Wrap your app with `AuthKitProvider`, get the access token, and pass it to Jazz.
4084
+ ## Managing members
3675
4085
 
3676
- ```tsx
3677
- function JazzWithWorkOS() {
3678
- const { user, getAccessToken } = useAuth();
3679
- const [token, setToken] = useState<string | undefined>();
4086
+ ### Adding a member
3680
4087
 
3681
- useEffect(() => {
3682
- if (!user) {
3683
- setToken(undefined);
3684
- return;
3685
- }
4088
+ ```ts
3686
4089
 
3687
- getAccessToken().then((accessToken) => {
3688
- setToken(accessToken ?? undefined);
3689
- });
3690
- }, [getAccessToken, user]);
4090
+ db: ReturnType<typeof useDb>,
4091
+ workspaceId: string,
4092
+ userId: string,
4093
+ role: "reader" | "writer" | "contributor" | "admin",
4094
+ ) {
4095
+ db.insert(app.workspaceMembers, { workspaceId, user_id: userId, role });
4096
+ }
4097
+ ```
3691
4098
 
3692
- return (
4099
+ ### Listing members
3693
4100
 
3694
- );
3695
- }
4101
+ ```tsx
3696
4102
 
3697
- return (
4103
+ const members = useAll(app.workspaceMembers.where({ workspaceId }));
3698
4104
 
3699
- );
3700
- }
3701
- ```
3702
-
3703
- ### Jazz server configuration
3704
-
3705
- Point the Jazz server at the WorkOS JWKS endpoint:
3706
-
3707
- ```bash
3708
- pnpm dlx jazz-tools@alpha server --jwks-url https://api.workos.com/sso/jwks/client_01ABC...
3709
- ```
3710
-
3711
- For a full working example, see the [WorkOS chat example](https://github.com/garden-co/jazz2/tree/main/examples/auth-workos-chat).
3712
-
3713
- See [Server setup](/docs/getting-started/server-setup) for the full set of server flags.
3714
-
3715
- ## Using JWT claims in permissions
3716
-
3717
- Your auth provider's JWT may include custom claims (roles, organisation IDs, etc.). Access them in permissions via `session.where(...)`.
3718
-
3719
- ```ts
3720
- s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
3721
- policy.todos.allowRead.where(
3722
- anyOf([{ owner_id: session.user_id }, session.where({ "claims.role": "manager" })]),
3723
- );
3724
- });
3725
- ```
3726
-
3727
- See [Permissions](/docs/auth/permissions) for the full claims API.
3728
-
3729
- ## Other providers
3730
-
3731
- 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.
3732
-
3733
- | Provider | JWKS endpoint | `sub` claim format |
3734
- | ----------- | ------------------------------------------------------------------------------------------- | ------------------ |
3735
- | Better Auth | `<baseURL>/api/auth/jwks` | User ID |
3736
- | WorkOS | `https://api.workos.com/sso/jwks/<clientId>` | `user_<id>` |
3737
- | Clerk | `https://<app>.clerk.accounts.dev/.well-known/jwks.json` | `user_<id>` |
3738
- | Auth0 | `https://<tenant>.auth0.com/.well-known/jwks.json` | `auth0\|<id>` |
3739
- | Firebase | `https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com` | Firebase UID |
3740
-
3741
- Jazz uses the JWT `sub` claim as `session.user_id`. If your provider keeps `sub` fixed to its own user ID, mint the JWT with `sub` set to the Jazz user ID yourself — either via a custom `getSubject` hook on the provider or by issuing the JWT directly from your server.
3742
-
3743
- 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).
3744
-
3745
- ===PAGE:recipes/nested-data===
3746
- TITLE:"Nested data with permission inheritance"
3747
- DESCRIPTION:Model a project/task/comment hierarchy with inherited permissions, queries, and multi-level inserts.
3748
-
3749
- This recipe shows how to model a simple hierarchy, inherit permissions from parent rows, and query/insert at each level.
3750
-
3751
- ## Schema
3752
-
3753
- A simple schema with three tables linked by foreign keys. A project has tasks, and tasks have comments.
3754
-
3755
- ```ts
3756
- const schema = {
3757
- projects: s.table({
3758
- name: s.string(),
3759
- }),
3760
- tasks: s.table({
3761
- title: s.string(),
3762
- done: s.boolean(),
3763
- projectId: s.ref("projects"),
3764
- }),
3765
- comments: s.table({
3766
- body: s.string(),
3767
- taskId: s.ref("tasks"),
3768
- }),
3769
- };
3770
-
3771
- type AppSchema = s.Schema<typeof schema>;
3772
-
3773
- ```
3774
-
3775
- ## Inherited permissions
3776
-
3777
- 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.
3778
-
3779
- ```ts
3780
- s.definePermissions(app, ({ policy, allowedTo, session }) => {
3781
- // Projects: only the creator
3782
- policy.projects.allowRead.where({ $createdBy: session.user_id });
3783
- policy.projects.allowInsert.always();
3784
- policy.projects.allowUpdate.where({ $createdBy: session.user_id });
3785
- policy.projects.allowDelete.where({ $createdBy: session.user_id });
3786
-
3787
- // Tasks: inherit from project
3788
- policy.tasks.allowRead.where(allowedTo.read("projectId"));
3789
- policy.tasks.allowInsert.where(allowedTo.read("projectId"));
3790
- policy.tasks.allowUpdate.where(allowedTo.update("projectId"));
3791
- policy.tasks.allowDelete.where(allowedTo.delete("projectId"));
3792
-
3793
- // Comments: inherit from task
3794
- policy.comments.allowRead.where(allowedTo.read("taskId"));
3795
- policy.comments.allowInsert.where(allowedTo.read("taskId"));
3796
- policy.comments.allowUpdate.where({ $createdBy: session.user_id });
3797
- policy.comments.allowDelete.where({ $createdBy: session.user_id });
3798
- });
3799
- ```
3800
-
3801
- The `allowedTo.read("projectId")` argument is the FK column name. See [Permissions](/docs/auth/permissions) for `maxDepth` and recursive inheritance.
3802
-
3803
- Projects use `$createdBy` instead of an explicit `owner_id` column. Jazz tracks who created each
3804
- row automatically, so you can reference it in permissions without adding a column to your schema.
3805
- Anyone can insert a `project`, and it will automatically be created with the appropriate
3806
- `$createdBy` information.
3807
-
3808
- ## Querying
3809
-
3810
- ```tsx
3811
-
3812
- const tasks = useAll(app.tasks.where({ projectId }).orderBy("$createdAt", "desc"));
3813
-
3814
- if (!tasks) return <p>Loading…</p>;
4105
+ if (!members) return <p>Loading…</p>;
3815
4106
 
3816
4107
  return (
3817
4108
  <ul>
3818
- {tasks.map((task) => (
3819
- <li key={task.id}>{task.title}</li>
4109
+ {members.map((member) => (
4110
+ <li key={member.id}>
4111
+ {member.user_id} — {member.role}
4112
+ </li>
3820
4113
  ))}
3821
4114
  </ul>
3822
4115
  );
3823
4116
  }
3824
4117
  ```
3825
4118
 
3826
- See [Includes and relations](/docs/reading/includes-and-relations) for `include()`, reverse relations, and `select()`.
3827
-
3828
- ## Inserting
3829
-
3830
- Insert from the top down&hairsp;—&hairsp;create the project first, then tasks referencing it.
3831
-
3832
- ```tsx
3833
-
3834
- const db = useDb();
3835
- const session = useSession();
3836
-
3837
- async function handleCreate() {
3838
- const { value: project } = db.insert(app.projects, {
3839
- name: "Website redesign",
3840
- });
3841
-
3842
- db.insert(app.tasks, {
3843
- title: "Design homepage",
3844
- done: false,
3845
- projectId: project.id,
3846
- });
3847
- }
3848
-
3849
- return <button onClick={handleCreate}>New project</button>;
3850
- }
3851
- ```
3852
-
3853
- 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.
3854
-
3855
- ===PAGE:recipes/real-time-collaborative-list===
3856
- TITLE:"Real-time collaborative list"
3857
- DESCRIPTION:Multiple users subscribing to the same data and seeing each other's changes in real-time.
3858
-
3859
- Every client subscribes to the same data and sees changes as they happen. This recipe shows the pattern end-to-end.
3860
-
3861
- ## Schema
3862
-
3863
- A shared project with collaboratively-edited tasks.
4119
+ ### Changing a member's role
3864
4120
 
3865
4121
  ```ts
3866
- const schema = {
3867
- projects: s.table({
3868
- name: s.string(),
3869
- }),
3870
- tasks: s.table({
3871
- title: s.string(),
3872
- done: s.boolean(),
3873
- assignee_id: s.string().optional(),
3874
- projectId: s.ref("projects"),
3875
- }),
3876
- projectMembers: s.table({
3877
- projectId: s.ref("projects"),
3878
- user_id: s.string(),
3879
- }),
3880
- };
3881
-
3882
- type AppSchema = s.Schema<typeof schema>;
3883
4122
 
4123
+ db: ReturnType<typeof useDb>,
4124
+ memberId: string,
4125
+ newRole: "reader" | "contributor" | "writer" | "admin",
4126
+ ) {
4127
+ db.update(app.workspaceMembers, memberId, { role: newRole });
4128
+ }
3884
4129
  ```
3885
4130
 
3886
- ## Permissions
3887
-
3888
- Project members can read and write tasks. The creator can manage membership. Tasks inherit access from their project via `allowedTo`.
4131
+ ### Removing a member
3889
4132
 
3890
4133
  ```ts
3891
- s.definePermissions(app, ({ policy, anyOf, allowedTo, session }) => {
3892
- // Projects: creator and members
3893
- policy.projects.allowRead.where((project) =>
3894
- anyOf([
3895
- { $createdBy: session.user_id },
3896
- policy.projectMembers.exists.where({
3897
- projectId: project.id,
3898
- user_id: session.user_id,
3899
- }),
3900
- ]),
3901
- );
3902
- policy.projects.allowInsert.always();
3903
- policy.projects.allowUpdate.where({ $createdBy: session.user_id });
3904
-
3905
- // Tasks: inherit from project
3906
- policy.tasks.allowRead.where(allowedTo.read("projectId"));
3907
- policy.tasks.allowInsert.where(allowedTo.read("projectId"));
3908
- policy.tasks.allowUpdate.where(allowedTo.read("projectId"));
3909
4134
 
3910
- // Members: only the creator can manage
3911
- policy.projectMembers.allowInsert.where((member) =>
3912
- policy.projects.exists.where({
3913
- id: member.projectId,
3914
- $createdBy: session.user_id,
3915
- }),
3916
- );
3917
- policy.projectMembers.allowRead.where((member) =>
3918
- anyOf([
3919
- policy.projects.exists.where({
3920
- id: member.projectId,
3921
- $createdBy: session.user_id,
3922
- }),
3923
- { user_id: session.user_id },
3924
- ]),
3925
- );
3926
- });
4135
+ db: ReturnType<typeof useDb>,
4136
+ workspaceId: string,
4137
+ userId: string,
4138
+ ) {
4139
+ const member = await db.one(app.workspaceMembers.where({ workspaceId, user_id: userId }));
4140
+ if (member) db.delete(app.workspaceMembers, member.id);
4141
+ }
3927
4142
  ```
3928
4143
 
3929
- ## Subscribing to shared data
3930
-
3931
- When multiple clients subscribe to the same query, they all see each other's changes in real-time.
4144
+ ## Querying documents
3932
4145
 
3933
4146
  ```tsx
3934
4147
 
3935
- const db = useDb();
3936
- const tasks = useAll(app.tasks.where({ projectId, done: false }).orderBy("$createdAt", "desc"));
3937
-
3938
- function addTask(title: string) {
3939
- db.insert(app.tasks, { title, done: false, projectId });
3940
- }
3941
-
3942
- function completeTask(taskId: string) {
3943
- db.update(app.tasks, taskId, { done: true });
3944
- }
4148
+ const docs = useAll(app.documents.where({ workspaceId }));
3945
4149
 
3946
- if (!tasks) return <p>Loading…</p>;
4150
+ if (!docs) return <p>Loading…</p>;
3947
4151
 
3948
4152
  return (
3949
4153
  <ul>
3950
- {tasks.map((task) => (
3951
- <li key={task.id}>
3952
- <button onClick={() => completeTask(task.id)}>Done</button>
3953
- {task.title}
3954
- </li>
4154
+ {docs.map((doc) => (
4155
+ <li key={doc.id}>{doc.title}</li>
3955
4156
  ))}
3956
4157
  </ul>
3957
4158
  );
3958
4159
  }
3959
4160
  ```
3960
4161
 
3961
- When Alice inserts a task it appears in her UI instantly, syncs to the server, and the server pushes it to Bob&hairsp;—&hairsp;whose `useAll` subscription re-renders automatically.
3962
-
3963
- 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`, the last write (by wall-clock time) wins.
3964
-
3965
- For most applications this is the right default. See [How sync works](/docs/concepts/how-sync-works) for more detail.
3966
-
3967
- ===PAGE:recipes/shared-access===
4162
+ ===PAGE:recipes/access-control/shared-access===
3968
4163
  TITLE:"Shared access between users"
3969
4164
  DESCRIPTION:Grant other users access to your data using a shares table and existence-based permissions.
3970
4165
 
3971
- After [user-owned data](/docs/recipes/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.
4166
+ 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.
3972
4167
 
3973
4168
  ## Schema
3974
4169
 
@@ -4089,7 +4284,7 @@ Delete the share row to revoke access. The server will stop syncing the todo to
4089
4284
  }
4090
4285
  ```
4091
4286
 
4092
- ===PAGE:recipes/user-owned-data===
4287
+ ===PAGE:recipes/access-control/user-owned-data===
4093
4288
  TITLE:"User-owned data"
4094
4289
  DESCRIPTION:"End-to-end walkthrough of the most common Jazz pattern: data that belongs to the user who created it."
4095
4290
 
@@ -4097,13 +4292,512 @@ This recipe covers building an app where users own their own data and no-one els
4097
4292
 
4098
4293
  ## Schema
4099
4294
 
4100
- There's no need to add an explicit owner column needed&hairsp;—&hairsp;Jazz tracks who created each row automatically via `$createdBy`.
4295
+ There's no need to add an explicit owner column needed&hairsp;—&hairsp;Jazz tracks who created each row automatically via `$createdBy`.
4296
+
4297
+ ```ts
4298
+ const schema = {
4299
+ todos: s.table({
4300
+ title: s.string(),
4301
+ done: s.boolean(),
4302
+ }),
4303
+ };
4304
+
4305
+ type AppSchema = s.Schema<typeof schema>;
4306
+
4307
+ ```
4308
+
4309
+ See [Defining tables](/docs/schemas/defining-tables) for the full schema DSL.
4310
+
4311
+ ## Permissions
4312
+
4313
+ Match `$createdBy` to the `session.user_id` 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()`.
4314
+
4315
+ ```ts
4316
+ s.definePermissions(app, ({ policy, session }) => {
4317
+ policy.todos.allowRead.where({ $createdBy: session.user_id });
4318
+ policy.todos.allowInsert.always();
4319
+ policy.todos.allowUpdate.where({ $createdBy: session.user_id });
4320
+ policy.todos.allowDelete.where({ $createdBy: session.user_id });
4321
+ });
4322
+ ```
4323
+
4324
+ These rules are enforced on the server. See [Permissions](/docs/auth/permissions) for combinators, `allowedTo`, and more complex options.
4325
+
4326
+ ## Querying
4327
+
4328
+ The table's permissions already scope results to the current user, so queries don't need a separate owner filter.
4329
+
4330
+ ```tsx
4331
+
4332
+ const todos = useAll(app.todos.where({ done: false }));
4333
+
4334
+ if (!todos) return <p>Loading…</p>;
4335
+
4336
+ return (
4337
+ <ul>
4338
+ {todos.map((todo) => (
4339
+ <li key={todo.id}>{todo.title}</li>
4340
+ ))}
4341
+ </ul>
4342
+ );
4343
+ }
4344
+ ```
4345
+
4346
+ See [Queries](/docs/reading/queries) for subscriptions, one-shot queries, and durability tiers.
4347
+
4348
+ ## Inserting
4349
+
4350
+ `$createdBy` is set automatically on insert, so we don't need to set an owner.
4351
+
4352
+ ```tsx
4353
+
4354
+ const db = useDb();
4355
+
4356
+ function handleAdd(title: string) {
4357
+ db.insert(app.todos, { title, done: false });
4358
+ }
4359
+
4360
+ return <button onClick={() => handleAdd("Buy milk")}>Add</button>;
4361
+ }
4362
+ ```
4363
+
4364
+ Use `db.insert(...).wait({ tier: "..." })` if you need confirmation that the write reached a specific [durability tier](/docs/reference/durability-tiers).
4365
+
4366
+ If ownership can be transferred after creation, use an explicit `owner_id` column instead of `$createdBy`.
4367
+
4368
+ ```ts
4369
+ const schemaExplicit = {
4370
+ todos: s.table({
4371
+ title: s.string(),
4372
+ done: s.boolean(),
4373
+ owner_id: s.string(),
4374
+ }),
4375
+ };
4376
+
4377
+ type ExplicitAppSchema = s.Schema<typeof schemaExplicit>;
4378
+
4379
+ ```
4380
+
4381
+ ```ts
4382
+ s.definePermissions(explicitApp, ({ policy, session }) => {
4383
+ policy.todos.allowRead.where({ owner_id: session.user_id });
4384
+ policy.todos.allowInsert.always();
4385
+ policy.todos.allowUpdate.whereOld({ owner_id: session.user_id });
4386
+ policy.todos.allowDelete.where({ owner_id: session.user_id });
4387
+ });
4388
+ ```
4389
+
4390
+ `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.
4391
+
4392
+ `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_id })` so clients can only create rows they own.
4393
+
4394
+ ===PAGE:recipes/auth/auth-provider-integration===
4395
+ TITLE:"Auth provider integration"
4396
+ DESCRIPTION:Connect an external auth provider to Jazz with JWT validation, with examples for Better Auth and WorkOS.
4397
+
4398
+ 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).
4399
+
4400
+ ## How it works
4401
+
4402
+ >Auth: Sign in
4403
+ Auth-->>Browser: JWT token
4404
+
4405
+ create participant Jazz as Jazz server
4406
+ Browser->>Jazz: Connect with JWT
4407
+ Jazz->>Auth: Fetch JWKS
4408
+ Auth-->>Jazz: Public keys
4409
+ Jazz->>Jazz: Verify JWT
4410
+ Jazz-->>Browser: Session ready
4411
+
4412
+ `} />
4413
+
4414
+ 1. The user signs in with your auth provider and gets a JWT.
4415
+ 2. Your client passes that JWT to Jazz.
4416
+ 3. The Jazz server validates the JWT signature against the provider's JWKS endpoint.
4417
+ 4. On success, Jazz uses the JWT's `sub` claim as `session.user_id`.
4418
+
4419
+ If you're unfamiliar with JWTs and JWKS, see the [explainer on the Authentication page](/docs/auth/authentication#external-auth-for-production).
4420
+
4421
+ ## Provider setup
4422
+
4423
+ [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.
4424
+
4425
+ ### Server
4426
+
4427
+ ```ts
4428
+
4429
+ // your database, email config, etc.
4430
+ plugins: [
4431
+ jwt({
4432
+ jwks: {
4433
+ keyPairConfig: { alg: "ES256" },
4434
+ },
4435
+ jwt: {
4436
+ issuer: "https://your-app.example.com",
4437
+ definePayload: ({ user }) => ({
4438
+ claims: { role: (user as { role?: string }).role ?? "" },
4439
+ }),
4440
+ },
4441
+ }),
4442
+ ],
4443
+ });
4444
+ ```
4445
+
4446
+ This exposes a JWKS endpoint at `/api/auth/jwks` (or wherever you mount Better Auth's handler).
4447
+
4448
+ ### Client
4449
+
4450
+ Create the auth client with the `jwtClient` plugin so you can request JWT tokens.
4451
+
4452
+ ```ts
4453
+
4454
+ plugins: [jwtClient()],
4455
+ });
4456
+ ```
4457
+
4458
+ ### Connecting to Jazz
4459
+
4460
+ Get the JWT from Better Auth and pass it to Jazz via the `config` prop.
4461
+
4462
+ ```tsx
4463
+
4464
+ const { data: session, isPending } = authClient.useSession();
4465
+ const [token, setToken] = useState<string | undefined>();
4466
+
4467
+ useEffect(() => {
4468
+ if (isPending || !session?.session) {
4469
+ setToken(undefined);
4470
+ return;
4471
+ }
4472
+
4473
+ authClient.token().then((res) => {
4474
+ if (res.error) return;
4475
+ setToken(res.data.token);
4476
+ });
4477
+ }, [isPending, session?.session?.id]);
4478
+
4479
+ return (
4480
+
4481
+ );
4482
+ }
4483
+ ```
4484
+
4485
+ ### Jazz server configuration
4486
+
4487
+ Point the Jazz server at your Better Auth JWKS endpoint:
4488
+
4489
+ ```bash
4490
+ pnpm dlx jazz-tools@alpha server --jwks-url https://your-app.example.com/api/auth/jwks
4491
+ ```
4492
+
4493
+ For a full working example, see the [Better Auth chat example](https://github.com/garden-co/jazz2/tree/main/examples/auth-betterauth-chat).
4494
+
4495
+ 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.
4496
+
4497
+ [WorkOS](https://workos.com/) is a managed auth service&hairsp;—&hairsp;no server-side auth code needed. Wrap your app with `AuthKitProvider`, get the access token, and pass it to Jazz.
4498
+
4499
+ ```tsx
4500
+ function JazzWithWorkOS() {
4501
+ const { user, getAccessToken } = useAuth();
4502
+ const [token, setToken] = useState<string | undefined>();
4503
+
4504
+ useEffect(() => {
4505
+ if (!user) {
4506
+ setToken(undefined);
4507
+ return;
4508
+ }
4509
+
4510
+ getAccessToken().then((accessToken) => {
4511
+ setToken(accessToken ?? undefined);
4512
+ });
4513
+ }, [getAccessToken, user]);
4514
+
4515
+ return (
4516
+
4517
+ );
4518
+ }
4519
+
4520
+ return (
4521
+
4522
+ );
4523
+ }
4524
+ ```
4525
+
4526
+ ### Jazz server configuration
4527
+
4528
+ Point the Jazz server at the WorkOS JWKS endpoint:
4529
+
4530
+ ```bash
4531
+ pnpm dlx jazz-tools@alpha server --jwks-url https://api.workos.com/sso/jwks/client_01ABC...
4532
+ ```
4533
+
4534
+ For a full working example, see the [WorkOS chat example](https://github.com/garden-co/jazz2/tree/main/examples/auth-workos-chat).
4535
+
4536
+ See [Server setup](/docs/getting-started/server-setup) for the full set of server flags.
4537
+
4538
+ ## Using JWT claims in permissions
4539
+
4540
+ Your auth provider's JWT may include custom claims (roles, organisation IDs, etc.). Access them in permissions via `session.where(...)`.
4541
+
4542
+ ```ts
4543
+ s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
4544
+ policy.todos.allowRead.where(
4545
+ anyOf([{ owner_id: session.user_id }, session.where({ "claims.role": "manager" })]),
4546
+ );
4547
+ });
4548
+ ```
4549
+
4550
+ See [Permissions](/docs/auth/permissions) for the full claims API.
4551
+
4552
+ ## Other providers
4553
+
4554
+ 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.
4555
+
4556
+ | Provider | JWKS endpoint | `sub` claim format |
4557
+ | ----------- | ------------------------------------------------------------------------------------------- | ------------------ |
4558
+ | Better Auth | `<baseURL>/api/auth/jwks` | User ID |
4559
+ | WorkOS | `https://api.workos.com/sso/jwks/<clientId>` | `user_<id>` |
4560
+ | Clerk | `https://<app>.clerk.accounts.dev/.well-known/jwks.json` | `user_<id>` |
4561
+ | Auth0 | `https://<tenant>.auth0.com/.well-known/jwks.json` | `auth0\|<id>` |
4562
+ | Firebase | `https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com` | Firebase UID |
4563
+
4564
+ Jazz uses the JWT `sub` claim as `session.user_id`. If your provider keeps `sub` fixed to its own user ID, mint the JWT with `sub` set to the Jazz user ID yourself — either via a custom `getSubject` hook on the provider or by issuing the JWT directly from your server.
4565
+
4566
+ 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).
4567
+
4568
+ ===PAGE:recipes/auth/better-auth-adapter===
4569
+ TITLE:Better Auth Adapter
4570
+ DESCRIPTION:Use Better Auth with Jazz as the database adapter.
4571
+
4572
+ 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).
4573
+
4574
+ ## Schema Workflow
4575
+
4576
+ 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.
4577
+
4578
+ ```bash
4579
+ # Generate the Better Auth schema module into schema-better-auth/
4580
+ npx @better-auth/cli@latest generate \
4581
+ --config ./src/lib/auth.ts \
4582
+ --output ./schema-better-auth/schema.ts
4583
+
4584
+ # Validate the generated schema source alongside your own
4585
+ pnpm dlx jazz-tools@alpha validate
4586
+ ```
4587
+
4588
+ Import the generated tables in your app's `schema.ts` and merge them into the app definition:
4589
+
4590
+ ```ts title="schema.ts"
4591
+
4592
+ const schema = {
4593
+ ...betterauthSchema,
4594
+ messages: s.table({
4595
+ author_name: s.string(),
4596
+ chat_id: s.string(),
4597
+ text: s.string(),
4598
+ sent_at: s.timestamp(),
4599
+ }),
4600
+ // ...your own tables
4601
+ };
4602
+
4603
+ type AppSchema = s.Schema<typeof schema>;
4604
+
4605
+ ```
4606
+
4607
+ 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 `context.asBackend()`, which authenticates with `backendSecret` and bypasses permission checks entirely.
4608
+
4609
+ ## Database Adapter
4610
+
4611
+ Create a server-side [Jazz context](/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.
4612
+
4613
+ ```ts title="src/lib/auth.ts"
4614
+
4615
+ const jazzContext = createJazzContext({
4616
+ appId: process.env.APP_ID!,
4617
+ driver: { type: "memory" },
4618
+ serverUrl: process.env.SYNC_SERVER_URL!,
4619
+ env: process.env.NODE_ENV === "production" ? "prod" : "dev",
4620
+ userBranch: "main",
4621
+ backendSecret: process.env.BACKEND_SECRET!,
4622
+ });
4623
+
4624
+ database: jazzAdapter({
4625
+ db: () => jazzContext.asBackend(app),
4626
+ schema: app.wasmSchema,
4627
+ }),
4628
+ // ...your Better Auth config
4629
+ });
4630
+ ```
4631
+
4632
+ | Option | Description |
4633
+ | ----------- | ------------------------------------------------------------------------------------------------------ |
4634
+ | `db` | A function returning a `Db` handle via `context.asBackend(app)`. Use the merged app, not `authSchema`. |
4635
+ | `schema` | Typically `app.wasmSchema` from your merged schema. A raw `s.App` is also accepted. |
4636
+ | `debugLogs` | Better Auth adapter debug logging controls. |
4637
+ | `usePlural` | Whether Better Auth model names should use plural table names. |
4638
+ | `prefix` | Table name prefix (defaults to `"better_auth_"`). |
4639
+
4640
+ The Jazz adapter does not support Better Auth experimental joins yet, so do not enable
4641
+ `experimental.joins`.
4642
+
4643
+ ### Publishing to a sync server
4644
+
4645
+ 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:
4646
+
4647
+ ```bash
4648
+ pnpm dlx jazz-tools@alpha deploy <appId>
4649
+ ```
4650
+
4651
+ See [Migrations](/docs/schemas/migrations) for the full deploy flow and how schema changes produce migration edges.
4652
+
4653
+ ## Compatibility
4654
+
4655
+ The adapter is currently aligned with Better Auth `1.5.5`.
4656
+
4657
+ | Plugin/Feature | Compatibility |
4658
+ | --------------------- | :-----------: |
4659
+ | Email & Password auth | ✅ |
4660
+ | Social Provider auth | ✅ |
4661
+ | Email OTP | ✅ |
4662
+
4663
+ This section reflects the adapter behavior currently covered by this repo's Better Auth
4664
+ integration and tests. If you add plugins that introduce extra tables or custom schema fields,
4665
+ regenerate `schema-better-auth/schema.ts` and re-run the Jazz schema workflow.
4666
+
4667
+ ===PAGE:recipes/data-patterns/nested-data===
4668
+ TITLE:"Nested data with permission inheritance"
4669
+ DESCRIPTION:Model a project/task/comment hierarchy with inherited permissions, queries, and multi-level inserts.
4670
+
4671
+ This recipe shows how to model a simple hierarchy, inherit permissions from parent rows, and query/insert at each level.
4672
+
4673
+ ## Schema
4674
+
4675
+ A simple schema with three tables linked by foreign keys. A project has tasks, and tasks have comments.
4676
+
4677
+ ```ts
4678
+ const schema = {
4679
+ projects: s.table({
4680
+ name: s.string(),
4681
+ }),
4682
+ tasks: s.table({
4683
+ title: s.string(),
4684
+ done: s.boolean(),
4685
+ projectId: s.ref("projects"),
4686
+ }),
4687
+ comments: s.table({
4688
+ body: s.string(),
4689
+ taskId: s.ref("tasks"),
4690
+ }),
4691
+ };
4692
+
4693
+ type AppSchema = s.Schema<typeof schema>;
4694
+
4695
+ ```
4696
+
4697
+ ## Inherited permissions
4698
+
4699
+ 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.
4700
+
4701
+ ```ts
4702
+ s.definePermissions(app, ({ policy, allowedTo, session }) => {
4703
+ // Projects: only the creator
4704
+ policy.projects.allowRead.where({ $createdBy: session.user_id });
4705
+ policy.projects.allowInsert.always();
4706
+ policy.projects.allowUpdate.where({ $createdBy: session.user_id });
4707
+ policy.projects.allowDelete.where({ $createdBy: session.user_id });
4708
+
4709
+ // Tasks: inherit from project
4710
+ policy.tasks.allowRead.where(allowedTo.read("projectId"));
4711
+ policy.tasks.allowInsert.where(allowedTo.read("projectId"));
4712
+ policy.tasks.allowUpdate.where(allowedTo.update("projectId"));
4713
+ policy.tasks.allowDelete.where(allowedTo.delete("projectId"));
4714
+
4715
+ // Comments: inherit from task
4716
+ policy.comments.allowRead.where(allowedTo.read("taskId"));
4717
+ policy.comments.allowInsert.where(allowedTo.read("taskId"));
4718
+ policy.comments.allowUpdate.where({ $createdBy: session.user_id });
4719
+ policy.comments.allowDelete.where({ $createdBy: session.user_id });
4720
+ });
4721
+ ```
4722
+
4723
+ The `allowedTo.read("projectId")` argument is the FK column name. See [Permissions](/docs/auth/permissions) for `maxDepth` and recursive inheritance.
4724
+
4725
+ Projects use `$createdBy` instead of an explicit `owner_id` column. Jazz tracks who created each
4726
+ row automatically, so you can reference it in permissions without adding a column to your schema.
4727
+ Anyone can insert a `project`, and it will automatically be created with the appropriate
4728
+ `$createdBy` information.
4729
+
4730
+ ## Querying
4731
+
4732
+ ```tsx
4733
+
4734
+ const tasks = useAll(app.tasks.where({ projectId }).orderBy("$createdAt", "desc"));
4735
+
4736
+ if (!tasks) return <p>Loading…</p>;
4737
+
4738
+ return (
4739
+ <ul>
4740
+ {tasks.map((task) => (
4741
+ <li key={task.id}>{task.title}</li>
4742
+ ))}
4743
+ </ul>
4744
+ );
4745
+ }
4746
+ ```
4747
+
4748
+ See [Includes and relations](/docs/reading/includes-and-relations) for `include()`, reverse relations, and `select()`.
4749
+
4750
+ ## Inserting
4751
+
4752
+ Insert from the top down&hairsp;—&hairsp;create the project first, then tasks referencing it.
4753
+
4754
+ ```tsx
4755
+
4756
+ const db = useDb();
4757
+ const session = useSession();
4758
+
4759
+ async function handleCreate() {
4760
+ const { value: project } = db.insert(app.projects, {
4761
+ name: "Website redesign",
4762
+ });
4763
+
4764
+ db.insert(app.tasks, {
4765
+ title: "Design homepage",
4766
+ done: false,
4767
+ projectId: project.id,
4768
+ });
4769
+ }
4770
+
4771
+ return <button onClick={handleCreate}>New project</button>;
4772
+ }
4773
+ ```
4774
+
4775
+ 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.
4776
+
4777
+ ===PAGE:recipes/data-patterns/real-time-collaborative-list===
4778
+ TITLE:"Real-time collaborative list"
4779
+ DESCRIPTION:Multiple users subscribing to the same data and seeing each other's changes in real-time.
4780
+
4781
+ Every client subscribes to the same data and sees changes as they happen. This recipe shows the pattern end-to-end.
4782
+
4783
+ ## Schema
4784
+
4785
+ A shared project with collaboratively-edited tasks.
4101
4786
 
4102
4787
  ```ts
4103
4788
  const schema = {
4104
- todos: s.table({
4789
+ projects: s.table({
4790
+ name: s.string(),
4791
+ }),
4792
+ tasks: s.table({
4105
4793
  title: s.string(),
4106
4794
  done: s.boolean(),
4795
+ assignee_id: s.string().optional(),
4796
+ projectId: s.ref("projects"),
4797
+ }),
4798
+ projectMembers: s.table({
4799
+ projectId: s.ref("projects"),
4800
+ user_id: s.string(),
4107
4801
  }),
4108
4802
  };
4109
4803
 
@@ -4111,92 +4805,86 @@ type AppSchema = s.Schema<typeof schema>;
4111
4805
 
4112
4806
  ```
4113
4807
 
4114
- See [Defining tables](/docs/schemas/defining-tables) for the full schema DSL.
4115
-
4116
4808
  ## Permissions
4117
4809
 
4118
- Match `$createdBy` to the `session.user_id` 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()`.
4810
+ Project members can read and write tasks. The creator can manage membership. Tasks inherit access from their project via `allowedTo`.
4119
4811
 
4120
4812
  ```ts
4121
- s.definePermissions(app, ({ policy, session }) => {
4122
- policy.todos.allowRead.where({ $createdBy: session.user_id });
4123
- policy.todos.allowInsert.always();
4124
- policy.todos.allowUpdate.where({ $createdBy: session.user_id });
4125
- policy.todos.allowDelete.where({ $createdBy: session.user_id });
4126
- });
4127
- ```
4128
-
4129
- These rules are enforced on the server. See [Permissions](/docs/auth/permissions) for combinators, `allowedTo`, and more complex options.
4130
-
4131
- ## Querying
4132
-
4133
- The table's permissions already scope results to the current user, so queries don't need a separate owner filter.
4134
-
4135
- ```tsx
4136
-
4137
- const todos = useAll(app.todos.where({ done: false }));
4813
+ s.definePermissions(app, ({ policy, anyOf, allowedTo, session }) => {
4814
+ // Projects: creator and members
4815
+ policy.projects.allowRead.where((project) =>
4816
+ anyOf([
4817
+ { $createdBy: session.user_id },
4818
+ policy.projectMembers.exists.where({
4819
+ projectId: project.id,
4820
+ user_id: session.user_id,
4821
+ }),
4822
+ ]),
4823
+ );
4824
+ policy.projects.allowInsert.always();
4825
+ policy.projects.allowUpdate.where({ $createdBy: session.user_id });
4138
4826
 
4139
- if (!todos) return <p>Loading…</p>;
4827
+ // Tasks: inherit from project
4828
+ policy.tasks.allowRead.where(allowedTo.read("projectId"));
4829
+ policy.tasks.allowInsert.where(allowedTo.read("projectId"));
4830
+ policy.tasks.allowUpdate.where(allowedTo.read("projectId"));
4140
4831
 
4141
- return (
4142
- <ul>
4143
- {todos.map((todo) => (
4144
- <li key={todo.id}>{todo.title}</li>
4145
- ))}
4146
- </ul>
4832
+ // Members: only the creator can manage
4833
+ policy.projectMembers.allowInsert.where((member) =>
4834
+ policy.projects.exists.where({
4835
+ id: member.projectId,
4836
+ $createdBy: session.user_id,
4837
+ }),
4147
4838
  );
4148
- }
4839
+ policy.projectMembers.allowRead.where((member) =>
4840
+ anyOf([
4841
+ policy.projects.exists.where({
4842
+ id: member.projectId,
4843
+ $createdBy: session.user_id,
4844
+ }),
4845
+ { user_id: session.user_id },
4846
+ ]),
4847
+ );
4848
+ });
4149
4849
  ```
4150
4850
 
4151
- See [Queries](/docs/reading/queries) for subscriptions, one-shot queries, and durability tiers.
4152
-
4153
- ## Inserting
4851
+ ## Subscribing to shared data
4154
4852
 
4155
- `$createdBy` is set automatically on insert, so we don't need to set an owner.
4853
+ When multiple clients subscribe to the same query, they all see each other's changes in real-time.
4156
4854
 
4157
4855
  ```tsx
4158
4856
 
4159
4857
  const db = useDb();
4858
+ const tasks = useAll(app.tasks.where({ projectId, done: false }).orderBy("$createdAt", "desc"));
4160
4859
 
4161
- function handleAdd(title: string) {
4162
- db.insert(app.todos, { title, done: false });
4860
+ function addTask(title: string) {
4861
+ db.insert(app.tasks, { title, done: false, projectId });
4163
4862
  }
4164
4863
 
4165
- return <button onClick={() => handleAdd("Buy milk")}>Add</button>;
4166
- }
4167
- ```
4168
-
4169
- Use `db.insert(...).wait({ tier: "..." })` if you need confirmation that the write reached a specific [durability tier](/docs/reference/durability-tiers).
4170
-
4171
- If ownership can be transferred after creation, use an explicit `owner_id` column instead of `$createdBy`.
4172
-
4173
- ```ts
4174
- const schemaExplicit = {
4175
- todos: s.table({
4176
- title: s.string(),
4177
- done: s.boolean(),
4178
- owner_id: s.string(),
4179
- }),
4180
- };
4864
+ function completeTask(taskId: string) {
4865
+ db.update(app.tasks, taskId, { done: true });
4866
+ }
4181
4867
 
4182
- type ExplicitAppSchema = s.Schema<typeof schemaExplicit>;
4868
+ if (!tasks) return <p>Loading…</p>;
4183
4869
 
4870
+ return (
4871
+ <ul>
4872
+ {tasks.map((task) => (
4873
+ <li key={task.id}>
4874
+ <button onClick={() => completeTask(task.id)}>Done</button>
4875
+ {task.title}
4876
+ </li>
4877
+ ))}
4878
+ </ul>
4879
+ );
4880
+ }
4184
4881
  ```
4185
4882
 
4186
- ```ts
4187
- s.definePermissions(explicitApp, ({ policy, session }) => {
4188
- policy.todos.allowRead.where({ owner_id: session.user_id });
4189
- policy.todos.allowInsert.where({ owner_id: session.user_id });
4190
- policy.todos.allowUpdate.where({ owner_id: session.user_id });
4191
- policy.todos.allowDelete.where({ owner_id: session.user_id });
4192
- });
4193
- ```
4883
+ When Alice inserts a task it appears in her UI instantly, syncs to the server, and the server pushes it to Bob&hairsp;—&hairsp;whose `useAll` subscription re-renders automatically.
4194
4884
 
4195
- ===PAGE:reference/column-types===
4196
- TITLE:Column Types
4197
- DESCRIPTION:Every column type available in the TypeScript DSL and its SQL equivalent.
4885
+ 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`, the last write (by wall-clock time) wins.
4198
4886
 
4199
- Any column can be made nullable by chaining `.optional()`. For binary data like images and file uploads, use the [Files & Blobs](/docs/writing/files-and-blobs) pattern rather than `s.bytes()` directly.
4887
+ For most applications this is the right default. See [How sync works](/docs/concepts/how-sync-works) for more detail.
4200
4888
 
4201
4889
  ===PAGE:reference/durability-tiers===
4202
4890
  TITLE:Durability Tiers
@@ -4280,6 +4968,64 @@ For most queries and subscriptions, omitting a tier is the right choice: Jazz de
4280
4968
  clients may arrive through edge tiers before being globally available **even if the durability of
4281
4969
  the write is set to 'global'**.
4282
4970
 
4971
+ ===PAGE:reference/examples===
4972
+ TITLE:Examples
4973
+ DESCRIPTION:What each example app in the Jazz monorepo uniquely demonstrates.
4974
+
4975
+ The `examples/` folder in the Jazz monorepo contains runnable apps that each
4976
+ highlight a different facet of Jazz&hairsp;—&hairsp;auth strategies, runtimes,
4977
+ framework bindings, server-side usage, or a specific product pattern. Use this
4978
+ table to jump straight to the example that covers the technique you need.
4979
+
4980
+ 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:
4981
+
4982
+ ```bash
4983
+ npm create jazz
4984
+ ```
4985
+
4986
+ Reach for the `examples/` apps below when you want to see a specific pattern worked through end-to-end.
4987
+
4988
+ ## At a glance
4989
+
4990
+ | Example | Stack | Uniquely demonstrates |
4991
+ | ------------------------------------------------------------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
4992
+ | [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 |
4993
+ | [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 |
4994
+ | [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&hairsp;—&hairsp;sync server points at the WorkOS JWKS, `getAccessToken()` is passed straight to `JazzProvider` |
4995
+ | [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; chunked file attachments |
4996
+ | [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`&hairsp;—&hairsp;no browser asset URLs |
4997
+ | [file-upload-react](https://github.com/garden-co/jazz/tree/main/examples/file-upload-react) | React + Vite | Uploading and rendering images using the `files` / `file_parts` chunked-binary pattern |
4998
+ | [moon-lander-react](https://github.com/garden-co/jazz/tree/main/examples/moon-lander-react) | React + Vite | Multiplayer game state&hairsp;—&hairsp;player positions, fuel deposits, inventory, and chat&hairsp;—&hairsp;synced through Jazz with no custom networking code |
4999
+ | [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` |
5000
+ | [todo-client-localfirst-expo](https://github.com/garden-co/jazz/tree/main/examples/todo-client-localfirst-expo) | Expo (React Native) | The React Native / Expo bindings end-to-end, with native Fjall storage and `ExpoAuthSecretStore` (backed by `expo-secure-store`) for local-first identity |
5001
+ | [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 (anonymous, locally generated secret) auth; `useAll` with composable `where()`, `useDb` writes, OPFS persistence, `attachDevTools` |
5002
+ | [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 |
5003
+ | [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.subscribeAll`, `db.onAuthChanged`, `BrowserAuthSecretStore` |
5004
+ | [todo-server-rs](https://github.com/garden-co/jazz/tree/main/examples/todo-server-rs) | Rust (axum) + `jazz-tools` | Using Jazz directly from Rust as the embedded database for an axum REST + SSE service&hairsp;—&hairsp;no browser, no WASM, no Node bindings |
5005
+ | [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 Fjall storage; `context.forSession(userId)` for per-user policy; SSE live snapshots; `wait({ tier })` durability |
5006
+ | [wequencer](https://github.com/garden-co/jazz/tree/main/examples/wequencer) | React + Vite + Tone.js | Clock-synchronised collaborative playback&hairsp;—&hairsp;`ClockSync` aligns peers to server epoch time, BPM nudged on drift; instrument samples stored as Jazz `files` |
5007
+ | [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) |
5008
+
5009
+ ## How the examples group together
5010
+
5011
+ - **The `todo-client-localfirst-*` family** (`-react`, `-svelte`, `-ts`, `-expo`)
5012
+ all implement the same schema and feature set, so the diff between them is the
5013
+ framework binding. They cover the local-first baseline: anonymous identity,
5014
+ reactive queries, synchronous writes, OPFS persistence, optional server sync.
5015
+ See also [Framework Patterns](/docs/reference/framework-patterns).
5016
+ - **The `auth-*-chat` family** (`auth-betterauth-chat`, `auth-simple-chat`,
5017
+ `auth-workos-chat`) all implement a role-gated chat against three different
5018
+ JWT-issuing auth setups, so the diff between them is the auth integration.
5019
+ - **The server-side examples** (`todo-server-ts`, `todo-server-rs`,
5020
+ `nextjs-csr-ssr`, `cloudflare-worker-runtime-ts`) show Jazz used as the
5021
+ database from a server runtime &mdash; Node, Rust, Next.js App Router, and
5022
+ Cloudflare Workers respectively.
5023
+ - **The product-shaped examples** (`chat-react`, `moon-lander-react`,
5024
+ `wequencer`, `world-tour`, `file-upload-react`) are full apps that lean on
5025
+ Jazz for a specific real-world pattern: real-time chat with invites and
5026
+ canvases, multiplayer game state, clock-synchronised collaborative playback,
5027
+ Vue + maps, and chunked binary uploads.
5028
+
4283
5029
  ===PAGE:reference/framework-patterns===
4284
5030
  TITLE:Framework Patterns
4285
5031
  DESCRIPTION:Side-by-side reference for React/Expo, Vue, and Svelte Jazz APIs.
@@ -4411,6 +5157,8 @@ const todos = useAll(app.todos.where({ done: false }));
4411
5157
  The `?? []` guard handles the `undefined` (not yet connected) case. See
4412
5158
  [Reading Data: The undefined loading state](/docs/reading/queries#the-undefined-loading-state) for patterns that depend on this signal.
4413
5159
 
5160
+ In Vue and Svelte, 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.
5161
+
4414
5162
  ## Accessing the database for writes
4415
5163
 
4416
5164
  Get a handle to the database for inserts, updates, and deletes. See [Writing Data](/docs/writing/writing-data) for the full API.
@@ -4793,6 +5541,97 @@ questions. A query's first callback is held until `QuerySettled` reaches the req
4793
5541
  That means `tier: "global"` gives you a globally settled first snapshot, not globally gated
4794
5542
  delivery forever after.
4795
5543
 
5544
+ ===PAGE:reference/local-first-auth-internals===
5545
+ TITLE:Local-first auth internals
5546
+ DESCRIPTION:"How local-first auth derives a stable account from a secret, how the self-signed JWT is structured, and how the server verifies it without an external key store."
5547
+
5548
+ This page explains how [local-first auth](/docs/auth/local-first-auth) works under the hood. You do
5549
+ not need any of this to use it. Read this if you are debugging, building a custom client, or
5550
+ reasoning about what the design actually guarantees.
5551
+
5552
+ ## The core idea
5553
+
5554
+ An account, in local-first auth, is a secret: a single 32-byte seed.
5555
+
5556
+ There is no registry mapping "user X → public key Y" sitting on a server somewhere. The seed
5557
+ deterministically produces a keypair, and the user ID is a deterministic function of the resulting
5558
+ public key, so holding the secret is the same thing as being the account: it lets you regenerate
5559
+ the keypair on demand and self-certify as that identity. Signing in is reconstructing the keypair
5560
+ from the stored secret; signing up is generating a new one.
5561
+
5562
+ This is why the design doesn't need a server-side user registry: the public key embedded in a token, paired with its signature, is everything the server needs to confirm the claimed account. Verification reuses the JWT-checking path the server already runs for external-auth tokens — only the source of the signing key differs (embedded in the token for local-first, fetched from a JWKS endpoint for external auth).
5563
+
5564
+ ## Identity derivation
5565
+
5566
+ The account is built from a single 32-byte secret the client stores locally.
5567
+
5568
+ 1. The 32-byte **seed** is hashed with SHA-512 to produce an Ed25519 **signing key**.
5569
+ 2. The corresponding **public key** (32 bytes) is extracted from the signing key.
5570
+ 3. A deterministic **user ID** is derived from the public key using UUIDv5 with the namespace
5571
+ `jazz-auth-key-v1`.
5572
+
5573
+ Every step is deterministic, so the same seed always produces the same user ID, on any device, at
5574
+ any time. Backing up the seed is backing up the identity.
5575
+
5576
+ ## The self-signed JWT
5577
+
5578
+ When the client talks to a server, it mints a JWT and signs it with the Ed25519 key derived above.
5579
+ The key claims are:
5580
+
5581
+ | Claim | Value | Purpose |
5582
+ | -------------- | ------------------------ | ---------------------------------------------------- |
5583
+ | `alg` | `"EdDSA"` | Ed25519 signature algorithm |
5584
+ | `iss` | `"urn:jazz:local-first"` | Marks the token as local-first (not provider-issued) |
5585
+ | `sub` | User ID (UUIDv5) | The claimed account ID |
5586
+ | `jazz_pub_key` | Public key (base64url) | Embedded so the server can verify without a lookup |
5587
+ | `aud` | App ID | Scopes the token to a specific app |
5588
+
5589
+ The public key travels inside the JWT and the whole JWT is signed with the matching private key, so the server doesn't need a separate key-distribution step or a shared secret with the client.
5590
+
5591
+ ## Server verification
5592
+
5593
+ When the server receives a local-first JWT (as identified by the `iss` field), it:
5594
+
5595
+ 1. Reads `jazz_pub_key` from the token
5596
+ 2. Uses it to verify the signature
5597
+ 3. Recomputes the UUIDv5 from that public key (same namespace, same algorithm)
5598
+ 4. Confirms the recomputed ID matches `sub`
5599
+
5600
+ A token where `sub` and `jazz_pub_key` disagree is rejected outright, because either would require
5601
+ forging the other. A token where the signature does not verify is rejected because the holder did
5602
+ not prove possession of the private key.
5603
+
5604
+ ## Upgrading to a provider account
5605
+
5606
+ When a local-first account upgrades to a real account via an external provider (BetterAuth,
5607
+ WorkOS, etc.), the user should keep the same Jazz account ID so their existing data carries over
5608
+ unchanged. This works because the account ID is a deterministic function of the keypair: if the
5609
+ client can prove (to the provider) that it holds the key behind account ID X, the provider can
5610
+ safely record X as the Jazz ID of the new user. Any JWT the provider issues afterwards uses X as
5611
+ its subject, and the user's existing data is already owned by X.
5612
+
5613
+ The proof takes the form of a **proof token** — a short-lived JWT signed by the same key the
5614
+ client uses for self-signed auth. Structurally it is the same as a local-first JWT, but scoped to
5615
+ a specific sign-up flow:
5616
+
5617
+ - `aud` is an application-defined string like `"my-app-signup"` (the client and server must agree).
5618
+ - `exp` is short — 60 seconds is usually enough for a sign-up round-trip.
5619
+ - `sub` and `jazz_pub_key` still pin the token to a specific account.
5620
+
5621
+ The provider's endpoint verifies the proof token the same way any Jazz server verifies a
5622
+ local-first JWT, then records the proven Jazz user ID against the newly created provider account.
5623
+
5624
+ >Client: db.getLocalFirstIdentityProof()
5625
+ Client->>Provider: Sign up (email + proofToken)
5626
+ Provider->>Provider: Verify proof, create user with same Jazz ID
5627
+ Provider-->>Client: Session + JWT
5628
+ Note over Client: Same Jazz ID, now with a provider account
5629
+
5630
+ `} />
5631
+
5632
+ See [Signing up with BetterAuth](/docs/auth/local-first-auth#signing-up-with-betterauth) for the
5633
+ practical, BetterAuth-specific flow.
5634
+
4796
5635
  ===PAGE:reference/mcp===
4797
5636
  TITLE:MCP Server
4798
5637
  DESCRIPTION:Give your AI assistant direct access to Jazz documentation via the Model Context Protocol.
@@ -5060,6 +5899,59 @@ All predicates passed to `where(...)` / chained `filter_*` calls are AND-combine
5060
5899
 
5061
5900
  For reactive framework bindings (`useAll` in React/Vue, `QuerySubscription` in Svelte), see [Framework Patterns](/docs/reference/framework-patterns#query-subscriptions).
5062
5901
 
5902
+ ===PAGE:schemas/column-types===
5903
+ TITLE:Column Types
5904
+ DESCRIPTION:Every column type available in the TypeScript DSL and its SQL equivalent.
5905
+
5906
+ Any column can be made nullable by chaining `.optional()`. For binary data like images and file uploads, use the [Files & Blobs](/docs/writing/files-and-blobs) pattern rather than `s.bytes()` directly.
5907
+
5908
+ ## Transformed Columns
5909
+
5910
+ > **Experimental:** Transformed columns are an early TypeScript API and may change before the stable release.
5911
+
5912
+ 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.
5913
+
5914
+ 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.
5915
+
5916
+ ```ts
5917
+
5918
+ type Priority = "low" | "medium" | "high";
5919
+
5920
+ const schema = {
5921
+ tasks: s.table({
5922
+ title: s.string(),
5923
+ priority: s.int().transform({
5924
+ from: (score) => (score >= 8 ? "high" : score >= 4 ? "medium" : "low"),
5925
+ to: (priority) => ({ low: 1, medium: 5, high: 10 })[priority],
5926
+ }),
5927
+ }),
5928
+ };
5929
+ ```
5930
+
5931
+ With this schema, `priority` is stored as an `INTEGER`, but TypeScript treats it as `Priority` when reading and writing rows:
5932
+
5933
+ ```ts
5934
+ db.insert(app.tasks, {
5935
+ title: "Write launch notes",
5936
+ priority: "high",
5937
+ });
5938
+
5939
+ db.update(app.tasks, task.id, {
5940
+ priority: "medium",
5941
+ });
5942
+
5943
+ const task = await db.one(app.tasks.where({ id: task.id }));
5944
+ task?.priority; // "low" | "medium" | "high"
5945
+ ```
5946
+
5947
+ Filters still use the stored column value, because arbitrary transforms cannot be translated into SQL predicates:
5948
+
5949
+ ```ts
5950
+ await db.all(app.tasks.where({ priority: { gte: 8 } }));
5951
+ ```
5952
+
5953
+ Transforms are TypeScript-client behavior. They do not change the generated SQL schema, migrations, permissions, indexes, or values stored on disk.
5954
+
5063
5955
  ===PAGE:schemas/defining-tables===
5064
5956
  TITLE:Defining Tables
5065
5957
  DESCRIPTION:Define tables and relationships in schema.ts using the TypeScript DSL.
@@ -5106,7 +5998,7 @@ Tables are defined in `schema.ts` using the Jazz DSL. Each `s.table(...)` call r
5106
5998
 
5107
5999
  When you change your schema on a shared app, create and push a migration. See [Migrations](/docs/schemas/migrations) for details.
5108
6000
 
5109
- If you need to clear local browser data after a schema change, see [How do I reset browser storage?](/docs/faq#reset-browser-storage).
6001
+ If you need to clear local browser data after a schema change, see [Auth Lifecycle](/docs/auth/lifecycle#storage-reset).
5110
6002
 
5111
6003
  ## Exporting the app
5112
6004
 
@@ -5130,6 +6022,94 @@ Extract precise TypeScript types from any table handle:
5130
6022
  | `s.InsertOf<typeof app.todos>` | The insert shape (no `id`, respects optionals and defaults) |
5131
6023
  | `s.WhereOf<typeof app.todos>` | The `where(...)` input shape for that table |
5132
6024
 
6025
+ ## Very Large Schemas
6026
+
6027
+ For most apps, `s.defineApp(schema)` is the right export: it gives you one typed table handle for
6028
+ each table in the schema, and Jazz uses the same schema for runtime validation, migrations, query
6029
+ planning, and TypeScript inference.
6030
+
6031
+ Very large apps can hit a different tradeoff. The runtime schema may need to contain hundreds of
6032
+ tables, while a given feature area only works with a much smaller subset. Because typed relations
6033
+ and reverse relations are derived from the app schema, asking TypeScript to understand the whole
6034
+ graph can make editor and build performance worse than the code you are writing actually needs.
6035
+
6036
+ Use `s.defineSliceableApp(schema)` when you want one complete runtime schema but smaller typed app
6037
+ surfaces:
6038
+
6039
+ ```ts title="schema.ts"
6040
+
6041
+ const schema = {
6042
+ accounts: s.table({
6043
+ name: s.string(),
6044
+ }),
6045
+ workspaces: s.table({
6046
+ name: s.string(),
6047
+ accountId: s.ref("accounts"),
6048
+ }),
6049
+ catalog_items: s.table({
6050
+ title: s.string(),
6051
+ workspaceId: s.ref("workspaces"),
6052
+ }),
6053
+ orders: s.table({
6054
+ number: s.string(),
6055
+ catalogItemId: s.ref("catalog_items"),
6056
+ buyerId: s.ref("users"),
6057
+ }),
6058
+ shipments: s.table({
6059
+ trackingCode: s.string(),
6060
+ orderId: s.ref("orders"),
6061
+ }),
6062
+ users: s.table({
6063
+ name: s.string(),
6064
+ }),
6065
+ support_tickets: s.table({
6066
+ workspaceId: s.ref("workspaces"),
6067
+ requesterId: s.ref("users"),
6068
+ }),
6069
+ };
6070
+
6071
+ const sliceableApp = s.defineSliceableApp(schema);
6072
+
6073
+ "accounts",
6074
+ "workspaces",
6075
+ "catalog_items",
6076
+ "orders",
6077
+ "shipments",
6078
+ );
6079
+
6080
+ ```
6081
+
6082
+ Each slice returns a normal typed `App` surface for only the selected tables:
6083
+
6084
+ ```ts
6085
+ await db.all(commerceApp.orders.include({ catalogItem: true }));
6086
+ await db.all(commerceApp.catalog_items.include({ ordersViaCatalogItem: true }));
6087
+ ```
6088
+
6089
+ Refs to tables inside the slice become typed relations and includes. Refs to tables outside the slice
6090
+ remain valid scalar ID columns:
6091
+
6092
+ ```ts
6093
+ type Order = s.RowOf<typeof commerceApp.orders>;
6094
+ // Order["catalogItemId"] is string, and commerceApp.orders.include({ catalogItem: true }) is typed.
6095
+ // Order["buyerId"] is string, but there is no typed `buyer` include unless `users` is in the slice.
6096
+ ```
6097
+
6098
+ Reverse relations are also derived only from the current slice. In the example above,
6099
+ `commerceApp.catalog_items` has `ordersViaCatalogItem`, while `supportApp.workspaces` has
6100
+ `support_ticketsViaWorkspace`.
6101
+
6102
+ All slices share the complete runtime schema:
6103
+
6104
+ ```ts
6105
+ commerceApp.wasmSchema === sliceableApp.wasmSchema;
6106
+ supportApp.wasmSchema === sliceableApp.wasmSchema;
6107
+ ```
6108
+
6109
+ That means schema hashing, migrations, permissions, runtime validation, query planning, inserts,
6110
+ updates, and row transforms still see the full schema. The slice only limits the TypeScript app
6111
+ graph you ask the compiler to expand.
6112
+
5133
6113
  ===PAGE:schemas/migrations===
5134
6114
  TITLE:Migrations
5135
6115
  DESCRIPTION:Structural schema evolution workflow and reviewed migration edges.
@@ -5138,7 +6118,23 @@ If you are trying to get your first app running, you can skip this page and retu
5138
6118
 
5139
6119
  ## Why Jazz migrations are different
5140
6120
 
5141
- Traditional migration systems run a linear sequence and require peers to converge before older versions stop writing. Jazz migrations are applied at read/write time, so mixed-version clients can keep operating while data is translated between schema versions.
6121
+ 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&hairsp;—&hairsp;which doesn't hold when your clients are local-first, frequently offline, and updating their app on their own schedule.
6122
+
6123
+ Jazz keeps every schema version addressable by hash 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.
6124
+
6125
+ ## Schemas, lenses and branches
6126
+
6127
+ 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.
6128
+
6129
+ Storage is partitioned by schema hash: rows written under a given schema live in
6130
+ that schema's own branch (`env-{hash}-userBranch`) and stay there unchanged.
6131
+ Non-adjacent reads compose lenses in sequence to bridge multiple schema versions.
6132
+
6133
+ In practice, this lets you:
6134
+
6135
+ - Ship a schema change without waiting for every user to update their app.
6136
+ - Roll out platform-by-platform (mobile, desktop, web) on independent cadences.
6137
+ - Accept writes from clients that have been offline since before the new schema landed.
5142
6138
 
5143
6139
  ## Workflow
5144
6140
 
@@ -5151,8 +6147,9 @@ Traditional migration systems run a linear sequence and require peers to converg
5151
6147
  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.
5152
6148
 
5153
6149
  2. **Edit `schema.ts`**&hairsp;—&hairsp;change the data shape as needed.
5154
- 3. **Validate locally**&hairsp;—&hairsp;optionally run `pnpm dlx jazz-tools@alpha validate` to ensure policies
5155
- are valid before they are deployed. `deploy` does not run these checks.
6150
+ 3. **Validate locally**&hairsp;—&hairsp;optionally run `pnpm dlx jazz-tools@alpha validate` to surface
6151
+ any policy diagnostics without publishing. `deploy` runs the same checks; `validate` is most
6152
+ useful as a fast pre-publish sanity check or in CI.
5156
6153
  4. **Create a migration stub for the updated schema**&hairsp;—&hairsp;run:
5157
6154
 
5158
6155
  ```bash
@@ -5175,25 +6172,29 @@ Traditional migration systems run a linear sequence and require peers to converg
5175
6172
  pnpm dlx jazz-tools@alpha deploy <appId>
5176
6173
  ```
5177
6174
 
5178
- `deploy` publishes the current schema if the server does not already know it, checks whether the
5179
- previous permissions schema is connected to the new one on the server, pushes the local migration
5180
- if needed, and then publishes the current permissions.
6175
+ `deploy` walks through the publish pipeline in one go:
6176
+ 1. Publishes the current schema if the server does not already have it.
6177
+ 2. If your previous `permissions.ts` was tied to an older schema hash, asks the server whether
6178
+ it already has a migration path between the two hashes. If not, pushes the local migration
6179
+ file that closes the gap (and fails with a helpful message if you haven't created one yet).
6180
+ 3. Publishes the current permissions, attached to the current schema hash.
5181
6181
 
5182
- Permission-only changes in `permissions.ts` do not need migrations, but they do still require
6182
+ Permission-only changes in `permissions.ts` don't need a migration but still need to be deployed:
5183
6183
  `pnpm dlx jazz-tools@alpha deploy <appId>`. See [Permissions](/docs/auth/permissions) for details.
5184
6184
 
5185
- ## Generated stub
6185
+ ## The migration file
5186
6186
 
5187
- Under the hood, each migration produces a **lens**&hairsp;—&hairsp;a bidirectional transformation
5188
- between two schema versions. The forward direction applies the migration; the backward direction is
5189
- generated automatically so older clients can still read data written under the new schema. The
5190
- generated stub contains the structural diff as declarative operations.
6187
+ The generated stub describes the diff as declarative operations which carry enough information to run in either direction. That
6188
+ is how older clients can still read data written under a newer schema: the same operations replay
6189
+ in reverse.
5191
6190
 
5192
6191
  If the diff contains ambiguities (e.g. a column was removed and a same-typed column was added,
5193
6192
  which could be a rename), the generated lens is marked as a **draft**. Draft lenses will fail at
5194
6193
  startup if they are in the path to a live schema. You need to review the draft lens and resolve
5195
6194
  the ambiguity before publishing.
5196
6195
 
6196
+ ### Generated stub
6197
+
5197
6198
  Here's a generated stub for adding a `description` column:
5198
6199
 
5199
6200
  ```ts
@@ -5228,7 +6229,7 @@ Here's a generated stub for adding a `description` column:
5228
6229
 
5229
6230
  ```
5230
6231
 
5231
- ## Customising defaults
6232
+ ### Customising defaults
5232
6233
 
5233
6234
  Review generated defaults before you publish. For example, you might replace a nullable default with a domain-specific value:
5234
6235
 
@@ -5266,7 +6267,7 @@ Review generated defaults before you publish. For example, you might replace a n
5266
6267
 
5267
6268
  ```
5268
6269
 
5269
- ## Backwards defaults
6270
+ ### Backwards defaults
5270
6271
 
5271
6272
  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:
5272
6273
 
@@ -5310,13 +6311,13 @@ When a newer schema drops a column that older clients still expect, define a bac
5310
6311
  ## Migrating historical schemas
5311
6312
 
5312
6313
  Jazz does not require you to create a migration for every schema change. You can just use the app and create new data.
5313
- Existing data will still be available, but you will not be able to read it until you create a migration.
6314
+ Existing data will still be stored in the database, but you will not be able to read it until you create a migration.
5314
6315
 
5315
6316
  This is particularly useful when you are iterating on a feature that is not yet ready to be released.
5316
6317
 
5317
6318
  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.
5318
6319
 
5319
- In order to do so, you'll need to **create the migration using explicit to/from schema hashes**:
6320
+ To do so, you'll need to **create the migration using explicit to/from schema hashes**:
5320
6321
 
5321
6322
  ```bash
5322
6323
  pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash>
@@ -5370,7 +6371,7 @@ schema hashes from a server, pass `<appId>` as the leading positional argument.
5370
6371
  ## Next steps
5371
6372
 
5372
6373
  - [Defining Tables](/docs/schemas/defining-tables)&hairsp;—&hairsp;table and column definitions
5373
- - [Column Types](/docs/reference/column-types)&hairsp;—&hairsp;full list of available column types
6374
+ - [Column Types](/docs/schemas/column-types)&hairsp;—&hairsp;full list of available column types
5374
6375
 
5375
6376
  ===PAGE:writing/files-and-blobs===
5376
6377
  TITLE:Files & Blobs
@@ -5812,6 +6813,25 @@ pub async fn write_todo_crud(client: &JazzClient, existing_id: ObjectId) -> jazz
5812
6813
  }
5813
6814
  ```
5814
6815
 
6816
+ ### Upsert with a known ID
6817
+
6818
+ Use `upsert(...)` when your app already knows the row ID and wants to create that row if it does
6819
+ not exist, or update it if it does. Like `insert`, `update`, and `delete`, it applies locally first
6820
+ and returns a write handle that can be awaited for durability.
6821
+
6822
+ ```ts
6823
+ const write = db.upsert(
6824
+ app.todos,
6825
+ {
6826
+ title: "Imported task",
6827
+ done: false,
6828
+ },
6829
+ { id: importedTodoId },
6830
+ );
6831
+
6832
+ await write.wait({ tier: "edge" });
6833
+ ```
6834
+
5815
6835
  ### Partial updates and nullable fields
5816
6836
 
5817
6837
  `update(...)` only modifies the keys you pass.
@@ -5886,7 +6906,7 @@ pub async fn write_todo_with_default_durability(
5886
6906
 
5887
6907
  See [Durability Tiers](/docs/reference/durability-tiers) for the full reference, including read durability, data flow between tiers, and consistency semantics.
5888
6908
 
5889
- Need to clear local data during development? See [How do I reset browser storage?](/docs/faq#reset-browser-storage)
6909
+ Need to clear local data during development? See [Auth Lifecycle](/docs/auth/lifecycle#storage-reset).
5890
6910
 
5891
6911
  `wait({ tier })` resolves when the batch reaches the requested durability tier. If the batch is rejected, it rejects with `PersistedWriteRejectedError` instead of hanging.
5892
6912
 
@@ -5933,10 +6953,7 @@ console.log(result.batchId);
5933
6953
  The changes made as part of the transaction are scoped to it, and will only be
5934
6954
  globally visible once it's committed and accepted by the authority.
5935
6955
 
5936
- `DbTransaction` can read its own staged state through `all(...)` and `one(...)` before commit.
5937
-
5938
- You can also use batches when you want to group several ordinary writes under one batch id, but still want them
5939
- to be locally visible immediately:
6956
+ You can also use batches when you want to group several ordinary writes and commit them together:
5940
6957
 
5941
6958
  ```ts
5942
6959
  const result = db.batch((batch) => {
@@ -5947,8 +6964,10 @@ await result.wait({ tier: "edge" });
5947
6964
  console.log(result.batchId);
5948
6965
  ```
5949
6966
 
6967
+ Transactions and batches can read its own local writes through `all(...)` and `one(...)` before commit.
6968
+
5950
6969
  When using `transaction` and `batch`, changes are automatically committed once the callback finishes running and,
5951
- in the case of transactions, if an error is thrown inside the callback, the whole transaction will be rolled back.
6970
+ if an error is thrown inside the callback, the open transaction or batch is rolled back instead of committed.
5952
6971
 
5953
6972
  Alternatively, you can use `beginTransaction` and `beginBatch` to get a transaction or batch object, respectively.
5954
6973
  This can be useful when making writes across multiple contexts. In this case however, you need to commit or rollback