npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

on-zero

v0.4.4

Published

A typed layer over @rocicorp/zero with queries, mutations, and permissions

Downloads

4,368

Readme

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 mutations with permissions
  • drizzle-zero - derive zero schema + relationships from your drizzle schema
  • permissions - serverWhere for simple query-based permissions

plus various hooks and helpers for react integration.

mutations live in mutations/ with their permissions. queries are just functions that use a global zql builder. schema is derived from drizzle.

queries

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.

query permissions

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 via getAuth() so you don't need to pass it manually.

mutations

mutations co-locate permissions and mutation handlers in one file. schema is derived from drizzle — no need to define it here.

// src/data/mutations/message.ts
import { mutations, serverWhere } from 'on-zero'

const permissions = serverWhere('message', (q, auth) => {
  return q.cmp('authorId', auth?.id || '')
})

// pass table name as string — types are inferred from schema
export const mutate = mutations('message', 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)

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:

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 via getAuth(), 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 mutation files for CRUD operations:

// src/data/mutations/message.ts
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)

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

generation

on-zero auto-generates glue files that wire up your mutations, queries, and types.

vite plugin (recommended)

the vite plugin handles generation and HMR automatically:

// vite.config.ts
import { onZeroPlugin } from 'on-zero/vite'

export default {
  plugins: [
    onZeroPlugin(),
    // ... other plugins
  ]
}

features:

  • generates on dev server start
  • watches for mutation/query changes and regenerates
  • enables HMR for mutations (no page reload when editing mutation files)
  • generates before production builds

options:

onZeroPlugin({
  // path to data directory (default: 'src/data')
  dataDir: 'src/data',

  // additional paths to apply HMR fix to
  hmrInclude: ['/src/zero/'],

  // disable generation (HMR only)
  disableGenerate: false,
})

cli (alternative)

if you prefer CLI over the vite plugin:

on-zero generate [dir]

generates all files needed to connect your mutations and queries:

  • schema.ts - zero schema derived from drizzle via drizzle-zero (tables + relationships)
  • models.ts - aggregates all mutation files into a single import
  • types.ts - typescript types derived from the schema
  • syncedQueries.ts - generates synced query definitions with valibot validators
  • syncedMutations.ts - generates valibot validators for mutation args (auto-validation on server)

options:

  • dir - base directory containing mutations/ 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"

types.ts:

import type { Row } from '@rocicorp/zero'
import type { schema } from './schema'

type Tables = typeof schema.tables

export type Channel = Row<Tables['channel']>
export type ChannelUpdate = Partial<Channel> & Pick<Channel, 'id'>

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

how it works

the generator:

  1. scans mutations/ for files with export const mutate
  2. scans queries/ for exported arrow functions
  3. parses TypeScript AST to extract parameter types
  4. converts types to valibot schemas
  5. wraps query functions in syncedQuery() with validators
  6. extracts mutation handler param types using the TS type checker (resolves imports, aliases, and cross-file references)
  7. generates syncedMutations.ts with valibot validators for mutation args

when using drizzle-zero integration, schema.ts is generated from your drizzle schema using generateDrizzleSchemaFile() — it produces table() + relationships() + createSchema() calls with full type inference.

exports named permission are automatically skipped during query generation.

drizzle-zero integration

on-zero can derive your zero schema (tables + relationships) from a drizzle schema via drizzle-zero. this eliminates duplicate column definitions — drizzle is the single source of truth.

// generate-schema.ts (run at build/dev time)
import { drizzleZeroConfig } from 'drizzle-zero'
import { generateDrizzleSchemaFile } from 'on-zero'
import * as drizzleSchema from './database/schema'
import { relations } from './database/relations'

const dzSchema = drizzleZeroConfig(
  { ...drizzleSchema, relations },
  {
    tables: {
      user: true,
      post: true,
      comment: true,
    },
    suppressDefaultsWarning: true,
  },
)

// generates a typed schema.ts with createSchema() + relationships()
const output = generateDrizzleSchemaFile(dzSchema)
writeFileSync('src/data/generated/schema.ts', output)

the generated file uses zero's table() builder and relationships() function, giving full type inference for zql queries including nested .related() calls.

mutations then reference tables by name:

export const mutate = mutations('post', permissions, { ... })

the mutations() string overload derives insert/update/delete types from the global schema type — no need to import table builders.

setup

client:

import { createZeroClient } from 'on-zero'
import { schema } from '~/data/generated/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={sessionToken}
  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'
import { mutationValidators } from '~/data/generated/syncedMutations'

export const zeroServer = createZeroServer({
  schema,
  models,
  database: process.env.DATABASE_URL,
  queries: syncedQueries, // required for synced queries / pull endpoint
  mutations: mutationValidators, // auto-validates mutation args with valibot
  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
})

server validation hooks

add custom validation for all queries and mutations:

