Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 121 additions & 8 deletions contents/docs/mutators.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'`.

<Note
heading="Custom `Database` implementations"
slug="retry-custom-database"
>
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.
</Note>

### Custom Mutate URL

By default, Zero sends mutations to the URL specified in the `ZERO_MUTATE_URL` parameter.
Expand Down Expand Up @@ -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.

<Note
heading="Returning data from mutators"
slug="returning-data"
>
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.
</Note>
### 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

Expand Down
78 changes: 78 additions & 0 deletions contents/docs/server-zql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,84 @@ export const mutators = defineMutators({

</CodeGroup>

### 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'`:

<CodeGroup
labels={[
{
text: 'Drizzle',
sync: {pgClient: 'drizzle'},
},
{
text: 'Kysely',
sync: {pgClient: 'kysely'},
},
{
text: 'Prisma',
sync: {pgClient: 'prisma'},
},
{
text: 'node-postgres',
sync: {pgClient: 'node-postgres'},
},
{
text: 'postgres.js',
sync: {pgClient: 'postgres-js'},
},
]}
>

```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'
})
```

</CodeGroup>

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.
Expand Down
Loading