Skip to main content
Mutations are how clients modify data in Zero. The server processes mutations in transactions, ensuring data consistency and applying business logic.

Overview

Mutation flow:
1. Client calls tx.mutate.table.insert(data)
2. Zero Cache sends mutation to your API server
3. Your server runs the mutation in a transaction
4. Changes committed to PostgreSQL
5. Zero Cache syncs changes to all clients

Mutation Handler

Implement a mutation endpoint using handleMutateRequest:
import {handleMutateRequest} from '@rocicorp/zero/server';
import {dbProvider} from './db.ts';
import {serverMutators} from './mutators.ts';

app.post('/mutate', async (request, reply) => {
  const response = await handleMutateRequest(
    dbProvider,
    async (transact, mutation) => {
      // Find the mutator by name
      const mutator = serverMutators[mutation.name];
      if (!mutator) {
        throw new Error(`Unknown mutation: ${mutation.name}`);
      }
      
      // Execute in transaction
      return await transact(async (tx, name, args) => {
        await mutator(tx, args);
      });
    },
    request.raw // Pass Request object
  );
  
  return response;
});

Defining Mutators

Basic Mutator

export const serverMutators = {
  createIssue: async (tx, args: {title: string; description: string}) => {
    await tx.mutate.issue.insert({
      id: nanoid(),
      title: args.title,
      description: args.description,
      creatorID: tx.clientID,
      created: Date.now(),
      open: true,
    });
  },
};

Nested Mutators

Organize mutators by namespace:
export const serverMutators = {
  issue: {
    create: async (tx, args: {title: string}) => {
      await tx.mutate.issue.insert({
        id: nanoid(),
        title: args.title,
        creatorID: tx.clientID,
        created: Date.now(),
        open: true,
      });
    },
    
    close: async (tx, args: {id: string}) => {
      await tx.mutate.issue.update({
        id: args.id,
        open: false,
      });
    },
    
    delete: async (tx, args: {id: string}) => {
      // Delete related records first
      const comments = await tx.run(
        zql.comment.where('issueID', args.id)
      );
      for (const comment of comments) {
        await tx.mutate.comment.delete({id: comment.id});
      }
      
      // Delete the issue
      await tx.mutate.issue.delete({id: args.id});
    },
  },
};
Call with dot notation:
// Client
await zero.mutate.issue.create({title: 'New bug'});

Transaction API

The transaction object (tx) provides:

Properties

tx.location      // 'server' | 'client'
tx.reason        // 'authoritative' for server
tx.clientID      // ID of the client making the mutation
tx.mutationID    // Sequential mutation ID

CRUD Operations

// Insert
await tx.mutate.user.insert({
  id: nanoid(),
  name: 'Alice',
  email: '[email protected]',
});

// Update
await tx.mutate.user.update({
  id: userId,
  name: 'Alice Smith',
});

// Upsert (insert or update)
await tx.mutate.user.upsert({
  id: userId,
  name: 'Alice',
  email: '[email protected]',
});

// Delete
await tx.mutate.user.delete({id: userId});

Running Queries

Query data within the transaction:
import {builder as zql} from './schema.ts';

const user = await tx.run(
  zql.user.where('id', tx.clientID).one()
);

const openIssues = await tx.run(
  zql.issue.where('open', true).where('assigneeID', user.id)
);

Raw SQL Access

Access the underlying database transaction:
// postgres.js
const result = await tx.dbTransaction.wrappedTransaction`
  SELECT COUNT(*) FROM issue WHERE open = true
`;

// Drizzle
import {sql} from 'drizzle-orm';
const result = await tx.dbTransaction.wrappedTransaction.execute(
  sql`SELECT COUNT(*) FROM issue WHERE open = true`
);

// Prisma
const count = await tx.dbTransaction.wrappedTransaction.issue.count({
  where: {open: true},
});

Validation

Validate input before mutations:
import {ApplicationError} from '@rocicorp/zero/server';
import {z} from 'zod';

const createIssueSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().max(10000),
});

export const serverMutators = {
  createIssue: async (tx, args: unknown) => {
    // Validate args
    const validated = createIssueSchema.parse(args);
    
    // Check authorization
    const user = await tx.run(
      zql.user.where('id', tx.clientID).one()
    );
    if (!user) {
      throw new ApplicationError('User not found');
    }
    
    // Create issue
    await tx.mutate.issue.insert({
      id: nanoid(),
      title: validated.title,
      description: validated.description,
      creatorID: tx.clientID,
      created: Date.now(),
      open: true,
    });
  },
};

Error Handling

Application Errors

Throw ApplicationError for user-facing errors:
import {ApplicationError} from '@rocicorp/zero/server';

