From e73c6a96396f0319bbf45d3dee92a02a0a9ae615 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Wed, 14 Oct 2026 15:50:27 +0000 Subject: [PATCH] docs(mutators): document return values, tx.retryOn() and isolation levels --- contents/docs/mutators.mdx | 129 ++++++++++++++++++++++++++++++++--- contents/docs/server-zql.mdx | 78 +++++++++++++++++++++ 2 files changed, 199 insertions(+), 8 deletions(-) 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.