on-zero 0.12.10-canary.1786069106964 → 0.12.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +1090 -0
  2. package/package.json +2 -2
package/README.md ADDED
@@ -0,0 +1,1090 @@
1
+ # on-zero
2
+
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="./on-zero-dark.svg">
5
+ <source media="(prefers-color-scheme: light)" srcset="./on-zero.svg">
6
+ <img src="./on-zero.svg" width="120" alt="on-zero">
7
+ </picture>
8
+
9
+ makes [zero](https://zero.rocicorp.dev) really simple to use.
10
+
11
+ it's what we use for our [takeout stack](https://takeout.tamagui.dev).
12
+
13
+ ## what it does
14
+
15
+ on-zero tries to bring Rails-like structure and DRY code to Zero + React.
16
+
17
+ it uses vanilla Zero and `zero-cache` by default. an experimental Orez Lite path
18
+ is noted separately at the end of setup.
19
+
20
+ it provides a few things:
21
+
22
+ - **generation** - cli with watch and generate commands
23
+ - **queries** - convert plain TS query functions into validated synced queries
24
+ - **mutations** - simply create CRUD mutations with permissions
25
+ - **drizzle-zero** - derive zero schema + relationships from your drizzle schema
26
+ - **permissions** - `serverWhere` for simple query-based permissions
27
+
28
+ plus various hooks and helpers for react integration.
29
+
30
+ each namespace is either one file exporting its queries and mutations, or a
31
+ folder with `queries.ts` and `mutations.ts`. queries use the global `zql`
32
+ builder. schema is derived from drizzle.
33
+
34
+ ## queries
35
+
36
+ write plain functions. they become synced queries automatically.
37
+
38
+ ```ts
39
+ // src/data/notification/queries.ts
40
+ import { zql, serverWhere } from 'on-zero'
41
+
42
+ const permission = serverWhere('notification', (q, auth) => {
43
+ return q.cmp('userId', auth?.id || '')
44
+ })
45
+
46
+ export const latestNotifications = (props: { userId: string; serverId: string }) => {
47
+ return zql.notification
48
+ .where(permission)
49
+ .where('userId', props.userId)
50
+ .where('serverId', props.serverId)
51
+ .orderBy('createdAt', 'desc')
52
+ .limit(20)
53
+ }
54
+ ```
55
+
56
+ zql is just the normal Zero query builder based on your typed schema.
57
+
58
+ use them:
59
+
60
+ ```tsx
61
+ const [data, state] = useQuery(latestNotifications, { userId, serverId })
62
+ ```
63
+
64
+ the function name becomes the query name. `useQuery` detects plain functions,
65
+ creates a cached `SyncedQuery` per function, and calls it with your params.
66
+
67
+ ### query permissions
68
+
69
+ define permissions inline using `serverWhere()`:
70
+
71
+ ```ts
72
+ const permission = serverWhere('channel', (q, auth) => {
73
+ if (auth?.role === 'admin') return true
74
+
75
+ return q.and(
76
+ q.cmp('deleted', '!=', true),
77
+ q.or(
78
+ q.cmp('private', false),
79
+ q.exists('role', (r) => r.whereExists('member', (m) => m.where('id', auth?.id)))
80
+ )
81
+ )
82
+ })
83
+ ```
84
+
85
+ then use in queries:
86
+
87
+ ```ts
88
+ export const channelById = (props: { channelId: string }) => {
89
+ return zql.channel.where(permission).where('id', props.channelId).one()
90
+ }
91
+ ```
92
+
93
+ permissions execute server-side only. on the client they automatically pass. the
94
+ `serverWhere()` helper automatically accesses auth data via `getAuth()` so you don't need to pass it manually.
95
+
96
+ ## mutations
97
+
98
+ mutations co-locate permissions and mutation handlers in one file. schema is
99
+ derived from drizzle — no need to define it here.
100
+
101
+ ```ts
102
+ // src/data/message/mutations.ts
103
+ import { ensureLoggedIn, mutations, serverWhere } from 'on-zero'
104
+
105
+ const permissions = serverWhere('message', (q, auth) => {
106
+ return q.cmp('authorId', auth?.id || '')
107
+ })
108
+
109
+ // pass table name as string — types are inferred from schema
110
+ export const mutate = mutations('message', permissions, {
111
+ async send(
112
+ ctx,
113
+ props: { id: string; content: string; channelId: string; createdAt: number }
114
+ ) {
115
+ const auth = ensureLoggedIn()
116
+
117
+ await ctx.tx.mutate.message.insert({
118
+ id: props.id,
119
+ content: props.content,
120
+ channelId: props.channelId,
121
+ authorId: auth.id,
122
+ createdAt: props.createdAt,
123
+ })
124
+ await ctx.can(permissions, props.id)
125
+
126
+ if (ctx.server) {
127
+ ctx.server.enqueueTask(async () => {
128
+ await ctx.server.actions.sendNotification(props)
129
+ })
130
+ }
131
+ },
132
+ })
133
+ ```
134
+
135
+ call mutations from react:
136
+
137
+ ```tsx
138
+ await zero.mutate.message.send({
139
+ id: randomId(),
140
+ content: 'hello',
141
+ channelId: 'ch-1',
142
+ createdAt: Date.now(),
143
+ })
144
+ ```
145
+
146
+ the second argument (`permissions`) enables auto-generated crud that checks
147
+ permissions:
148
+
149
+ ```tsx
150
+ zero.mutate.message.insert(message)
151
+ zero.mutate.message.update(message)
152
+ zero.mutate.message.delete(message)
153
+ zero.mutate.message.upsert(message)
154
+ ```
155
+
156
+ if you define `insert`, `upsert`, `update`, or `delete` in the third argument,
157
+ that handler replaces the generated operation completely. on-zero does not add
158
+ an automatic permission check or write. validate however the handler needs to;
159
+ `ctx.can()` is available for query-based permissions, but any thrown error
160
+ rejects and rolls back the transaction. when using `ctx.can()`, check before an
161
+ update or delete. for an insert, write first and then check so the permission
162
+ query can see the new row. a custom handler can no-op with a normal `return`.
163
+
164
+ ## permissions
165
+
166
+ on-zero's permissions system is optional - you can implement your own
167
+ permission logic however you like. `serverWhere()` is a light helper for
168
+ RLS-style permissions that automatically integrate with queries and mutations.
169
+
170
+ permissions use the `serverWhere()` helper to create Zero `ExpressionBuilder`
171
+ conditions:
172
+
173
+ ```ts
174
+ export const permissions = serverWhere('channel', (q, auth) => {
175
+ if (auth?.role === 'admin') return true
176
+
177
+ return q.or(
178
+ q.cmp('public', true),
179
+ q.exists('members', (m) => m.where('userId', auth?.id))
180
+ )
181
+ })
182
+ ```
183
+
184
+ the `serverWhere()` helper automatically gets auth data via `getAuth()`, so you don't manually pass it. permissions only execute
185
+ server-side - on the client they automatically pass.
186
+
187
+ **for queries:** define permissions inline as a constant in query files:
188
+
189
+ ```ts
190
+ // src/data/channel/queries.ts
191
+ const permission = serverWhere('channel', (q, auth) => {
192
+ return q.cmp('userId', auth?.id || '')
193
+ })
194
+
195
+ export const myChannels = () => {
196
+ return zql.channel.where(permission)
197
+ }
198
+ ```
199
+
200
+ **for mutations:** define permissions in mutation files for CRUD operations:
201
+
202
+ ```ts
203
+ // src/data/message/mutations.ts
204
+ const permissions = serverWhere('message', (q, auth) => {
205
+ return q.cmp('authorId', auth?.id || '')
206
+ })
207
+ ```
208
+
209
+ built-in CRUD mutations automatically apply them. custom mutations, including
210
+ CRUD overrides, own their validation. they can use `can()` for query-based
211
+ permissions or throw from any other validation:
212
+
213
+ ```ts
214
+ await ctx.can(permissions, messageId)
215
+ ```
216
+
217
+ check permissions in React with `usePermission()`:
218
+
219
+ ```tsx
220
+ const canEdit = usePermission('message', messageId)
221
+ ```
222
+
223
+ ### composable query partials
224
+
225
+ for complex or reusable query logic, create partials in a `where/` directory.
226
+ use `serverWhere` without a table name to create partials that work across
227
+ multiple tables:
228
+
229
+ ```ts
230
+ // src/data/where/server.ts
231
+ import { serverWhere } from 'on-zero'
232
+
233
+ type RelatedToServer = 'role' | 'channel' | 'message'
234
+
235
+ export const hasServerAdminPermission = serverWhere<RelatedToServer>((_, auth) =>
236
+ _.exists('server', (q) =>
237
+ q.whereExists('role', (r) =>
238
+ r
239
+ .where('canAdmin', true)
240
+ .whereExists('member', (m) => m.where('id', auth?.id || ''))
241
+ )
242
+ )
243
+ )
244
+
245
+ export const hasServerReadPermission = serverWhere<RelatedToServer>((_, auth) =>
246
+ _.exists('server', (q) =>
247
+ q.where((_) =>
248
+ _.or(
249
+ _.cmp('private', false),
250
+ _.exists('member', (m) => m.where('id', auth?.id || ''))
251
+ )
252
+ )
253
+ )
254
+ )
255
+ ```
256
+
257
+ then compose them in other permissions:
258
+
259
+ ```ts
260
+ // src/data/where/channel.ts
261
+ import { serverWhere } from 'on-zero'
262
+ import { hasServerAdminPermission, hasServerReadPermission } from './server'
263
+
264
+ type RelatedToChannel = 'message' | 'pin' | 'channelTopic'
265
+
266
+ const hasChannelRole = serverWhere<RelatedToChannel>((_, auth) =>
267
+ _.exists('channel', (q) =>
268
+ q.whereExists('role', (r) =>
269
+ r.whereExists('member', (m) => m.where('id', auth?.id || ''))
270
+ )
271
+ )
272
+ )
273
+
274
+ export const hasChannelReadPermission = serverWhere<RelatedToChannel>((_, auth) => {
275
+ const isServerMember = hasServerReadPermission(_, auth)
276
+ const isChannelMember = hasChannelRole(_, auth)
277
+ const isAdmin = hasServerAdminPermission(_, auth)
278
+
279
+ return _.or(isServerMember, isChannelMember, isAdmin)
280
+ })
281
+ ```
282
+
283
+ use in queries:
284
+
285
+ ```ts
286
+ import { hasChannelReadPermission } from '../where/channel'
287
+
288
+ export const channelMessages = (props: { channelId: string }) => {
289
+ return zql.message.where(hasChannelReadPermission).where('channelId', props.channelId)
290
+ }
291
+ ```
292
+
293
+ ## generation
294
+
295
+ on-zero auto-generates glue files that wire up your mutations, queries, and types.
296
+
297
+ ### vite plugin (recommended)
298
+
299
+ the vite plugin handles generation and HMR automatically:
300
+
301
+ ```ts
302
+ // vite.config.ts
303
+ import { onZeroPlugin } from 'on-zero/vite'
304
+
305
+ export default {
306
+ plugins: [
307
+ onZeroPlugin(),
308
+ // ... other plugins
309
+ ],
310
+ }
311
+ ```
312
+
313
+ **features:**
314
+
315
+ - generates on dev server start
316
+ - watches for mutation/query changes and regenerates
317
+ - enables HMR for mutations (no page reload when editing mutation files)
318
+ - generates before production builds
319
+
320
+ **options:**
321
+
322
+ ```ts
323
+ onZeroPlugin({
324
+ // path to data directory (default: 'src/data')
325
+ dataDir: 'src/data',
326
+
327
+ // additional paths to apply HMR fix to
328
+ hmrInclude: ['/src/zero/'],
329
+
330
+ // disable generation (HMR only)
331
+ disableGenerate: false,
332
+ })
333
+ ```
334
+
335
+ ### cli (alternative)
336
+
337
+ if you prefer CLI over the vite plugin:
338
+
339
+ **`on-zero generate [dir]`**
340
+
341
+ generates all files needed to connect your mutations and queries:
342
+
343
+ - `schema.ts` - zero schema derived from drizzle via drizzle-zero (tables +
344
+ relationships)
345
+ - `models.ts` - aggregates all mutation files into a single import
346
+ - `types.ts` - typescript types derived from the schema
347
+ - `syncedQueries.ts` - generates synced query definitions with valibot
348
+ validators
349
+ - `syncedMutations.ts` - generates valibot validators for mutation args
350
+ (auto-validation on server)
351
+
352
+ **options:**
353
+
354
+ - `dir` - base directory containing namespace files/folders (default: `src/data`)
355
+ - `--watch` - watch for changes and regenerate automatically
356
+ - `--after` - command to run after generation completes
357
+ - `--force` - ignore cached inputs and regenerate all outputs
358
+
359
+ **examples:**
360
+
361
+ ```bash
362
+ # generate once
363
+ bun on-zero generate
364
+
365
+ # generate and watch
366
+ bun on-zero generate --watch
367
+
368
+ # custom directory
369
+ bun on-zero generate ./app/data
370
+
371
+ # run linter after generation
372
+ bun on-zero generate --after "bun lint:fix"
373
+
374
+ # regenerate after upgrading schema or type dependencies
375
+ bun on-zero generate --force
376
+ ```
377
+
378
+ **types.ts:**
379
+
380
+ ```ts
381
+ import type { Row } from '@rocicorp/zero'
382
+ import type { schema } from './schema'
383
+
384
+ type Tables = typeof schema.tables
385
+
386
+ export type Channel = Row<Tables['channel']>
387
+ export type ChannelUpdate = Partial<Channel> & Pick<Channel, 'id'>
388
+ ```
389
+
390
+ **syncedQueries.ts:**
391
+
392
+ ```ts
393
+ import * as v from 'valibot'
394
+ import { syncedQuery } from '@rocicorp/zero'
395
+ import * as messageQueries from '../message/queries'
396
+
397
+ export const latestMessages = syncedQuery(
398
+ 'latestMessages',
399
+ v.parser(
400
+ v.tuple([
401
+ v.object({
402
+ channelId: v.string(),
403
+ limit: v.optional(v.number()),
404
+ }),
405
+ ])
406
+ ),
407
+ (arg) => {
408
+ return messageQueries.latestMessages(arg)
409
+ }
410
+ )
411
+ ```
412
+
413
+ ### how it works
414
+
415
+ the generator:
416
+
417
+ 1. discovers namespace files and folders, plus explicit multi-instance config
418
+ 2. derives each instance's related-table closure, mutation support tables, and scope
419
+ 3. parses TypeScript AST to extract parameter types
420
+ 4. converts types to valibot schemas
421
+ 5. wraps query functions in `syncedQuery()` with validators
422
+ 6. extracts mutation handler param types using the TS type checker (resolves
423
+ imports, aliases, and cross-file references)
424
+ 7. generates `syncedMutations.ts` with valibot validators for mutation args
425
+
426
+ when using drizzle-zero integration, `schema.ts` is generated from your drizzle
427
+ schema using `generateDrizzleSchemaFile()` — it produces `table()` +
428
+ `relationships()` + `createSchema()` calls with full type inference.
429
+
430
+ exports named `permission` are automatically skipped during query generation.
431
+
432
+ ### drizzle-zero integration
433
+
434
+ on-zero can derive your zero schema (tables + relationships) from a drizzle
435
+ schema via [drizzle-zero](https://github.com/rocicorp/drizzle-zero). this
436
+ eliminates duplicate column definitions — drizzle is the single source of truth.
437
+
438
+ ```ts
439
+ // generate-schema.ts (run at build/dev time)
440
+ import { drizzleZeroConfig } from 'drizzle-zero'
441
+ import {
442
+ deriveDataMembership,
443
+ generateDrizzleSchemaFile,
444
+ generateDrizzleSchemaInputFile,
445
+ } from 'on-zero/generate'
446
+ import * as drizzleSchema from './data/generated/drizzleSchema'
447
+
448
+ const { allTables } = await deriveDataMembership({ dir: 'src/data' })
449
+ writeFileSync(
450
+ 'src/data/generated/drizzleSchema.ts',
451
+ await generateDrizzleSchemaInputFile({
452
+ dir: 'src/data',
453
+ schemaImportPath: '../../database/schema',
454
+ })
455
+ )
456
+ const dzSchema = drizzleZeroConfig(drizzleSchema, {
457
+ tables: Object.fromEntries(allTables.map((table) => [table, true])),
458
+ suppressDefaultsWarning: true,
459
+ })
460
+
461
+ // generates a typed schema.ts with createSchema() + relationships()
462
+ const output = generateDrizzleSchemaFile(dzSchema)
463
+ writeFileSync('src/data/generated/schema.ts', output)
464
+ ```
465
+
466
+ `allTables` includes synced tables plus fileless tables reached through static
467
+ `tx.mutate.<table>` and `tx.query.<table>` accesses in mutation modules and their
468
+ local helpers. these support tables type server pushes but do not become client
469
+ query namespaces or part of `syncTables`. the generated drizzle input filters out
470
+ relations whose source or target is outside `allTables`.
471
+
472
+ the generated file uses zero's `table()` builder and `relationships()` function,
473
+ giving full type inference for zql queries including nested `.related()` calls.
474
+
475
+ mutations then reference tables by name:
476
+
477
+ ```ts
478
+ export const mutate = mutations('post', permissions, { ... })
479
+ ```
480
+
481
+ the `mutations()` string overload derives insert/update/delete types from the
482
+ global schema type — no need to import table builders.
483
+
484
+ ## setup
485
+
486
+ the supported setup uses vanilla Zero and `zero-cache`:
487
+
488
+ ```tsx
489
+ import { createZeroClient } from 'on-zero'
490
+ import { schema } from '~/data/generated/schema'
491
+ import { models } from '~/data/generated/models'
492
+ import * as groupedQueries from '~/data/generated/groupedQueries'
493
+
494
+ export const { ProvideZero, useQuery, zero, usePermission } = createZeroClient({
495
+ schema,
496
+ models,
497
+ groupedQueries,
498
+ })
499
+ ```
500
+
501
+ ### vanilla Zero
502
+
503
+ vanilla Zero uses the standard `zero-cache` server and its built-in WebSocket
504
+ transport. mount the shared client without a `transport` prop:
505
+
506
+ ```tsx
507
+ // in your app root
508
+ <ProvideZero
509
+ cacheURL="http://localhost:4848"
510
+ userID={user.id}
511
+ auth={sessionToken}
512
+ authData={{ id: user.id, email: user.email, role: user.role }}
513
+ >
514
+ <App />
515
+ </ProvideZero>
516
+ ```
517
+
518
+ configure `zero-cache`, `ZERO_QUERY_URL`, and `ZERO_MUTATE_URL` using the
519
+ standard [Zero installation guide](https://zero.rocicorp.dev/docs/install).
520
+
521
+ ### multiple client instances
522
+
523
+ one page can run several zero clients (e.g. a global control-plane instance
524
+ plus a per-project instance with its own storage key and sync url). add one
525
+ `on-zero.config.ts` at the data root. every instance is explicit; its `dir`
526
+ defaults to the instance key and otherwise resolves relative to the config file.
527
+
528
+ ```ts
529
+ // src/data/on-zero.config.ts
530
+ import { defineConfig } from 'on-zero'
531
+
532
+ export default defineConfig({
533
+ instances: {
534
+ default: { dir: '.', supportTables: ['accountRepo', 'usageLedger'] },
535
+ project: { dir: './project-data', scope: 'projectId' },
536
+ },
537
+ })
538
+ ```
539
+
540
+ single-instance applications omit the config and keep namespaces directly in
541
+ the data root. multi-instance applications may keep those root namespaces by
542
+ declaring an instance with `dir: '.'`; nested instance directories remain
543
+ independently owned. `instance.ts` and `defineInstance` were removed.
544
+
545
+ generation auto-discovers the config. the cli also accepts its explicit path:
546
+
547
+ ```sh
548
+ on-zero generate ./src/data/on-zero.config.ts
549
+ ```
550
+
551
+ generation derives each instance's queries, models, support tables, and
552
+ sync-table closure and rejects missing or multiply claimed directories,
553
+ duplicate namespaces, missing scope columns, and cross-instance reach.
554
+
555
+ ```tsx
556
+ import { createZeroClients } from 'on-zero/multi'
557
+ import { instances } from '~/data/generated/instances'
558
+
559
+ const clients = createZeroClients(instances)
560
+ const control = clients.clients.control
561
+ const project = clients.clients.project
562
+ const ProvideControlZero = clients.providers.control
563
+ const ProvideProjectZero = clients.providers.project
564
+
565
+ // useQuery/run/preload/getQuery dispatch by the query fn's namespace,
566
+ // zero.mutate.<namespace> dispatches by model namespace
567
+ export const { useQuery, zero, run, preload, getQuery, zeroEvents } = clients.combined
568
+ ;<ProvideControlZero cacheURL={controlUrl} userID={user.id}>
569
+ <ProvideProjectZero cacheURL={projectUrl} userID={`${user.id}:${projectId}`}>
570
+ <App />
571
+ </ProvideProjectZero>
572
+ </ProvideControlZero>
573
+ ```
574
+
575
+ constraints:
576
+
577
+ - each instance needs its own client-group identity (separate `userID` /
578
+ storage key / cache url) — never swap the backing namespace under a live
579
+ instance.
580
+ - single-instance apps can keep using plain `createZeroClient`.
581
+ - give the INNER slot to the instance owning the bulk of the subscriptions —
582
+ inner queries use zero-react's native context path. outer instances use the
583
+ direct adapter on their own mounted zero, so keep those instances on bounded,
584
+ low-fanout queries (current user, settings, directories).
585
+ - a mutator may only read/write tables owned by its own instance. its
586
+ transaction runs on that instance alone; cross-instance writes are not
587
+ detectable at registration and will silently miss the other store.
588
+ - omitting `instanceName` keeps the exact single-instance behavior.
589
+
590
+ ### server validation hooks
591
+
592
+ add custom validation for all queries and mutations:
593
+
594
+ ```ts
595
+ export const zeroBindings = createZeroServerBindings({
596
+ schema,
597
+ models,
598
+ queries: syncedQueries,
599
+ createServerActions: () => ({ ... }),
600
+
601
+ // validate all queries before execution (must be sync, throw to reject)
602
+ validateQuery({ authData, queryName, params }) {
603
+ if (queryName === 'adminOnlyQuery' && authData?.role !== 'admin') {
604
+ throw new Error('admin only')
605
+ }
606
+ },
607
+
608
+ // validate all mutations before execution (can be async)
609
+ async validateMutation({ authData, tableName, mutatorName, args }) {
610
+ if (tableName === 'user' && mutatorName === 'delete') {
611
+ await auditLog('user.delete', authData, args)
612
+ }
613
+ },
614
+
615
+ // admin role bypass for permissions (default: 'all')
616
+ // - 'all': admin bypasses both query and mutation permissions
617
+ // - 'queries': admin bypasses only query permissions
618
+ // - 'mutations': admin bypasses only mutation permissions
619
+ // - 'off': no admin bypass, normal permission checks apply
620
+ defaultAllowAdminRole: 'all',
621
+
622
+ })
623
+ ```
624
+
625
+ ### mutation arg validation
626
+
627
+ on-zero can auto-generate valibot validators for all mutation arguments. the
628
+ generator uses the TypeScript type checker to deeply resolve param types -
629
+ including imported types, aliases, and cross-file references - then converts them
630
+ to valibot schemas.
631
+
632
+ pass the generated `mutationValidators` to `createZeroServerBindings`:
633
+
634
+ ```ts
635
+ import { mutationValidators } from '~/data/generated/syncedMutations'
636
+
637
+ export const zeroBindings = createZeroServerBindings({
638
+ // ...
639
+ mutations: mutationValidators,
640
+ })
641
+ ```
642
+
643
+ this auto-validates args before every mutation runs. for a model like:
644
+
645
+ ```ts
646
+ export const mutate = mutations('message', permissions, {
647
+ async send(ctx, props: { content: string; channelId: string }) {
648
+ // ...
649
+ },
650
+ })
651
+ ```
652
+
653
+ the generator produces validators for both the CRUD operations (derived from the
654
+ schema columns) and custom mutations (derived from handler param types). if
655
+ validation fails, the mutation throws before executing.
656
+
657
+ the generated `syncedMutations.ts` looks like:
658
+
659
+ ```ts
660
+ import * as v from 'valibot'
661
+
662
+ export const mutationValidators = {
663
+ message: {
664
+ insert: v.object({ id: v.string(), content: v.string(), ... }),
665
+ update: v.object({ id: v.string(), content: v.optional(v.string()), ... }),
666
+ delete: v.object({ id: v.string() }),
667
+ send: v.object({ content: v.string(), channelId: v.string() }),
668
+ },
669
+ }
670
+ ```
671
+
672
+ validation runs before the `validateMutation` hook, so both layers stack:
673
+ valibot validates shape/types, then your custom hook can add business logic.
674
+
675
+ type augmentation:
676
+
677
+ ```ts
678
+ // src/zero/types.ts
679
+ import type { schema } from '~/data/schema'
680
+ import type { AuthData } from './auth'
681
+
682
+ declare module 'on-zero' {
683
+ interface Config {
684
+ schema: typeof schema
685
+ authData: AuthData
686
+ }
687
+ }
688
+ ```
689
+
690
+ ### Orez Lite (experimental)
691
+
692
+ Orez Lite is our custom Rust engine and a separate alternative to vanilla Zero.
693
+ it is still pre-alpha, so its setup is intentionally not documented here yet.
694
+
695
+ ## mutation context
696
+
697
+ every mutation receives `MutatorContext` as first argument:
698
+
699
+ ```ts
700
+ type MutatorContext = {
701
+ tx: Transaction // database transaction
702
+ authData: AuthData | null // current user
703
+ environment: 'server' | 'client' // where executing
704
+ can: (where, obj) => Promise<void> // permission checker
705
+ server?: {
706
+ actions: ServerActions // async server functions
707
+ enqueueTask(task: AsyncTask, opts?: { barrier?: boolean }): void
708
+ enqueueAction(action: AsyncAction, opts?: { barrier?: boolean }): void
709
+ }
710
+ }
711
+ ```
712
+
713
+ use it:
714
+
715
+ ```ts
716
+ export const mutate = mutations('message', permissions, {
717
+ async archive(ctx, { messageId }) {
718
+ await ctx.can(permissions, messageId)
719
+ await ctx.tx.mutate.message.update({ id: messageId, archived: true })
720
+
721
+ ctx.server?.enqueueTask(async () => {
722
+ await ctx.server.actions.indexForSearch(messageId)
723
+
724
+ // zeroServer.mutate works here too - authData is auto-inherited
725
+ await zeroServer.mutate.activity.insert({
726
+ id: randomId(),
727
+ type: 'archive',
728
+ messageId,
729
+ })
730
+ })
731
+ },
732
+ })
733
+ ```
734
+
735
+ `enqueueTask()` runs after the transaction commits and does not block the push
736
+ response by default. Pass `{ barrier: true }` only when the client's next writes
737
+ depend on the effect, such as provisioning a namespace before the client writes
738
+ through a new Zero instance.
739
+
740
+ ### typed async actions
741
+
742
+ for effects that may need to cross a worker or service-binding boundary, augment
743
+ `Config.asyncAction` with a discriminated union and configure one executor on the
744
+ server bindings:
745
+
746
+ ```ts
747
+ type AppAction =
748
+ | { type: 'project.provisionNamespace'; projectId: string; userId: string }
749
+ | { type: 'project.invalidateAccess'; projectId: string }
750
+
751
+ declare module 'on-zero' {
752
+ interface Config {
753
+ asyncAction: AppAction
754
+ }
755
+ }
756
+
757
+ const zeroBindings = createZeroServerBindings({
758
+ schema,
759
+ models,
760
+ createServerActions,
761
+ actions: {
762
+ execute: executeAppAction,
763
+ // when this runtime cannot execute app effects locally, inject a remote
764
+ // dispatcher. it becomes the only route; a failure never runs locally too.
765
+ dispatchRemote,
766
+ },
767
+ })
768
+ ```
769
+
770
+ mutators call `ctx.server?.enqueueAction(action, { barrier })`. on-zero schedules
771
+ it through the same post-commit task mechanism as `enqueueTask`, preserving the
772
+ barrier and auth scope without a global dispatcher.
773
+
774
+ ### awaiting and queueing mutations
775
+
776
+ each `createZeroClient` result owns settlement helpers and one serial background
777
+ queue:
778
+
779
+ ```ts
780
+ await client.awaitMutationClient(client.zero.mutate.note.update(note), 'save note')
781
+ await client.awaitMutationServer(client.zero.mutate.note.insert(note), 'create note')
782
+
783
+ void client.enqueueBackgroundMutation(
784
+ 'stream note',
785
+ () => client.zero.mutate.note.update(note),
786
+ { coalesceKey: `note:${note.id}` }
787
+ )
788
+ ```
789
+
790
+ the queue settles the client commit by default; use `settle: 'server'` only when
791
+ later work requires the authoritative server row. same-key work that has not
792
+ started is superseded by the newest write. recovery and instance replacement
793
+ fence queued and in-flight work internally. direct settlement rejects with
794
+ `StaleGenerationError`; the best-effort background queue drops that condition
795
+ quietly. `MutationTimeoutError`, `MutationResultError`, and
796
+ `mutationErrorMessage()` expose typed failure details.
797
+
798
+ ## getAuth
799
+
800
+ `getAuth()` returns the current user's auth data. works inside both queries and
801
+ mutations:
802
+
803
+ ```ts
804
+ import { getAuth } from 'on-zero'
805
+
806
+ const auth = getAuth() // AuthData | null
807
+ ```
808
+
809
+ it resolves auth from whichever context is active — mutation context, query
810
+ context, or client-side global. most of the time you won't need this directly
811
+ since `serverWhere()` passes auth to your callback automatically. use `getAuth()`
812
+ when you need auth data outside of those callbacks, like in a shared utility.
813
+
814
+ ### ensureAuth
815
+
816
+ `ensureAuth()` is the same as `getAuth()` but throws if the user is not
817
+ authenticated instead of returning null:
818
+
819
+ ```ts
820
+ import { ensureAuth } from 'on-zero'
821
+
822
+ const auth = ensureAuth() // AuthData (throws if not authenticated)
823
+ ```
824
+
825
+ ## recovery
826
+
827
+ on-zero self-heals a client whose local sync state is lost or rejected. this is
828
+ **on by default** — a consumer that passes nothing gets the full behavior. the
829
+ hooks below let you compose ONE extra behavior (gate the reload, reload
830
+ natively, drop a benign log, refresh auth) without re-implementing the stack.
831
+
832
+ ### what's on by default
833
+
834
+ `ProvideZero` installs Zero's `onUpdateNeeded` / `onClientStateNotFound` and a
835
+ log sink that watches for the fatal store-loss / desync signatures. on a match it
836
+ drops the affected instance's local store and reloads the page ONCE. this covers:
837
+
838
+ - **update-needed** — `SchemaVersionNotSupported` (drops local state, the rows
839
+ are now incompatible), `NewClientGroup` / `VersionNotSupported` (reload
840
+ without dropping, so a sibling tab's shared IndexedDB survives).
841
+ - **client-state-not-found** — the store is unusable; drop it and reload.
842
+ - **log-only fatals** — `Expected IndexedDB not found`, native sqlite
843
+ `This statement has been finalized`, and repeated `Store is closed`.
844
+ - **the mutation/connection desync class** — `sent mutation ID … but expected`,
845
+ `oooMutation`, `already processed`, `InvalidConnectionRequestBaseCookie` /
846
+ `…LastMutationID`, `ClientNotFound`, `connection userID mismatch`. these
847
+ surface only through the error log, so the log sink recovers on them too.
848
+
849
+ two consecutive server acknowledgement timeouts reconstruct the client in place
850
+ without deleting local state or reloading the page. one timeout remains a normal
851
+ slow-server failure. configure the threshold with
852
+ `serverAckTimeoutRecoveryThreshold` on `createZeroClient`.
853
+
854
+ ### the hooks (all optional props on `ProvideZero`)
855
+
856
+ - **`scheduleReload?: (ctx) => void`** — take over WHEN/HOW the recovery reload
857
+ happens. `ctx = { reason, reasonKey, dropLocalState, performReload }`. the
858
+ default is an immediate reload; inject this to gate it (only reload when the
859
+ user is on a safe surface), show a countdown toast, or reload natively —
860
+ then call `ctx.performReload()` to run the real deletes-then-reload work. the
861
+ store delete is deferred until `performReload` runs, so a gated reload never
862
+ leaves the app on an already-deleted store. `performReload` is idempotent.
863
+
864
+ ```tsx
865
+ // native (expo): reload the bundle instead of location.reload()
866
+ <ProvideZero
867
+ scheduleReload={(ctx) => {
868
+ void ctx
869
+ .performReload()
870
+ .then(() => Updates.reloadAsync())
871
+ .catch(() => DevSettings.reload())
872
+ }}
873
+ …
874
+ />
875
+ ```
876
+
877
+ - **`beforeReload?: () => Promise<void>`** — awaited right before the reload
878
+ (e.g. wait for the dev origin to come back so the reload doesn't hit a
879
+ restarting server). composes with `scheduleReload`.
880
+
881
+ - **`benignLogPatterns?: readonly (string | RegExp)[]`**: classified recovery
882
+ logs matching one of these patterns remain benign. a client transport can
883
+ provide its own patterns through `transport.logClassifications.benign`; app and
884
+ transport patterns are combined. the log still reaches the sink.
885
+
886
+ - **`refreshAuth?: () => Promise<string | undefined>`** — called when the
887
+ connection enters `needs-auth` (an expired token). return a fresh token and
888
+ on-zero reconnects in place — no reload. fires once per needs-auth transition.
889
+
890
+ - **`guardStorage?: { getItem, setItem }`** — the loop-guard's cross-reload
891
+ backing store. defaults to `sessionStorage` on web; inject a native KV
892
+ (MMKV/sqlite) on Hermes so native gets real cross-reload loop protection.
893
+
894
+ - **`connectionDataset?: boolean`** — mirror this instance's connection state
895
+ onto `document.body.dataset.zero*` (`zeroState`, `zeroConnected`,
896
+ `zeroReason`, `zeroCacheUrl`) for e2e/diagnostics. enable on ONE instance so
897
+ multiple instances don't clobber the dataset.
898
+
899
+ `zeroEvents` always carries a typed `reasonKey`. recovery events use
900
+ `ZeroRecoveryReasonKey`; connection errors use `connection-error` or
901
+ `connection-needs-auth`, so consumers can switch on stable keys instead of
902
+ matching message strings.
903
+
904
+ recoverable connection interruptions emit `{ type: 'reconnect', status:
905
+ 'trying' | 'waiting', reasonKey, reason }`, followed by `{ type: 'reconnect',
906
+ status: 'connected' }` after sync reconnects. `ServerOverloaded` stays in
907
+ Zero's built-in retry/backoff loop, transport errors such as `Failed to fetch`
908
+ resume the paused connection, and acknowledgement timeouts reconstruct the
909
+ client with its existing local state. none of these paths delete local state or
910
+ reload the page.
911
+
912
+ `createZeroClient` and a combined client also expose `reloadPage()`. it performs
913
+ a plain page reload and returns `false` where no page location exists. this is
914
+ the opt-in action for a host's reconnect toast; it never clears local state.
915
+
916
+ ### guard + latch semantics
917
+
918
+ - a **per-reason guard** (60s window) means the SAME reason re-failing right
919
+ after its reload is surfaced as `fatal` instead of reload-storming; distinct
920
+ reasons never suppress each other. it's two-tier: an in-memory map (real loop
921
+ protection within a page-load, works on Hermes) plus the cross-reload
922
+ `guardStorage` (survives the reload to catch an immediate re-fire).
923
+ - a **one-reload latch** means every affected instance of a combined client
924
+ drops its own store but only ONE reload fires. the latch **times out** (15s)
925
+ so a reload that never lands (a gated/native reload, a failed `reload()`)
926
+ can't kill recovery for the rest of the page's life.
927
+
928
+ ### remint — in-place recovery without a reload
929
+
930
+ `createZeroClient` returns **`remint(opts?)`** — the supported, native-safe
931
+ recovery path (a reload may never land on prod native, wedging the latch).
932
+ it drops the current instance's local store (unless `dropLocalState: false`) and
933
+ reconstructs a fresh Zero client in place, no page reload. it is rate-guarded
934
+ in-memory (12s between mints, 5 attempts before backing off, reset after 60s
935
+ stable) and returns `false` when suppressed. route your own
936
+ `onClientStateNotFound` to it if you need in-place recovery:
937
+
938
+ ```tsx
939
+ const { remint, ProvideZero } = createZeroClient({ … })
940
+ <ProvideZero onClientStateNotFound={() => { void remint() }} … />
941
+ ```
942
+
943
+ ### stale-poke resume (automatic)
944
+
945
+ a recoverable stale-cookie / stale-poke error (`Server returned unexpected base
946
+ cookie during sync`; `Received cookie … is < than last snapshot cookie … ignoring
947
+ client view`) is generic Zero behavior — on-zero's connection monitor reconnects
948
+ instead of surfacing a fatal error, deduped per reason. no configuration.
949
+
950
+ ## patterns
951
+
952
+ **server-only mutations:**
953
+
954
+ ```ts
955
+ await zeroServer.mutate.user.insert(user)
956
+
957
+ // with explicit auth (optional - authData auto-resolves from context)
958
+ await zeroServer.mutate.user.insert(user, { authData: { id: userId, email } })
959
+ ```
960
+
961
+ the second argument is an options object:
962
+
963
+ - `authData` — override auth for this call (optional, auto-resolves from context)
964
+
965
+ authData is automatically resolved in this order:
966
+
967
+ 1. explicit `authData` in options (if passed)
968
+ 2. current mutation context (inside a mutation)
969
+ 3. auth scope (inside async tasks - automatically inherited)
970
+
971
+ **one-off queries with `run()`:**
972
+
973
+ run a query once without subscribing. works on both client and server:
974
+
975
+ ```ts
976
+ import { run } from 'on-zero'
977
+ import { userById } from '~/data/user/queries'
978
+
979
+ // with params - defaults to cache only on client
980
+ const user = await run(userById, { id: userId })
981
+
982
+ // fetch from server (waits for sync)
983
+ const user = await run(userById, { id: userId }, 'complete')
984
+
985
+ // without params
986
+ const allUsers = await run(allUsers)
987
+
988
+ // without params, fetch from server
989
+ const allUsers = await run(allUsers, 'complete')
990
+ ```
991
+
992
+ on-zero run is smart:
993
+
994
+ - on client, uses client `zero.run()`
995
+ - on server, uses server `zero.run()`
996
+ - in a mutation, uses `tx.run()`
997
+
998
+ **getQuery — resolve a query object directly:**
999
+
1000
+ 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):
1001
+
1002
+ ```ts
1003
+ import { getQuery } from '~/zero/client'
1004
+ import { postById } from '~/data/post/queries'
1005
+
1006
+ // returns the zero query object — same as what useQuery resolves internally
1007
+ const query = getQuery(postById, { postId: '123' })
1008
+
1009
+ // pass to any hook that accepts a zero query directly
1010
+ const [rows] = useRows(getQuery(feedPosts, { limit: 50 }))
1011
+ ```
1012
+
1013
+ same signature as `useQuery` — `getQuery(fn, params?)`.
1014
+
1015
+ **preloading data (client only):**
1016
+
1017
+ preload query results into cache without subscribing:
1018
+
1019
+ ```ts
1020
+ import { preload } from '~/zero/client'
1021
+ import { userNotifications } from '~/data/notification/queries'
1022
+
1023
+ // preload after login
1024
+ const { complete, cleanup } = preload(userNotifications, { userId, limit: 100 })
1025
+ await complete
1026
+
1027
+ // cleanup if needed
1028
+ cleanup()
1029
+ ```
1030
+
1031
+ useful for prefetching data before navigation to avoid loading states.
1032
+
1033
+ **server-only queries:**
1034
+
1035
+ for ad-hoc queries that don't use query functions:
1036
+
1037
+ ```ts
1038
+ const user = await zeroServer.query({ userID: userId }, (q) =>
1039
+ q.user.where('id', userId).one()
1040
+ )
1041
+ ```
1042
+
1043
+ **controlling queries with `ControlQueries`:**
1044
+
1045
+ disable all `useQuery` and `usePermission` calls within a subtree. useful for
1046
+ hiding screens, background tabs, or any UI where you want to pause syncing:
1047
+
1048
+ ```tsx
1049
+ import { ControlQueries } from '~/zero/client'
1050
+
1051
+ // disable queries, returns null for all useQuery/usePermission calls
1052
+ <ControlQueries action="disable">
1053
+ <ExpensiveScreen />
1054
+ </ControlQueries>
1055
+
1056
+ // disable but keep returning the last value (no flash to empty)
1057
+ <ControlQueries action="disable" whenDisabled="last-value">
1058
+ <ExpensiveScreen />
1059
+ </ControlQueries>
1060
+
1061
+ // re-enable inside a disabled subtree
1062
+ <ControlQueries action="disable" whenDisabled="last-value">
1063
+ <ControlQueries action="enable">
1064
+ <AlwaysLiveWidget />
1065
+ </ControlQueries>
1066
+ </ControlQueries>
1067
+ ```
1068
+
1069
+ props:
1070
+
1071
+ - `action` — `'enable' | 'disable'` (default `'disable'`)
1072
+ - `whenDisabled` — `'empty' | 'last-value'` (default `'empty'`)
1073
+ - `'empty'` — queries return `[null, { type: 'unknown' }]`
1074
+ - `'last-value'` — queries return their most recent result
1075
+
1076
+ **batch processing:**
1077
+
1078
+ ```ts
1079
+ import { batchQuery } from 'on-zero'
1080
+
1081
+ await batchQuery(
1082
+ zql.message.where('processed', false),
1083
+ async (messages) => {
1084
+ for (const msg of messages) {
1085
+ await processMessage(msg)
1086
+ }
1087
+ },
1088
+ { chunk: 100, pause: 50 }
1089
+ )
1090
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "on-zero",
3
- "version": "0.12.10-canary.1786069106964",
3
+ "version": "0.12.11",
4
4
  "description": "A typed layer over @rocicorp/zero with queries, mutations, and permissions",
5
5
  "sideEffects": false,
6
6
  "files": [
@@ -105,7 +105,7 @@
105
105
  "chokidar": "^4.0.3",
106
106
  "citty": "^0.1.6",
107
107
  "dequal": "^2.0.3",
108
- "orez-lite": "0.12.10-canary.1786069106964",
108
+ "orez-lite": "0.12.11",
109
109
  "valibot": "^1.1.0"
110
110
  },
111
111
  "peerDependencies": {