wwwwwwwwwwwwwwwwwww

Zero

Client-first sync with Zero and over-zero - instant UI, offline support, real-time updates

Takeout uses Zero for data sync, wrapped with over-zero for a streamlined developer experience. Zero is a sync engine that keeps a client-side replica of your data, enabling instant queries and optimistic mutations. The Zero documentation covers the core concepts in depth—this page focuses on how Takeout structures its data layer.

For database schema management and migrations, see the Database page.

How It Works

Zero maintains a partial slice of data on the client. Queries run against the local replica for 0 network latency. Mutations apply optimistically, then sync to Postgres. Changes from other clients stream in automatically.

Directory Structure

The data layer lives in src/data/:

src/data/ ├── models/ # table schemas + write permissions + mutations │ ├── post.ts │ ├── user.ts │ └── comment.ts ├── queries/ # query functions + read permissions │ ├── post.ts │ ├── user.ts │ └── comment.ts ├── server/ # server-only code │ ├── createServerActions.ts │ └── actions/ ├── generated/ # auto-generated (don’t edit) │ ├── types.ts │ ├── tables.ts │ ├── models.ts │ ├── groupedQueries.ts │ └── syncedQueries.ts ├── relationships.ts # table relationships ├── schema.ts # schema assembly └── types.ts # type exports src/zero/ ├── client.tsx # client setup ├── server.ts # server setup └── types.ts # type augmentation

Models

Models define table schemas, write permissions, and mutations. Each table gets its own file in src/data/models/:

// src/data/models/post.ts
import { boolean, number, string, table } from ‘@rocicorp/zero’
import { mutations, serverWhere } from ‘over-zero’
import type { Post } from ’../types’
export const schema = table(‘post’)
.columns({
id: string(),
userId: string(),
image: string(),
caption: string().optional(),
hiddenByAdmin: boolean(),
commentCount: number(),
createdAt: number(),
updatedAt: number().optional(),
})
.primaryKey(‘id’)
const permissions = serverWhere(‘post’, (_, auth) => {
return _.cmp(‘userId’, auth?.id ||)
})
export const mutate = mutations(schema, permissions, {
insert: async ({ tx, environment, server, authData }, post: Post) => {
await tx.mutate.post.insert(post)
if (environment === ‘server’ && server && authData) {
server.asyncTasks.push(() =>
server.actions
.analyticsActions()
.logEvent(authData.id, ‘post_created’, {
postId: post.id,
}),
)
}
},
})

The mutations() function takes the schema and permissions, then auto-generates CRUD operations, this is an optional but helpful feature that over-zero provides:

await zero.mutate.post.insert(post)
await zero.mutate.post.update({ id, caption: ‘updated’ })
await zero.mutate.post.delete({ id })
await zero.mutate.post.upsert(post)

Custom mutations go in the third argument. They receive a context with the transaction, auth data, and server actions.

Mutation Context

Every mutation receives MutatorContext:

type MutatorContext = {
tx: Transaction // database transaction
authData: AuthData | null // current user
environment: ‘server’ | ‘client’
can: (where, obj) => Promise<void>
server?: {
actions: ServerActions
asyncTasks: AsyncAction[]
}
}

Use ctx.server?.asyncTasks for work that should run after the transaction commits—analytics, notifications, search indexing.

Convergent Mutations

Mutations run on both client and server. Both must produce identical database state. To avoid lots of “rebasing” back and forth, you want to follow some patterns.

The rule: never generate non-deterministic values inside mutations.

For example, never call Date.now() inside a mutation. Reuse timestamps from the data you’re given.

Pattern 1: Pass IDs and timestamps from the caller

// caller generates these values
await zero.mutate.post.insert({
id: randomId(), // generated before mutation
createdAt: Date.now(), // captured before mutation
userId: currentUser.id,
content: ‘Hello’,
})

The mutation receives pre-generated values. Both client and server use the same id and createdAt. When a mutation creates related entities, derive their IDs deterministically from the parent:

// from chat app: insertServer.ts
async function insertServer(tx, server) {
await tx.mutate.server.insert(server)
// derive role IDs from server ID - same on client and server
const adminRoleId = `${server.id}-role-admin`
const teamRoleId = `${server.id}-role-team`
await tx.mutate.role.insert({
id: adminRoleId,
serverId: server.id,
name: ‘Admin’,
createdAt: server.createdAt, // reuse parent timestamp
})
await tx.mutate.role.insert({
id: teamRoleId,
serverId: server.id,
name: ‘Team’,
createdAt: server.createdAt, // reuse parent timestamp
})
// derive channel IDs from server ID
await tx.mutate.channel.insert({
id: `${server.id}-tasks`,
serverId: server.id,
createdAt: server.createdAt,
})
}

