| name | factory-mutators-to-define-mutator |
| description | Migrate from Zero custom mutators (factory pattern) to the defineMutator API. Use when upgrading mutator definitions to the new syntax. |
Migrating to Zero defineMutator API
This skill helps migrate from Zero's custom mutator factory pattern to the new defineMutator API.
Docs for new API: https://zero.rocicorp.dev/docs/mutators.md
The old factory-based API doesn't have public docs anymore, but it looks like this:
export function createMutators(authData: AuthData | undefined) {
return {
cart: {
add: async (
tx: Transaction<Schema>,
{ albumID, addedAt }: { albumID: string; addedAt: number },
) => {
if (!authData) {
throw new Error("Not authenticated");
}
await tx.mutate.cartItem.insert({
userId: authData.sub,
albumId: albumID,
addedAt: tx.location === "client" ? addedAt : Date.now(),
});
},
},
} as const satisfies CustomMutatorDefs;
}
export type Mutators = ReturnType<typeof createMutators>;
Prerequisites
The repo should be using the old factory-based mutators. If it is using the even
older CRUD-based mutators (https://zero.rocicorp.dev/docs/deprecated/crud-mutators.md)
see the crud-mutators-to-define-mutator skill. If it is already using
defineMutator this skill isn't needed at all.
You must have upgraded to the string-based auth API before doing this. See the auth-callback-to-string-auth skill for that.
Overview
This migration requires converting all mutators at once (not incrementally) because the old and new APIs both require passing a mutators param to the Zero constructor, but they have different types.
The type system also changes - you no longer pass a Mutators type parameter to Zero<Schema, Mutators>.
Step 1: Set Up Default Types
Before mutators can use auth data, you must define ZeroContext and tell Zero about it via module augmentation. You also need to register your schema so defineMutator uses the correct types.
Find the existing AuthData type that the code already uses for authenticating the factory mutators. You can trace this from mutators.ts.
With defineMutator this same type is referred to as the Zero Context.
Important: The ctx parameter in mutators is typed as exactly what you define in DefaultTypes.context. If your old code checked if (!authData) to handle not-logged-in users, you must include | undefined in the context type.
If other code still uses the AuthData type (e.g., ad-hoc queries), keep it and alias ZeroContext to it:
export type AuthData = { sub: string };
export type ZeroContext = AuthData | undefined;
declare module "@rocicorp/zero" {
interface DefaultTypes {
schema: typeof schema;
context: ZeroContext;
}
}
If custom mutators are the only thing using auth data, just define ZeroContext directly:
export type ZeroContext = { sub: string } | undefined;
declare module "@rocicorp/zero" {
interface DefaultTypes {
schema: typeof schema;
context: ZeroContext;
}
}
The schema registration is required so that defineMutator uses your schema type. Without it, you'll get type errors when calling zero.mutate() with the new mutators.
Step 2: Convert All Mutators
Convert the entire createMutators factory to defineMutators with defineMutator for each mutator.
Before
import { Transaction, CustomMutatorDefs } from "@rocicorp/zero";
export function createMutators(authData: AuthData | undefined) {
return {
cart: {
add: async (
tx: Transaction<Schema>,
{ albumID, addedAt }: { albumID: string; addedAt: number },
) => {
if (!authData) {
throw new Error("Not authenticated");
}
await tx.mutate.cartItem.insert({
userId: authData.sub,
albumId: albumID,
addedAt: tx.location === "client" ? addedAt : Date.now(),
});
},
remove: async (tx: Transaction<Schema>, albumId: string) => {
if (!authData) {
throw new Error("Not authenticated");
}
},
},
} as const satisfies CustomMutatorDefs;
}
export type Mutators = ReturnType<typeof createMutators>;
After
import { defineMutator, defineMutators } from "@rocicorp/zero";
import { z } from "zod";
export const mutators = defineMutators({
cart: {
add: defineMutator(
z.object({
albumID: z.string(),
addedAt: z.number(),
}),
async ({ tx, args, ctx }) => {
if (!ctx) {
throw new Error("Not authenticated");
}
await tx.mutate.cartItem.insert({
userId: ctx.sub,
albumId: args.albumID,
addedAt: tx.location === "client" ? args.addedAt : Date.now(),
});
},
),
remove: defineMutator(
z.object({ albumId: z.string() }),
async ({ tx, args, ctx }) => {
if (!ctx) {
throw new Error("Not authenticated");
}
},
),
},
});
Key changes:
authData → ctx (passed in the callback object, not via closure)
- Args are now an object with a zod schema, accessed via
args.fieldName
- No more
Mutators type export needed
Step 3: Update ZeroProvider
Pass context and mutators directly to ZeroProvider.
Before
const mutators = useMemo(
() => createMutators(session.data ? { sub: session.data.userID } : undefined),
[session.data?.userID]
);
return (
<ZeroProvider
userID={userId}
auth={auth}
server={serverUrl}
schema={schema}
mutators={mutators}
>
{children}
</ZeroProvider>
);
After
import { mutators } from "zero/mutators";
const context = session.data?.userID
? { sub: session.data.userID }
: undefined;
return (
<ZeroProvider
userID={userId}
auth={auth}
server={serverUrl}
schema={schema}
context={context}
mutators={mutators}
>
{children}
</ZeroProvider>
);
Step 4: Update Call Sites
Change all mutator calls to the new convention.
Before
zero.mutate.cart.add({ albumID: album.id, addedAt: Date.now() });
zero.mutate.cart.remove(albumId);
After
import { mutators } from "zero/mutators";
zero.mutate(mutators.cart.add({ albumID: album.id, addedAt: Date.now() }));
zero.mutate(mutators.cart.remove({ albumId }));
Note: All args are now objects (even single-arg mutators like remove).
Step 5: Remove Mutators Type Parameter
Update all Zero<Schema, Mutators> types to just Zero<Schema>.
Before
import { Zero } from "@rocicorp/zero";
import { Schema } from "zero/schema";
import { Mutators } from "zero/mutators";
function query(zero: Zero<Schema, Mutators>, id: string) {
return zero.query.artist.where("id", id);
}
export interface RouterContext {
zero: Zero<Schema, Mutators>;
}
After
import { Zero } from "@rocicorp/zero";
import { Schema } from "zero/schema";
function query(zero: Zero<Schema>, id: string) {
return zero.query.artist.where("id", id);
}
export interface RouterContext {
zero: Zero<Schema>;
}
Step 6: Update Server Endpoint
Change from PushProcessor to handleMutateRequest with mustGetMutator.
Before
import {
PushProcessor,
ZQLDatabase,
PostgresJSConnection,
} from "@rocicorp/zero/pg";
const processor = new PushProcessor(
new ZQLDatabase(new PostgresJSConnection(postgres(PG_URL)), schema),
);
const mutators = createServerMutators(authData, postCommitTasks);
const response = await processor.process(mutators, queryParams, parsedBody);
After
import { mustGetMutator } from "@rocicorp/zero";
import { handleMutateRequest } from "@rocicorp/zero/server";
import { zeroPostgresJS } from "@rocicorp/zero/server/adapters/postgresjs";
import { mutators } from "zero/mutators";
const dbProvider = zeroPostgresJS(schema, postgres(PG_URL));
const ctx = userID ? { sub: userID } : undefined;
const result = await handleMutateRequest(
dbProvider,
(transact, mutation) => {
const mutator = mustGetMutator(mutators, mutation.name);
return transact((tx, name, args) => mutator.fn({ tx, args, ctx }));
},
request,
"info",
);
Key changes:
zeroPostgresJS(schema, sql) replaces ZQLDatabase + PostgresJSConnection
handleMutateRequest replaces processor.process
ctx is passed explicitly to each mutator via mutator.fn({ tx, args, ctx })
IMPORTANT: Use args from the transact callback parameters (tx, name, args), NOT mutation.args. The mutation.args is the raw unparsed value (an array), while the callback's args is properly parsed as an object by the zod schema. Using mutation.args directly will cause "expected object, received array" validation errors.
Server Mutators with PostCommitTasks
If you have server-specific mutators that use postCommitTasks, you still need a factory, but it returns defineMutators:
import { defineMutator, defineMutators } from "@rocicorp/zero";
import { mutators, sendArgsSchema } from "../shared/mutators";
export type PostCommitTask = () => Promise<void>;
export function createServerMutators(postCommitTasks: PostCommitTask[]) {
return defineMutators(mutators, {
message: {
send: defineMutator(sendArgsSchema, async ({ tx, args, ctx }) => {
await mutators.message.send.fn({
tx,
args: { ...args, timestamp: Date.now() },
ctx,
});
postCommitTasks.push(async () => {
await sendNotification(...);
});
}),
},
});
}
The server endpoint would then use:
const postCommitTasks: PostCommitTask[] = [];
const serverMutators = createServerMutators(postCommitTasks);
const result = await handleMutateRequest(
dbProvider,
(transact, mutation) => {
const mutator = mustGetMutator(serverMutators, mutation.name);
return transact((tx, name, args) => mutator.fn({ tx, args, ctx }));
},
request,
"info",
);
for (const task of postCommitTasks) {
await task();
}
Key Patterns
Calling a Mutator Programmatically
Schema Organization
Define zod schemas at top of file, export them for reuse in server mutators:
export const sendArgsSchema = z.object({
id: z.string(),
text: z.string(),
timestamp: z.number(),
});
export const messageMutators = {
send: defineMutator(sendArgsSchema, async ({ tx, args, ctx }) => {
}),
};
export { sendArgsSchema } from "./messages";
Step 7: Configure ZERO_MUTATE_URL
Add ZERO_MUTATE_URL to your environment to point Zero at your new mutate endpoint:
ZERO_MUTATE_URL=http://localhost:3000/api/zero/mutate
This tells Zero to send mutations to your custom endpoint instead of zero-cache.
Step 8: Test All Mutators
Before cleanup, manually test every mutator in the app to ensure they work correctly:
Only proceed to cleanup once all mutators are verified working.
Cleanup Checklist
After migration: