@take-out/docs 0.0.42
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/aggregates.md +584 -0
- package/cloudflare-dev-tunnel.md +41 -0
- package/database.md +229 -0
- package/docs.md +8 -0
- package/emitters.md +562 -0
- package/hot-updater.md +223 -0
- package/native-hot-update.md +252 -0
- package/one-components.md +234 -0
- package/one-hooks.md +570 -0
- package/one-routes.md +660 -0
- package/package-json.md +115 -0
- package/package.json +12 -0
- package/react-native-navigation-flow.md +184 -0
- package/scripts.md +147 -0
- package/sync-prompt.md +208 -0
- package/tamagui.md +478 -0
- package/testing-integration.md +564 -0
- package/triggers.md +450 -0
- package/xcodebuild-mcp.md +127 -0
- package/zero.md +719 -0
package/zero.md
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zero
|
|
3
|
+
description: Zero sync, offline-first queries, and mutations guide. INVOKE WHEN: useQuery, zql, queries, mutations, mutate, CRUD, permissions, where clauses, auth checks, relations, joins, related data, offline-first, sync, real-time, optimistic updates, convergent mutations.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# zero sync guide
|
|
7
|
+
|
|
8
|
+
zero powers offline-first sync in this app via postgres replication. this guide
|
|
9
|
+
covers patterns using over-zero.
|
|
10
|
+
|
|
11
|
+
## queries
|
|
12
|
+
|
|
13
|
+
queries are plain exported functions in `src/data/queries/` that use the global
|
|
14
|
+
`zql` builder:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// src/data/queries/post.ts
|
|
18
|
+
import { where, zql } from 'over-zero'
|
|
19
|
+
|
|
20
|
+
const permission = where('post', () => {
|
|
21
|
+
return true
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export const allPosts = (props: { limit?: number }) => {
|
|
25
|
+
return zql.post
|
|
26
|
+
.where(permission)
|
|
27
|
+
.orderBy('createdAt', 'desc')
|
|
28
|
+
.orderBy('id', 'desc')
|
|
29
|
+
.limit(props.limit || 20)
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
the `zql` builder is the standard Zero query builder typed to your schema. each
|
|
34
|
+
exported function automatically becomes a synced query when you run
|
|
35
|
+
`over-zero generate`.
|
|
36
|
+
|
|
37
|
+
the `bun dev` command runs `bun watch` which automatically watches and
|
|
38
|
+
re-generates queries for you, so if that's running you don't need to
|
|
39
|
+
re-generate.
|
|
40
|
+
|
|
41
|
+
### using queries
|
|
42
|
+
|
|
43
|
+
use with `useQuery`:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
import { useQuery } from '~/zero/client'
|
|
47
|
+
import { allPosts } from '~/data/queries/post'
|
|
48
|
+
|
|
49
|
+
const [posts, status] = useQuery(allPosts, { limit: 20 })
|
|
50
|
+
|
|
51
|
+
if (status.type !== 'complete') {
|
|
52
|
+
return <Loading />
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`useQuery` detects plain functions, creates a cached `SyncedQuery` per function,
|
|
57
|
+
and calls it with your params.
|
|
58
|
+
|
|
59
|
+
### useQuery patterns
|
|
60
|
+
|
|
61
|
+
three ways to call it:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
// with params
|
|
65
|
+
useQuery(queryFn, { param1, param2 })
|
|
66
|
+
|
|
67
|
+
// with params + options
|
|
68
|
+
useQuery(queryFn, { param1, param2 }, { enabled: true })
|
|
69
|
+
|
|
70
|
+
// no params + options
|
|
71
|
+
useQuery(queryFn, { enabled: true })
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
conditional queries:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
const [data] = useQuery(userById, { userId }, { enabled: Boolean(userId) })
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### query generation
|
|
81
|
+
|
|
82
|
+
when you run `over-zero generate`, it scans your query files and creates
|
|
83
|
+
`src/data/generated/syncedQueries.ts`:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// auto-generated
|
|
87
|
+
import * as v from 'valibot'
|
|
88
|
+
import { syncedQuery } from '@rocicorp/zero'
|
|
89
|
+
import * as postQueries from '../queries/post'
|
|
90
|
+
|
|
91
|
+
export const allPosts = syncedQuery(
|
|
92
|
+
'allPosts',
|
|
93
|
+
v.parser(
|
|
94
|
+
v.tuple([
|
|
95
|
+
v.object({
|
|
96
|
+
limit: v.optional(v.number()),
|
|
97
|
+
}),
|
|
98
|
+
]),
|
|
99
|
+
),
|
|
100
|
+
(arg) => {
|
|
101
|
+
return postQueries.allPosts(arg)
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
this wraps your query functions with valibot validators for runtime type safety.
|
|
107
|
+
don't even import from the "generated" folder.
|
|
108
|
+
|
|
109
|
+
## query permissions
|
|
110
|
+
|
|
111
|
+
define permissions inline in query files using `where()`:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// src/data/queries/user.ts
|
|
115
|
+
const permission = where('userPublic', () => {
|
|
116
|
+
return true // public access
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
export const userById = (props: { userId: string }) => {
|
|
120
|
+
return zql.userPublic.where(permission).where('id', props.userId).one()
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
permissions with auth checks:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const permission = where('userPublic', (q, auth) => {
|
|
128
|
+
if (auth?.role === 'admin') return true
|
|
129
|
+
return q.cmp('id', auth?.id || '')
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
the `where()` helper automatically accesses auth data from `queryContext()` or
|
|
134
|
+
`mutatorContext()`. permissions only execute server-side - on the client they
|
|
135
|
+
automatically pass.
|
|
136
|
+
|
|
137
|
+
### reusable permission helpers
|
|
138
|
+
|
|
139
|
+
extract common patterns to `src/data/where/`. note that we use "serverWhere" as
|
|
140
|
+
these conditions will only run on the server, where permissions should be
|
|
141
|
+
enforced.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// src/data/where/isUsersOwn.ts
|
|
145
|
+
import { where } from 'over-zero'
|
|
146
|
+
|
|
147
|
+
export const isUsersOwn = serverWehere<'userPublic'>((q, auth) =>
|
|
148
|
+
q.cmp('id', '=', auth?.id || ''),
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
use in queries:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { isUsersOwn } from '~/data/where/isUsersOwn'
|
|
156
|
+
|
|
157
|
+
export const currentUser = (props: { userId: string }) => {
|
|
158
|
+
return zql.userPublic.where(isUsersOwn).where('id', props.userId).one()
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## complex conditions
|
|
163
|
+
|
|
164
|
+
use expression builder for advanced filtering:
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
const postsWithBlocks = (props: {
|
|
168
|
+
userId?: string
|
|
169
|
+
blockedUserIds: string[]
|
|
170
|
+
pageSize: number
|
|
171
|
+
}) => {
|
|
172
|
+
return zql.post
|
|
173
|
+
.where((eb) => {
|
|
174
|
+
if (props.blockedUserIds.length > 0) {
|
|
175
|
+
return eb.not(eb.cmp('userId', 'IN', props.blockedUserIds))
|
|
176
|
+
}
|
|
177
|
+
return eb.cmp('id', '!=', '')
|
|
178
|
+
})
|
|
179
|
+
.orderBy('createdAt', 'desc')
|
|
180
|
+
.limit(props.pageSize)
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## relations
|
|
185
|
+
|
|
186
|
+
zero supports nested relations:
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
export const userWithState = (props: { userId: string }) => {
|
|
190
|
+
return zql.userPublic
|
|
191
|
+
.where('id', props.userId)
|
|
192
|
+
.one()
|
|
193
|
+
.related('state', (q) => q.where('userId', props.userId).one())
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
define relationships in `src/data/relationships.ts`:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { relationships } from '@rocicorp/zero'
|
|
201
|
+
import * as tables from './generated/tables'
|
|
202
|
+
|
|
203
|
+
export const userRelationships = relationships(
|
|
204
|
+
tables.userPublic,
|
|
205
|
+
({ many, one }) => ({
|
|
206
|
+
state: one({
|
|
207
|
+
sourceField: ['id'],
|
|
208
|
+
destSchema: tables.userState,
|
|
209
|
+
destField: ['userId'],
|
|
210
|
+
}),
|
|
211
|
+
posts: many({
|
|
212
|
+
sourceField: ['id'],
|
|
213
|
+
destSchema: tables.post,
|
|
214
|
+
destField: ['userId'],
|
|
215
|
+
}),
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## pagination
|
|
221
|
+
|
|
222
|
+
use `.start()` for cursor-based pagination:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
export const postsPaginated = (props: {
|
|
226
|
+
pageSize: number
|
|
227
|
+
cursor?: { id: string } | null
|
|
228
|
+
}) => {
|
|
229
|
+
let query = zql.post
|
|
230
|
+
.orderBy('createdAt', 'desc')
|
|
231
|
+
.orderBy('id', 'desc')
|
|
232
|
+
.limit(props.pageSize)
|
|
233
|
+
|
|
234
|
+
if (props.cursor) {
|
|
235
|
+
query = query.start(props.cursor)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return query
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## query organization
|
|
243
|
+
|
|
244
|
+
queries live in `src/data/queries/` organized by model:
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
src/data/queries/
|
|
248
|
+
├── post.ts # post queries + read permissions
|
|
249
|
+
├── user.ts # user queries + read permissions
|
|
250
|
+
└── block.ts # block queries + read permissions
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
import directly from the file:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import { allPosts, postById } from '~/data/queries/post'
|
|
257
|
+
import { userById } from '~/data/queries/user'
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## models
|
|
261
|
+
|
|
262
|
+
models live in `src/data/models/` and define schema, permissions, and mutations:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
// src/data/models/post.ts
|
|
266
|
+
import { boolean, number, string, table } from '@rocicorp/zero'
|
|
267
|
+
import { mutations, where } from 'over-zero'
|
|
268
|
+
import type { Post } from '../types'
|
|
269
|
+
|
|
270
|
+
export const schema = table('post')
|
|
271
|
+
.columns({
|
|
272
|
+
id: string(),
|
|
273
|
+
userId: string(),
|
|
274
|
+
image: string(),
|
|
275
|
+
caption: string().optional(),
|
|
276
|
+
hiddenByAdmin: boolean(),
|
|
277
|
+
createdAt: number(),
|
|
278
|
+
updatedAt: number().optional(),
|
|
279
|
+
})
|
|
280
|
+
.primaryKey('id')
|
|
281
|
+
|
|
282
|
+
const permissions = where('post', (q, auth) => {
|
|
283
|
+
return q.cmp('userId', auth?.id || '')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
export const mutate = mutations(schema, permissions, {
|
|
287
|
+
insert: async ({ tx, environment, server, authData }, post: Post) => {
|
|
288
|
+
await tx.mutate.post.insert(post)
|
|
289
|
+
|
|
290
|
+
if (environment === 'server' && server && authData) {
|
|
291
|
+
server.asyncTasks.push(() =>
|
|
292
|
+
server.actions
|
|
293
|
+
.analyticsActions()
|
|
294
|
+
.logEvent(authData.id, 'post_created', {
|
|
295
|
+
postId: post.id,
|
|
296
|
+
}),
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### auto-generated crud
|
|
304
|
+
|
|
305
|
+
passing `schema` and `permissions` to `mutations()` generates CRUD operations:
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
zero.mutate.post.insert(post)
|
|
309
|
+
zero.mutate.post.update(post)
|
|
310
|
+
zero.mutate.post.delete(post)
|
|
311
|
+
zero.mutate.post.upsert(post)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
these check permissions automatically on the server.
|
|
315
|
+
|
|
316
|
+
### custom mutations
|
|
317
|
+
|
|
318
|
+
add custom mutations to the third argument:
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
export const mutate = mutations(schema, permissions, {
|
|
322
|
+
async customAction(ctx, props: { id: string }) {
|
|
323
|
+
await ctx.can(permissions, props.id)
|
|
324
|
+
await ctx.tx.mutate.post.update({ id: props.id, updated: true })
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
call from client:
|
|
330
|
+
|
|
331
|
+
```tsx
|
|
332
|
+
await zero.mutate.post.customAction({ id: 'post-1' })
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## mutation context
|
|
336
|
+
|
|
337
|
+
every mutation receives `MutatorContext` as first argument:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
type MutatorContext = {
|
|
341
|
+
tx: Transaction // database transaction
|
|
342
|
+
authData: AuthData | null // current user
|
|
343
|
+
environment: 'server' | 'client' // where executing
|
|
344
|
+
can: (where, obj) => Promise<void> // permission checker
|
|
345
|
+
server?: {
|
|
346
|
+
actions: ServerActions // async server functions
|
|
347
|
+
asyncTasks: AsyncAction[] // run after transaction
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
use it:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
export const mutate = mutations(schema, permissions, {
|
|
356
|
+
async archive(ctx, { postId }) {
|
|
357
|
+
await ctx.can(permissions, postId)
|
|
358
|
+
await ctx.tx.mutate.post.update({ id: postId, archived: true })
|
|
359
|
+
|
|
360
|
+
if (ctx.server) {
|
|
361
|
+
ctx.server.asyncTasks.push(async () => {
|
|
362
|
+
await ctx.server.actions.updateSearchIndex(postId)
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
})
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### async tasks
|
|
370
|
+
|
|
371
|
+
queue work to run after transaction commits:
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
if (ctx.server) {
|
|
375
|
+
ctx.server.asyncTasks.push(async () => {
|
|
376
|
+
await ctx.server.actions.sendPushNotification(message)
|
|
377
|
+
await ctx.server.actions.indexForSearch(message)
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
tasks run in parallel after the transaction. keep transactions fast - move slow
|
|
383
|
+
work to async tasks.
|
|
384
|
+
|
|
385
|
+
### server actions
|
|
386
|
+
|
|
387
|
+
define server-only functions in `src/data/server/createServerActions.ts`:
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
export const createServerActions = () => ({
|
|
391
|
+
analyticsActions: () => ({
|
|
392
|
+
async logEvent(userId: string, event: string, data: any) {
|
|
393
|
+
// analytics logic
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
|
|
397
|
+
async sendEmail(to: string, subject: string, body: string) {
|
|
398
|
+
await fetch('https://api.postmarkapp.com/email', {
|
|
399
|
+
method: 'POST',
|
|
400
|
+
body: JSON.stringify({ to, subject, body }),
|
|
401
|
+
})
|
|
402
|
+
},
|
|
403
|
+
})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
use in mutations:
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
ctx.server.actions.analyticsActions().logEvent(userId, 'event_name', data)
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## convergence
|
|
413
|
+
|
|
414
|
+
mutations run on both client and server. they must produce the same result.
|
|
415
|
+
|
|
416
|
+
bad (non-convergent):
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
async insert(ctx, post) {
|
|
420
|
+
await ctx.tx.mutate.post.insert({
|
|
421
|
+
...post,
|
|
422
|
+
id: randomId(), // different on each run!
|
|
423
|
+
createdAt: Date.now() // different timing!
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
good (convergent):
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
async insert(ctx, post) {
|
|
432
|
+
// client generates id and timestamp once
|
|
433
|
+
await ctx.tx.mutate.post.insert(post)
|
|
434
|
+
|
|
435
|
+
// server-only side effects don't affect data
|
|
436
|
+
if (ctx.server) {
|
|
437
|
+
await ctx.server.actions.sendEmail(post)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
pass all random/time values from the client.
|
|
443
|
+
|
|
444
|
+
## type generation
|
|
445
|
+
|
|
446
|
+
when you add or modify schemas, regenerate types:
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
over-zero generate
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
or use the project's alias:
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
bun tko zero generate
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
this creates:
|
|
459
|
+
|
|
460
|
+
- `src/data/generated/types.ts` - TypeScript types from schemas
|
|
461
|
+
- `src/data/generated/tables.ts` - table schema exports
|
|
462
|
+
- `src/data/generated/models.ts` - model aggregation
|
|
463
|
+
- `src/data/generated/syncedQueries.ts` - synced query definitions with validators
|
|
464
|
+
|
|
465
|
+
use generated types:
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
import type { Post, PostUpdate, User } from '~/data/types'
|
|
469
|
+
|
|
470
|
+
const post: Post = {
|
|
471
|
+
id: randomId(),
|
|
472
|
+
userId: 'user-1',
|
|
473
|
+
image: 'https://...',
|
|
474
|
+
caption: 'hello',
|
|
475
|
+
hiddenByAdmin: false,
|
|
476
|
+
createdAt: Date.now(),
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const update: PostUpdate = {
|
|
480
|
+
id: post.id,
|
|
481
|
+
caption: 'updated',
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
in development, `bun dev` runs `watch:zero:generate` automatically.
|
|
486
|
+
|
|
487
|
+
## schema organization
|
|
488
|
+
|
|
489
|
+
schema lives in `src/data/`:
|
|
490
|
+
|
|
491
|
+
```
|
|
492
|
+
src/data/
|
|
493
|
+
├── models/ # model definitions with mutations
|
|
494
|
+
│ ├── post.ts
|
|
495
|
+
│ ├── user.ts
|
|
496
|
+
│ └── block.ts
|
|
497
|
+
├── queries/ # query functions
|
|
498
|
+
│ ├── post.ts
|
|
499
|
+
│ ├── user.ts
|
|
500
|
+
│ └── block.ts
|
|
501
|
+
├── where/ # reusable permission helpers
|
|
502
|
+
│ └── isUsersOwn.ts
|
|
503
|
+
├── server/ # server-only code
|
|
504
|
+
│ └── createServerActions.ts
|
|
505
|
+
├── generated/ # auto-generated files
|
|
506
|
+
│ ├── types.ts
|
|
507
|
+
│ ├── tables.ts
|
|
508
|
+
│ ├── models.ts
|
|
509
|
+
│ └── syncedQueries.ts
|
|
510
|
+
├── relationships.ts # relationship definitions
|
|
511
|
+
├── schema.ts # schema assembly
|
|
512
|
+
└── types.ts # type exports + custom types
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
## setup
|
|
516
|
+
|
|
517
|
+
### client setup
|
|
518
|
+
|
|
519
|
+
```tsx
|
|
520
|
+
// src/zero/client.tsx
|
|
521
|
+
import { createZeroClient } from 'over-zero'
|
|
522
|
+
import { schema } from '~/data/schema'
|
|
523
|
+
import { models } from '~/data/generated/models'
|
|
524
|
+
|
|
525
|
+
export const { ProvideZero, useQuery, zero, usePermission } = createZeroClient({
|
|
526
|
+
schema,
|
|
527
|
+
models,
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
// in your app root
|
|
531
|
+
<ProvideZero
|
|
532
|
+
server="http://localhost:4848"
|
|
533
|
+
userID={user.id}
|
|
534
|
+
auth={jwtToken}
|
|
535
|
+
authData={{ id: user.id, role: user.role }}
|
|
536
|
+
kvStore="idb" // or "mem"
|
|
537
|
+
>
|
|
538
|
+
<App />
|
|
539
|
+
</ProvideZero>
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### server setup
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
// src/zero/server.ts
|
|
546
|
+
import { createZeroServer } from 'over-zero/server'
|
|
547
|
+
import { schema } from '~/data/schema'
|
|
548
|
+
import { models } from '~/data/generated/models'
|
|
549
|
+
import * as queries from '~/data/generated/queries'
|
|
550
|
+
import { createServerActions } from '~/data/server/createServerActions'
|
|
551
|
+
|
|
552
|
+
export const zeroServer = createZeroServer({
|
|
553
|
+
schema,
|
|
554
|
+
models,
|
|
555
|
+
queries,
|
|
556
|
+
createServerActions,
|
|
557
|
+
database: process.env.ZERO_UPSTREAM_DB,
|
|
558
|
+
})
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
### type augmentation
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
// src/zero/types.ts
|
|
565
|
+
import type { schema } from '~/data/schema'
|
|
566
|
+
import type { AuthData } from '~/features/auth/types'
|
|
567
|
+
|
|
568
|
+
declare module 'over-zero' {
|
|
569
|
+
interface Config {
|
|
570
|
+
schema: typeof schema
|
|
571
|
+
authData: AuthData
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
## permissions
|
|
577
|
+
|
|
578
|
+
permissions use the `where()` helper to create conditions:
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
export const permissions = where('post', (q, auth) => {
|
|
582
|
+
if (auth?.role === 'admin') return true
|
|
583
|
+
return q.cmp('userId', auth?.id || '')
|
|
584
|
+
})
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
**for queries:** define inline as constants in query files
|
|
588
|
+
|
|
589
|
+
**for mutations:** define in model files and pass to `mutations()`
|
|
590
|
+
|
|
591
|
+
check in mutations:
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
await ctx.can(permissions, postId)
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
check in react:
|
|
598
|
+
|
|
599
|
+
```tsx
|
|
600
|
+
const canEdit = usePermission('post', postId)
|
|
601
|
+
if (!canEdit) return null
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
## calling mutations
|
|
605
|
+
|
|
606
|
+
optimistic client update:
|
|
607
|
+
|
|
608
|
+
```tsx
|
|
609
|
+
await zero.mutate.post.update(post)
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
wait for server confirmation:
|
|
613
|
+
|
|
614
|
+
```tsx
|
|
615
|
+
const result = await zero.mutate.post.update(post).server
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
## performance tips
|
|
619
|
+
|
|
620
|
+
- **one query per route** - fetch all needed data upfront
|
|
621
|
+
- **limit relations** - each `.related()` adds joins
|
|
622
|
+
- **use indexes** - add to postgres schema for frequently queried fields
|
|
623
|
+
- **avoid n+1** - fetch related data with `.related()` instead of separate
|
|
624
|
+
queries
|
|
625
|
+
- **paginate** - use `.limit()` and `.start()` for large lists
|
|
626
|
+
- **memoization** - zero recreates objects on updates, use `useMemo` for
|
|
627
|
+
expensive derivations
|
|
628
|
+
|
|
629
|
+
## debugging
|
|
630
|
+
|
|
631
|
+
add `?debug=2` to url for detailed zero logs.
|
|
632
|
+
|
|
633
|
+
check query performance in postgres:
|
|
634
|
+
|
|
635
|
+
```sql
|
|
636
|
+
SELECT * FROM pg_stat_statements
|
|
637
|
+
WHERE query LIKE '%post%'
|
|
638
|
+
ORDER BY total_exec_time DESC;
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
development mode warns if query AST changes frequently in a component.
|
|
642
|
+
|
|
643
|
+
## common patterns
|
|
644
|
+
|
|
645
|
+
### filtering with conditions
|
|
646
|
+
|
|
647
|
+
```tsx
|
|
648
|
+
const activeUsers = (props: { minPosts: number }) => {
|
|
649
|
+
return zql.userPublic
|
|
650
|
+
.where((eb) => eb.cmp('postsCount', '>=', props.minPosts))
|
|
651
|
+
.orderBy('joinedAt', 'desc')
|
|
652
|
+
}
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
### soft deletes
|
|
656
|
+
|
|
657
|
+
```ts
|
|
658
|
+
export const mutate = mutations(schema, permissions, {
|
|
659
|
+
async delete(ctx, { id }) {
|
|
660
|
+
await ctx.tx.mutate.post.update({
|
|
661
|
+
id,
|
|
662
|
+
deleted: true,
|
|
663
|
+
})
|
|
664
|
+
},
|
|
665
|
+
})
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
filter in queries:
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
.where('deleted', false)
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### cross-model mutations
|
|
675
|
+
|
|
676
|
+
```ts
|
|
677
|
+
export const mutate = mutations(schema, permissions, {
|
|
678
|
+
async createWithRelated(ctx, props: { userId: string }) {
|
|
679
|
+
const postId = randomId()
|
|
680
|
+
|
|
681
|
+
await ctx.tx.mutate.post.insert({
|
|
682
|
+
id: postId,
|
|
683
|
+
userId: props.userId,
|
|
684
|
+
createdAt: Date.now(),
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
await ctx.tx.mutate.userPublic.update({
|
|
688
|
+
id: props.userId,
|
|
689
|
+
postsCount:
|
|
690
|
+
(await ctx.tx.query.userPublic.where('id', props.userId).one().run())
|
|
691
|
+
?.postsCount + 1,
|
|
692
|
+
})
|
|
693
|
+
},
|
|
694
|
+
})
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
## migration
|
|
698
|
+
|
|
699
|
+
schema changes in `src/data/models/*.ts` auto-migrate in development via
|
|
700
|
+
docker-compose watching model files.
|
|
701
|
+
|
|
702
|
+
in production, run:
|
|
703
|
+
|
|
704
|
+
```bash
|
|
705
|
+
bun migrate
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
this runs drizzle migrations and updates zero's cvr database.
|
|
709
|
+
|
|
710
|
+
## resources
|
|
711
|
+
|
|
712
|
+
- zero docs: https://zero.rocicorp.dev
|
|
713
|
+
- over-zero readme: packages/over-zero/readme.md
|
|
714
|
+
- models: src/data/models/
|
|
715
|
+
- queries: src/data/queries/
|
|
716
|
+
- schema: src/data/schema.ts
|
|
717
|
+
- relationships: src/data/relationships.ts
|
|
718
|
+
- client setup: src/zero/client.tsx
|
|
719
|
+
- server setup: src/zero/server.ts
|