@take-out/docs 0.0.52 → 0.0.57

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/zero.md CHANGED
@@ -1,13 +1,8 @@
1
1
  ---
2
2
  name: takeout-zero
3
- description: Zero sync, offline-first queries, and mutations guide. useQuery, zql, queries, mutations, mutate, CRUD, permissions, where clauses, auth checks, relations, joins, related data, offline-first, sync, real-time, optimistic updates, convergent mutations.
3
+ description: Zero data layer guide. useQuery, zql, mutations, CRUD, permissions, serverWhere, exists(), relations, .related(), pagination, cursor, convergence, optimistic updates.
4
4
  ---
5
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
6
  ## queries
12
7
 
13
8
  queries are plain exported functions in `src/data/queries/` that use the global
@@ -15,31 +10,18 @@ queries are plain exported functions in `src/data/queries/` that use the global
15
10
 
16
11
  ```ts
17
12
  // src/data/queries/post.ts
18
- import { where, zql } from 'over-zero'
13
+ import { serverWhere, zql } from 'over-zero'
19
14
 
20
- const permission = where('post', () => {
21
- return true
22
- })
15
+ const permission = serverWhere('post', () => true)
23
16
 
24
17
  export const allPosts = (props: { limit?: number }) => {
25
18
  return zql.post
26
19
  .where(permission)
27
20
  .orderBy('createdAt', 'desc')
28
- .orderBy('id', 'desc')
29
21
  .limit(props.limit || 20)
30
22
  }
31
23
  ```
32
24
 
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
25
  use with `useQuery`:
44
26
 
45
27
  ```tsx
@@ -47,19 +29,10 @@ import { useQuery } from '~/zero/client'
47
29
  import { allPosts } from '~/data/queries/post'
48
30
 
49
31
  const [posts, status] = useQuery(allPosts, { limit: 20 })
50
-
51
- if (status.type !== 'complete') {
52
- return <Loading />
53
- }
54
32
  ```
55
33
 
56
- `useQuery` detects plain functions, creates a cached `SyncedQuery` per function,
57
- and calls it with your params.
58
-
59
34
  ### useQuery patterns
60
35
 
61
- three ways to call it:
62
-
63
36
  ```tsx
64
37
  // with params
65
38
  useQuery(queryFn, { param1, param2 })
@@ -67,154 +40,79 @@ useQuery(queryFn, { param1, param2 })
67
40
  // with params + options
68
41
  useQuery(queryFn, { param1, param2 }, { enabled: true })
69
42
 
70
- // no params + options
71
- useQuery(queryFn, { enabled: true })
72
- ```
73
-
74
- conditional queries:
75
-
76
- ```tsx
43
+ // conditional - only runs when enabled is true
77
44
  const [data] = useQuery(userById, { userId }, { enabled: Boolean(userId) })
78
45
  ```
79
46
 
80
- ### query generation
47
+ ## permissions
81
48
 
82
- when you run `over-zero generate`, it scans your query files and creates
83
- `src/data/generated/syncedQueries.ts`:
49
+ permissions only execute server-side. use `serverWhere` for query permissions:
84
50
 
85
51
  ```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
- )
52
+ const permission = serverWhere('post', (q, auth) => {
53
+ if (auth?.role === 'admin') return true
54
+ return q.cmp('userId', auth?.id || '')
55
+ })
104
56
  ```
105
57
 
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()`:
58
+ for reusable permissions, extract to `src/data/where/`:
112
59
 
113
60
  ```ts