export const serverMutators = {
  assignIssue: async (tx, args: {issueID: string; userID: string}) => {
    const issue = await tx.run(
      zql.issue.where('id', args.issueID).one()
    );
    
    if (!issue) {
      throw new ApplicationError('Issue not found', {
        details: {issueID: args.issueID},
      });
    }
    
    if (!issue.open) {
      throw new ApplicationError('Cannot assign closed issue');
    }
    
    await tx.mutate.issue.update({
      id: args.issueID,
      assigneeID: args.userID,
    });
  },
};
Client receives the error:
try {
  await zero.mutate.assignIssue({issueID, userID});
} catch (error) {
  console.error(error.message); // "Issue not found"
}

Database Errors

Database errors are automatically caught and the transaction is rolled back. Zero Cache will retry transient errors.

Out-of-Order Mutations

Zero ensures mutations are processed in order per client. Out-of-order mutations are rejected with OutOfOrderMutation error.

Advanced Patterns

Side Effects

Perform side effects after successful commit:
export const serverMutators = {
  createIssue: async (tx, args: {title: string}) => {
    const issueID = nanoid();
    
    await tx.mutate.issue.insert({
      id: issueID,
      title: args.title,
      creatorID: tx.clientID,
      created: Date.now(),
      open: true,
    });
    
    // This runs BEFORE commit - DO NOT use for side effects
    // Instead, use a post-commit hook or separate endpoint
  },
};
For side effects, call a separate endpoint after mutation succeeds on the client.

Idempotency

Mutations with the same mutationID are processed only once:
// This mutation runs exactly once even if retried
await zero.mutate.createIssue({title: 'Bug'});

Batch Operations

Process multiple records:
export const serverMutators = {
  bulkAssign: async (tx, args: {issueIDs: string[]; userID: string}) => {
    for (const issueID of args.issueIDs) {
      await tx.mutate.issue.update({
        id: issueID,
        assigneeID: args.userID,
      });
    }
  },
};

Cascading Deletes

export const serverMutators = {
  deleteProject: async (tx, args: {projectID: string}) => {
    // Delete all issues in project
    const issues = await tx.run(
      zql.issue.where('projectID', args.projectID)
    );
    
    for (const issue of issues) {
      // Delete comments for each issue
      const comments = await tx.run(
        zql.comment.where('issueID', issue.id)
      );
      for (const comment of comments) {
        await tx.mutate.comment.delete({id: comment.id});
      }
      
      // Delete issue
      await tx.mutate.issue.delete({id: issue.id});
    }
    
    // Delete project
    await tx.mutate.project.delete({id: args.projectID});
  },
};

Context and Authentication

Access request context in mutators:
app.post('/mutate', async (request, reply) => {
  // Extract user from auth cookie/token
  const session = await verifySession(request.cookies.session);
  
  const response = await handleMutateRequest(
    dbProvider,
    async (transact, mutation) => {
      const mutator = serverMutators[mutation.name];
      
      return await transact(async (tx, name, args) => {
        // Pass session as context
        await mutator(tx, args, {session});
      });
    },
    request.raw
  );
  
  return response;
});
Use in mutator:
type Context = {session: {userID: string; role: string}};

export const serverMutators = {
  createIssue: async (
    tx,
    args: {title: string},
    ctx: Context
  ) => {
    if (ctx.session.role !== 'admin') {
      throw new ApplicationError('Insufficient permissions');
    }
    
    await tx.mutate.issue.insert({
      id: nanoid(),
      title: args.title,
      creatorID: ctx.session.userID,
      created: Date.now(),
      open: true,
    });
  },
};

Testing

Test mutators with a test database:
import {describe, test, expect, beforeEach} from 'vitest';
import {dbProvider} from './db.ts';
import {serverMutators} from './mutators.ts';

describe('createIssue', () => {
  beforeEach(async () => {
    // Clean database
    await dbProvider.transaction(async tx => {
      await tx.dbTransaction.wrappedTransaction`
        TRUNCATE TABLE issue CASCADE
      `;
    });
  });
  
  test('creates issue', async () => {
    await dbProvider.transaction(async tx => {
      await serverMutators.createIssue(tx, {
        title: 'Test issue',
        description: 'Test description',
      });
    });
    
    const issues = await dbProvider.transaction(async tx => {
      return await tx.run(zql.issue);
    });
    
    expect(issues).toHaveLength(1);
    expect(issues[0].title).toBe('Test issue');
  });
});

Performance

Connection Pooling

Use connection pooling for better performance:
import {Pool} from 'pg';

const pool = new Pool({
  connectionString: process.env.ZERO_UPSTREAM_DB,
  max: 20, // Maximum pool size
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

Batch Queries

Fetch related data in one query:
// Instead of N queries
for (const issueID of issueIDs) {
  const issue = await tx.run(zql.issue.where('id', issueID).one());
}

// Do one query
const issues = await tx.run(
  zql.issue.where('id', 'in', issueIDs)
);

Next Steps

Build docs developers (and LLMs) love