@take-out/docs 0.2.2 → 0.2.3
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/zero.md +0 -384
package/package.json
CHANGED
package/zero.md
DELETED
|
@@ -1,384 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: takeout-zero
|
|
3
|
-
description: Zero data layer guide. useQuery, zql, mutations, CRUD, permissions, serverWhere, exists(), relations, .related(), pagination, cursor, convergence, optimistic updates.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
## queries
|
|
7
|
-
|
|
8
|
-
queries are plain exported functions in `src/data/queries/` that use the global
|
|
9
|
-
`zql` builder:
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
// src/data/queries/post.ts
|
|
13
|
-
import { serverWhere, zql } from 'on-zero'
|
|
14
|
-
|
|
15
|
-
const permission = serverWhere('post', () => true)
|
|
16
|
-
|
|
17
|
-
export const allPosts = (props: { limit?: number }) => {
|
|
18
|
-
return zql.post
|
|
19
|
-
.where(permission)
|
|
20
|
-
.orderBy('createdAt', 'desc')
|
|
21
|
-
.limit(props.limit || 20)
|
|
22
|
-
}
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
use with `useQuery`:
|
|
26
|
-
|
|
27
|
-
```tsx
|
|
28
|
-
import { useQuery } from '~/zero/client'
|
|
29
|
-
import { allPosts } from '~/data/queries/post'
|
|
30
|
-
|
|
31
|
-
const [posts, status] = useQuery(allPosts, { limit: 20 })
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
### useQuery patterns
|
|
35
|
-
|
|
36
|
-
```tsx
|
|
37
|
-
// with params
|
|
38
|
-
useQuery(queryFn, { param1, param2 })
|
|
39
|
-
|
|
40
|
-
// with params + options
|
|
41
|
-
useQuery(queryFn, { param1, param2 }, { enabled: true })
|
|
42
|
-
|
|
43
|
-
// conditional - only runs when enabled is true
|
|
44
|
-
const [data] = useQuery(userById, { userId }, { enabled: Boolean(userId) })
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
## permissions
|
|
48
|
-
|
|
49
|
-
permissions only execute server-side. use `serverWhere` for query permissions:
|
|
50
|
-
|
|
51
|
-
```ts
|
|
52
|
-
const permission = serverWhere('post', (q, auth) => {
|
|
53
|
-
if (auth?.role === 'admin') return true
|
|
54
|
-
return q.cmp('userId', auth?.id || '')
|
|
55
|
-
})
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
for reusable permissions, extract to `src/data/where/`:
|
|
59
|
-
|
|
60
|
-
```ts
|
|
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
|
-
))
|
|
67
|
-
})
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
`exists()` requires a relationship. add to `src/data/relationships.ts`:
|
|
71
|
-
|
|
72
|
-
```ts
|
|
73
|
-
// post -> block via post.userId = block.blockedId
|
|
74
|
-
authorBlockedBy: many({
|
|
75
|
-
sourceField: ['userId'],
|
|
76
|
-
destSchema: tables.block,
|
|
77
|
-
destField: ['blockedId'],
|
|
78
|
-
})
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
this filters posts where author is blocked, without exposing block data to client.
|
|
82
|
-
|
|
83
|
-
## relations
|
|
84
|
-
|
|
85
|
-
include related data with `.related()`:
|
|
86
|
-
|
|
87
|
-
```ts
|
|
88
|
-
export const postWithComments = (props: { postId: string }) => {
|
|
89
|
-
return zql.post
|
|
90
|
-
.where('id', props.postId)
|
|
91
|
-
.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
|
-
)
|
|
98
|
-
}
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
define relationships in `src/data/relationships.ts`:
|
|
102
|
-
|
|
103
|
-
```ts
|
|
104
|
-
export const postRelationships = relationships(tables.post, ({ one, many }) => ({
|
|
105
|
-
user: one({
|
|
106
|
-
sourceField: ['userId'],
|
|
107
|
-
destSchema: tables.userPublic,
|
|
108
|
-
destField: ['id'],
|
|
109
|
-
}),
|
|
110
|
-
comments: many({
|
|
111
|
-
sourceField: ['id'],
|
|
112
|
-
destSchema: tables.comment,
|
|
113
|
-
destField: ['postId'],
|
|
114
|
-
}),
|
|
115
|
-
}))
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
## pagination
|
|
119
|
-
|
|
120
|
-
use `.start()` for cursor-based pagination:
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
export const postsPaginated = (props: {
|
|
124
|
-
pageSize: number
|
|
125
|
-
cursor?: { id: string; createdAt: number } | null
|
|
126
|
-
}) => {
|
|
127
|
-
let query = zql.post
|
|
128
|
-
.orderBy('createdAt', 'desc')
|
|
129
|
-
.orderBy('id', 'desc')
|
|
130
|
-
.limit(props.pageSize)
|
|
131
|
-
|
|
132
|
-
if (props.cursor) {
|
|
133
|
-
query = query.start(props.cursor)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return query
|
|
137
|
-
}
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
## models
|
|
141
|
-
|
|
142
|
-
models in `src/data/models/` define schema, permissions, and mutations:
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
// src/data/models/post.ts
|
|
146
|
-
import { boolean, number, string, table } from '@rocicorp/zero'
|
|
147
|
-
import { mutations, serverWhere } from 'on-zero'
|
|
148
|
-
|
|
149
|
-
export const schema = table('post')
|
|
150
|
-
.columns({
|
|
151
|
-
id: string(),
|
|
152
|
-
userId: string(),
|
|
153
|
-
caption: string().optional(),
|
|
154
|
-
createdAt: number(),
|
|
155
|
-
})
|
|
156
|
-
.primaryKey('id')
|
|
157
|
-
|
|
158
|
-
const permissions = serverWhere('post', (q, auth) => {
|
|
159
|
-
return q.cmp('userId', auth?.id || '')
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
export const mutate = mutations(schema, permissions, {
|
|
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')
|
|
169
|
-
)
|
|
170
|
-
}
|
|
171
|
-
},
|
|
172
|
-
})
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
passing `schema` and `permissions` to `mutations()` generates CRUD:
|
|
176
|
-
|
|
177
|
-
```tsx
|
|
178
|
-
zero.mutate.post.insert(post)
|
|
179
|
-
zero.mutate.post.update(post)
|
|
180
|
-
zero.mutate.post.delete(post)
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
### mutation context
|
|
184
|
-
|
|
185
|
-
```ts
|
|
186
|
-
type MutatorContext = {
|
|
187
|
-
tx: Transaction
|
|
188
|
-
authData: AuthData | null
|
|
189
|
-
environment: 'server' | 'client'
|
|
190
|
-
can: (where, obj) => Promise<void>
|
|
191
|
-
server?: {
|
|
192
|
-
actions: ServerActions
|
|
193
|
-
asyncTasks: AsyncAction[] // runs after transaction commits
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### async tasks
|
|
199
|
-
|
|
200
|
-
move slow work out of transactions:
|
|
201
|
-
|
|
202
|
-
```ts
|
|
203
|
-
if (ctx.server) {
|
|
204
|
-
ctx.server.asyncTasks.push(async () => {
|
|
205
|
-
await ctx.server.actions.sendPushNotification(message)
|
|
206
|
-
})
|
|
207
|
-
}
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
## convergence
|
|
211
|
-
|
|
212
|
-
mutations run on both client and server - they must produce the same result.
|
|
213
|
-
|
|
214
|
-
**bad:**
|
|
215
|
-
|
|
216
|
-
```ts
|
|
217
|
-
async insert(ctx, post) {
|
|
218
|
-
await ctx.tx.mutate.post.insert({
|
|
219
|
-
...post,
|
|
220
|
-
id: randomId(), // different on each run!
|
|
221
|
-
createdAt: Date.now() // different timing!
|
|
222
|
-
})
|
|
223
|
-
}
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
**good:**
|
|
227
|
-
|
|
228
|
-
```ts
|
|
229
|
-
async insert(ctx, post) {
|
|
230
|
-
// client generates id and timestamp once, passes to mutation
|
|
231
|
-
await ctx.tx.mutate.post.insert(post)
|
|
232
|
-
|
|
233
|
-
// server-only side effects are fine
|
|
234
|
-
if (ctx.server) {
|
|
235
|
-
await ctx.server.actions.sendEmail(post)
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
## calling mutations
|
|
241
|
-
|
|
242
|
-
```tsx
|
|
243
|
-
// optimistic - updates UI immediately
|
|
244
|
-
zero.mutate.post.update(post)
|
|
245
|
-
|
|
246
|
-
// wait for server confirmation
|
|
247
|
-
const result = await zero.mutate.post.update(post).server
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
## anti-patterns
|
|
251
|
-
|
|
252
|
-
### useAuth() vs useUser()
|
|
253
|
-
|
|
254
|
-
**bad - waterfall:**
|
|
255
|
-
|
|
256
|
-
```tsx
|
|
257
|
-
const { user } = useUser() // queries database, waits
|
|
258
|
-
const [posts] = useQuery(postsByUserId, { userId: user?.id || '' })
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
**good - immediate:**
|
|
262
|
-
|
|
263
|
-
```tsx
|
|
264
|
-
const { user } = useAuth() // available immediately from jwt
|
|
265
|
-
const [posts] = useQuery(postsByUserId, { userId: user?.id || '' })
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
use `useAuth()` for query params. only use `useUser()` when you need full user
|
|
269
|
-
record (profile data, settings).
|
|
270
|
-
|
|
271
|
-
### n+1 queries in lists
|
|
272
|
-
|
|
273
|
-
**bad:**
|
|
274
|
-
|
|
275
|
-
```tsx
|
|
276
|
-
function PostCard({ post }) {
|
|
277
|
-
const [author] = useQuery(userById, { userId: post.userId }) // N+1!
|
|
278
|
-
return <div>{author?.username}</div>
|
|
279
|
-
}
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
**good:**
|
|
283
|
-
|
|
284
|
-
```ts
|
|
285
|
-
// include relation in query
|
|
286
|
-
export const feedPosts = () => {
|
|
287
|
-
return zql.post.related('user', (q) => q.one())
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function PostCard({ post }) {
|
|
291
|
-
return <div>{post.user?.username}</div> // already loaded
|
|
292
|
-
}
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
### client-side filtering
|
|
296
|
-
|
|
297
|
-
**bad:**
|
|
298
|
-
|
|
299
|
-
```tsx
|
|
300
|
-
const [blockedUsers] = useQuery(blockedByMe, { userId })
|
|
301
|
-
const blockedIds = blockedUsers.map(b => b.blockedId)
|
|
302
|
-
const [posts] = useQuery(postsFiltered, { blockedUserIds: blockedIds })
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
**good:**
|
|
306
|
-
|
|
307
|
-
```ts
|
|
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
|
-
})
|
|
313
|
-
|
|
314
|
-
export const feedPosts = () => zql.post.where(notBlocked)
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
### index vs detail page queries
|
|
318
|
-
|
|
319
|
-
design queries so index pages load all data needed for detail pages:
|
|
320
|
-
|
|
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
|
-
}
|
|
332
|
-
|
|
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
|
-
}
|
|
344
|
-
```
|
|
345
|
-
|
|
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).
|
|
349
|
-
|
|
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.
|
|
352
|
-
|
|
353
|
-
## debugging
|
|
354
|
-
|
|
355
|
-
add `?debug=2` to url for detailed zero logs.
|
|
356
|
-
|
|
357
|
-
## soft deletes
|
|
358
|
-
|
|
359
|
-
```ts
|
|
360
|
-
// mutation
|
|
361
|
-
async delete(ctx, { id }) {
|
|
362
|
-
await ctx.tx.mutate.post.update({ id, deleted: true })
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// queries filter deleted
|
|
366
|
-
.where('deleted', false)
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
## type generation
|
|
370
|
-
|
|
371
|
-
regenerate after schema changes:
|
|
372
|
-
|
|
373
|
-
```bash
|
|
374
|
-
bun tko zero generate
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
`bun dev` watches and regenerates automatically.
|
|
378
|
-
|
|
379
|
-
## resources
|
|
380
|
-
|
|
381
|
-
- zero docs: https://zero.rocicorp.dev
|
|
382
|
-
- on-zero: packages/on-zero/readme.md
|
|
383
|
-
- models: src/data/models/
|
|
384
|
-
- queries: src/data/queries/
|