114
- // src/data/queries/user.ts
115
- const permission = where('userPublic', () => {
116
- return true // public access
61
+ // src/data/where/notBlockedByViewer.ts
62
+ export const notBlockedByViewer = serverWhere('post', (q, auth) => {
63
+ if (!auth?.id) return true
64
+ return q.not(q.exists('authorBlockedBy', (block) =>
65
+ block.where('blockerId', auth.id)
66
+ ))
117
67
  })
118
-
119
- export const userById = (props: { userId: string }) => {
120
- return zql.userPublic.where(permission).where('id', props.userId).one()
121
- }
122
68
  ```
123
69
 
124
- permissions with auth checks:
70
+ `exists()` requires a relationship. add to `src/data/relationships.ts`:
125
71
 
126
72
  ```ts
127
- const permission = where('userPublic', (q, auth) => {
128
- if (auth?.role === 'admin') return true
129
- return q.cmp('id', auth?.id || '')
73
+ // post -> block via post.userId = block.blockedId
74
+ authorBlockedBy: many({
75
+ sourceField: ['userId'],
76
+ destSchema: tables.block,
77
+ destField: ['blockedId'],
130
78
  })
131
79
  ```
132
80
 
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'
81
+ this filters posts where author is blocked, without exposing block data to client.
146
82
 
147
- export const isUsersOwn = serverWhere<'userPublic'>((q, auth) =>
148
- q.cmp('id', '=', auth?.id || ''),
149
- )
150
- ```
83
+ ## relations
151
84
 
152
- use in queries:
85
+ include related data with `.related()`:
153
86
 
154
87
  ```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
- }) => {
88
+ export const postWithComments = (props: { postId: string }) => {
172
89
  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)
90
+ .where('id', props.postId)
192
91
  .one()
193
- .related('state', (q) => q.where('userId', props.userId).one())
92
+ .related('user', (q) => q.one())
93
+ .related('comments', (q) =>
94
+ q.orderBy('createdAt', 'desc')
95
+ .limit(50)
96
+ .related('user', (u) => u.one())
97
+ )
194
98
  }
195
99
  ```
196
100
 
197
101
  define relationships in `src/data/relationships.ts`:
198
102
 
199
103
  ```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
- }),
104
+ export const postRelationships = relationships(tables.post, ({ one, many }) => ({
105
+ user: one({
106
+ sourceField: ['userId'],
107
+ destSchema: tables.userPublic,
108
+ destField: ['id'],
216
109
  }),
217
- )
110
+ comments: many({
111
+ sourceField: ['id'],
112
+ destSchema: tables.comment,
113
+ destField: ['postId'],
114
+ }),
115
+ }))
218
116
  ```
219
117
 
220
118
  ## pagination
@@ -224,7 +122,7 @@ use `.start()` for cursor-based pagination:
224
122
  ```ts
225
123
  export const postsPaginated = (props: {
226
124
  pageSize: number
227
- cursor?: { id: string } | null
125
+ cursor?: { id: string; createdAt: number } | null
228
126
  }) => {
229
127
  let query = zql.post
230
128
  .orderBy('createdAt', 'desc')
@@ -239,181 +137,81 @@ export const postsPaginated = (props: {
239
137
  }
240
138
  ```
241
139
 
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
140
  ## models
261
141
 
262
- models live in `src/data/models/` and define schema, permissions, and mutations:
142
+ models in `src/data/models/` define schema, permissions, and mutations:
263
143
 
264
144
  ```ts
265
145
  // src/data/models/post.ts
266
146
  import { boolean, number, string, table } from '@rocicorp/zero'
267
- import { mutations, where } from 'over-zero'
268
- import type { Post } from '../types'
147
+ import { mutations, serverWhere } from 'over-zero'
269
148
 
270
149
  export const schema = table('post')
271
150
  .columns({
272
151
  id: string(),
273
152
  userId: string(),
274
- image: string(),
275
153
  caption: string().optional(),
276
- hiddenByAdmin: boolean(),
277
154
  createdAt: number(),
278
- updatedAt: number().optional(),
279
155
  })
280
156
  .primaryKey('id')
281
157
 
282
- const permissions = where('post', (q, auth) => {
158
+ const permissions = serverWhere('post', (q, auth) => {
283
159
  return q.cmp('userId', auth?.id || '')
284
160
  })
285
161
 
286
162
  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
- }),
163
+ insert: async (ctx, post: Post) => {
164
+ await ctx.tx.mutate.post.insert(post)
165
+
166
+ if (ctx.server) {
167
+ ctx.server.asyncTasks.push(() =>
168
+ ctx.server.actions.analyticsActions().logEvent(ctx.authData.id, 'post_created')
297
169
  )
298
170
  }
299
171
  },
300
172
  })
301
173
  ```
302
174
 
303
- ### auto-generated crud
304
-
305
- passing `schema` and `permissions` to `mutations()` generates CRUD operations:
175
+ passing `schema` and `permissions` to `mutations()` generates CRUD:
306
176
 
307
177
  ```tsx
308
178
  zero.mutate.post.insert(post)
309
179
  zero.mutate.post.update(post)
310
180
  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
181
  ```
334
182
 
335
- ## mutation context
336
-
337
- every mutation receives `MutatorContext` as first argument:
183
+ ### mutation context
338
184
 
339
185
  ```ts
340
186
  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
187
+ tx: Transaction
188
+ authData: AuthData | null
189
+ environment: 'server' | 'client'
190
+ can: (where, obj) => Promise<void>
345
191
  server?: {
346
- actions: ServerActions // async server functions
347
- asyncTasks: AsyncAction[] // run after transaction
192
+ actions: ServerActions
193
+ asyncTasks: AsyncAction[] // runs after transaction commits
348
194
  }
349
195
  }
350
196
  ```
351
197
 
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
198
  ### async tasks
370
199
 
371
- queue work to run after transaction commits:
200
+ move slow work out of transactions:
372
201
 
373
202
  ```ts
374
203
  if (ctx.server) {
375
204
  ctx.server.asyncTasks.push(async () => {
376
205
  await ctx.server.actions.sendPushNotification(message)
377
- await ctx.server.actions.indexForSearch(message)
378
206
  })
379
207
  }
380
208
  ```
381
209
 
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
210
  ## convergence
413
211
 
414
- mutations run on both client and server. they must produce the same result.
212
+ mutations run on both client and server - they must produce the same result.
415
213
 
416
- bad (non-convergent):
214
+ **bad:**
417
215
 
418
216
  ```ts
419
217
  async insert(ctx, post) {
@@ -425,295 +223,162 @@ async insert(ctx, post) {
425
223
  }
426
224
  ```
427
225
 
428
- good (convergent):
226
+ **good:**
429
227
 
430
228
  ```ts
431
229
  async insert(ctx, post) {
432
- // client generates id and timestamp once
230
+ // client generates id and timestamp once, passes to mutation
433
231
  await ctx.tx.mutate.post.insert(post)
434
232
 
435
- // server-only side effects don't affect data
233
+ // server-only side effects are fine
436
234
  if (ctx.server) {
437
235
  await ctx.server.actions.sendEmail(post)
438
236
  }
439
237
  }
440
238
  ```
441
239
 
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
- ```
240
+ ## calling mutations
451
241
 
452
- or use the project's alias:
242
+ ```tsx
243
+ // optimistic - updates UI immediately
244
+ zero.mutate.post.update(post)
453
245
 
454
- ```bash
455
- bun tko zero generate
246
+ // wait for server confirmation
247
+ const result = await zero.mutate.post.update(post).server
456
248
  ```
457
249
 
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
250
+ ## anti-patterns
464
251
 
465
- use generated types:
252
+ ### useAuth() vs useUser()
466
253
 
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
- }
254
+ **bad - waterfall:**
478
255
 
479
- const update: PostUpdate = {
480
- id: post.id,
481
- caption: 'updated',
482
- }
256
+ ```tsx
257
+ const { user } = useUser() // queries database, waits
258
+ const [posts] = useQuery(postsByUserId, { userId: user?.id || '' })
483
259
  ```
484
260
 
485
- in development, `bun dev` runs `watch:zero:generate` automatically.
486
-
487
- ## schema organization
261
+ **good - immediate:**
488
262
 
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
263
+ ```tsx
264
+ const { user } = useAuth() // available immediately from jwt
265
+ const [posts] = useQuery(postsByUserId, { userId: user?.id || '' })
513
266
  ```
514
267
 
515
- ## setup
268
+ use `useAuth()` for query params. only use `useUser()` when you need full user
269
+ record (profile data, settings).
516
270
 
517
- ### client setup
271
+ ### n+1 queries in lists
518
272
 
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
- })
273
+ **bad:**
529
274
 
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>
275
+ ```tsx
276
+ function PostCard({ post }) {
277
+ const [author] = useQuery(userById, { userId: post.userId }) // N+1!
278
+ return <div>{author?.username}</div>
279
+ }
540
280
  ```
541
281
 
542
- ### server setup
282
+ **good:**
543
283
 
544
284
  ```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
285
+ // include relation in query
286
+ export const feedPosts = () => {
287
+ return zql.post.related('user', (q) => q.one())
288
+ }
562
289
 
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
- }
290
+ function PostCard({ post }) {
291
+ return <div>{post.user?.username}</div> // already loaded
573
292
  }
574
293
  ```
575
294
 
576
- ## permissions
295
+ ### client-side filtering
577
296
 
578
- permissions use the `where()` helper to create conditions:
297
+ **bad:**
579
298
 
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
- })
299
+ ```tsx
300
+ const [blockedUsers] = useQuery(blockedByMe, { userId })
301
+ const blockedIds = blockedUsers.map(b => b.blockedId)
302
+ const [posts] = useQuery(postsFiltered, { blockedUserIds: blockedIds })
585
303
  ```