This pattern scales—channels get ${server.id}-tasks, permissions get ${channel.id}-permission, notifications get ${message.id}-notification.

Pattern 2: Pass “next” IDs for follow-up entities

When a mutation needs to create a follow-up entity (like a draft after sending a message), pass its ID from the caller:

// message model receives nextDraftId from caller
async send(ctx, props: SendMessage) {
const { nextDraftId, …message } = props
await ctx.tx.mutate.message.update(message)
// create next draft with the pre-generated ID
await ctx.tx.mutate.message.insert(
createDraftMessage({
id: nextDraftId, // passed in, not generated here
channelId: message.channelId,
creatorId: message.creatorId,
})
)
}
// caller generates both IDs
await zero.mutate.message.send({
id: randomId(),
nextDraftId: randomId(), // pre-generate the follow-up ID
content: ‘Hello’,
createdAt: Date.now(),
})

What’s safe inside mutations

Server-only code doesn’t need to converge:

async insert(ctx, post) {
await ctx.tx.mutate.post.insert(post)
// safe: only runs server-side
if (ctx.server) {
ctx.server.asyncTasks.push(async () => {
// these can use Date.now(), randomId(), etc.
await ctx.server.actions.analytics.logEvent({
id: randomId(),
timestamp: Date.now(),
event: ‘post_created’,
})
})
}
}

Queries

Queries are plain functions in src/data/queries/ that use the global zql builder:

// src/data/queries/post.ts
import { serverWhere, zql } from ‘over-zero’
const permission = serverWhere(‘post’, () => true)
export const postById = (props: { postId: string }) => {
return zql.post.where(permission).where(‘id’, props.postId).one()
}
export const postsByUserId = (props: { userId: string; limit?: number }) => {
return zql.post
.where(permission)
.where(‘userId’, props.userId)
.orderBy(‘createdAt’, ‘desc’)
.orderBy(‘id’, ‘desc’)
.limit(props.limit || 20)
}

Use them with useQuery:

import { useQuery } from ’~/zero/client’
import { postById, postsByUserId } from ’~/data/queries/post’
function PostDetail({ postId }) {
const [post] = useQuery(postById, { postId })
return <Text>{post?.caption}</Text>
}
function UserPosts({ userId }) {
const [posts, status] = useQuery(postsByUserId, { userId, limit: 20 })
if (status.type === ‘loading’) return <Loading />
return posts.map((post) => <PostCard key={post.id} post={post} />)
}

Query Options

Three ways to call useQuery:

// with params
useQuery(queryFn, { param1, param2 })
// with params + options
useQuery(queryFn, { param1 }, { enabled: Boolean(param1) })
// no params + options
useQuery(queryFn, { enabled: isReady })

Advanced Filtering

Use the expression builder for complex conditions:

export const postsWithBlocks = (props: {
blockedUserIds: string[]
pageSize: number
}) => {
return zql.post
.where(permission)
.where((eb) => {
if (props.blockedUserIds.length > 0) {
return eb.not(eb.cmp(‘userId’,IN, props.blockedUserIds))
}
return eb.cmp(‘id’,!=,)
})
.orderBy(‘createdAt’, ‘desc’)
.limit(props.pageSize)
}
export const searchPosts = (props: {
searchText: string
pageSize: number
}) => {
return zql.post
.where(permission)
.where((eb) => eb.cmp(‘caption’,LIKE, `%${props.searchText}%`))
.orderBy(‘createdAt’, ‘desc’)
.limit(props.pageSize)
}

Available operators: =, !=, <, >, <=, >=, IN, NOT IN, LIKE, ILIKE, IS, IS NOT.

Pagination

Use .start() for cursor-based pagination:

export const postsPaginated = (props: {
pageSize: number
cursor?: { id: string; createdAt: number } | null
}) => {
let query = zql.post
.where(permission)
.orderBy(‘createdAt’, ‘desc’)
.orderBy(‘id’, ‘desc’)
.limit(props.pageSize)
if (props.cursor) {
query = query.start(props.cursor)
}
return query
}

The cursor is an object with values for each orderBy field. Pass the last item’s values to fetch the next page.

Relationships

Define how tables connect in src/data/relationships.ts:

import { relationships } from ‘@rocicorp/zero’
import * as tables from ’./generated/tables’
export const postRelationships = relationships(
tables.post,
({ one, many }) => ({
user: one({
sourceField: [‘userId’],
destSchema: tables.userPublic,
destField: [‘id’],
}),
comments: many({
sourceField: [‘id’],
destSchema: tables.comment,
destField: [‘postId’],
}),
}),
)
export const commentRelationships = relationships(
tables.comment,
({ one }) => ({
post: one({
sourceField: [‘postId’],
destSchema: tables.post,
destField: [‘id’],
}),
user: one({
sourceField: [‘userId’],
destSchema: tables.userPublic,
destField: [‘id’],
}),
}),
)
export const allRelationships = [postRelationships, commentRelationships]

