Skip to content

Instantly share code, notes, and snippets.

@danielcompton
Forked from elledienne/zero.mdc
Created April 7, 2025 00:10
Show Gist options
  • Save danielcompton/731cf02006630397d65be731daea4a85 to your computer and use it in GitHub Desktop.
Save danielcompton/731cf02006630397d65be731daea4a85 to your computer and use it in GitHub Desktop.
Zero Custom Mutators MDC

Instructions for Using Zero Custom Mutators

Overview

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.

Architecture

  1. Client: Runs the client-side implementation of the mutator instantly. Uses @rocicorp/zero.
  2. Server: Our Remix backend executes the server-side implementation of the mutator.
  3. zero-cache: Orchestrates sync, calls the push endpoint, replicates data changes, and sends updates to clients.

Implementing Mutators

Each custom mutator requires two implementations:

  1. 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 });
    }
  2. 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
    }

Using Custom Mutators on the Client

  1. 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>;
    }
  2. Write Data on the Client:

    • Inside a client mutator function, use the tx.mutate API. It provides insert, update, upsert, and delete 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" });
    }
  3. 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 });
    }
  4. 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);
      }
    }

Advanced Server Techniques

  • 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>;
    }
  • Permissions:

    • Implement checks within your mutator functions using the authData passed into createMutators.
    • 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);
    };
  • 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);
            });
          },
        },
      };
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment