Synced Queries
Synced Queries are Zero's new read API.
With the existing query system, queries are defined on the client and run directly against the server. The permission system restricts which rows can be read, but clients can still run any query they want against those rows.
With synced queries, clients instead invoke named queries that are defined both on the client and on your server. Your server controls the implementation of these queries, and can add additional filters to enforce permissions.
This has many benefits:
- No need for a separate permission system. Your server enforces permissions by constructing queries dynamically. Clients have no direct access to the database.
- Simpler auth. Your server can use any auth system you want – Zero does not need to know or understand it.
- Previews. Since synced queries are implemented on your server, they can naturally support branch previews on platforms like Vercel. This doesn't quite work yet, but will be coming to Zero soon.
Introduction
A synced query is basically a named function that returns a ZQL query.
Here's an example:
import { syncedQuery, createBuilder } from '@rocicorp/zero';
import z from "zod";
import { schema } from './schema.ts';
const builder = createBuilder(schema);
const myPostsQuery = syncedQuery(
'postsByAuthor',
z.tuple([z.string()]),
(authorID: string) => {
return builder.post.where('authorID', authorID);
}
);
See using synced queries for more details on the syncedQuery
function.
An implementation of each synced query exists on both the client and on your server:
Query architecture
Often the implementations will be the same, and you can just share their code. This is easy with full-stack frameworks like TanStack Start or Next.js.
But the implementations don't have to be the same, or even compute the same result. For example, the server can add extra filters to enforce permissions that the client query does not.
Life of a Synced Query
Calling a synced query invokes the wrapped queryFn
and returns a ZQL query. You can pass that to useQuery
just like any other ZQL query:
const [posts] = useQuery(myPostsQuery('user123'));
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
Just as with ZQL queries, synced queries runs client-side immediately. You get an instant result with any available local data:
Registering a query
And just as with other ZQL queries, synced queries are live. Optimistic mutations are reflected immediatley and automatically:
Local updates
Query Subscription
When you run a synced query, zero-client
"subscribes"
to the query by sending the query's name and arguments
to zero-cache
.
Zero-cache then turns around and asks your server for the
definition of that query by calling its /get-queries
endpoint:
Query subscription
See Server Setup for details on implementing this endpoint. We provide some TypeScript libraries to make it easy.
Server Hydration
Once zero-cache
has the query definition from your server, it creates an
instance of the query on the server, computes the initial result,
and returns the response to the client:
Server result
These results are merged into the local datastore, updating client queries and the app as needed.
Ongoing Sync
zero-cache
receives updates from Postgres via logical replication.
It feeds these changes into all open queries, which are incrementally updated. The resulting deltas are sent to the clients, which merge them into their local datastore, updating client queries and the app as needed:
Server result
Using Synced Queries
The syncedQuery
function creates a synced query:
import z from "zod";
import { syncedQuery, createBuilder } from '@rocicorp/zero';
import { schema } from './schema.ts';
const builder = createBuilder(schema);
export const myPosts = syncedQuery(
'myPosts',
z.tuple([userId: z.string()]),
(userId) => {
return builder.post.where('userID', userId);
}
);
It takes three parameters:
queryName
: A unique name for the query. This is used by clients to identify the query to the server.parser
: A function that validates the arguments passed to the query on the server. This is any function matching the signature(args: unknown[]) => ParsedArgs
whereParsedArgs
is a tuple type. You can also pass an object that has a matchingparse
method as a convenience.queryFn
: A function that takes the validated arguments and returns a ZQL query.
Use the createBuilder
function to create a builder
from your schema. This allows you to define ZQL queries without needing a Zero
instance.
A common convention is to define and export a builder in your schema.ts
file:
// schema.ts
import { createSchema, createBuilder } from "@rocicorp/zero";
// ... table and relationship definitions ...
export const schema = createSchema({
tables: [user, medium, message],
relationships: [messageRelationships],
});
export const builder = createBuilder(schema);
Authenticated Queries
To define a synced query that uses the current authenticated user, use syncedQueryWithContext
:
import z from "zod";
import { syncedQueryWithContext } from '@rocicorp/zero';
import { builder } from './schema.ts';
type QueryContext = {
userID: string;
};
export const myIssues = syncedQueryWithContext(
'myIssues',
z.tuple([z.boolean()]),
(ctx: QueryContext, isOpen) => {
return builder.issue
.where('userID', ctx.userID)
.where("isOpen", isOpen);
}
);
The context type passed can be anything, but at the very least it will most commonly include the current user's ID. It can also be useful to put other information tied to the current user here, such as their role.
It makes most sense to use the same context type for all your contextful queries, so that you can treat queries generically in your server handler. Also, if you use custom mutators you will likely use the same AuthData
struct you use there, or some related type, so that you can share auth code between synced queries and custom mutators.
Invoking Queries
The most common way to invoke a synced query is with useQuery
from the React or SolidJS bindings as shown above.
But there are also new zero.run
, zero.materialize
, and zero.preload
methods that allow you to run synced queries outside of UI components:
// Run a query once
const posts = await zero.run(myPosts('user123'));
// Materialize a view of a query
const postsView = zero.materialize(myPosts('user123'));
postsView.addListener((posts) => {
console.log('posts updated', posts);
});
// Preload a query to ensure it's ready when you need it
await zero.preload(myPosts('user123'));
See the descriptions of run, materialize, and preload from the existing query system for more details on these method. They work the same.
Server Setup
Use the ZERO_GET_QUERIES_URL
parameter to tell Zero the URL to call:
ZERO_GET_QUERIES_URL="http://localhost:3000/api/zero/get-queries"
You can easily implement this endpoint in TypeScript with the handleGetQueriesRequest
and withValidation
functions.
For example, with Hono:
import { withValidation, ReadonlyJSONValue } from "@rocicorp/zero";
import { handleGetQueriesRequest } from "@rocicorp/zero/server";
import { issuesByLabel, allLabels } from "issue-list.ts";
import { schema } from "../shared/schema";
app.post("/get-queries", async (c) => {
return await c.json(await handleGetQueriesRequest(getQuery, schema, c.req.raw));
});
// Build a map of queries with validation by name.
const validated = Object.fromEntries(
[issuesByLabel, allLabels].map(
q => [q.queryName, withValidation(q)])
);
function getQuery(name: string, args: readonly ReadonlyJSONValue[]) {
const q = validated[name];
if (!q) {
throw new Error(`No such query: ${name}`);
}
return {
// First param is the context for contextful queries.
// `args` are validated using the `parser` you provided with
// the query definition.
query: q(undefined, ...args),
};
}
handleGetQueriesRequest
accepts a standard Request
and returns a JSON object which can be serialized and returned by your server framework of choice.
withValidation
uses the parser
function you provided when defining the synced query to validate the arguments passed to the query. If validation fails, invoking the synced queries throws and an error is returned to the client.
Providing Auth Data
The function returned by withValidation
always takes a context
argument as its first parameter.
If the validated query was defined with syncedQuery
, this argument is ignored and anything can be passed to it. If the query was defined with syncedQueryWithContext
, this argument is passed into the queryFn
that the query was defined with.
This makes it easy to handle authenticated and unauthenticated queries generically with a pattern like:
import { handleGetQueriesRequest } from "@rocicorp/zero/server";
import { withValidation } from "@rocicorp/zero";
import { issuesByLabel, allLabels } from "issue-list.ts";
import { schema } from "../shared/schema";
import { ReadonlyJSONValue } from "@rocicorp/zero";
const validated = Object.fromEntries(
[
// unauth'd queries
issuesByLabel, allLabels,
// auth'd query
myIssues
].map((q) => [q.queryName, withValidation(q)])
);
export async function handleGetQueries(request: Request) {
const authData = await authenticateUser(request);
if (!authData) {
return new Response('Unauthorized', {status: 401});
}
return await handleGetQueriesRequest(
(name, args) => getQuery(authData, name, args),
schema, request);
}
function getQuery(
authData: AuthData,
name: string,
args: readonly ReadonlyJSONValue[]) {
const q = validated[name];
if (!q) {
throw new Error(`No such query: ${name}`);
}
return {
// Pass authData to both auth'd and unauth'd queries
query: q(authData, ...args),
};
}
Custom Server Implementation
It is possible to implement the ZERO_GET_QUERIES_URL
endpoint without using Zero's TypeScript libraries, or even in a different language entirely.
The endpoint receives a POST
request with a JSON body of the form:
type TransformRequestBody = {
id: string;
name: string;
args: readonly ReadonlyJSONValue[];
}[]
And responds with:
type TransformResponseBody = ({
id: string;
name: string;
// See https://github.com/rocicorp/mono/blob/main/packages/zero-protocol/src/ast.ts
ast: AST;
} | {
error: "app";
id: string;
name: string;
details: ReadonlyJSONValue;
} | {
error: "zero";
id: string;
name: string;
details: ReadonlyJSONValue;
} | {
error: "http";
id: string;
name: string;
status: number;
details: ReadonlyJSONValue;
})[]
Disabling Old Queries
If you don't want clients to be able to run arbitrary ZQL queries anymore, you can disable them with the enableLegacyQueries
flag in your Zero schema:
export const schema = createSchema({
// ...
enableLegacyQueries: false,
});
The schema
object itself has the same field. So if you use a generator like drizzle-zero
, you can just add the flag yourself:
import { Schema as ZeroSchema } from '@rocicorp/zero';
import {schema as genSchema} from './schema.gen';
export const schema = {
...genSchema,
enableLegacyQueries: false,
} as const satisfies ZeroSchema;
You will still be able to call useQuery(z.query.myTable)
or useQuery(builder.myTable)
, but those queries won't sync. Only queries defined with syncedQuery
will sync.
Local-Only Queries
It can sometime be useful to run queries only on the client. For example, to implement typeahead search, it really doesn't make sense to register every single keystroke with the server.
See disabling legacy queries for how to do this.
Examples
See zslack, zbugs, and hello-zero-solid for examples of complete apps using synced queries.