on-zero 0.16.0 → 0.16.1

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/package.json +2 -2
  2. package/README.md +0 -1140
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "on-zero",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
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.16.0",
108
+ "orez-lite": "0.16.1",
109
109
  "valibot": "^1.1.0"
110
110
  },
111
111
  "peerDependencies": {
package/README.md DELETED
@@ -1,1140 +0,0 @@
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
- - `aggregates.ts` - compiles every namespace's aggregate declarations into one set
347
- - `types.ts` - typescript types derived from the schema
348
- - `syncedQueries.ts` - generates synced query definitions with valibot
349
- validators
350
- - `syncedMutations.ts` - generates valibot validators for mutation args
351
- (auto-validation on server)
352
-
353
- **options:**
354
-
355
- - `dir` - base directory containing namespace files/folders (default: `src/data`)
356
- - `--watch` - watch for changes and regenerate automatically
357
- - `--after` - command to run after generation completes
358
- - `--force` - ignore cached inputs and regenerate all outputs
359
-
360
- **examples:**
361
-
362
- ```bash
363
- # generate once
364
- bun on-zero generate
365
-
366
- # generate and watch
367
- bun on-zero generate --watch
368
-
369
- # custom directory
370
- bun on-zero generate ./app/data
371
-
372
- # run linter after generation
373
- bun on-zero generate --after "bun lint:fix"
374
-
375
- # regenerate after upgrading schema or type dependencies
376
- bun on-zero generate --force
377
- ```
378
-
379
- **types.ts:**
380
-
381
- ```ts
382
- import type { Row } from '@rocicorp/zero'
383
- import type { schema } from './schema'
384
-
385
- type Tables = typeof schema.tables
386
-
387
- export type Channel = Row<Tables['channel']>
388
- export type ChannelUpdate = Partial<Channel> & Pick<Channel, 'id'>
389
- ```
390
-
391
- **syncedQueries.ts:**
392
-
393
- ```ts
394
- import * as v from 'valibot'
395
- import { syncedQuery } from '@rocicorp/zero'
396
- import * as messageQueries from '../message/queries'
397
-
398
- export const latestMessages = syncedQuery(
399
- 'latestMessages',
400
- v.parser(
401
- v.tuple([
402
- v.object({
403
- channelId: v.string(),
404
- limit: v.optional(v.number()),
405
- }),
406
- ])
407
- ),
408
- (arg) => {
409
- return messageQueries.latestMessages(arg)
410
- }
411
- )
412
- ```
413
-
414
- ### how it works
415
-
416
- the generator:
417
-
418
- 1. discovers namespace files and folders, plus explicit multi-instance config
419
- 2. derives each instance's related-table closure, mutation support tables, and scope
420
- 3. parses TypeScript AST to extract parameter types
421
- 4. converts types to valibot schemas
422
- 5. wraps query functions in `syncedQuery()` with validators
423
- 6. extracts mutation handler param types using the TS type checker (resolves
424
- imports, aliases, and cross-file references)
425
- 7. generates `syncedMutations.ts` with valibot validators for mutation args
426
-
427
- when using drizzle-zero integration, `schema.ts` is generated from your drizzle
428
- schema using `generateDrizzleSchemaFile()` — it produces `table()` +
429
- `relationships()` + `createSchema()` calls with full type inference.
430
-
431
- exports named `permission` are automatically skipped during query generation.
432
-
433
- ### drizzle-zero integration
434
-
435
- on-zero can derive your zero schema (tables + relationships) from a drizzle
436
- schema via [drizzle-zero](https://github.com/rocicorp/drizzle-zero). this
437
- eliminates duplicate column definitions — drizzle is the single source of truth.
438
-
439
- ```ts
440
- // generate-schema.ts (run at build/dev time)
441
- import { drizzleZeroConfig } from 'drizzle-zero'
442
- import {
443
- deriveDataMembership,
444
- generateDrizzleSchemaFile,
445
- generateDrizzleSchemaInputFile,
446
- } from 'on-zero/generate'
447
- import * as drizzleSchema from './data/generated/drizzleSchema'
448
-
449
- const { allTables } = await deriveDataMembership({ dir: 'src/data' })
450
- writeFileSync(
451
- 'src/data/generated/drizzleSchema.ts',
452
- await generateDrizzleSchemaInputFile({
453
- dir: 'src/data',
454
- schemaImportPath: '../../database/schema',
455
- })
456
- )
457
- const dzSchema = drizzleZeroConfig(drizzleSchema, {
458
- tables: Object.fromEntries(allTables.map((table) => [table, true])),
459
- suppressDefaultsWarning: true,
460
- })
461
-
462
- // generates a typed schema.ts with createSchema() + relationships()
463
- const output = generateDrizzleSchemaFile(dzSchema)
464
- writeFileSync('src/data/generated/schema.ts', output)
465
- ```
466
-
467
- `allTables` includes synced tables plus fileless tables reached through static
468
- `tx.mutate.<table>` and `tx.query.<table>` accesses in mutation modules and their
469
- local helpers. these support tables type server pushes but do not become client
470
- query namespaces or part of `syncTables`. the generated drizzle input filters out
471
- relations whose source or target is outside `allTables`.
472
-
473
- the generated file uses zero's `table()` builder and `relationships()` function,
474
- giving full type inference for zql queries including nested `.related()` calls.
475
-
476
- mutations then reference tables by name:
477
-
478
- ```ts
479
- export const mutate = mutations('post', permissions, { ... })
480
- ```
481
-
482
- the `mutations()` string overload derives insert/update/delete types from the
483
- global schema type — no need to import table builders.
484
-
485
- ## setup
486
-
487
- the supported setup uses vanilla Zero and `zero-cache`:
488
-
489
- ```tsx
490
- import { createZeroClient } from 'on-zero'
491
- import { schema } from '~/data/generated/schema'
492
- import { models } from '~/data/generated/models'
493
- import { aggregates } from '~/data/generated/aggregates'
494
- import * as groupedQueries from '~/data/generated/groupedQueries'
495
-
496
- export const { ProvideZero, useQuery, zero, usePermission } = createZeroClient({
497
- schema,
498
- models,
499
- groupedQueries,
500
- aggregates,
501
- })
502
- ```
503
-
504
- ### vanilla Zero
505
-
506
- vanilla Zero uses the standard `zero-cache` server and its built-in WebSocket
507
- transport. mount the shared client without a `transport` prop:
508
-
509
- ```tsx
510
- // in your app root
511
- <ProvideZero
512
- cacheURL="http://localhost:4848"
513
- userID={user.id}
514
- auth={sessionToken}
515
- authData={{ id: user.id, email: user.email, role: user.role }}
516
- >
517
- <App />
518
- </ProvideZero>
519
- ```
520
-
521
- configure `zero-cache`, `ZERO_QUERY_URL`, and `ZERO_MUTATE_URL` using the
522
- standard [Zero installation guide](https://zero.rocicorp.dev/docs/install).
523
-
524
- `ProvideZero` creates the client during its own render, so descendants get a
525
- live instance in their FIRST render — `zero.query`, `zero.preload`, and a
526
- `transport` all work without waiting for an effect. that matters in any host
527
- where passive effects are delayed or never flush. the exception is a server
528
- runtime, where `ProvideZero` creates nothing and descendants get the inert stub
529
- with every `useQuery` returning its empty shape.
530
-
531
- ### multiple client instances
532
-
533
- one page can run several zero clients (e.g. a global control-plane instance
534
- plus a per-project instance with its own storage key and sync url). add one
535
- `on-zero.config.ts` at the data root. every instance is explicit; its `dir`
536
- defaults to the instance key and otherwise resolves relative to the config file.
537
-
538
- ```ts
539
- // src/data/on-zero.config.ts
540
- import { defineConfig } from 'on-zero'
541
-
542
- export default defineConfig({
543
- instances: {
544
- default: { dir: '.', supportTables: ['accountRepo', 'usageLedger'] },
545
- project: { dir: './project-data', scope: 'projectId' },
546
- },
547
- })
548
- ```
549
-
550
- single-instance applications omit the config and keep namespaces directly in
551
- the data root. multi-instance applications may keep those root namespaces by
552
- declaring an instance with `dir: '.'`; nested instance directories remain
553
- independently owned. `instance.ts` and `defineInstance` were removed.
554
-
555
- generation auto-discovers the config. the cli also accepts its explicit path:
556
-
557
- ```sh
558
- on-zero generate ./src/data/on-zero.config.ts
559
- ```
560
-
561
- generation derives each instance's queries, models, support tables, and
562
- sync-table closure and rejects missing or multiply claimed directories,
563
- duplicate namespaces, missing scope columns, and cross-instance reach.
564
-
565
- ```tsx
566
- import { createZeroClients } from 'on-zero/multi'
567
- import { instances } from '~/data/generated/instances'
568
-
569
- const clients = createZeroClients(instances)
570
- const control = clients.clients.control
571
- const project = clients.clients.project
572
- const ProvideControlZero = clients.providers.control
573
- const ProvideProjectZero = clients.providers.project
574
-
575
- // useQuery/run/preload/getQuery dispatch by the query fn's namespace,
576
- // zero.mutate.<namespace> dispatches by model namespace, and the mutation
577
- // lifecycle helpers dispatch to the instance that issued the mutation
578
- export const {
579
- useQuery,
580
- zero,
581
- run,
582
- preload,
583
- getQuery,
584
- zeroEvents,
585
- awaitMutationClient,
586
- awaitMutationServer,
587
- drainBackgroundMutations,
588
- enqueueBackgroundMutation,
589
- } = clients.combined
590
- ;<ProvideControlZero cacheURL={controlUrl} userID={user.id}>
591
- <ProvideProjectZero cacheURL={projectUrl} userID={`${user.id}:${projectId}`}>
592
- <App />
593
- </ProvideProjectZero>
594
- </ProvideControlZero>
595
- ```
596
-
597
- constraints:
598
-
599
- - each instance needs its own client-group identity (separate `userID` /
600
- storage key / cache url) — never swap the backing namespace under a live
601
- instance.
602
- - single-instance apps can keep using plain `createZeroClient`.
603
- - give the INNER slot to the instance owning the bulk of the subscriptions —
604
- inner queries use zero-react's native context path. outer instances use the
605
- direct adapter on their own mounted zero, so keep those instances on bounded,
606
- low-fanout queries (current user, settings, directories).
607
- - a mutator may only read/write tables owned by its own instance. its
608
- transaction runs on that instance alone; cross-instance writes are not
609
- detectable at registration and will silently miss the other store.
610
- - take `enqueueBackgroundMutation` from `clients.combined`. a per-instance queue
611
- gives the app a second serial queue with its own coalescing map, so same-key
612
- writes stop superseding each other across instances.
613
- - omitting `instanceName` keeps the exact single-instance behavior.
614
-
615
- ### server validation hooks
616
-
617
- add custom validation for all queries and mutations:
618
-
619
- ```ts
620
- export const zeroBindings = createZeroServerBindings({
621
- schema,
622
- models,
623
- queries: syncedQueries,
624
- createServerActions: () => ({ ... }),
625
-
626
- // validate all queries before execution (must be sync, throw to reject)
627
- validateQuery({ authData, queryName, params }) {
628
- if (queryName === 'adminOnlyQuery' && authData?.role !== 'admin') {
629
- throw new Error('admin only')
630
- }
631
- },
632
-
633
- // validate all mutations before execution (can be async)
634
- async validateMutation({ authData, tableName, mutatorName, args }) {
635
- if (tableName === 'user' && mutatorName === 'delete') {
636
- await auditLog('user.delete', authData, args)
637
- }
638
- },
639
-
640
- // admin role bypass for permissions (default: 'all')
641
- // - 'all': admin bypasses both query and mutation permissions
642
- // - 'queries': admin bypasses only query permissions
643
- // - 'mutations': admin bypasses only mutation permissions
644
- // - 'off': no admin bypass, normal permission checks apply
645
- defaultAllowAdminRole: 'all',
646
-
647
- })
648
- ```
649
-
650
- ### mutation arg validation
651
-
652
- on-zero can auto-generate valibot validators for all mutation arguments. the
653
- generator uses the TypeScript type checker to deeply resolve param types -
654
- including imported types, aliases, and cross-file references - then converts them
655
- to valibot schemas.
656
-
657
- pass the generated `mutationValidators` to `createZeroServerBindings`:
658
-
659
- ```ts
660
- import { mutationValidators } from '~/data/generated/syncedMutations'
661
-
662
- export const zeroBindings = createZeroServerBindings({
663
- // ...
664
- mutations: mutationValidators,
665
- })
666
- ```
667
-
668
- this auto-validates args before every mutation runs. for a model like:
669
-
670
- ```ts
671
- export const mutate = mutations('message', permissions, {
672
- async send(ctx, props: { content: string; channelId: string }) {
673
- // ...
674
- },
675
- })
676
- ```
677
-
678
- the generator produces validators for both the CRUD operations (derived from the
679
- schema columns) and custom mutations (derived from handler param types). if
680
- validation fails, the mutation throws before executing.
681
-
682
- the generated `syncedMutations.ts` looks like:
683
-
684
- ```ts
685
- import * as v from 'valibot'
686
-
687
- export const mutationValidators = {
688
- message: {
689
- insert: v.object({ id: v.string(), content: v.string(), ... }),
690
- update: v.object({ id: v.string(), content: v.optional(v.string()), ... }),
691
- delete: v.object({ id: v.string() }),
692
- send: v.object({ content: v.string(), channelId: v.string() }),
693
- },
694
- }
695
- ```
696
-
697
- validation runs before the `validateMutation` hook, so both layers stack:
698
- valibot validates shape/types, then your custom hook can add business logic.
699
-
700
- type augmentation:
701
-
702
- ```ts
703
- // src/zero/types.ts
704
- import type { schema } from '~/data/schema'
705
- import type { AuthData } from './auth'
706
-
707
- declare module 'on-zero' {
708
- interface Config {
709
- schema: typeof schema
710
- authData: AuthData
711
- }
712
- }
713
- ```
714
-
715
- ### Orez Lite (experimental)
716
-
717
- Orez Lite is our custom Rust engine and a separate alternative to vanilla Zero.
718
- it is still pre-alpha, so its setup is intentionally not documented here yet.
719
-
720
- ## mutation context
721
-
722
- every mutation receives `MutatorContext` as first argument:
723
-
724
- ```ts
725
- type MutatorContext = {
726
- tx: Transaction // database transaction
727
- authData: AuthData | null // current user
728
- environment: 'server' | 'client' // where executing
729
- can: (where, obj) => Promise<void> // permission checker
730
- server?: {
731
- actions: ServerActions // async server functions
732
- enqueueTask(task: AsyncTask, opts?: { barrier?: boolean }): void
733
- enqueueAction(action: AsyncAction, opts?: { barrier?: boolean }): void
734
- }
735
- }
736
- ```
737
-
738
- use it:
739
-
740
- ```ts
741
- export const mutate = mutations('message', permissions, {
742
- async archive(ctx, { messageId }) {
743
- await ctx.can(permissions, messageId)
744
- await ctx.tx.mutate.message.update({ id: messageId, archived: true })
745
-
746
- ctx.server?.enqueueTask(async () => {
747
- await ctx.server.actions.indexForSearch(messageId)
748
-
749
- // zeroServer.mutate works here too - authData is auto-inherited
750
- await zeroServer.mutate.activity.insert({
751
- id: randomId(),
752
- type: 'archive',
753
- messageId,
754
- })
755
- })
756
- },
757
- })
758
- ```
759
-
760
- `enqueueTask()` runs after the transaction commits and does not block the push
761
- response by default. Pass `{ barrier: true }` only when the client's next writes
762
- depend on the effect, such as provisioning a namespace before the client writes
763
- through a new Zero instance.
764
-
765
- ### typed async actions
766
-
767
- for effects that may need to cross a worker or service-binding boundary, augment
768
- `Config.asyncAction` with a discriminated union and configure one executor on the
769
- server bindings:
770
-
771
- ```ts
772
- type AppAction =
773
- | { type: 'project.provisionNamespace'; projectId: string; userId: string }
774
- | { type: 'project.invalidateAccess'; projectId: string }
775
-
776
- declare module 'on-zero' {
777
- interface Config {
778
- asyncAction: AppAction
779
- }
780
- }
781
-
782
- const zeroBindings = createZeroServerBindings({
783
- schema,
784
- models,
785
- createServerActions,
786
- actions: {
787
- execute: executeAppAction,
788
- // when this runtime cannot execute app effects locally, inject a remote
789
- // dispatcher. it becomes the only route; a failure never runs locally too.
790
- dispatchRemote,
791
- },
792
- })
793
- ```
794
-
795
- mutators call `ctx.server?.enqueueAction(action, { barrier })`. on-zero schedules
796
- it through the same post-commit task mechanism as `enqueueTask`, preserving the
797
- barrier and auth scope without a global dispatcher.
798
-
799
- ### awaiting and queueing mutations
800
-
801
- each `createZeroClient` result owns settlement helpers and one serial background
802
- queue:
803
-
804
- ```ts
805
- await client.awaitMutationClient(client.zero.mutate.note.update(note), 'save note')
806
- await client.awaitMutationServer(client.zero.mutate.note.insert(note), 'create note')
807
-
808
- void client.enqueueBackgroundMutation(
809
- 'stream note',
810
- () => client.zero.mutate.note.update(note),
811
- { coalesceKey: `note:${note.id}` }
812
- )
813
- const drain = await client.drainBackgroundMutations({ timeoutMs: 30_000 })
814
- ```
815
-
816
- the queue settles the client commit by default; use `settle: 'server'` only when
817
- later work requires the authoritative server row. same-key work that has not
818
- started is superseded by the newest write. recovery and instance replacement
819
- fence queued and in-flight work internally. direct settlement rejects with
820
- `StaleGenerationError`; the best-effort background queue drops that condition
821
- quietly. `MutationTimeoutError`, `MutationResultError`, and
822
- `mutationErrorMessage()` expose typed failure details.
823
-
824
- `drainBackgroundMutations()` boundedly waits for the serial queue and the server
825
- acknowledgements of client-settled writes that are still in flight. it returns
826
- any server errors, timeout state, and pending counts instead of throwing, so a
827
- failed write cannot cancel teardown. passive observation of a client-settled
828
- write never enters the acknowledgement-timeout recovery counter or reconnects
829
- the client. call it after stopping the producers and before releasing an
830
- authorization claim or disposing the client that those writes need.
831
-
832
- `combineZeroClients` exposes the same helpers across every instance: one
833
- serial queue with one coalescing map, and each mutation fenced by the instance
834
- that issued it. `awaitMutationClient` / `awaitMutationServer` settle a mutation
835
- on the instance that issued it no matter which client you call them on; take
836
- `enqueueBackgroundMutation` from `clients.combined` so the app keeps ONE queue
837
- and one coalescing map instead of one per instance.
838
-
839
- a queued write is pinned to the generation its instance was on when it was
840
- queued, so an instance replaced in the meantime refuses that write instead of
841
- replaying it onto its replacement. **the pin covers the synchronous window in
842
- which `create()` calls `zero.mutate`.** a `create()` that awaits first — a
843
- read-then-write — is outside it: that write goes to whichever instance is live
844
- when it fires, exactly like a direct call, and is still acknowledged by the
845
- instance that issued it. keep read-modify-write inside the mutator (`tx.run` in
846
- the same transaction) and the queued write stays pinned.
847
-
848
- ## getAuth
849
-
850
- `getAuth()` returns the current user's auth data. works inside both queries and
851
- mutations:
852
-
853
- ```ts
854
- import { getAuth } from 'on-zero'
855
-
856
- const auth = getAuth() // AuthData | null
857
- ```
858
-
859
- it resolves auth from whichever context is active — mutation context, query
860
- context, or client-side global. most of the time you won't need this directly
861
- since `serverWhere()` passes auth to your callback automatically. use `getAuth()`
862
- when you need auth data outside of those callbacks, like in a shared utility.
863
-
864
- ### ensureAuth
865
-
866
- `ensureAuth()` is the same as `getAuth()` but throws if the user is not
867
- authenticated instead of returning null:
868
-
869
- ```ts
870
- import { ensureAuth } from 'on-zero'
871
-
872
- const auth = ensureAuth() // AuthData (throws if not authenticated)
873
- ```
874
-
875
- ## recovery
876
-
877
- on-zero self-heals a client whose local sync state is lost or rejected. this is
878
- **on by default** — a consumer that passes nothing gets the full behavior. the
879
- hooks below let you compose ONE extra behavior (gate the reload, reload
880
- natively, drop a benign log, refresh auth) without re-implementing the stack.
881
-
882
- ### what's on by default
883
-
884
- `ProvideZero` installs Zero's `onUpdateNeeded` / `onClientStateNotFound` and a
885
- log sink that watches for the fatal store-loss / desync signatures. on a match it
886
- drops the affected instance's local store and reloads the page ONCE. this covers:
887
-
888
- - **update-needed** — `SchemaVersionNotSupported` (drops local state, the rows
889
- are now incompatible), `NewClientGroup` / `VersionNotSupported` (reload
890
- without dropping, so a sibling tab's shared IndexedDB survives).
891
- - **client-state-not-found** — the store is unusable; drop it and reload.
892
- - **log-only fatals** — `Expected IndexedDB not found`, native sqlite
893
- `This statement has been finalized`, and repeated `Store is closed`.
894
- - **the mutation/connection desync class** — `sent mutation ID … but expected`,
895
- `oooMutation`, `already processed`, `InvalidConnectionRequestBaseCookie` /
896
- `…LastMutationID`, `ClientNotFound`, `connection userID mismatch`. these
897
- surface only through the error log, so the log sink recovers on them too.
898
-
899
- two consecutive server acknowledgement timeouts reconstruct the client in place
900
- without deleting local state or reloading the page. one timeout remains a normal
901
- slow-server failure. configure the threshold with
902
- `serverAckTimeoutRecoveryThreshold` on `createZeroClient`.
903
-
904
- ### the hooks (all optional props on `ProvideZero`)
905
-
906
- - **`scheduleReload?: (ctx) => void`** — take over WHEN/HOW the recovery reload
907
- happens. `ctx = { reason, reasonKey, dropLocalState, performReload }`. the
908
- default is an immediate reload; inject this to gate it (only reload when the
909
- user is on a safe surface), show a countdown toast, or reload natively —
910
- then call `ctx.performReload()` to run the real deletes-then-reload work. the
911
- store delete is deferred until `performReload` runs, so a gated reload never
912
- leaves the app on an already-deleted store. `performReload` is idempotent.
913
-
914
- ```tsx
915
- // native (expo): reload the bundle instead of location.reload()
916
- <ProvideZero
917
- scheduleReload={(ctx) => {
918
- void ctx
919
- .performReload()
920
- .then(() => Updates.reloadAsync())
921
- .catch(() => DevSettings.reload())
922
- }}
923
-
924
- />
925
- ```
926
-
927
- - **`beforeReload?: () => Promise<void>`** — awaited right before the reload
928
- (e.g. wait for the dev origin to come back so the reload doesn't hit a
929
- restarting server). composes with `scheduleReload`.
930
-
931
- - **`benignLogPatterns?: readonly (string | RegExp)[]`**: classified recovery
932
- logs matching one of these patterns remain benign. a client transport can
933
- provide its own patterns through `transport.logClassifications.benign`; app and
934
- transport patterns are combined. the log still reaches the sink.
935
-
936
- - **`refreshAuth?: () => Promise<string | undefined>`** — called when the
937
- connection enters `needs-auth` (an expired token). return a fresh token and
938
- on-zero reconnects in place — no reload. fires once per needs-auth transition.
939
-
940
- - **`guardStorage?: { getItem, setItem }`** — the loop-guard's cross-reload
941
- backing store. defaults to `sessionStorage` on web; inject a native KV
942
- (MMKV/sqlite) on Hermes so native gets real cross-reload loop protection.
943
-
944
- - **`connectionDataset?: boolean`** — mirror this instance's connection state
945
- onto `document.body.dataset.zero*` (`zeroState`, `zeroConnected`,
946
- `zeroReason`, `zeroCacheUrl`) for e2e/diagnostics. enable on ONE instance so
947
- multiple instances don't clobber the dataset.
948
-
949
- `zeroEvents` always carries a typed `reasonKey`. recovery events use
950
- `ZeroRecoveryReasonKey`; connection errors use `connection-error` or
951
- `connection-needs-auth`, so consumers can switch on stable keys instead of
952
- matching message strings.
953
-
954
- recoverable connection interruptions emit `{ type: 'reconnect', status:
955
- 'trying' | 'waiting', reasonKey, reason }`, followed by `{ type: 'reconnect',
956
- status: 'connected' }` after sync reconnects. `ServerOverloaded` stays in
957
- Zero's built-in retry/backoff loop, transport errors such as `Failed to fetch`
958
- resume the paused connection, and acknowledgement timeouts reconstruct the
959
- client with its existing local state. none of these paths delete local state or
960
- reload the page.
961
-
962
- `createZeroClient` and a combined client also expose `reloadPage()`. it performs
963
- a plain page reload and returns `false` where no page location exists. this is
964
- the opt-in action for a host's reconnect toast; it never clears local state.
965
-
966
- ### guard + latch semantics
967
-
968
- - a **per-reason guard** (60s window) means the SAME reason re-failing right
969
- after its reload is surfaced as `fatal` instead of reload-storming; distinct
970
- reasons never suppress each other. it's two-tier: an in-memory map (real loop
971
- protection within a page-load, works on Hermes) plus the cross-reload
972
- `guardStorage` (survives the reload to catch an immediate re-fire).
973
- - a **one-reload latch** means every affected instance of a combined client
974
- drops its own store but only ONE reload fires. the latch **times out** (15s)
975
- so a reload that never lands (a gated/native reload, a failed `reload()`)
976
- can't kill recovery for the rest of the page's life.
977
-
978
- ### remint — in-place recovery without a reload
979
-
980
- `createZeroClient` returns **`remint(opts?)`** — the supported, native-safe
981
- recovery path (a reload may never land on prod native, wedging the latch).
982
- it drops the current instance's local store (unless `dropLocalState: false`) and
983
- reconstructs a fresh Zero client in place, no page reload. it is rate-guarded
984
- in-memory (12s between mints, 5 attempts before backing off, reset after 60s
985
- stable) and returns `false` when suppressed. route your own
986
- `onClientStateNotFound` to it if you need in-place recovery:
987
-
988
- ```tsx
989
- const { remint, ProvideZero } = createZeroClient({ … })
990
- <ProvideZero onClientStateNotFound={() => { void remint() }} … />
991
- ```
992
-
993
- ### stale-poke resume (automatic)
994
-
995
- a recoverable stale-cookie / stale-poke error (`Server returned unexpected base
996
- cookie during sync`; `Received cookie … is < than last snapshot cookie … ignoring
997
- client view`) is generic Zero behavior — on-zero's connection monitor reconnects
998
- instead of surfacing a fatal error, deduped per reason. no configuration.
999
-
1000
- ## patterns
1001
-
1002
- **server-only mutations:**
1003
-
1004
- ```ts
1005
- await zeroServer.mutate.user.insert(user)
1006
-
1007
- // with explicit auth (optional - authData auto-resolves from context)
1008
- await zeroServer.mutate.user.insert(user, { authData: { id: userId, email } })
1009
- ```
1010
-
1011
- the second argument is an options object:
1012
-
1013
- - `authData` — override auth for this call (optional, auto-resolves from context)
1014
-
1015
- authData is automatically resolved in this order:
1016
-
1017
- 1. explicit `authData` in options (if passed)
1018
- 2. current mutation context (inside a mutation)
1019
- 3. auth scope (inside async tasks - automatically inherited)
1020
-
1021
- **one-off queries with `run()`:**
1022
-
1023
- run a query once without subscribing. works on both client and server:
1024
-
1025
- ```ts
1026
- import { run } from 'on-zero'
1027
- import { userById } from '~/data/user/queries'
1028
-
1029
- // with params - defaults to cache only on client
1030
- const user = await run(userById, { id: userId })
1031
-
1032
- // fetch from server (waits for sync)
1033
- const user = await run(userById, { id: userId }, 'complete')
1034
-
1035
- // without params
1036
- const allUsers = await run(allUsers)
1037
-
1038
- // without params, fetch from server
1039
- const allUsers = await run(allUsers, 'complete')
1040
- ```
1041
-
1042
- on-zero run is smart:
1043
-
1044
- - on client, uses client `zero.run()`
1045
- - on server, uses server `zero.run()`
1046
- - in a mutation, uses `tx.run()`
1047
-
1048
- **getQuery — resolve a query object directly:**
1049
-
1050
- 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):
1051
-
1052
- ```ts
1053
- import { getQuery } from '~/zero/client'
1054
- import { postById } from '~/data/post/queries'
1055
-
1056
- // returns the zero query object — same as what useQuery resolves internally
1057
- const query = getQuery(postById, { postId: '123' })
1058
-
1059
- // pass to any hook that accepts a zero query directly
1060
- const [rows] = useRows(getQuery(feedPosts, { limit: 50 }))
1061
- ```
1062
-
1063
- same signature as `useQuery` — `getQuery(fn, params?)`.
1064
-
1065
- **preloading data (client only):**
1066
-
1067
- preload query results into cache without subscribing:
1068
-
1069
- ```ts
1070
- import { preload } from '~/zero/client'
1071
- import { userNotifications } from '~/data/notification/queries'
1072
-
1073
- // preload after login
1074
- const { complete, cleanup } = preload(userNotifications, { userId, limit: 100 })
1075
- await complete
1076
-
1077
- // cleanup if needed
1078
- cleanup()
1079
- ```
1080
-
1081
- useful for prefetching data before navigation to avoid loading states.
1082
-
1083
- **server-only queries:**
1084
-
1085
- for ad-hoc queries that don't use query functions:
1086
-
1087
- ```ts
1088
- const user = await zeroServer.query({ userID: userId }, (q) =>
1089
- q.user.where('id', userId).one()
1090
- )
1091
- ```
1092
-
1093
- **controlling queries with `ControlQueries`:**
1094
-
1095
- disable all `useQuery` and `usePermission` calls within a subtree. useful for
1096
- hiding screens, background tabs, or any UI where you want to pause syncing:
1097
-
1098
- ```tsx
1099
- import { ControlQueries } from '~/zero/client'
1100
-
1101
- // disable queries, returns null for all useQuery/usePermission calls
1102
- <ControlQueries action="disable">
1103
- <ExpensiveScreen />
1104
- </ControlQueries>
1105
-
1106
- // disable but keep returning the last value (no flash to empty)
1107
- <ControlQueries action="disable" whenDisabled="last-value">
1108
- <ExpensiveScreen />
1109
- </ControlQueries>
1110
-
1111
- // re-enable inside a disabled subtree
1112
- <ControlQueries action="disable" whenDisabled="last-value">
1113
- <ControlQueries action="enable">
1114
- <AlwaysLiveWidget />
1115
- </ControlQueries>
1116
- </ControlQueries>
1117
- ```
1118
-
1119
- props:
1120
-
1121
- - `action` — `'enable' | 'disable'` (default `'disable'`)
1122
- - `whenDisabled` — `'empty' | 'last-value'` (default `'empty'`)
1123
- - `'empty'` — queries return `[null, { type: 'unknown' }]`
1124
- - `'last-value'` — queries return their most recent result
1125
-
1126
- **batch processing:**
1127
-
1128
- ```ts
1129
- import { batchQuery } from 'on-zero'
1130
-
1131
- await batchQuery(
1132
- zql.message.where('processed', false),
1133
- async (messages) => {
1134
- for (const msg of messages) {
1135
- await processMessage(msg)
1136
- }
1137
- },
1138
- { chunk: 100, pause: 50 }
1139
- )
1140
- ```