Skip to main content
The zero-server package provides APIs for processing mutation requests from Zero clients. Use these functions to handle client mutations in your API server.

handleMutateRequest

Processes a mutation request from a Zero client.
import {handleMutateRequest} from '@rocicorp/zero/server';
import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg';
import {Pool} from 'pg';

const pool = new Pool({connectionString: process.env.ZERO_UPSTREAM_DB});
const zql = zeroNodePg(schema, pool);

app.post('/api/zero/mutate', async (req, res) => {
  const response = await handleMutateRequest(
    zql,
    async (transact, mutation) => {
      const mutator = getMutation(serverMutators, mutation.name);
      return transact(async (tx, name, args) => {
        await mutator(tx, args, {});
      });
    },
    req,
    'info'
  );
  
  res.json(response);
});

Parameters

dbProvider
Database<T>
required
Database provider implementing the Database interface. Use one of the database adapters.
callback
function
required
Callback function that processes each mutation. Receives:
  • transact: Function to execute the mutation in a transaction
  • mutation: The mutation object with name, args, id, and clientID
Must return a MutationResponse.
request
Request | URLSearchParams | Record<string, string>
required
The HTTP request or query parameters containing mutation data.
body
ReadonlyJSONValue
Request body (when using URLSearchParams overload).
logLevel
LogLevel
default:"'info'"
Logging level: 'debug', 'info', 'warn', or 'error'.

Returns

Returns a Promise<PushResponse> containing mutation results or error information.

Overloads

// Request overload
function handleMutateRequest<D extends Database<ExtractTransactionType<D>>>(
  dbProvider: D,
  callback: (transact: TransactFn<D>, mutation: CustomMutation) => Promise<MutationResponse>,
  request: Request,
  logLevel?: LogLevel,
): Promise<PushResponse>;

// Query string overload
function handleMutateRequest<D extends Database<ExtractTransactionType<D>>>(
  dbProvider: D,
  callback: (transact: TransactFn<D>, mutation: CustomMutation) => Promise<MutationResponse>,
  queryString: URLSearchParams | Record<string, string>,
  body: ReadonlyJSONValue,
  logLevel?: LogLevel,
): Promise<PushResponse>;

getMutation

Retrieves a mutator function by name from a mutator registry.
import {getMutation} from '@rocicorp/zero/server';

const mutator = getMutation(serverMutators, 'todo.create');

Parameters

mutators
AnyMutatorRegistry | CustomMutatorDefs<any>
required
Mutator registry or custom mutator definitions.
name
string
required
Mutator name. Supports dot notation (e.g., 'todo.create') and pipe notation (e.g., 'todo|create').

Returns

Returns a CustomMutatorImpl function that can be called with (tx, args, ctx).

Transaction Types

Database

Abstract interface for databases that can execute transactions.
interface Database<T> {
  transaction: <R>(
    callback: (tx: T, transactionHooks: TransactionProviderHooks) => MaybePromise<R>,
    transactionInput?: TransactionProviderInput,
  ) => Promise<R>;
}

TransactionProviderHooks

Hooks provided by the database adapter to manage client mutation tracking.
interface TransactionProviderHooks {
  updateClientMutationID: () => Promise<{lastMutationID: number | bigint}>;
  writeMutationResult: (result: MutationResponse) => Promise<void>;
  deleteMutationResults: (args: CleanupResultsArg) => Promise<void>;
}

TransactionProviderInput

Input provided to transactions for mutation tracking.
interface TransactionProviderInput {
  upstreamSchema: string;
  clientGroupID: string;
  clientID: string;
  mutationID: number;
}

Error Handling

OutOfOrderMutation

Thrown when a client sends a mutation with an unexpected ID.
class OutOfOrderMutation extends Error {
  constructor(
    clientID: string,
    receivedMutationID: number,
    lastMutationID: number | bigint,
  );
}

ApplicationError

Wrap errors to provide structured error information to clients.
import {ApplicationError} from '@rocicorp/zero/server';

throw new ApplicationError('User not found', {userId: args.id});
message
string
required
Error message displayed to the client.
details
ReadonlyJSONValue
Additional error details sent to the client.

Complete Example

import {handleMutateRequest, getMutation, ApplicationError} from '@rocicorp/zero/server';
import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs';
import {defineMutator, defineMutators} from '@rocicorp/zero';
import postgres from 'postgres';
import {z} from 'zod/mini';
import express from 'express';

const sql = postgres(process.env.ZERO_UPSTREAM_DB!);
const zql = zeroPostgresJS(schema, sql);

const serverMutators = defineMutators({
  todo: {
    create: defineMutator(
      z.object({id: z.string(), text: z.string()}),
      async ({tx, args}) => {
        if (tx.location !== 'server') {
          throw new Error('Server-only mutator');
        }
        
        // Validate text length
        if (args.text.length > 1000) {
          throw new ApplicationError('Text too long', {
            maxLength: 1000,
            actualLength: args.text.length,
          });
        }
        
        await tx.dbTransaction.wrappedTransaction`
          INSERT INTO todo (id, text, complete)
          VALUES (${args.id}, ${args.text}, false)
        `;
      },
    ),
  },
});

const app = express();
app.use(express.json());

app.post('/api/zero/mutate', async (req, res) => {
  const response = await handleMutateRequest(
    zql,
    async (transact, mutation) => {
      const mutator = getMutation(serverMutators, mutation.name);
      return transact(async (tx, name, args) => {
        await mutator(tx, args, {});
      });
    },
    req,
    'info'
  );
  
  res.json(response);
});

app.listen(3000);

Best Practices

  1. Validate inputs: Always validate mutation arguments before executing
  2. Use ApplicationError: Throw ApplicationError for user-facing errors
  3. Check transaction location: Verify tx.location === 'server' for server-only mutations
  4. Handle retries: ApplicationError instances are retried automatically
  5. Set appropriate log levels: Use 'debug' in development, 'info' in production

Build docs developers (and LLMs) love