586
304
 
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:
305
+ **good:**
592
306
 
593
307
  ```ts
594
- await ctx.can(permissions, postId)
595
- ```
596
-
597
- check in react:
308
+ // filter server-side with exists()
309
+ const notBlocked = serverWhere('post', (q, auth) => {
310
+ if (!auth?.id) return true
311
+ return q.not(q.exists('authorBlockedBy', (b) => b.where('blockerId', auth.id)))
312
+ })
598
313
 
599
- ```tsx
600
- const canEdit = usePermission('post', postId)
601
- if (!canEdit) return null
314
+ export const feedPosts = () => zql.post.where(notBlocked)
602
315
  ```
603
316
 
604
- ## calling mutations
605
-
606
- optimistic client update:
317
+ ### index vs detail page queries
607
318
 
608
- ```tsx
609
- await zero.mutate.post.update(post)
610
- ```
319
+ design queries so index pages load all data needed for detail pages:
611
320
 
612
- wait for server confirmation:
321
+ ```ts
322
+ // feedPosts - used on index, includes everything detail page needs
323
+ export const feedPosts = (props: { limit: number }) => {
324
+ return zql.post
325
+ .where(permission)
326
+ .limit(props.limit)
327
+ .related('user', (q) => q.one())
328
+ .related('comments', (q) =>
329
+ q.limit(50).related('user', (u) => u.one())
330
+ )
331
+ }
613
332
 
614
- ```tsx
615
- const result = await zero.mutate.post.update(post).server
333
+ // postDetail - used on detail page, same shape
334
+ export const postDetail = (props: { postId: string }) => {
335
+ return zql.post
336
+ .where(permission)
337
+ .where('id', props.postId)
338
+ .one()
339
+ .related('user', (q) => q.one())
340
+ .related('comments', (q) =>
341
+ q.limit(50).related('user', (u) => u.one())
342
+ )
343
+ }
616
344
  ```
617
345
 
618
- ## performance tips
346
+ when navigating from feed to detail, Zero's local cache already has the data from
347
+ `feedPosts`, so `postDetail` resolves instantly. the detail query is only slow on
348
+ direct navigation (refresh, shared link).
619
349
 
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
350
+ **key insight:** with Zero, "re-querying" isn't expensive if data is synced - the
351
+ query runs against local cache. design queries with same relations so cache hits.
628
352
 
629
353
  ## debugging
630
354
 
631
355
  add `?debug=2` to url for detailed zero logs.
632
356
 
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
357
+ ## soft deletes
656
358
 
657
359
  ```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:
360
+ // mutation
361
+ async delete(ctx, { id }) {
362
+ await ctx.tx.mutate.post.update({ id, deleted: true })
363
+ }
669
364
 
670
- ```ts
365
+ // queries filter deleted
671
366
  .where('deleted', false)
672
367
  ```
673
368
 
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.
369
+ ## type generation
701
370
 
702
- in production, run:
371
+ regenerate after schema changes:
703
372
 
704
373
  ```bash
705
- bun migrate
374
+ bun tko zero generate
706
375
  ```
707
376
 
708
- this runs drizzle migrations and updates zero's cvr database.
377
+ `bun dev` watches and regenerates automatically.
709
378
 
710
379
  ## resources
711
380
 
712
381
  - zero docs: https://zero.rocicorp.dev
713
- - over-zero readme: packages/over-zero/readme.md
382
+ - over-zero: packages/over-zero/readme.md
714
383
  - models: src/data/models/
715
384
  - 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