Zero Custom Mutators provide a powerful mechanism for defining data write operations beyond simple CRUD. They allow you to embed arbitrary code within your write logic, running both client-side for optimistic updates and server-side for authority and complex operations.
Key Concepts:
- Arbitrary Code: Mutators are functions, enabling complex validation, permissions, calling external services (like LLMs or sending emails), calling queue, etc.
- Client-Side Execution: Mutators run immediately on the client for instant UI feedback.
- Server-Side Execution: Mutators are synced and executed on a server endpoint you control, providing authority.
- Server Authority: The server's execution result is definitive. Client-side changes are speculative and reconciled with the server's outcome.
- Sync Integration: Custom mutators fully integrate with Zero's sync engine.
- Client: Runs the client-side implementation of the mutator instantly. Uses
@rocicorp/zero
. - Server: Our Remix backend executes the server-side implementation of the mutator.
zero-cache
: Orchestrates sync, calls the push endpoint, replicates data changes, and sends updates to clients.
Each custom mutator requires two implementations:
-
Client Implementation:
- Written in TypeScript and defined in
app/mutators/shared
- Receives a
Transaction
object (tx
). - Uses ZQL via
tx.query
for reads. - Uses the CRUD-style API via
tx.mutate
for writes. - Runs speculatively for immediate feedback.
// Example Client Mutator async function updateIssue(tx: Transaction, { id, title }: { id: string; title: string }) { // Authentication logic assertIsPartner(authData); // Read existing data for validation const prev = await tx.query.issue.where("id", id).one().run(); // Client-side validation if (!prev.isLegacy && title.length > 100) { throw new Error(`Title is too long`); } // Perform write operation await tx.mutate.issue.update({ id, title }); }
- Written in TypeScript and defined in
-
Server Implementation:
- Runs within your push endpoint against your actual database.
- Zero provides a
ServerTransaction
interface and helpers (PushProcessor
,connectionProvider
) to simplify implementation and potentially reuse client mutator code. - The server's result is authoritative. If it throws an error or modifies data differently, the client will eventually reflect the server's state.
- Defined in
app/mutators/server
// Example Server Mutator (TypeScript using ServerTransaction, reusing client logic) async function updateIssueOnServer(tx: ServerTransaction, args: { id: string; title: string }) { // Optional: Add server-only logic (e.g., complex validation). Slow operations should be executed outside the transaction using `postCommitTasks` (See "Advanced Server Techniques" section) const isSpam = await checkTitle(args.title); if (isSpam) { throw new Error("Title appears to be spam."); } // Delegate core logic to the shared client mutator function // ServerTransaction implements the same interface but executes against Postgres (or other configured DB) await mutators.issue.update(tx, args); // Assuming this is the client mutator function }
-
Define Client Mutators:
- Conventionally defined in
mutators/shared/index
.createMutators
can be extended to support additional mutators. This pattern facilitates passing authentication data for permissions.
// mutators/shared/index.ts import { CustomMutatorDefs } from " rocicorp/zero"; import { schema } from "~/services/zero/schema"; import { assertIsPartner, AuthData } from "../permissions"; // Accept auth data for permissions export function createMutators(authData: Omit<AuthData, "partnerAbility">) { return { issue: { async update(tx, { id, title }: { id: string; title: string }) { assertIsPartner(authData); if (title.length > 100) { throw new Error(`Title is too long`); } await tx.mutate.issue.update({ id, title }); }, // Add other mutators like create, delete, custom actions... // e.g., async launchMissiles(tx, args) => { ... permission check ... } }, // other namespaces... } as const satisfies CustomMutatorDefs<typeof schema>; }
- Conventionally defined in
-
Write Data on the Client:
- Inside a client mutator function, use the
tx.mutate
API. It providesinsert
,update
,upsert
, anddelete
methods for each table defined in your schema.
async function myMutator(tx: Transaction) { // Insert await tx.mutate.issue.insert({ id: "new-id", title: "New Issue" /* ... */ }); // Update await tx.mutate.issue.update({ id: "existing-id", title: "Updated Title" }); // Upsert (Insert or Update) await tx.mutate.issue.upsert({ id: "maybe-id", title: "Upserted Title" /* ... */ }); // Delete await tx.mutate.issue.delete({ id: "to-delete-id" }); }
- Inside a client mutator function, use the
-
Read Data on the Client:
- Inside a client mutator function, use the
tx.query
API with ZQL to read data transactionally.
async function checkAndUpdate(tx, { id, title }: { id: string; title: string }) { const existing = await tx.query.issue.where("id", id).one().run(); if (!existing) { throw new Error("Issue not found"); } // Use 'existing' data in logic... await tx.mutate.issue.update({ id, title }); }
- Inside a client mutator function, use the
-
Invoke Client Mutators:
- Call mutators from your application code using the
zero.mutate
object. - The call returns immediately after the client-side execution finishes.
- You can optionally
await
the.server
property on the return value to wait for server confirmation (or error).
// Fire-and-forget (updates UI instantly) zero.mutate.issue.update({ id: "issue-123", title: "New title" }); // Invoke and wait for server result async function updateIssueAndWait(id: string, title: string) { try { const result = zero.mutate.issue.update({ id, title }); // UI has already updated optimistically here const serverResult = await result.server; // Wait for server confirmation if (serverResult.error) { console.error("Server rejected the mutation:", serverResult.error); // Here, Zero will automatically roll back the optimistic client change } else { console.log("Server successfully applied the mutation."); } } catch (clientError) { // Catch errors thrown by the *client-side* mutator execution console.error("Client-side mutation failed:", clientError); } }
- Call mutators from your application code using the
-
Server-Specific Code:
- Wrapping:
mutators/server/index.ts
imports client mutators and wraps them, adding server-only logic (like audit logs, external API calls after transaction). If no specific server logic is required, client mutators can be used directly. - Conditional Logic: Use
tx.location === 'server'
inside a shared mutator function (less common - prefer separating client and server logic).
import { CustomMutatorDefs } from "@rocicorp/zero"; import { schema } from "~/services/zero/schema"; export function createServerMutators( authData: AuthData, postCommitTasks: PostCommitTask[] ) { const mutators = createMutators(authData); return { ...mutators, // Keep most client mutators // Override client mutators with server-only logic where needed issue: { ...clientMutators.issue, // Keep most issue mutators update: async (tx, args: { id: string; title: string }) => { // Call shared client logic first await mutators.issue.update(tx, args); // Server-only logic: Add audit log (within the same transaction) await tx.mutate.auditLog.insert({ /* ... audit data ... */ }); }, }, } as const satisfies CustomMutatorDefs<typeof schema>; }
- Wrapping:
-
Permissions:
- Implement checks within your mutator functions using the
authData
passed intocreateMutators
. - Query the database using
tx.query
(ZQL) or raw SQL (tx.dbTransaction
) to verify user permissions. - Where possible, define permissions as reusable functions.
- Throw an error if the user is not authorized.
// Inside mutators/permissions/index.ts export async function assertIsPartnerRow( authData: AuthData, query: Query<typeof schema, "collaborations">, id: string ) { const partnerId = invariant( await query.where("id", id).one().run(), `entity ${id} does not exist` ).partnerId; invariant(authData.sub === partnerId, "User does not have permission to view this issue"); } // Inside a mutator in mutators/server updateCollaboration: async ( tx: Transaction, change: UpdateValue<typeof schema.tables.collaborations> ) => { await assertIsPartnerRow(authData, zero.query.collaborations, change.id); await tx.mutate.collaborations.update(change); };
- Implement checks within your mutator functions using the
-
Notifications & Async Work:
- Avoid performing slow, external network calls (email, Slack, etc.) inside the database transaction of the mutator.
- Pattern: Collect async tasks during mutator execution. Execute them after
processor.process
successfully completes and the transaction is committed.
export type PostCommitTask = () => Promise<void>; export function createServerMutators( authData: AuthData, postCommitTasks: PostCommitTask[] ) { const mutators = createMutators(authData); return { ...mutators, issue: { update: async (tx, args) => { await tx.mutate.issue.update(args); // Add async task to the list *without* awaiting it here postCommitTasks.push(async () => { await sendEmailToSubscribers(args.id); }); }, }, }; }