Skip to content

Repository files navigation

on-zero

on-zero

makes zero really simple to use.

it's what we use for our takeout stack.

what it does

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.

queries

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.

query permissions

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

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)

permissions

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)

composable query partials

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)}

generation

on-zero has a CLI that auto-generates glue files that wire up your models, queries, and types.

cli commands

on-zero generate [dir]

generates all files needed to connect your models and queries:

  • models.ts - aggregates all model files into a single import
  • types.ts - generates TypeScript types from table schemas
  • tables.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:

# 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 .ts files 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/queries

what gets generated

models.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)},)

how it works

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 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.

setup

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}}

mutation context

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)})},})

patterns

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},)

About

A thin layer over @rocicorp/zero for Rails-like, DRY models, queries, mutations, permissions

Resources

Stars

40 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages