diff --git a/contents/docs/mutators.mdx b/contents/docs/mutators.mdx
index 919ea384..08ee27e2 100644
--- a/contents/docs/mutators.mdx
+++ b/contents/docs/mutators.mdx
@@ -864,6 +864,75 @@ If Zero receives HTTP 401 or 403, the client will enter the needs auth state and
If you want a different behavior, it is possible to [implement the mutate endpoint](#custom-mutate-implementation) yourself and handle errors differently.
+### Retrying Mutators
+
+Some errors go away if you try again. For example, at the `repeatable read` or `serializable` [isolation level](/docs/server-zql#isolation-level), Postgres can abort a transaction that conflicts with a concurrent one. The error is a serialization failure, code `40001`.
+
+Call `tx.retryOn()` in a server mutator to have Zero run the mutator again, in a fresh transaction, when it fails with an error you choose:
+
+```ts
+function isSerializationFailure(error: unknown) {
+ return (
+ error instanceof Error &&
+ 'code' in error &&
+ error.code === '40001'
+ )
+}
+
+const updateIssue = defineMutator(
+ z.object({id: z.string(), title: z.string()}),
+ async ({tx, args: {id, title}}) => {
+ if (tx.location === 'server') {
+ tx.retryOn(isSerializationFailure)
+ }
+ await tx.mutate.issue.update({id, title})
+ }
+)
+```
+
+`retryOn()` takes a predicate. It gets the error and the attempt number: `1` when deciding the first re-run, `2` for the second, and so on. Return `true` to run the mutator again. If it returns anything else or throws, Zero records the error and skips the mutation, as with any [other error](#handling-errors).
+
+The predicate sees errors the mutator throws, and also errors Postgres raises at `COMMIT`, which the mutator can't catch. Everything the failed run wrote is rolled back, and the re-run reads fresh data.
+
+The error is passed as it was thrown. node-postgres and postgres.js errors have the Postgres error code in `code`. Other libraries can wrap the error, so check what yours throws.
+
+The predicate can also return a promise. Zero waits for it, so you can use it to back off before the next run:
+
+```ts
+tx.retryOn(async (error, attempt) => {
+ if (!isSerializationFailure(error)) {
+ return false
+ }
+ // Wait 50ms, then 100ms, then 150ms, and so on.
+ await new Promise(resolve =>
+ setTimeout(resolve, attempt * 50)
+ )
+ return true
+})
+```
+
+A few things to know:
+
+- `retryOn()` covers only the current run. The re-run calls it again because it runs the mutator again. Call it before anything that can fail.
+- You can call `retryOn()` more than once. Zero runs the mutator again if any predicate returns `true`.
+- Zero runs a mutator at most 5 times per mutation in each request to your mutate endpoint. If the last run fails, Zero records the error.
+- The predicate runs after the transaction has ended, so it can't use `tx`. Reading or writing through `tx` there throws.
+- Only retry mutators that are safe to run more than once. Work outside the database, such as sending an email, isn't rolled back.
+- `retryOn()` is only available on the server, when `tx.location === 'server'`.
+
+
+ The built-in adapters and `ZQLDatabase` support
+ `retryOn()`. If you pass your own `Database` to
+ `handleMutateRequest`, it must pass the
+ `addRetryPredicate` it receives in
+ `TransactionProviderInput` on to the transaction it
+ creates with `CRUDMutatorFactory.createTransaction()`.
+ Otherwise `tx.retryOn()` throws.
+
+
### Custom Mutate URL
By default, Zero sends mutations to the URL specified in the `ZERO_MUTATE_URL` parameter.
@@ -1064,14 +1133,58 @@ const read2 = await zero.run(
If the client-side mutator fails, `.server` also resolves to an error result. Awaiting `.server` therefore covers both client- and server-side failures.
-
- There is not yet a way to return data from mutators in the
- success case. [Let us know](https://discord.rocicorp.dev/)
- if you need this.
-
+### Returning Data
+
+A mutator can return a JSON value. The caller gets it in the `data` field of the success result.
+
+The client and server runs can return different values. For example, the server can return a value that only the database knows:
+
+```ts
+const createIssue = defineMutator(
+ z.object({id: z.string(), title: z.string()}),
+ async ({tx, args: {id, title}}) => {
+ await tx.mutate.issue.insert({id, title})
+
+ if (tx.location === 'client') {
+ // The client doesn't know the short ID yet.
+ return {shortID: null}
+ }
+
+ // Postgres assigns shortID when the row is inserted.
+ const issue = await tx.run(
+ zql.issue.where('id', id).one()
+ )
+ return {shortID: issue?.shortID ?? null}
+ }
+)
+```
+
+The `data` of the `.client` result is what the client run returned. The `data` of the `.server` result is what the server run returned:
+
+```ts
+const write = zero.mutate(
+ mutators.issue.create({
+ id: crypto.randomUUID(),
+ title: 'New title'
+ })
+)
+
+const serverRes = await write.server
+if (serverRes.type === 'success') {
+ // `data` is a plain JSON value, so check its shape.
+ const {shortID} = z
+ .object({shortID: z.number().nullable()})
+ .parse(serverRes.data)
+ console.log('Short ID:', shortID)
+}
+```
+
+A few things to know:
+
+- `data` is typed as `ReadonlyJSONValue | undefined`, not as the mutator's return type. Check its shape before you use it.
+- If the mutator returns nothing, `data` is `undefined`.
+- The server value only reaches the client if your [mutate handler](#implementing-the-endpoint) returns the mutator's result, as `return mutator.fn({args, tx})` does in the examples above.
+- Zero saves the server's return value in your database along with the mutation's result, so the client still gets it if the response from your mutate endpoint is lost. A mutator that returns nothing doesn't write this.
## Permissions
diff --git a/contents/docs/server-zql.mdx b/contents/docs/server-zql.mdx
index b3be33f8..4931459d 100644
--- a/contents/docs/server-zql.mdx
+++ b/contents/docs/server-zql.mdx
@@ -287,6 +287,84 @@ export const mutators = defineMutators({
+### Isolation Level
+
+By default, each transaction starts with a plain `BEGIN`, so Postgres uses its default isolation level. On a stock Postgres, that is `read committed`.
+
+To use a different level, pass `isolationLevel` in the options argument of the adapter factory. It can be `'read committed'`, `'repeatable read'`, or `'serializable'`:
+
+
+
+```ts
+export const dbProvider = zeroDrizzle(
+ schema,
+ drizzleClient,
+ {
+ isolationLevel: 'serializable'
+ }
+)
+```
+
+```ts
+export const dbProvider = zeroKysely(schema, kysely, {
+ isolationLevel: 'serializable'
+})
+```
+
+```ts
+// Use the Postgres spelling, not Prisma's 'Serializable'.
+export const dbProvider = zeroPrisma(schema, prisma, {
+ isolationLevel: 'serializable'
+})
+```
+
+```ts
+// The third argument is an optional logger.
+export const dbProvider = zeroNodePg(
+ schema,
+ pool,
+ undefined,
+ {
+ isolationLevel: 'serializable'
+ }
+)
+```
+
+```ts
+export const dbProvider = zeroPostgresJS(schema, sql, {
+ isolationLevel: 'serializable'
+})
+```
+
+
+
+Every transaction the adapter opens uses this level, including the ones your mutators run in. The `TransactionOptions` and `IsolationLevel` types are exported from `@rocicorp/zero/server`.
+
+At `repeatable read` and `serializable`, Postgres can abort a transaction that conflicts with a concurrent one. Use [`tx.retryOn()`](/docs/mutators#retrying-mutators) to run the mutator again when that happens.
+
### Custom Database
To implement support for some other Postgres bindings library, you will implement the `DBConnection` interface.