Query related data with .related():

export const postWithComments = (props: { postId: string }) => {
return zql.post
.where(‘id’, props.postId)
.one()
.related(‘user’)
.related(‘comments’, (q) =>
q.orderBy(‘createdAt’, ‘desc’).limit(50).related(‘user’),
)
}

Permissions

Zero permissions are flexible, you can use plain functions or whatever technique you choose. over-zero has a serverWhere() helper which makes it easy to do “query-based” permissions. Basically on the server-side they will run, but on the client they always pass.

Read permissions go in query files:

// src/data/queries/post.ts
const permission = serverWhere(‘post’, (q, auth) => {
return q.or(
q.cmp(‘published’,=, true),
q.cmp(‘userId’,=, auth?.id ||),
)
})
export const allPosts = () => zql.post.where(permission)

Write permissions go in model files:

// src/data/models/post.ts
const permissions = serverWhere(‘post’, (q, auth) => {
if (auth?.role === ‘admin’) return true
return q.cmp(‘userId’, auth?.id ||)
})
export const mutate = mutations(schema, permissions, {
/* … */
})

Check permissions in mutations with ctx.can():

async customMutation(ctx, props) {
await ctx.can(permissions, props.postId)
// proceeds only if allowed
}

Check in React with usePermission():

const canEdit = usePermission(‘post’, postId)

Server Actions

Server actions run only server-side—for emails, webhooks, analytics, external APIs. Define them in src/data/server/createServerActions.ts:

export const createServerActions = () => ({
analyticsActions: () => ({
async logEvent(userId: string, event: string, data: Record<string, any>) {
// analytics logic
},
}),
async sendPushNotification(userId: string, message: string) {
// push notification logic
},
})

Use in mutations:

if (ctx.server) {
ctx.server.asyncTasks.push(async () => {
await ctx.server.actions.analyticsActions().logEvent(userId, ‘event’, data)
})
}

Async tasks run after the transaction commits.

In general you always want to do any long-running (non zero-mutation related) thing inside an asyncTask. Zero keeps the transaction open so long as your mutator is not resolved on the server, which will slow your database down.

Code Generation

Your bun dev automatially watches and re-generates over-zero glue code for you.

To run manually:

Terminal

bun zero:generate

This creates files in src/data/generated/:

  • types.ts - TypeScript types from schemas
  • tables.ts - Table schema exports
  • models.ts - Aggregated model exports
  • syncedQueries.ts - Query functions wrapped with valibot validators

Import types from ~/data/types:

import type { Post, PostUpdate, User } from ’~/data/types’

Client Setup

The client is configured in src/zero/client.tsx:

import { createZeroClient } from ‘over-zero’
import * as groupedQueries from ’~/data/generated/groupedQueries’
import { models } from ’~/data/generated/models’
import { schema } from ’~/data/schema’
export const {
useQuery,
usePermission,
zero,
ProvideZero: ProvideZeroWithoutAuth,
} = createZeroClient({
models,
schema,
groupedQueries,
})

Wrap your app with the provider:

<ProvideZeroWithoutAuth server={ZERO_SERVER_URL} userID={userId} auth={jwtToken} authData={{ id: userId, role }} kvStore={isWeb ? ‘idb’ : ‘mem’} >
<App />
</ProvideZeroWithoutAuth>

Storage backends:

  • ‘idb’ - IndexedDB for web (persistent)
  • ‘sqlite’ - SQLite for native (persistent, faster)
  • ‘mem’ - In-memory for anonymous users or SSR

Server Setup

The server is configured in src/zero/server.ts:

import { createZeroServer } from ‘over-zero/server’
import { models } from ’~/data/generated/models’
import { queries } from ’~/data/generated/syncedQueries’
import { schema } from ’~/data/schema’
import { createServerActions } from ’~/data/server/createServerActions’
export const zeroServer = createZeroServer({
schema,
models,
createServerActions,
queries,
database: process.env.ZERO_UPSTREAM_DB,
})

Zero needs two API routes. These are already set up in app/api/zero/.

Type Augmentation

Configure global types in src/zero/types.ts:

import type { schema } from ’~/data/schema’
import type { AuthData } from ’~/features/auth/types’
import type { ServerActions } from ’~/data/server/createServerActions’
declare module ‘over-zero’ {
interface Config {
schema: typeof schema
authData: AuthData
serverActions: ServerActions
}
}

Debugging

Add ?debug=2 to your URL for detailed Zero logs.

Common issues:

  • Queries not updating - Ensure you’re using useQuery, not a one-time fetch
  • Mutations not syncing - Check network tab for push errors; verify permissions
  • Type errors after schema change - Run bun tko zero/generate

Learn More

Edit this page on GitHub.