| name | define-query-migration |
| description | Migrate from Zero ad-hoc queries to the defineQuery API. Use when upgrading Zero queries to the new custom query pattern. |
Migrating to Zero Custom Queries (defineQuery API)
This skill helps migrate from Zero's deprecated ad-hoc queries (zero.query.*) to the new defineQuery API.
Prerequisites:
-
You should have already adopted the new auth API. The parameter to auth in the Zero constructor or ZeroProvider should be a string, not a function. If this is not the case, see the auth-callback-to-string-auth.
-
Not required, but it's usually easiest to upgrade to defineMutators first. You should see defineMutators in this codebase. If not, see factory-mutators-to-define-mutators.
Docs:
CRITICAL: All Query Inputs Are Untrusted
Every query must have permission checks applied. Query arguments (IDs, filters, etc.) come from clients and are untrusted - a malicious user could pass any IDs to try to access data they shouldn't see.
Rules:
- Root table must always have permission check - use
allowedFoos(ctx) as your starting point
- Never trust caller-provided IDs - even if the UI "should" only pass valid IDs, enforce permissions in the query
- For tables without direct partnerId/creatorId - use
whereExists to verify via parent relationship:
export function allowedCampaignBriefs(ctx: ZeroContext) {
return zql.campaignBrief.whereExists("campaign", (q) =>
q.where((eb) => allowIfPartnerRow(ctx, eb))
);
}
byCampaignIds: defineQuery(
z.object({ campaignIds: z.array(z.string()) }),
({ ctx, args: { campaignIds } }) =>
allowedCampaignBriefs(ctx).where(({ cmp }) => cmp("campaignId", "IN", campaignIds))
),
- Related tables can skip redundant checks - if the parent permission already ensures access (e.g., comments on a document you can see), the child doesn't need its own check
Step 1: Set Up Query Infrastructure
Create the query equivalent of your existing mutator infrastructure:
1a. Create query definitions file
Analogous to your mutators.ts, create a queries.ts (or app/queries/shared/index.ts):
import { defineQueries, defineQuery } from "@rocicorp/zero";
import z from "zod";
export const conversationQueries = {
byId: defineQuery(z.object({ id: z.string() }), ({ ctx, args: { id } }) =>
allowedConversations(ctx).where("id", id).one(),
),
all: defineQuery(({ ctx }) => allowedConversations(ctx)),
};
export const messageQueries = {
byConversationId: defineQuery(
z.object({ conversationId: z.string() }),
({ ctx, args: { conversationId } }) =>
allowedMessages(ctx).where("conversationId", conversationId),
),
};
export const queries = defineQueries({
conversation: conversationQueries,
message: messageQueries,
});
Grouping queries by domain (conversation, message, campaign, etc.) and combining them with defineQueries() isn't required, but it creates a nice organized layout. You get queries.conversation.byId(), queries.message.byConversationId(), etc.
1b. Create query endpoint
Analogous to your push or mutate endpoint, create a /api/zero/query endpoint:
import { ActionFunctionArgs } from "@remix-run/node";
import { mustGetQuery } from "@rocicorp/zero";
import { handleQueryRequest } from "@rocicorp/zero/server";
import { queries } from "~/queries/shared";
import { schema } from "~/services/zero/schema";
export const action = async function zeroQueryHandler(
args: ActionFunctionArgs,
) {
const authData = await getYourAuthData(args);
const result = await handleQueryRequest(
(name, queryArgs) => {
const query = mustGetQuery(queries, name);
return query.fn({ args: queryArgs, ctx: authData });
},
schema,
args.request,
);
return Response.json(result);
};
1c. Configure URLs
Add ZERO_QUERY_URL to your environment (for zero-cache):
ZERO_QUERY_URL="http://localhost:3000/api/zero/query"
If you have a custom mutateURL in your Zero client config, you'll likely need a custom queryURL too (same pattern, different endpoint).
Step 2: Create Shared Permission Helpers
Create a permissions.ts file with reusable helpers for common permission patterns in your schema:
import { ExpressionBuilder } from "@rocicorp/zero";
import { ZeroContext } from "~/mutators/permissions";
import { Schema } from "~/services/zero/schema";
export type PartnerOwnedTable =
| "collaboration"
| "conversation"
| "campaign"
| "invoice";
export type CreatorOwnedTable = "conversation";
export function allowIfPartnerRow(
ctx: ZeroContext,
{ or, cmp }: ExpressionBuilder<PartnerOwnedTable, Schema>,
) {
if (!("partnerId" in ctx)) {
return or();
}
return cmp("partnerId", "=", ctx.partnerId);
}
export function allowIfCreatorRow(
ctx: ZeroContext,
{ or, cmp }: ExpressionBuilder<CreatorOwnedTable, Schema>,
) {
if (!("creatorId" in ctx)) {
return or();
}
return cmp("creatorId", ctx.creatorId);
}
The union types ensure these helpers work across all tables with that column. Add new tables to the union as needed.
Step 3: Create "allowed" Factory Functions
For each entity, create a factory function that returns a query with permissions pre-applied. Use plural naming since they return multiple rows:
import { zql } from "~/services/zero/schema";
import { allowIfPartnerRow, allowIfCreatorRow } from "./permissions";
export function allowedConversations(ctx: ZeroContext) {
return zql.conversation.where((eb) =>
eb.or(allowIfPartnerRow(ctx, eb), allowIfCreatorRow(ctx, eb)),
);
}
export function allowedCampaigns(ctx: ZeroContext) {
return zql.campaign.where((eb) => allowIfPartnerRow(ctx, eb));
}
export function allowedMessages(ctx: ZeroContext) {
return zql.message.where(({ exists }) =>
exists("conversation", (q) =>
q.where((eb) =>
eb.or(allowIfPartnerRow(ctx, eb), allowIfCreatorRow(ctx, eb)),
),
),
);
}
This pattern:
- Makes permissions impossible to forget (they're the starting point)
- Reads naturally:
allowedConversations(ctx).where(...)
- Is shorter than
zql.conversation.where(allowedConversation(ctx)).where(...)
Step 4: Migrate Queries Page by Page
Pick a component or page and migrate its queries one at a time.
4a. Identify the query
Find a useQuery() call using the old pattern:
const [data] = useQuery(zero.query.conversation.where("partnerId", id).one());
4b. Define the query with defineQuery
export const conversationQueries = {
byPartnerId: defineQuery(
z.object({ partnerId: z.string() }),
({ ctx, args: { partnerId } }) =>
allowedConversations(ctx).where("partnerId", partnerId).one(),
),
};
Key points:
- Zod schema defines the args the query accepts (omit for no-args queries)
ctx contains auth context (userId, partnerId, etc.) - use this for permissions
args contains the validated arguments from the schema
4c. Update the component
import { queries } from "~/queries/shared";
const [data] = useQuery(queries.conversation.byPartnerId({ partnerId: id }));
Important: Don't Over-Apply Permissions
Do not put permissions on every table. This is expensive and often unnecessary for child/related queries.
Example: If you're fetching documents with related comments, and your system says "if you can see the doc, you can see its comments" - then only put permissions on the document query, not the comments:
allowedDocuments(ctx)
.where("id", id)
.related("comments", (q) => q.orderBy("createdAt", "asc"));
allowedDocuments(ctx)
.where("id", id)
.related("comments", (q) =>
q
.where(allowedComments(ctx))
.orderBy("createdAt", "asc"),
);
Permissions in Related Queries
When a related table has independent permissions (not inherited from parent), you need to filter within the .related() callback. Use the allowIf* condition helper - NOT the allowed* factory:
.related('cartItems', q => q.where(eb => allowIfCartOwner(ctx, eb)))
.related('cartItems', () => allowedCartItems(ctx))
.related('cartItems', q => ctx ? q.where('userId', ctx.sub) : q.where(() => false))
The allowIf* condition helpers are the reusable building blocks that work everywhere:
- In standalone queries via factory:
allowedCartItems(ctx) which wraps zql.cartItem.where(eb => allowIfCartOwner(ctx, eb))
- In
.related() callbacks: q.where(eb => allowIfCartOwner(ctx, eb))
Type Helpers
Use QueryRowType to extract the row type from a query:
import { QueryRowType } from "@rocicorp/zero";
export type OfferEntity = NonNullable<QueryRowType<typeof queries.offer.byId>>;
See: https://zero.rocicorp.dev/docs/zql#type-helpers
Preloading and One-Time Queries
Custom queries work with zero.preload() and zero.run():
zero.preload(queries.campaign.curationFeed({ campaignId, isImpersonating }), {
ttl: "30s",
});
const result = await zero.run(queries.conversation.byId({ id }));
Use zero.preload() to warm the cache before navigating to a view. Use zero.run() for one-off data fetches where you don't need reactivity.
Optimization: Scalar Subqueries
Use {scalar: true} on exists() filters where the subquery result is unlikely to change during a session (e.g., user's partnerId, creatorId). This pre-resolves the subquery server-side for better performance.
zql.message.where(({ exists }) =>
exists("conversation", (q) => q.where("partnerId", ctx.partnerId)),
);
zql.message.where(({ exists }) =>
exists("conversation", (q) => q.where("partnerId", ctx.partnerId), {
scalar: true,
}),
);
Best for: permission checks, stable FK lookups (partnerId, creatorId, orgId).
Avoid for: frequently changing data or subqueries that return multiple rows.
See: https://zero.rocicorp.dev/docs/zql
Checklist
Cleanup (after all queries migrated)