@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/package.json +1 -1
- package/sync-prompt.md +1 -1
- package/tamagui.md +1 -1
- package/zero.md +154 -489
- package/aggregates.md +0 -584
- package/cloudflare-dev-tunnel.md +0 -41
- package/database.md +0 -229
- package/docs.md +0 -8
- package/emitters.md +0 -562
- package/hot-updater.md +0 -223
- package/native-hot-update.md +0 -252
- package/one-components.md +0 -234
- package/one-hooks.md +0 -570
- package/one-routes.md +0 -660
- package/package-json.md +0 -115
- package/react-native-navigation-flow.md +0 -184
- package/scripts.md +0 -144
- package/testing-integration.md +0 -564
- package/triggers.md +0 -450
- package/xcodebuild-mcp.md +0 -127
package/zero.md
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: takeout-zero
|
|
3
|
-
description: Zero
|
|
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 {
|
|
13
|
+
import { serverWhere, zql } from 'over-zero'
|
|
19
14
|
|
|
20
|
-
const permission =
|
|
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
|
-
//
|
|
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
|
-
|
|
47
|
+
## permissions
|
|
81
48
|
|
|
82
|
-
|
|
83
|
-
`src/data/generated/syncedQueries.ts`:
|
|
49
|
+
permissions only execute server-side. use `serverWhere` for query permissions:
|
|
84
50
|
|
|
85
51
|
```ts
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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/
|
|
115
|
-
const
|
|
116
|
-
return true
|
|
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
|
-
|
|
70
|
+
`exists()` requires a relationship. add to `src/data/relationships.ts`:
|
|
125
71
|
|
|
126
72
|
```ts
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
q.cmp('id', '=', auth?.id || ''),
|
|
149
|
-
)
|
|
150
|
-
```
|
|
83
|
+
## relations
|
|
151
84
|
|
|
152
|
-
|
|
85
|
+
include related data with `.related()`:
|
|
153
86
|
|
|
154
87
|
```ts
|
|
155
|
-
|
|
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(
|
|
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('
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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 (
|
|
288
|
-
await tx.mutate.post.insert(post)
|
|
289
|
-
|
|
290
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
every mutation receives `MutatorContext` as first argument:
|
|
183
|
+
### mutation context
|
|
338
184
|
|
|
339
185
|
```ts
|
|
340
186
|
type MutatorContext = {
|
|
341
|
-
tx: Transaction
|
|
342
|
-
authData: AuthData | null
|
|
343
|
-
environment: 'server' | 'client'
|
|
344
|
-
can: (where, obj) => Promise<void>
|
|
187
|
+
tx: Transaction
|
|
188
|
+
authData: AuthData | null
|
|
189
|
+
environment: 'server' | 'client'
|
|
190
|
+
can: (where, obj) => Promise<void>
|
|
345
191
|
server?: {
|
|
346
|
-
actions: ServerActions
|
|
347
|
-
asyncTasks: AsyncAction[]
|
|
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
|
-
|
|
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
|
|
212
|
+
mutations run on both client and server - they must produce the same result.
|
|
415
213
|
|
|
416
|
-
bad
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
242
|
+
```tsx
|
|
243
|
+
// optimistic - updates UI immediately
|
|
244
|
+
zero.mutate.post.update(post)
|
|
453
245
|
|
|
454
|
-
|
|
455
|
-
|
|
246
|
+
// wait for server confirmation
|
|
247
|
+
const result = await zero.mutate.post.update(post).server
|
|
456
248
|
```
|
|
457
249
|
|
|
458
|
-
|
|
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
|
-
|
|
252
|
+
### useAuth() vs useUser()
|
|
466
253
|
|
|
467
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
256
|
+
```tsx
|
|
257
|
+
const { user } = useUser() // queries database, waits
|
|
258
|
+
const [posts] = useQuery(postsByUserId, { userId: user?.id || '' })
|
|
483
259
|
```
|
|
484
260
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
## schema organization
|
|
261
|
+
**good - immediate:**
|
|
488
262
|
|
|
489
|
-
|
|
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
|
-
|
|
268
|
+
use `useAuth()` for query params. only use `useUser()` when you need full user
|
|
269
|
+
record (profile data, settings).
|
|
516
270
|
|
|
517
|
-
###
|
|
271
|
+
### n+1 queries in lists
|
|
518
272
|
|
|
519
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
282
|
+
**good:**
|
|
543
283
|
|
|
544
284
|
```ts
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
564
|
-
//
|
|
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
|
-
|
|
295
|
+
### client-side filtering
|
|
577
296
|
|
|
578
|
-
|
|
297
|
+
**bad:**
|
|
579
298
|
|
|
580
|
-
```
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
600
|
-
const canEdit = usePermission('post', postId)
|
|
601
|
-
if (!canEdit) return null
|
|
314
|
+
export const feedPosts = () => zql.post.where(notBlocked)
|
|
602
315
|
```
|
|
603
316
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
optimistic client update:
|
|
317
|
+
### index vs detail page queries
|
|
607
318
|
|
|
608
|
-
|
|
609
|
-
await zero.mutate.post.update(post)
|
|
610
|
-
```
|
|
319
|
+
design queries so index pages load all data needed for detail pages:
|
|
611
320
|
|
|
612
|
-
|
|
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
|
-
|
|
615
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
365
|
+
// queries filter deleted
|
|
671
366
|
.where('deleted', false)
|
|
672
367
|
```
|
|
673
368
|
|
|
674
|
-
|
|
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
|
-
|
|
371
|
+
regenerate after schema changes:
|
|
703
372
|
|
|
704
373
|
```bash
|
|
705
|
-
bun
|
|
374
|
+
bun tko zero generate
|
|
706
375
|
```
|
|
707
376
|
|
|
708
|
-
|
|
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
|
|
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
|