makes zero really simple to use.
it's what we use for our takeout stack.
on-zero tries to bring Rails-like structure and DRY code to Zero + React.
it provides a few things:
- generation - cli with watch and generate commands
- queries - convert plain TS query functions into validated synced queries
- mutations - simply create CRUD queries with permissions
- models - standardized co-locating schema/permissions/mutations
- permissions -
serverWherefor simple query-based permissions
plus various hooks and helpers for react integration.
models live alongside their permissions and mutations. queries are just
functions that use a global zql builder.
write plain functions. they become synced queries automatically.
// src/data/queries/notification.ts
import { zql, serverWhere } from 'on-zero'
const permission = serverWhere('notification', (q, auth) => {
return q.cmp('userId', auth?.id || '')
})
export const latestNotifications = (props: {
userId: string
serverId: string
}) => {
return zql.notification
.where(permission)
.where('userId', props.userId)
.where('serverId', props.serverId)
.orderBy('createdAt', 'desc')
.limit(20)
}zql is just the normal Zero query builder based on your typed schema.
use them:
const [data, state] = useQuery(latestNotifications, { userId, serverId })the function name becomes the query name. useQuery detects plain functions,
creates a cached SyncedQuery per function, and calls it with your params.
define permissions inline using serverWhere():
const permission = serverWhere('channel', (q, auth) => {
if (auth?.role === 'admin') return true
return q.and(
q.cmp('deleted', '!=', true),
q.or(
q.cmp('private', false),
q.exists('role', (r) =>
r.whereExists('member', (m) => m.where('id', auth?.id)),
),
),
)
})then use in queries:
export const channelById = (props: { channelId: string }) => {
return zql.channel.where(permission).where('id', props.channelId).one()
}permissions execute server-side only. on the client they automatically pass. the
serverWhere() helper automatically accesses auth data from queryContext() or
mutatorContext() so you don't need to pass it manually.
models co-locate schema, permissions, and mutations in one file:
// src/data/models/message.ts
import { number, string, table } from '@rocicorp/zero'
import { mutations, serverWhere } from 'on-zero'
export const schema = table('message')
.columns({
id: string(),
content: string(),
authorId: string(),
channelId: string(),
createdAt: number(),
})
.primaryKey('id')
export const permissions = serverWhere('message', (q, auth) => {
return q.cmp('authorId', auth?.id || '')
})
// CRUD mutations with permissions by passing schema + permissions:
export const mutate = mutations(schema, permissions, {
async send(ctx, props: { content: string; channelId: string }) {
await ctx.can(permissions, props)
await ctx.tx.mutate.message.insert({
id: randomId(),
content: props.content,
channelId: props.channelId,
authorId: ctx.authData!.id,
createdAt: Date.now(),
})
if (ctx.server) {
ctx.server.asyncTasks.push(async () => {
await ctx.server.actions.sendNotification(props)
})
}
},
})call mutations from react:
await zero.mutate.message.send({ content: 'hello', channelId: 'ch-1' })the second argument (permissions) enables auto-generated crud that checks
permissions:
zero.mutate.message.insert(message)
zero.mutate.message.update(message)
zero.mutate.message.delete(message)
zero.mutate.message.upsert(message)on-zero's permissions system is optional - you can implement your own
permission logic however you like. serverWhere() is a light helper for
RLS-style permissions that automatically integrate with queries and mutations.
permissions use the serverWhere() helper to create Zero ExpressionBuilder
conditions:
export const permissions = serverWhere('channel', (q, auth) => {
if (auth?.role === 'admin') return true
return q.or(
q.cmp('public', true),
q.exists('members', (m) => m.where('userId', auth?.id)),
)
})the serverWhere() helper automatically gets auth data from queryContext() or
mutatorContext(), so you don't manually pass it. permissions only execute
server-side - on the client they automatically pass.
for queries: define permissions inline as a constant in query files:
// src/data/queries/channel.ts
const permission = serverWhere('channel', (q, auth) => {
return q.cmp('userId', auth?.id || '')
})
export const myChannels = () => {
return zql.channel.where(permission)
}for mutations: define permissions in model files for CRUD operations:
// src/data/models/message.ts
export const permissions = serverWhere('message', (q, auth) => {
return q.cmp('authorId', auth?.id || '')
})CRUD mutations automatically apply them, but for custom mutations use can():
await ctx.can(permissions, messageId)check permissions in React with usePermission():
const canEdit = usePermission('message', messageId)for complex or reusable query logic, create partials in a where/ directory.
use serverWhere without a table name to create partials that work across
multiple tables:
// src/data/where/server.ts
import { serverWhere } from 'on-zero'
type RelatedToServer = 'role' | 'channel' | 'message'
export const hasServerAdminPermission = serverWhere<RelatedToServer>((_, auth) =>
_.exists('server', (q) =>
q.whereExists('role', (r) =>
r.where('canAdmin', true)
.whereExists('member', (m) => m.where('id', auth?.id || ''))
)
)
)
export const hasServerReadPermission = serverWhere<RelatedToServer>((_, auth) =>
_.exists('server', (q) =>
q.where((_) =>
_.or(
_.cmp('private', false),
_.exists('member', (m) => m.where('id', auth?.id || ''))
)
)
)
)then compose them in other permissions:
// src/data/where/channel.ts
import { serverWhere } from 'on-zero'
import { hasServerAdminPermission, hasServerReadPermission } from './server'
type RelatedToChannel = 'message' | 'pin' | 'channelTopic'
const hasChannelRole = serverWhere<RelatedToChannel>((_, auth) =>
_.exists('channel', (q) =>
q.whereExists('role', (r) =>
r.whereExists('member', (m) => m.where('id', auth?.id || ''))
)
)
)
export const hasChannelReadPermission = serverWhere<RelatedToChannel>((_, auth) => {
const isServerMember = hasServerReadPermission(_, auth)
const isChannelMember = hasChannelRole(_, auth)
const isAdmin = hasServerAdminPermission(_, auth)
return _.or(isServerMember, isChannelMember, isAdmin)
})use in queries:
import { hasChannelReadPermission } from '../where/channel'
export const channelMessages = (props: { channelId: string }) => {
return zql.message
.where(hasChannelReadPermission)
.where('channelId', props.channelId)
}on-zero has a CLI that auto-generates glue files that wire up your models,
queries, and types.
on-zero generate [dir]
generates all files needed to connect your models and queries:
models.ts- aggregates all model files into a single importtypes.ts- generates TypeScript types from table schemastables.ts- exports table schemas (separate to avoid circular types)syncedQueries.ts- generates synced query definitions with valibot validators
options:
dir- base directory containingmodels/andqueries/folders (default:src/data)--watch- watch for changes and regenerate automatically--after- command to run after generation completes
examples:
# generate once
bun on-zero generate
# generate and watch
bun on-zero generate --watch
# custom directory
bun on-zero generate ./app/data
# run linter after generation
bun on-zero generate --after "bun lint:fix"on-zero generate-queries <dir>
generates query validators from TypeScript query functions. this is included in
generate but can be run standalone.
- parses exported arrow functions from
.tsfiles in the queries directory - extracts parameter types using TypeScript compiler API
- generates valibot schemas using typebox-codegen
example:
bun on-zero generate-queries src/data/queriesmodels.ts:
import * as channel from '~/data/models/channel'
import * as message from '~/data/models/message'
export const models = {
channel,
message,
}types.ts:
import type { TableInsertRow, TableUpdateRow } from 'on-zero'
import type * as schema from './tables'
export type Channel = TableInsertRow<typeof schema.channel>
export type ChannelUpdate = TableUpdateRow<typeof schema.channel>tables.ts:
export { schema as channel } from '~/data/models/channel'
export { schema as message } from '~/data/models/message'syncedQueries.ts:
import * as v from 'valibot'
import { syncedQuery } from '@rocicorp/zero'
import * as messageQueries from '../queries/message'
export const latestMessages = syncedQuery(
'latestMessages',
v.parser(
v.tuple([
v.object({
channelId: v.string(),
limit: v.optional(v.number()),
}),
]),
),
(arg) => {
return messageQueries.latestMessages(arg)
},
)the generator:
- scans
models/for files withexport const schema = table(...) - scans
queries/for exported arrow functions - parses TypeScript AST to extract parameter types
- converts types to valibot schemas using typebox-codegen
- wraps query functions in
syncedQuery()with validators - handles special cases (void params, user → userPublic mapping)
- groups query imports by source file
queries with no parameters get wrapped in v.parser(v.tuple([])) while queries
with params get validators like v.parser(v.tuple([v.object({ ... })])).
exports named permission are automatically skipped during query generation.
client:
import { createZeroClient } from 'on-zero'
import { schema } from '~/data/schema'
import { models } from '~/data/generated/models'
import * as groupedQueries from '~/data/generated/groupedQueries'
export const { ProvideZero, useQuery, zero, usePermission } = createZeroClient({
schema,
models,
groupedQueries,
})
// in your app root
<ProvideZero
server="http://localhost:4848"
userID={user.id}
auth={jwtToken}
authData={{ id: user.id, email: user.email, role: user.role }}
>
<App />
</ProvideZero>server:
import { createZeroServer } from 'on-zero/server'
import { syncedQueries } from '~/data/generated/syncedQueries'
export const zeroServer = createZeroServer({
schema,
models,
database: process.env.DATABASE_URL,
queries: syncedQueries, // required for synced queries / pull endpoint
createServerActions: () => ({
sendEmail: async (to, subject, body) => { ... }
})
})
// push endpoint for mutations
app.post('/api/zero/push', async (req) => {
const authData = await getAuthFromRequest(req)
const { response } = await zeroServer.handleMutationRequest({
authData,
request: req
})
return response
})
// pull endpoint for synced queries
app.post('/api/zero/pull', async (req) => {
const authData = await getAuthFromRequest(req)
const { response } = await zeroServer.handleQueryRequest({
authData,
request: req
})
return response
})type augmentation:
// src/zero/types.ts
import type { schema } from '~/data/schema'
import type { AuthData } from './auth'
declare module 'on-zero' {
interface Config {
schema: typeof schema
authData: AuthData
}
}every mutation receives MutatorContext as first argument:
type MutatorContext = {
tx: Transaction // database transaction
authData: AuthData | null // current user
environment: 'server' | 'client' // where executing
can: (where, obj) => Promise<void> // permission checker
server?: {
actions: ServerActions // async server functions
asyncTasks: AsyncAction[] // run after transaction
}
}use it:
export const mutate = mutations(schema, permissions, {
async archive(ctx, { messageId }) {
await ctx.can(permissions, messageId)
await ctx.tx.mutate.message.update({ id: messageId, archived: true })
ctx.server?.asyncTasks.push(async () => {
await ctx.server.actions.indexForSearch(messageId)
})
},
})server-only mutations:
await zeroServer.mutate(async (tx, mutators) => {
await mutators.user.insert(tx, user)
})one-off queries with run():
run a query once without subscribing. works on both client and server:
import { run } from 'on-zero'
import { userById } from '~/data/queries/user'
// with params
const user = await run(userById, { id: userId })
// without params
const allUsers = await run(allUsers)
// with options (client only)
const cached = await run(userById, { id: userId }, { type: 'unknown' })on client, uses zero.run() under the hood. on server, uses transaction-based
execution. same query functions work in both environments.
preloading data (client only):
preload query results into cache without subscribing:
import { preload } from '~/zero/client'
import { userNotifications } from '~/data/queries/notification'
// preload after login
const { complete, cleanup } = preload(userNotifications, { userId, limit: 100 })
await complete
// cleanup if needed
cleanup()useful for prefetching data before navigation to avoid loading states.
server-only queries:
for ad-hoc queries that don't use query functions:
const user = await zeroServer.query((q) => q.user.where('id', userId).one())batch processing:
import { batchQuery } from 'on-zero'
await batchQuery(
zql.message.where('processed', false),
async (messages) => {
for (const msg of messages) {
await processMessage(msg)
}
},
{ chunk: 100, pause: 50 },
)