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

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

Registering a query

And just as with other ZQL queries, synced queries are live. Optimistic mutations are reflected immediatley and automatically:

Local updates

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

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

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

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 where ParsedArgs is a tuple type. You can also pass an object that has a matching parse 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.