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.
- package/package.json +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.
|
|
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.
|
|
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
|
-
```
|