A typed layer over @rocicorp/zero with queries, mutations, and permissions
npm install on-zero
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 - serverWhere for 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.
``ts
// 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:
`tsx`
const [data, state] = useQuery(latestNotifications, { userId, serverId })
the function name becomes the query name. useQuery detects plain functions,SyncedQuery
creates a cached per function, and calls it with your params.
define permissions inline using serverWhere():
`ts
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:
`ts`
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() ormutatorContext() so you don't need to pass it manually.
models co-locate schema, permissions, and mutations in one file:
`ts
// 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:
`tsx`
await zero.mutate.message.send({ content: 'hello', channelId: 'ch-1' })
the second argument (permissions) enables auto-generated crud that checks
permissions:
`tsx`
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:
`ts
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() ormutatorContext(), 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:
`ts
// 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:
`ts`
// 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():
`ts`
await ctx.can(permissions, messageId)
check permissions in React with usePermission():
`tsx`
const canEdit = usePermission('message', messageId)
for complex or reusable query logic, create partials in a where/ directory.serverWhere
use without a table name to create partials that work across
multiple tables:
`ts
// src/data/where/server.ts
import { serverWhere } from 'on-zero'
type RelatedToServer = 'role' | 'channel' | 'message'
export const hasServerAdminPermission = serverWhere
_.exists('server', (q) =>
q.whereExists('role', (r) =>
r.where('canAdmin', true)
.whereExists('member', (m) => m.where('id', auth?.id || ''))
)
)
)
export const hasServerReadPermission = serverWhere
_.exists('server', (q) =>
q.where((_) =>
_.or(
_.cmp('private', false),
_.exists('member', (m) => m.where('id', auth?.id || ''))
)
)
)
)
`
then compose them in other permissions:
`ts
// src/data/where/channel.ts
import { serverWhere } from 'on-zero'
import { hasServerAdminPermission, hasServerReadPermission } from './server'
type RelatedToChannel = 'message' | 'pin' | 'channelTopic'
const hasChannelRole = serverWhere
_.exists('channel', (q) =>
q.whereExists('role', (r) =>
r.whereExists('member', (m) => m.where('id', auth?.id || ''))
)
)
)
export const hasChannelReadPermission = serverWhere
const isServerMember = hasServerReadPermission(_, auth)
const isChannelMember = hasChannelRole(_, auth)
const isAdmin = hasServerAdminPermission(_, auth)
return _.or(isServerMember, isChannelMember, isAdmin)
})
`
use in queries:
`ts
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 containing models/ and queries/ folders (default:src/data
)--watch
- - watch for changes and regenerate automatically--after
- - command to run after generation completes
examples:
`bashgenerate once
bun on-zero generate
on-zero generate-queries generates query validators from TypeScript query functions. this is included in
generate but can be run standalone.- parses exported arrow functions from
.ts files in the queries directory
- extracts parameter types using TypeScript compiler API
- generates valibot schemas using typebox-codegenexample:
`bash
bun on-zero generate-queries src/data/queries
`$3
models.ts:
`ts
import * as channel from '~/data/models/channel'
import * as message from '~/data/models/message'export const models = {
channel,
message,
}
`types.ts:
`ts
import type { TableInsertRow, TableUpdateRow } from 'on-zero'
import type * as schema from './tables'export type Channel = TableInsertRow
export type ChannelUpdate = TableUpdateRow
`tables.ts:
`ts
export { schema as channel } from '~/data/models/channel'
export { schema as message } from '~/data/models/message'
`syncedQueries.ts:
`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)
},
)
`$3
the generator:
1. scans
models/ for files with export const schema = table(...)
2. scans queries/ for exported arrow functions
3. parses TypeScript AST to extract parameter types
4. converts types to valibot schemas using typebox-codegen
5. wraps query functions in syncedQuery() with validators
6. handles special cases (void params, user → userPublic mapping)
7. groups query imports by source filequeries 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.setup
client:
`tsx
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
server="http://localhost:4848"
userID={user.id}
auth={jwtToken}
authData={{ id: user.id, email: user.email, role: user.role }}
>
`server:
`ts
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:
`ts
// 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
}
}
`mutation context
every mutation receives
MutatorContext as first argument:`ts
type MutatorContext = {
tx: Transaction // database transaction
authData: AuthData | null // current user
environment: 'server' | 'client' // where executing
can: (where, obj) => Promise // permission checker
server?: {
actions: ServerActions // async server functions
asyncTasks: AsyncAction[] // run after transaction
}
}
`use it:
`ts
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)
})
},
})
`patterns
server-only mutations:
`ts
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:
`ts
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-zero run is smart:
- on client, uses client
zero.run()
- on server, uses server zero.run()
- in a mutation, uses tx.run()this gives you a nice api.
preloading data (client only):
preload query results into cache without subscribing:
`ts
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:
`ts
const user = await zeroServer.query((q) => q.user.where('id', userId).one())
`batch processing:
`ts
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 },
)
``