@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/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