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.
- package/bin/docs-index.db +0 -0
- package/bin/docs-index.txt +1653 -634
- package/bin/native/jazz-tools-darwin-arm64 +0 -0
- package/bin/native/jazz-tools-darwin-x64 +0 -0
- package/bin/native/jazz-tools-linux-arm64 +0 -0
- package/bin/native/jazz-tools-linux-x64 +0 -0
- package/dist/better-auth-adapter/index.test.js +5 -5
- package/dist/better-auth-adapter/index.test.js.map +1 -1
- package/dist/cli.js +13 -3
- package/dist/cli.js.map +1 -1
- package/dist/cli.test.js +20 -1
- package/dist/cli.test.js.map +1 -1
- package/dist/dev/dev-server.d.ts +0 -3
- package/dist/dev/dev-server.d.ts.map +1 -1
- package/dist/dev/dev-server.js +0 -3
- package/dist/dev/dev-server.js.map +1 -1
- package/dist/dev/dev-server.test.js +1 -0
- package/dist/dev/dev-server.test.js.map +1 -1
- package/dist/dev/managed-runtime.d.ts.map +1 -1
- package/dist/dev/managed-runtime.js +4 -4
- package/dist/dev/managed-runtime.js.map +1 -1
- package/dist/dev/managed-runtime.test.js +41 -0
- package/dist/dev/managed-runtime.test.js.map +1 -1
- package/dist/dev/sveltekit.d.ts +37 -7
- package/dist/dev/sveltekit.d.ts.map +1 -1
- package/dist/dev/sveltekit.js +103 -77
- package/dist/dev/sveltekit.js.map +1 -1
- package/dist/dev/sveltekit.test.js +38 -18
- package/dist/dev/sveltekit.test.js.map +1 -1
- package/dist/dev/vite.d.ts +0 -3
- package/dist/dev/vite.d.ts.map +1 -1
- package/dist/dev/vite.js.map +1 -1
- package/dist/dev-tools/extension-panel.d.ts.map +1 -1
- package/dist/dev-tools/extension-panel.js +0 -12
- package/dist/dev-tools/extension-panel.js.map +1 -1
- package/dist/mcp/backend-naive.d.ts.map +1 -1
- package/dist/mcp/backend-naive.js +22 -3
- package/dist/mcp/backend-naive.js.map +1 -1
- package/dist/mcp/backend-naive.test.js +9 -6
- package/dist/mcp/backend-naive.test.js.map +1 -1
- package/dist/mcp/backend-sqlite.js +2 -2
- package/dist/mcp/backend-sqlite.js.map +1 -1
- package/dist/mcp/build-index.d.ts +2 -38
- package/dist/mcp/build-index.d.ts.map +1 -1
- package/dist/mcp/build-index.js +5 -145
- package/dist/mcp/build-index.js.map +1 -1
- package/dist/mcp/fallback-no-sqlite.test.d.ts +2 -0
- package/dist/mcp/fallback-no-sqlite.test.d.ts.map +1 -0
- package/dist/mcp/fallback-no-sqlite.test.js +135 -0
- package/dist/mcp/fallback-no-sqlite.test.js.map +1 -0
- package/dist/mcp/parse.d.ts +39 -0
- package/dist/mcp/parse.d.ts.map +1 -0
- package/dist/mcp/parse.js +154 -0
- package/dist/mcp/parse.js.map +1 -0
- package/dist/permissions/index.d.ts +1 -1
- package/dist/permissions/index.d.ts.map +1 -1
- package/dist/permissions/index.js +13 -3
- package/dist/permissions/index.js.map +1 -1
- package/dist/permissions/index.test.js +40 -0
- package/dist/permissions/index.test.js.map +1 -1
- package/dist/react-native/db.test.js +3 -0
- package/dist/react-native/db.test.js.map +1 -1
- package/dist/react-native/jazz-rn-runtime-adapter.d.ts +3 -8
- package/dist/react-native/jazz-rn-runtime-adapter.d.ts.map +1 -1
- package/dist/react-native/jazz-rn-runtime-adapter.js +3 -27
- package/dist/react-native/jazz-rn-runtime-adapter.js.map +1 -1
- package/dist/react-native/jazz-rn-runtime-adapter.test.js +11 -38
- package/dist/react-native/jazz-rn-runtime-adapter.test.js.map +1 -1
- package/dist/runtime/auth-secret-store.d.ts +2 -1
- package/dist/runtime/auth-secret-store.d.ts.map +1 -1
- package/dist/runtime/auth-secret-store.js +16 -7
- package/dist/runtime/auth-secret-store.js.map +1 -1
- package/dist/runtime/auth-secret-store.test.js +14 -0
- package/dist/runtime/auth-secret-store.test.js.map +1 -1
- package/dist/runtime/client-tests/for-request.test.js +2 -89
- package/dist/runtime/client-tests/for-request.test.js.map +1 -1
- package/dist/runtime/client-tests/support.d.ts +1 -2
- package/dist/runtime/client-tests/support.d.ts.map +1 -1
- package/dist/runtime/client-tests/support.js +1 -2
- package/dist/runtime/client-tests/support.js.map +1 -1
- package/dist/runtime/client.d.ts +8 -142
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +19 -569
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/client.mutations.test.js +1 -52
- package/dist/runtime/client.mutations.test.js.map +1 -1
- package/dist/runtime/client.test.js +1 -46
- package/dist/runtime/client.test.js.map +1 -1
- package/dist/runtime/context.d.ts +1 -1
- package/dist/runtime/db-runtime-module.d.ts +0 -2
- package/dist/runtime/db-runtime-module.d.ts.map +1 -1
- package/dist/runtime/db-runtime-module.js.map +1 -1
- package/dist/runtime/db.d.ts +11 -10
- package/dist/runtime/db.d.ts.map +1 -1
- package/dist/runtime/db.js +56 -205
- package/dist/runtime/db.js.map +1 -1
- package/dist/runtime/db.persisted.test.js +0 -1
- package/dist/runtime/db.persisted.test.js.map +1 -1
- package/dist/runtime/db.transaction.test.js +4 -76
- package/dist/runtime/db.transaction.test.js.map +1 -1
- package/dist/runtime/db.transport.test.js +4 -4
- package/dist/runtime/db.transport.test.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/napi.sqlite-compat.test.js +35 -17
- package/dist/runtime/napi.sqlite-compat.test.js.map +1 -1
- package/dist/runtime/permissions.repro.test.js +85 -0
- package/dist/runtime/permissions.repro.test.js.map +1 -1
- package/dist/runtime/sync-telemetry.js +49 -24
- package/dist/runtime/sync-telemetry.js.map +1 -1
- package/dist/runtime/sync-telemetry.test.js +16 -4
- package/dist/runtime/sync-telemetry.test.js.map +1 -1
- package/dist/runtime/wasm-runtime-module.d.ts +1 -1
- package/dist/runtime/wasm-runtime-module.d.ts.map +1 -1
- package/dist/runtime/wasm-runtime-module.js +2 -3
- package/dist/runtime/wasm-runtime-module.js.map +1 -1
- package/dist/runtime/worker-bridge.d.ts +1 -5
- package/dist/runtime/worker-bridge.d.ts.map +1 -1
- package/dist/runtime/worker-bridge.js +0 -16
- package/dist/runtime/worker-bridge.js.map +1 -1
- package/dist/svelte/index.d.ts +1 -0
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +1 -0
- package/dist/svelte/local-first-auth.svelte.d.ts +49 -0
- package/dist/svelte/local-first-auth.svelte.d.ts.map +1 -0
- package/dist/svelte/local-first-auth.svelte.js +115 -0
- package/dist/svelte/local-first-auth.svelte.test.js +292 -0
- package/dist/svelte/rune-patterns.svelte.test.js +12 -13
- package/dist/testing/index.test.js +2 -2
- package/dist/testing/index.test.js.map +1 -1
- package/dist/typed-app.d.ts +3 -3
- package/dist/typed-app.d.ts.map +1 -1
- package/dist/typed-app.js +5 -0
- package/dist/typed-app.js.map +1 -1
- package/dist/vue/index.d.ts +1 -0
- package/dist/vue/index.d.ts.map +1 -1
- package/dist/vue/index.js +1 -0
- package/dist/vue/index.js.map +1 -1
- package/dist/vue/use-local-first-auth.d.ts +52 -0
- package/dist/vue/use-local-first-auth.d.ts.map +1 -0
- package/dist/vue/use-local-first-auth.js +123 -0
- package/dist/vue/use-local-first-auth.js.map +1 -0
- package/dist/vue/use-local-first-auth.test.d.ts +2 -0
- package/dist/vue/use-local-first-auth.test.d.ts.map +1 -0
- package/dist/vue/use-local-first-auth.test.js +237 -0
- package/dist/vue/use-local-first-auth.test.js.map +1 -0
- package/package.json +6 -6
package/bin/docs-index.txt
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
|
270
|
-
| `jazz-tools/passkey-backup` | Browser only | Fast recovery on devices that support passkeys |
|
|
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`
|
|
275
|
-
|
|
276
|
-
Jazz
|
|
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
|
-
```
|
|
503
|
+
```ts
|
|
293
504
|
|
|
294
|
-
isLoading
|
|
295
|
-
recoveryPhrase
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
321
|
-
|
|
322
|
-
const { login } = useLocalFirstAuth();
|
|
561
|
+
```ts
|
|
323
562
|
|
|
324
563
|
return async (userInput: string) => {
|
|
325
564
|
const restoredSecret = RecoveryPhrase.toSecret(userInput);
|
|
326
|
-
await
|
|
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
|
-
|
|
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,
|
|
336
|
-
`
|
|
337
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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 — 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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
-
|
|
1550
|
-
|
|
1551
|
-
{#snippet children()}
|
|
1552
|
-
<h1>Todos</h1>
|
|
1969
|
+
{#snippet children()}
|
|
1970
|
+
<h1>Todos</h1>
|
|
1553
1971
|
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
-
|
|
2027
|
+
#### Browser storage eviction
|
|
1611
2028
|
|
|
1612
|
-
|
|
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 — 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
|
-
|
|
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
|
-
|
|
1620
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
3572
|
-
TITLE:"
|
|
3573
|
-
DESCRIPTION:
|
|
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
|
-
|
|
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 — teams, projects, channels, organisations.
|
|
3576
3952
|
|
|
3577
|
-
##
|
|
3953
|
+
## Roles at a glance
|
|
3578
3954
|
|
|
3579
|
-
|
|
3580
|
-
|
|
3955
|
+
| Role | Read | Create | Edit own | Edit any | Manage members |
|
|
3956
|
+
| ------------- | ---- | ------ | -------- | -------- | -------------- |
|
|
3957
|
+
| `reader` | ✓ | | | | |
|
|
3958
|
+
| `contributor` | ✓ | ✓ | ✓ | | |
|
|
3959
|
+
| `writer` | ✓ | ✓ | ✓ | ✓ | |
|
|
3960
|
+
| `admin` | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
3581
3961
|
|
|
3582
|
-
|
|
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
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
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
|
-
|
|
3983
|
+
type AppSchema = s.Schema<typeof schema>;
|
|
3597
3984
|
|
|
3598
|
-
|
|
3985
|
+
```
|
|
3599
3986
|
|
|
3600
|
-
|
|
3987
|
+
## Permissions
|
|
3601
3988
|
|
|
3602
|
-
|
|
3989
|
+
```ts
|
|
3990
|
+
type Role = "reader" | "writer" | "contributor" | "admin";
|
|
3603
3991
|
|
|
3604
|
-
|
|
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
|
-
|
|
3607
|
-
|
|
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
|
-
|
|
4000
|
+
const isAdmin = (workspaceId: string) => hasRole(workspaceId, "admin");
|
|
3624
4001
|
|
|
3625
|
-
|
|
4002
|
+
// --- documents ---
|
|
3626
4003
|
|
|
3627
|
-
|
|
4004
|
+
policy.documents.allowRead.where((doc) => isMember(doc.workspaceId));
|
|
3628
4005
|
|
|
3629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4032
|
+
// --- workspaces ---
|
|
3638
4033
|
|
|
3639
|
-
|
|
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
|
-
|
|
3642
|
-
const [token, setToken] = useState<string | undefined>();
|
|
4039
|
+
// --- workspaceMembers ---
|
|
3643
4040
|
|
|
3644
|
-
|
|
3645
|
-
if (isPending || !session?.session) {
|
|
3646
|
-
setToken(undefined);
|
|
3647
|
-
return;
|
|
3648
|
-
}
|
|
4041
|
+
policy.workspaceMembers.allowRead.where((member) => isMember(member.workspaceId));
|
|
3649
4042
|
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 — 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
|
-
|
|
4067
|
+
See [Permissions](/docs/auth/permissions) for more on `exists.where`, `anyOf`, `allOf`, and `$createdBy`.
|
|
3665
4068
|
|
|
3666
|
-
|
|
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
|
-
|
|
4071
|
+
```ts
|
|
3671
4072
|
|
|
3672
|
-
|
|
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
|
-
|
|
4084
|
+
## Managing members
|
|
3675
4085
|
|
|
3676
|
-
|
|
3677
|
-
function JazzWithWorkOS() {
|
|
3678
|
-
const { user, getAccessToken } = useAuth();
|
|
3679
|
-
const [token, setToken] = useState<string | undefined>();
|
|
4086
|
+
### Adding a member
|
|
3680
4087
|
|
|
3681
|
-
|
|
3682
|
-
if (!user) {
|
|
3683
|
-
setToken(undefined);
|
|
3684
|
-
return;
|
|
3685
|
-
}
|
|
4088
|
+
```ts
|
|
3686
4089
|
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
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
|
-
|
|
4099
|
+
### Listing members
|
|
3693
4100
|
|
|
3694
|
-
|
|
3695
|
-
}
|
|
4101
|
+
```tsx
|
|
3696
4102
|
|
|
3697
|
-
|
|
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
|
-
{
|
|
3819
|
-
<li key={
|
|
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
|
-
|
|
3827
|
-
|
|
3828
|
-
## Inserting
|
|
3829
|
-
|
|
3830
|
-
Insert from the top down — 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
|
-
|
|
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
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
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
|
-
##
|
|
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
|
|
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 (!
|
|
4150
|
+
if (!docs) return <p>Loading…</p>;
|
|
3947
4151
|
|
|
3948
4152
|
return (
|
|
3949
4153
|
<ul>
|
|
3950
|
-
{
|
|
3951
|
-
<li key={
|
|
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
|
-
|
|
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 — Jazz tracks who created each row automatically via `$createdBy`.
|
|
4295
|
+
There's no need to add an explicit owner column needed — 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 — 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 — 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4123
|
-
policy.
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
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
|
-
|
|
4152
|
-
|
|
4153
|
-
## Inserting
|
|
4851
|
+
## Subscribing to shared data
|
|
4154
4852
|
|
|
4155
|
-
|
|
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
|
|
4162
|
-
db.insert(app.
|
|
4860
|
+
function addTask(title: string) {
|
|
4861
|
+
db.insert(app.tasks, { title, done: false, projectId });
|
|
4163
4862
|
}
|
|
4164
4863
|
|
|
4165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 — whose `useAll` subscription re-renders automatically.
|
|
4194
4884
|
|
|
4195
|
-
|
|
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
|
-
|
|
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 — 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 — 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` — 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 — player positions, fuel deposits, inventory, and chat — 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 — 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 — `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 — 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 [
|
|
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
|
-
|
|
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 — 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`** — change the data shape as needed.
|
|
5154
|
-
3. **Validate locally** — optionally run `pnpm dlx jazz-tools@alpha validate` to
|
|
5155
|
-
|
|
6150
|
+
3. **Validate locally** — 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** — 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`
|
|
5179
|
-
|
|
5180
|
-
|
|
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`
|
|
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
|
-
##
|
|
6185
|
+
## The migration file
|
|
5186
6186
|
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) — table and column definitions
|
|
5373
|
-
- [Column Types](/docs/
|
|
6374
|
+
- [Column Types](/docs/schemas/column-types) — 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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|