export const zeroServer = createZeroServer({
  schema,
  models,
  database: process.env.DATABASE_URL,
  queries: syncedQueries,
  createServerActions: () => ({ ... }),

  // validate all queries before execution (must be sync, throw to reject)
  validateQuery({ authData, queryName, params }) {
    if (queryName === 'adminOnlyQuery' && authData?.role !== 'admin') {
      throw new Error('admin only')
    }
  },

  // validate all mutations before execution (can be async)
  async validateMutation({ authData, tableName, mutatorName, args }) {
    if (tableName === 'user' && mutatorName === 'delete') {
      await auditLog('user.delete', authData, args)
    }
  },

  // admin role bypass for permissions (default: 'all')
  // - 'all': admin bypasses both query and mutation permissions
  // - 'queries': admin bypasses only query permissions
  // - 'mutations': admin bypasses only mutation permissions
  // - 'off': no admin bypass, normal permission checks apply
  defaultAllowAdminRole: 'all',

  // default authData for zeroServer.mutate when none is provided or in scope
  // defaults to {}
  defaultMutateAuthData: { id: 'system', email: '[email protected]' },
})

mutation arg validation

on-zero can auto-generate valibot validators for all mutation arguments. the generator uses the TypeScript type checker to deeply resolve param types - including imported types, aliases, and cross-file references - then converts them to valibot schemas.

pass the generated mutationValidators to createZeroServer:

import { mutationValidators } from '~/data/generated/syncedMutations'

export const zeroServer = createZeroServer({
  // ...
  mutations: mutationValidators,
})

this auto-validates args before every mutation runs. for a model like:

export const mutate = mutations('message', permissions, {
  async send(ctx, props: { content: string; channelId: string }) {
    // ...
  },
})

the generator produces validators for both the CRUD operations (derived from the schema columns) and custom mutations (derived from handler param types). if validation fails, the mutation throws before executing.

the generated syncedMutations.ts looks like:

import * as v from 'valibot'

export const mutationValidators = {
  message: {
    insert: v.object({ id: v.string(), content: v.string(), ... }),
    update: v.object({ id: v.string(), content: v.optional(v.string()), ... }),
    delete: v.object({ id: v.string() }),
    send: v.object({ content: v.string(), channelId: v.string() }),
  },
}

validation runs before the validateMutation hook, so both layers stack: valibot validates shape/types, then your custom hook can add business logic.

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

mutation context

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('message', 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)

      // zeroServer.mutate works here too - authData is auto-inherited
      await zeroServer.mutate.activity.insert({
        id: randomId(),
        type: 'archive',
        messageId,
      })
    })
  },
})

getAuth

getAuth() returns the current user's auth data. works inside both queries and mutations:

import { getAuth } from 'on-zero'

const auth = getAuth() // AuthData | null

it resolves auth from whichever context is active — mutation context, query context, or client-side global. most of the time you won't need this directly since serverWhere() passes auth to your callback automatically. use getAuth() when you need auth data outside of those callbacks, like in a shared utility.

ensureAuth

ensureAuth() is the same as getAuth() but throws if the user is not authenticated instead of returning null:

import { ensureAuth } from 'on-zero'

const auth = ensureAuth() // AuthData (throws if not authenticated)

patterns

server-only mutations:

await zeroServer.mutate.user.insert(user)

// with explicit auth (optional - authData auto-resolves from context)
await zeroServer.mutate.user.insert(user, { authData: { id: userId, email } })

// await async tasks (effects) before returning
await zeroServer.mutate.user.insert(user, { awaitEffects: true })

the second argument is an options object:

  • authData — override auth for this call (optional, auto-resolves from context)
  • awaitEffects — if true, awaits async tasks before returning (default: fire-and-forget)

authData is automatically resolved in this order:

  1. explicit authData in options (if passed)
  2. current mutation context (inside a mutation)
  3. auth scope (inside asyncTasks - automatically inherited)
  4. defaultMutateAuthData from createZeroServer config (defaults to {})

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 - defaults to cache only on client
const user = await run(userById, { id: userId })

// fetch from server (waits for sync)
const user = await run(userById, { id: userId }, 'complete')

// without params
const allUsers = await run(allUsers)

// without params, fetch from server
const allUsers = await run(allUsers, 'complete')

on-zero run is smart:

  • on client, uses client zero.run()
  • on server, uses server zero.run()
  • in a mutation, uses tx.run()

getQuery — resolve a query object directly:

use getQuery when you need the raw zero query object rather than subscribing via useQuery. useful for passing to third-party hooks that accept zero query objects directly (e.g. virtualized list hooks):

import { getQuery } from '~/zero/client'
import { postById } from '~/data/queries/post'

// returns the zero query object — same as what useQuery resolves internally
const query = getQuery(postById, { postId: '123' })

// pass to any hook that accepts a zero query directly
const [rows] = useRows(getQuery(feedPosts, { limit: 50 }))

same signature as useQuerygetQuery(fn, params?).

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

controlling queries with ControlQueries:

disable all useQuery and usePermission calls within a subtree. useful for hiding screens, background tabs, or any UI where you want to pause syncing:

import { ControlQueries } from '~/zero/client'

// disable queries, returns null for all useQuery/usePermission calls
<ControlQueries action="disable">
  <ExpensiveScreen />
</ControlQueries>

// disable but keep returning the last value (no flash to empty)
<ControlQueries action="disable" whenDisabled="last-value">
  <ExpensiveScreen />
</ControlQueries>

// re-enable inside a disabled subtree
<ControlQueries action="disable" whenDisabled="last-value">
  <ControlQueries action="enable">
    <AlwaysLiveWidget />
  </ControlQueries>
</ControlQueries>

props:

  • action'enable' | 'disable' (default 'disable')
  • whenDisabled'empty' | 'last-value' (default 'empty')
    • 'empty' — queries return [null, { type: 'unknown' }]
    • 'last-value' — queries return their most recent result

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