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.tsimport{zql,serverWhere}from'on-zero'constpermission=serverWhere('notification',(q,auth)=>{returnq.cmp('userId',auth?.id||'')})exportconstlatestNotifications=(props: {userId: stringserverId: string})=>{returnzql.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():
constpermission=serverWhere('channel',(q,auth)=>{if(auth?.role==='admin')returntruereturnq.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:
exportconstchannelById=(props: {channelId: string})=>{returnzql.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.tsimport{number,string,table}from'@rocicorp/zero'import{mutations,serverWhere}from'on-zero'exportconstschema=table('message').columns({id: string(),content: string(),authorId: string(),channelId: string(),createdAt: number(),}).primaryKey('id')exportconstpermissions=serverWhere('message',(q,auth)=>{returnq.cmp('authorId',auth?.id||'')})// CRUD mutations with permissions by passing schema + permissions:exportconstmutate=mutations(schema,permissions,{asyncsend(ctx,props: {content: string;channelId: string}){awaitctx.can(permissions,props)awaitctx.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()=>{awaitctx.server.actions.sendNotification(props)})}},})call mutations from react:
awaitzero.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:
exportconstpermissions=serverWhere('channel',(q,auth)=>{if(auth?.role==='admin')returntruereturnq.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.tsconstpermission=serverWhere('channel',(q,auth)=>{returnq.cmp('userId',auth?.id||'')})exportconstmyChannels=()=>{returnzql.channel.where(permission)}for mutations: define permissions in model files for CRUD operations:
// src/data/models/message.tsexportconstpermissions=serverWhere('message',(q,auth)=>{returnq.cmp('authorId',auth?.id||'')})CRUD mutations automatically apply them, but for custom mutations use can():
awaitctx.can(permissions,messageId)check permissions in React with usePermission():
constcanEdit=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.tsimport{serverWhere}from'on-zero'typeRelatedToServer='role'|'channel'|'message'exportconsthasServerAdminPermission=serverWhere<RelatedToServer>((_,auth)=>_.exists('server',(q)=>q.whereExists('role',(r)=>r.where('canAdmin',true).whereExists('member',(m)=>m.where('id',auth?.id||'')))))exportconsthasServerReadPermission=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.tsimport{serverWhere}from'on-zero'import{hasServerAdminPermission,hasServerReadPermission}from'./server'typeRelatedToChannel='message'|'pin'|'channelTopic'consthasChannelRole=serverWhere<RelatedToChannel>((_,auth)=>_.exists('channel',(q)=>q.whereExists('role',(r)=>r.whereExists('member',(m)=>m.where('id',auth?.id||'')))))exportconsthasChannelReadPermission=serverWhere<RelatedToChannel>((_,auth)=>{constisServerMember=hasServerReadPermission(_,auth)constisChannelMember=hasChannelRole(_,auth)constisAdmin=hasServerAdminPermission(_,auth)return_.or(isServerMember,isChannelMember,isAdmin)})use in queries:
import{hasChannelReadPermission}from'../where/channel'exportconstchannelMessages=(props: {channelId: string})=>{returnzql.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*aschannelfrom'~/data/models/channel'import*asmessagefrom'~/data/models/message'exportconstmodels={
channel,
message,}types.ts:
importtype{TableInsertRow,TableUpdateRow}from'on-zero'importtype*asschemafrom'./tables'exporttypeChannel=TableInsertRow<typeofschema.channel>exporttypeChannelUpdate=TableUpdateRow<typeofschema.channel>tables.ts:
export{schemaaschannel}from'~/data/models/channel'export{schemaasmessage}from'~/data/models/message'syncedQueries.ts:
import*asvfrom'valibot'import{syncedQuery}from'@rocicorp/zero'import*asmessageQueriesfrom'../queries/message'exportconstlatestMessages=syncedQuery('latestMessages',v.parser(v.tuple([v.object({channelId: v.string(),limit: v.optional(v.number()),}),]),),(arg)=>{returnmessageQueries.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*asgroupedQueriesfrom'~/data/generated/groupedQueries'exportconst{ ProvideZero, useQuery, zero, usePermission }=createZeroClient({
schema,
models,
groupedQueries,})// in your app root<ProvideZeroserver="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'exportconstzeroServer=createZeroServer({
schema,
models,database: process.env.DATABASE_URL,queries: syncedQueries,// required for synced queries / pull endpointcreateServerActions: ()=>({sendEmail: async(to,subject,body)=>{ ... }})})// push endpoint for mutationsapp.post('/api/zero/push',async(req)=>{constauthData=awaitgetAuthFromRequest(req)const{ response }=awaitzeroServer.handleMutationRequest({
authData,request: req})returnresponse})// pull endpoint for synced queriesapp.post('/api/zero/pull',async(req)=>{constauthData=awaitgetAuthFromRequest(req)const{ response }=awaitzeroServer.handleQueryRequest({
authData,request: req})returnresponse})type augmentation:
// src/zero/types.tsimporttype{schema}from'~/data/schema'importtype{AuthData}from'./auth'declare module 'on-zero'{interfaceConfig{schema: typeofschemaauthData: AuthData}}every mutation receives MutatorContext as first argument:
typeMutatorContext={tx: Transaction// database transactionauthData: AuthData|null// current userenvironment: 'server'|'client'// where executingcan: (where,obj)=>Promise<void>// permission checkerserver?: {actions: ServerActions// async server functionsasyncTasks: AsyncAction[]// run after transaction}}use it:
exportconstmutate=mutations(schema,permissions,{asyncarchive(ctx,{ messageId }){awaitctx.can(permissions,messageId)awaitctx.tx.mutate.message.update({id: messageId,archived: true})ctx.server?.asyncTasks.push(async()=>{awaitctx.server.actions.indexForSearch(messageId)})},})server-only mutations:
awaitzeroServer.mutate(async(tx,mutators)=>{awaitmutators.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 paramsconstuser=awaitrun(userById,{id: userId})// without paramsconstallUsers=awaitrun(allUsers)// with options (client only)constcached=awaitrun(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 loginconst{ complete, cleanup }=preload(userNotifications,{ userId,limit: 100})awaitcomplete// cleanup if neededcleanup()useful for prefetching data before navigation to avoid loading states.
server-only queries:
for ad-hoc queries that don't use query functions:
constuser=awaitzeroServer.query((q)=>q.user.where('id',userId).one())batch processing:
import{batchQuery}from'on-zero'awaitbatchQuery(zql.message.where('processed',false),async(messages)=>{for(constmsgofmessages){awaitprocessMessage(msg)}},{chunk: 100,pause: 50},)