一键导入
define-query-migration
Migrate from Zero ad-hoc queries to the defineQuery API. Use when upgrading Zero queries to the new custom query pattern.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Migrate from Zero ad-hoc queries to the defineQuery API. Use when upgrading Zero queries to the new custom query pattern.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| 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. |
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:
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.
allowedFoos(ctx) as your starting pointwhereExists to verify via parent relationship:// campaignBrief doesn't have partnerId, but campaign does
export function allowedCampaignBriefs(ctx: ZeroContext) {
return zql.campaignBrief.whereExists("campaign", (q) =>
q.where((eb) => allowIfPartnerRow(ctx, eb))
);
}
// Then queries always start with allowedCampaignBriefs
byCampaignIds: defineQuery(
z.object({ campaignIds: z.array(z.string()) }),
({ ctx, args: { campaignIds } }) =>
allowedCampaignBriefs(ctx).where(({ cmp }) => cmp("campaignId", "IN", campaignIds))
),
Create the query equivalent of your existing mutator infrastructure:
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";
// Group queries by domain
export const conversationQueries = {
// Query with args - pass Zod schema first
byId: defineQuery(z.object({ id: z.string() }), ({ ctx, args: { id } }) =>
allowedConversations(ctx).where("id", id).one(),
),
// Query with no args - omit schema entirely
all: defineQuery(({ ctx }) => allowedConversations(ctx)),
};
export const messageQueries = {
byConversationId: defineQuery(
z.object({ conversationId: z.string() }),
({ ctx, args: { conversationId } }) =>
allowedMessages(ctx).where("conversationId", conversationId),
),
};
// Combine all query groups
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.
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,
) {
// Extract auth the same way your push/mutate endpoint does
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);
};
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).
Create a permissions.ts file with reusable helpers for common permission patterns in your schema:
// app/queries/shared/permissions.ts
import { ExpressionBuilder } from "@rocicorp/zero";
import { ZeroContext } from "~/mutators/permissions";
import { Schema } from "~/services/zero/schema";
/** Tables that have a partnerId column */
export type PartnerOwnedTable =
| "collaboration"
| "conversation"
| "campaign"
| "invoice";
// ... add all tables with partnerId
/** Tables that have a creatorId column */
export type CreatorOwnedTable = "conversation";
/** Grant access to rows where partnerId matches ctx.partnerId */
export function allowIfPartnerRow(
ctx: ZeroContext,
{ or, cmp }: ExpressionBuilder<PartnerOwnedTable, Schema>,
) {
if (!("partnerId" in ctx)) {
return or(); // false - no access
}
return cmp("partnerId", "=", ctx.partnerId);
}
/** Grant access to rows where creatorId matches ctx.creatorId */
export function allowIfCreatorRow(
ctx: ZeroContext,
{ or, cmp }: ExpressionBuilder<CreatorOwnedTable, Schema>,
) {
if (!("creatorId" in ctx)) {
return or(); // false - no access
}
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.
For each entity, create a factory function that returns a query with permissions pre-applied. Use plural naming since they return multiple rows:
// app/queries/shared/conversations.ts
import { zql } from "~/services/zero/schema";
import { allowIfPartnerRow, allowIfCreatorRow } from "./permissions";
/** Returns a conversation query with permissions pre-applied */
export function allowedConversations(ctx: ZeroContext) {
return zql.conversation.where((eb) =>
eb.or(allowIfPartnerRow(ctx, eb), allowIfCreatorRow(ctx, eb)),
);
}
// app/queries/shared/campaigns.ts
/** Returns a campaign query with permissions pre-applied */
export function allowedCampaigns(ctx: ZeroContext) {
return zql.campaign.where((eb) => allowIfPartnerRow(ctx, eb));
}
// app/queries/shared/messages.ts
/** Returns a message query with permissions pre-applied (via parent conversation) */
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:
allowedConversations(ctx).where(...)zql.conversation.where(allowedConversation(ctx)).where(...)Pick a component or page and migrate its queries one at a time.
Find a useQuery() call using the old pattern:
// Old pattern
const [data] = useQuery(zero.query.conversation.where("partnerId", id).one());
// In your queries file
export const conversationQueries = {
byPartnerId: defineQuery(
z.object({ partnerId: z.string() }),
({ ctx, args: { partnerId } }) =>
allowedConversations(ctx).where("partnerId", partnerId).one(),
),
};
Key points:
ctx contains auth context (userId, partnerId, etc.) - use this for permissionsargs contains the validated arguments from the schema// New pattern - no more `zero.query.*`
import { queries } from "~/queries/shared";
const [data] = useQuery(queries.conversation.byPartnerId({ partnerId: id }));
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:
// Good - permissions on parent only
allowedDocuments(ctx)
.where("id", id)
.related("comments", (q) => q.orderBy("createdAt", "asc"));
// Bad - redundant permissions on child
allowedDocuments(ctx)
.where("id", id)
.related("comments", (q) =>
q
.where(allowedComments(ctx)) // unnecessary overhead
.orderBy("createdAt", "asc"),
);
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:
// ✅ Correct - filter the relation query with the condition helper
.related('cartItems', q => q.where(eb => allowIfCartOwner(ctx, eb)))
// ❌ Wrong - ignores q parameter, returns unrelated query
.related('cartItems', () => allowedCartItems(ctx))
// ❌ Wrong - inlines logic instead of using the reusable helper
.related('cartItems', q => ctx ? q.where('userId', ctx.sub) : q.where(() => false))
The allowIf* condition helpers are the reusable building blocks that work everywhere:
allowedCartItems(ctx) which wraps zql.cartItem.where(eb => allowIfCartOwner(ctx, eb)).related() callbacks: q.where(eb => allowIfCartOwner(ctx, eb))Use QueryRowType to extract the row type from a query:
import { QueryRowType } from "@rocicorp/zero";
// Instead of: NonNullable<Row<ReturnType<typeof queries.offer.byId>>>
export type OfferEntity = NonNullable<QueryRowType<typeof queries.offer.byId>>;
See: https://zero.rocicorp.dev/docs/zql#type-helpers
Custom queries work with zero.preload() and zero.run():
// Preload a custom query (warms the cache)
zero.preload(queries.campaign.curationFeed({ campaignId, isImpersonating }), {
ttl: "30s",
});
// Run a one-time query (doesn't subscribe to updates)
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.
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.
// Without scalar - joins at query time
zql.message.where(({ exists }) =>
exists("conversation", (q) => q.where("partnerId", ctx.partnerId)),
);
// With scalar - pre-resolves partnerId check server-side
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
ZERO_QUERY_URL added to environmentqueryURL added to Zero client config (if using custom mutateURL)permissions.tsallowed* factory functions for each entityzero.query.* usages as you gopermissions export from schema.tsdeploy-permissions CI stepenableLegacyQueries from schema generator config (schema.prisma or drizzle equivalent)--schema-path flag from zero-cache-dev command (no longer needed without permissions)