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

over-zero

v0.0.42

Published

<img src="./over-zero.svg" width="120" alt="over-zero" />

Downloads

604

Readme

over-zero

helpers for building offline-first apps with zero

what it does

over-zero provides three integrated systems on top of zero:

queries - plain functions that become synced queries mutations - server-validated operations with auto-generated crud permissions - composable access control checked at runtime

the package handles schema setup, type generation, and 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.ts
import { zql, where } from 'over-zero'

const permission = where('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 where():

const permission = where('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 where() helper automatically accesses auth data from queryContext() or mutatorContext() so you don't need to pass it manually.

mutations

define schema, permissions, and mutations together:

// src/data/models/message.ts
import { table, mutations, where } from 'over-zero'

export const schema = table('message')
  .columns({
    id: string(),
    content: string(),
    authorId: string(),
    channelId: string(),
    createdAt: number(),
  })
  .primaryKey('id')

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

// CRUD migrations 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)

permissions

permissions use the where() helper to create Zero ExpressionBuilder conditions:

export const permissions = where('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 where() 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 = where('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 = where('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)

generation

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

cli commands

over-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 over-zero generate

# generate and watch
bun over-zero generate --watch

# custom directory
bun over-zero generate ./app/data

# run linter after generation
bun over-zero generate --after "bun lint:fix"

over-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 over-zero generate-queries src/data/queries

what gets generated

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

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 'over-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 'over-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 'over-zero' {
  interface Config {
    schema: typeof schema
    authData: AuthData
  }
}

disableInlineQueries

pass disableInlineQueries: true to createZeroClient to prevent the footgun pattern of passing inline queries directly to useQuery:

const { useQuery } = createZeroClient({
  schema,
  models,
  groupedQueries,
  disableInlineQueries: true, // recommended
})

// ✅ allowed: function reference + params
const [posts] = useQuery(allPosts, { limit: 20 })

// ❌ type error: inline query bypasses synced queries and permissions
const [posts] = useQuery(zero.query.post.where('userId', id))

this prevents a common footgun where inline queries skip the synced query system and server-side permission checks, causing optimistic updates to be reverted.

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

client-side optimistic updates:

zero.mutate.message.update(message).client

wait for server confirmation:

const result = await zero.mutate.message.update(message).server

server-only mutations:

await zeroServer.mutate(async (tx, mutators) => {
  await mutators.user.insert(tx, user)
})

one-off queries:

const user = await zeroServer.query((q) => q.user.where('id', userId).one())

batch processing:

import { batchQuery } from 'over-zero'

await batchQuery(
  zql.message.where('processed', false),
  async (messages) => {
    for (const msg of messages) {
      await processMessage(msg)
    }
  },
  { chunk: 100, pause: 50 },
)