lazyconvex 0.0.0
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/README.md +926 -0
- package/dist/components/index.mjs +937 -0
- package/dist/error-D4GuI0ot.mjs +71 -0
- package/dist/file-field-BqVgy8xY.mjs +205 -0
- package/dist/form-BXJK_j10.d.mts +99 -0
- package/dist/index.d.mts +433 -0
- package/dist/index.mjs +1 -0
- package/dist/index2.d.mts +5 -0
- package/dist/index3.d.mts +35 -0
- package/dist/index4.d.mts +101 -0
- package/dist/index5.d.mts +842 -0
- package/dist/next/index.mjs +151 -0
- package/dist/org-CmJBb8z-.d.mts +56 -0
- package/dist/react/index.mjs +158 -0
- package/dist/retry.d.mts +12 -0
- package/dist/retry.mjs +35 -0
- package/dist/schema.d.mts +23 -0
- package/dist/schema.mjs +15 -0
- package/dist/server/index.mjs +2572 -0
- package/dist/types-DWBVRtit.d.mts +322 -0
- package/dist/use-online-status-CMr73Jlk.mjs +155 -0
- package/dist/use-upload-DtELytQi.mjs +95 -0
- package/dist/zod.d.mts +18 -0
- package/dist/zod.mjs +87 -0
- package/package.json +40 -0
- package/src/components/editors-section.tsx +86 -0
- package/src/components/fields.tsx +884 -0
- package/src/components/file-field.tsx +234 -0
- package/src/components/form.tsx +191 -0
- package/src/components/index.ts +11 -0
- package/src/components/offline-indicator.tsx +15 -0
- package/src/components/org-avatar.tsx +13 -0
- package/src/components/permission-guard.tsx +36 -0
- package/src/components/role-badge.tsx +14 -0
- package/src/components/suspense-wrap.tsx +8 -0
- package/src/index.ts +40 -0
- package/src/next/active-org.ts +33 -0
- package/src/next/auth.ts +9 -0
- package/src/next/image.ts +134 -0
- package/src/next/index.ts +3 -0
- package/src/react/form-meta.ts +53 -0
- package/src/react/form.ts +201 -0
- package/src/react/index.ts +8 -0
- package/src/react/org.tsx +96 -0
- package/src/react/use-active-org.ts +48 -0
- package/src/react/use-bulk-selection.ts +47 -0
- package/src/react/use-online-status.ts +21 -0
- package/src/react/use-optimistic.ts +54 -0
- package/src/react/use-upload.ts +101 -0
- package/src/retry.ts +47 -0
- package/src/schema.ts +30 -0
- package/src/server/cache-crud.ts +175 -0
- package/src/server/check-schema.ts +29 -0
- package/src/server/child.ts +98 -0
- package/src/server/crud.ts +384 -0
- package/src/server/db.ts +7 -0
- package/src/server/error.ts +39 -0
- package/src/server/file.ts +372 -0
- package/src/server/helpers.ts +214 -0
- package/src/server/index.ts +12 -0
- package/src/server/org-crud.ts +307 -0
- package/src/server/org-helpers.ts +54 -0
- package/src/server/org.ts +572 -0
- package/src/server/schema-helpers.ts +107 -0
- package/src/server/setup.ts +138 -0
- package/src/server/test-crud.ts +211 -0
- package/src/server/test.ts +554 -0
- package/src/server/types.ts +392 -0
- package/src/server/unique.ts +28 -0
- package/src/zod.ts +141 -0
package/README.md
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
# lazyconvex
|
|
2
|
+
|
|
3
|
+
Typesafe fullstack in minutes. Zod schema → Convex backend → React frontend.
|
|
4
|
+
|
|
5
|
+
Define a Zod schema once → get CRUD endpoints, typesafe forms, file handling, real-time queries, conflict detection, and optimistic updates with zero boilerplate. No REST routes, no API layer, no manual validation.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
// WITHOUT: ~80 lines per table (route handler + validation + auth + pagination + error handling + form)
|
|
9
|
+
export const createProduct = mutation({
|
|
10
|
+
args: { title: v.string(), price: v.number(), photo: v.id('_storage') },
|
|
11
|
+
handler: async (ctx, args) => {
|
|
12
|
+
const user = await getAuthUserId(ctx)
|
|
13
|
+
if (!user) throw new ConvexError('NOT_AUTHENTICATED')
|
|
14
|
+
return ctx.db.insert('product', { ...args, userId: user, createdAt: Date.now(), updatedAt: Date.now() })
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
// + updateProduct, deleteProduct, getProduct, listProducts, searchProducts, countProducts...
|
|
18
|
+
// + file cleanup on delete, cascade deletes, soft delete, conflict detection, ownership checks...
|
|
19
|
+
// + React form with Zod validation, file upload, error handling, optimistic updates...
|
|
20
|
+
|
|
21
|
+
// WITH: 3 lines. Same result, fully typesafe.
|
|
22
|
+
const { create, pub, rm, update } = crud('product', owned.product)
|
|
23
|
+
export const { all, count, list, read, search } = pub
|
|
24
|
+
export { create, rm, update }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun i lazyconvex
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Setup (5 minutes)
|
|
34
|
+
|
|
35
|
+
### 1. Define your schemas with Zod
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// schemas.ts
|
|
39
|
+
import { array, boolean, number, object, string } from 'zod/v4'
|
|
40
|
+
import { zid } from 'convex-helpers/server/zod4'
|
|
41
|
+
import { cvFile, cvFiles } from 'lazyconvex/schema'
|
|
42
|
+
|
|
43
|
+
export const owned = {
|
|
44
|
+
product: object({
|
|
45
|
+
title: string().min(1),
|
|
46
|
+
price: number().min(0),
|
|
47
|
+
photo: cvFile().nullable(),
|
|
48
|
+
attachments: cvFiles().max(5).optional()
|
|
49
|
+
}),
|
|
50
|
+
blog: object({
|
|
51
|
+
title: string().min(1),
|
|
52
|
+
content: string(),
|
|
53
|
+
published: boolean(),
|
|
54
|
+
tags: array(string()).max(10).optional()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const orgScoped = {
|
|
59
|
+
wiki: object({
|
|
60
|
+
title: string().min(1),
|
|
61
|
+
content: string().optional(),
|
|
62
|
+
status: zenum(['draft', 'published']),
|
|
63
|
+
editors: array(zid('users')).max(100).optional()
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 2. Register tables in your Convex schema
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
// convex/schema.ts
|
|
72
|
+
import { defineSchema } from 'convex/server'
|
|
73
|
+
import { orgTable, orgTables, ownedTable, uploadTables } from 'lazyconvex/server'
|
|
74
|
+
import { orgScoped, owned } from '../schemas'
|
|
75
|
+
|
|
76
|
+
export default defineSchema({
|
|
77
|
+
...uploadTables,
|
|
78
|
+
...orgTables,
|
|
79
|
+
product: ownedTable(owned.product),
|
|
80
|
+
blog: ownedTable(owned.blog),
|
|
81
|
+
wiki: orgTable(orgScoped.wiki),
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 3. Initialize lazyconvex
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
// convex/lazy.ts
|
|
89
|
+
import { getAuthUserId } from '@convex-dev/auth/server'
|
|
90
|
+
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
|
|
91
|
+
import { setup } from 'lazyconvex/server'
|
|
92
|
+
|
|
93
|
+
export const { crud, orgCrud, childCrud, cacheCrud, uniqueCheck, pq, q, m } = setup({
|
|
94
|
+
query, mutation, action, internalQuery, internalMutation,
|
|
95
|
+
getAuthUserId,
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 4. Create endpoints — one line per table
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
// convex/product.ts
|
|
103
|
+
import { crud } from './lazy'
|
|
104
|
+
import { owned } from '../schemas'
|
|
105
|
+
|
|
106
|
+
const { create, pub, rm, update } = crud('product', owned.product)
|
|
107
|
+
export const { all, count, list, read, search } = pub
|
|
108
|
+
export { create, rm, update }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 5. Use in React
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { useQuery, useMutation } from 'convex/react'
|
|
115
|
+
import { api } from '../convex/_generated/api'
|
|
116
|
+
|
|
117
|
+
const products = useQuery(api.product.all)
|
|
118
|
+
const createProduct = useMutation(api.product.create)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
That's it. You have a fully typed, real-time, authenticated CRUD API with file handling, pagination, search, and ownership checks.
|
|
122
|
+
|
|
123
|
+
## Contents
|
|
124
|
+
|
|
125
|
+
- [Quick Start](#quick-start) · [Recipes](#recipes) · [Which Hook?](#which-hook)
|
|
126
|
+
- [Table Types](#table-types) · [CRUD Factory](#crud-factory) · [Child CRUD](#child-crud) · [Cache Factory](#cache-factory)
|
|
127
|
+
- [Frontend](#frontend): [Data Fetching](#data-fetching) · [Forms](#forms-create) · [`useFormMutation`](#useformmutation) · [`pickValues`](#forms-update) · [File Upload](#file-upload) · [Optimistic Updates](#optimistic-updates)
|
|
128
|
+
- [Custom Queries](#custom-queries) · [Relations](#relations) · [Where Clauses](#where-clause-reference)
|
|
129
|
+
- [Error Handling](#error-handling)
|
|
130
|
+
- [Organizations](#organizations) · [ACL (Editors)](#acl-editors)
|
|
131
|
+
- [Known Limitations](#known-limitations) · [Imports Reference](#imports-reference)
|
|
132
|
+
|
|
133
|
+
## Quick Start
|
|
134
|
+
|
|
135
|
+
### Owned Table (user-private data)
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
// 1. Schema
|
|
139
|
+
owned = {
|
|
140
|
+
product: object({
|
|
141
|
+
title: string().min(1),
|
|
142
|
+
price: number().min(0),
|
|
143
|
+
photo: cvFile().nullable(),
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
// 2. Register: product: ownedTable(owned.product)
|
|
147
|
+
// 3. Endpoints
|
|
148
|
+
const { create, pub, rm, update } = crud('product', owned.product)
|
|
149
|
+
export const { all, count, list, read, search } = pub
|
|
150
|
+
export { create, rm, update }
|
|
151
|
+
// 4. React
|
|
152
|
+
const products = useQuery(api.product.all)
|
|
153
|
+
const createProduct = useMutation(api.product.create)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Org-Scoped Table with ACL
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
// 1. Schema
|
|
160
|
+
orgScoped = {
|
|
161
|
+
wiki: object({
|
|
162
|
+
title: string().min(1),
|
|
163
|
+
content: string().optional(),
|
|
164
|
+
status: zenum(['draft', 'published']),
|
|
165
|
+
editors: array(zid('users')).max(100).optional()
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
// 2. Register: wiki: orgTable(orgScoped.wiki)
|
|
169
|
+
// 3. Endpoints — acl: true auto-generates editor endpoints
|
|
170
|
+
const { addEditor, all, create, editors, list, read, removeEditor, rm, setEditors, update } = orgCrud(
|
|
171
|
+
'wiki', orgScoped.wiki, { acl: true }
|
|
172
|
+
)
|
|
173
|
+
export { addEditor, all, create, editors, list, read, removeEditor, rm, setEditors, update }
|
|
174
|
+
// 4. React (inside OrgProvider)
|
|
175
|
+
const wikis = useOrgQuery(api.wiki.list, { paginationOpts: { cursor: null, numItems: 20 } })
|
|
176
|
+
const editorsList = useOrgQuery(api.wiki.editors, { wikiId })
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Recipes
|
|
180
|
+
|
|
181
|
+
End-to-end patterns for common pages. Org examples assume `<OrgProvider>` context.
|
|
182
|
+
|
|
183
|
+
### List Page
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
const items = useOrgQuery(api.project.list, { paginationOpts: { cursor: null, numItems: 20 } })
|
|
187
|
+
const bulkRm = useMutation(api.project.bulkRm)
|
|
188
|
+
const { selected, toggleSelect, handleBulkDelete } = useBulkSelection({
|
|
189
|
+
bulkRm, items: items?.page ?? [], label: 'project(s)', orgId: org._id
|
|
190
|
+
})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Create Page
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
const form = useFormMutation({
|
|
197
|
+
mutation: api.wiki.create,
|
|
198
|
+
schema: orgScoped.wiki,
|
|
199
|
+
transform: d => ({ ...d, orgId: org._id }),
|
|
200
|
+
onSuccess: () => { toast.success('Created'); router.push(`/org/${org.slug}/wiki`) },
|
|
201
|
+
resetOnSuccess: true
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
<Form form={form} render={({ Text, Choose, Submit }) => (
|
|
205
|
+
<>
|
|
206
|
+
<Text name='title' label='Title' />
|
|
207
|
+
<Choose name='status' label='Status' />
|
|
208
|
+
<Submit>Create</Submit>
|
|
209
|
+
</>
|
|
210
|
+
)} />
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Edit Page
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
const doc = useOrgQuery(api.wiki.read, { id: wikiId })
|
|
217
|
+
const remove = useOrgMutation(api.wiki.rm)
|
|
218
|
+
const editorsList = useOrgQuery(api.wiki.editors, { wikiId })
|
|
219
|
+
const canEditDoc = canEditResource({ editorsList, isAdmin, resource: doc, userId: me._id })
|
|
220
|
+
const form = useFormMutation({
|
|
221
|
+
mutation: api.wiki.update,
|
|
222
|
+
schema: orgScoped.wiki,
|
|
223
|
+
values: doc ? pickValues(orgScoped.wiki, doc) : undefined,
|
|
224
|
+
transform: d => ({ ...d, id: wikiId, orgId: org._id }),
|
|
225
|
+
onSuccess: () => { toast.success('Updated'); router.push(`/org/${org.slug}/wiki`) }
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
<PermissionGuard canAccess={canEditDoc} backHref={backUrl} backLabel='wiki' resource='wiki'>
|
|
229
|
+
<Form form={form} render={({ Text, Choose, Submit }) => (
|
|
230
|
+
<>
|
|
231
|
+
<Text name='title' label='Title' />
|
|
232
|
+
<Submit>Save</Submit>
|
|
233
|
+
</>
|
|
234
|
+
)} />
|
|
235
|
+
</PermissionGuard>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Derived Schemas
|
|
239
|
+
|
|
240
|
+
Use `.partial()`, `.omit()` for edit vs create forms:
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
const createBlog = owned.blog.omit({ published: true })
|
|
244
|
+
const editBlog = owned.blog.partial()
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Which Hook?
|
|
248
|
+
|
|
249
|
+
| Task | Hook | When |
|
|
250
|
+
|------|------|------|
|
|
251
|
+
| Query (owned/public) | `useQuery(api.x.y, args)` | No org context |
|
|
252
|
+
| Query (org-scoped) | `useOrgQuery(api.x.y, args?)` | Inside `OrgProvider`, auto-injects `orgId` |
|
|
253
|
+
| Mutation (owned/public) | `useMutation(api.x.y)` | No org context |
|
|
254
|
+
| Mutation (org-scoped) | `useOrgMutation(api.x.y)` | Inside `OrgProvider`, auto-injects `orgId` |
|
|
255
|
+
| Form (custom submit) | `useForm({ schema, onSubmit })` | Need custom submit logic |
|
|
256
|
+
| Form + mutation | `useFormMutation({ mutation, schema })` | Standard create/update pattern |
|
|
257
|
+
| Edit form values | `pickValues(schema, doc)` | Extract doc fields matching schema |
|
|
258
|
+
|
|
259
|
+
`useOrgQuery` supports `'skip'` as second arg. `useOrgMutation` returns `(args?) => Promise`. Both omit `orgId` from caller args.
|
|
260
|
+
|
|
261
|
+
## Table Types
|
|
262
|
+
|
|
263
|
+
| Type | Factory | Schema Registration | Use Case |
|
|
264
|
+
|------|---------|---------------------|----------|
|
|
265
|
+
| `owned` | `crud()` | `ownedTable()` | User-owned data (has `userId`) |
|
|
266
|
+
| `orgScoped` | `orgCrud()` | `orgTable()` | Org-scoped data (has `orgId` + membership check) |
|
|
267
|
+
| `children` | `childCrud()` | `orgChildTable()` | Nested under parent (e.g., tasks in project) |
|
|
268
|
+
| `base` | `cacheCrud()` | `baseTable()` | External API cache (e.g., TMDB movies) |
|
|
269
|
+
|
|
270
|
+
## CRUD Factory
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
const {
|
|
274
|
+
create, update, rm, // mutations
|
|
275
|
+
pub: { all, count, list, read, search }, // public queries
|
|
276
|
+
auth: { all, count, list, read, search }, // auth-required queries
|
|
277
|
+
bulkUpdate, bulkRm, // batch ops (max 100)
|
|
278
|
+
restore // only with softDelete: true
|
|
279
|
+
} = crud('product', owned.product, {
|
|
280
|
+
cascade: false, // opt out of cascade deletes (default: true)
|
|
281
|
+
softDelete: true // enable soft delete + restore
|
|
282
|
+
})
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Soft Delete**: Add `deletedAt: number().optional()` to your Zod schema.
|
|
286
|
+
|
|
287
|
+
### `orgCrud` Return Shape
|
|
288
|
+
|
|
289
|
+
```tsx
|
|
290
|
+
const {
|
|
291
|
+
create, update, rm, // mutations (membership required)
|
|
292
|
+
list, read, all, // queries (membership required)
|
|
293
|
+
bulkUpdate, bulkRm, // batch ops (admin only, max 100)
|
|
294
|
+
// only when acl: true:
|
|
295
|
+
addEditor, removeEditor, // mutations (admin only)
|
|
296
|
+
editors, // query (any member) → { userId, name, email }[]
|
|
297
|
+
setEditors // mutation (admin only)
|
|
298
|
+
} = orgCrud('wiki', orgScoped.wiki, { acl: true })
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Without `acl: true`, the editor endpoints are not returned.
|
|
302
|
+
|
|
303
|
+
## Child CRUD
|
|
304
|
+
|
|
305
|
+
For entities nested under a parent (messages in a chat, tasks in a project):
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
import { child } from 'lazyconvex/schema'
|
|
309
|
+
const children = {
|
|
310
|
+
message: child({
|
|
311
|
+
parent: 'chat',
|
|
312
|
+
foreignKey: 'chatId',
|
|
313
|
+
schema: object({ text: string() })
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
const { create, list, update, rm } = childCrud('message', children.message)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Parent ownership is verified automatically. `list` uses the `by_${parent}` index by default. `foreignKey` is type-checked — a typo raises a compile error.
|
|
320
|
+
|
|
321
|
+
### Cascade Delete
|
|
322
|
+
|
|
323
|
+
Deleting a parent removes all children automatically (enabled by default):
|
|
324
|
+
|
|
325
|
+
```tsx
|
|
326
|
+
await rm({ id: chatId }) // removes chat + all messages
|
|
327
|
+
// Opt out:
|
|
328
|
+
const { rm } = crud('chat', owned.chat, { cascade: false })
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Cascade relationships are derived from `children.*.parent` — define once, works everywhere.
|
|
332
|
+
|
|
333
|
+
### Org Cascade (`orgCascade`)
|
|
334
|
+
|
|
335
|
+
For org-scoped tables, use `orgCascade` for typed cascade config:
|
|
336
|
+
|
|
337
|
+
```tsx
|
|
338
|
+
import { orgCascade } from 'lazyconvex/server'
|
|
339
|
+
|
|
340
|
+
const ops = orgCrud('project', orgScoped.project, {
|
|
341
|
+
acl: true,
|
|
342
|
+
cascade: orgCascade({ foreignKey: 'projectId', table: 'task' })
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Both `foreignKey` and `table` are type-checked against your schema — typos raise compile errors.
|
|
347
|
+
|
|
348
|
+
## Cache Factory
|
|
349
|
+
|
|
350
|
+
For caching external API responses with automatic TTL expiration:
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
const c = cacheCrud({
|
|
354
|
+
table: 'movie',
|
|
355
|
+
schema: base.movie,
|
|
356
|
+
key: 'tmdb_id',
|
|
357
|
+
ttl: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
358
|
+
fetcher: async (ctx, id) => fetchFromTMDB(id)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
export const { load, refresh, get, all, invalidate, purge } = c
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
| Method | Description |
|
|
365
|
+
|--------|-------------|
|
|
366
|
+
| `load` | Returns cached or fetches if missing/expired |
|
|
367
|
+
| `refresh` | Force re-fetch and update cache |
|
|
368
|
+
| `get` | Get cached only (null if expired) |
|
|
369
|
+
| `all` | List all cached entries |
|
|
370
|
+
| `invalidate` | Delete specific cache entry |
|
|
371
|
+
| `purge` | Delete all expired entries |
|
|
372
|
+
|
|
373
|
+
```tsx
|
|
374
|
+
const movie = await ctx.runAction(api.movie.load, { tmdb_id: 550 })
|
|
375
|
+
const fresh = await ctx.runAction(api.movie.refresh, { tmdb_id: 550 })
|
|
376
|
+
const cached = useQuery(api.movie.get, { tmdb_id: 550 })
|
|
377
|
+
const movies = useQuery(api.movie.all, { includeExpired: false })
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
| TTL | Use Case |
|
|
381
|
+
|-----|----------|
|
|
382
|
+
| 1 hour | Real-time prices, stock data |
|
|
383
|
+
| 24 hours | User profiles, frequently updated |
|
|
384
|
+
| 7 days | Movie metadata, static content |
|
|
385
|
+
| 30 days | Rarely changing reference data |
|
|
386
|
+
|
|
387
|
+
## Frontend
|
|
388
|
+
|
|
389
|
+
### Data Fetching
|
|
390
|
+
|
|
391
|
+
```tsx
|
|
392
|
+
// Real-time subscription
|
|
393
|
+
const products = useQuery(api.product.all)
|
|
394
|
+
// Filters (see Where Clause Reference)
|
|
395
|
+
const published = useQuery(api.blog.all, { where: { published: true } })
|
|
396
|
+
const expensive = useQuery(api.product.all, { where: { price: { $gte: 100 } } })
|
|
397
|
+
// Own filter (current user's docs only)
|
|
398
|
+
const myPosts = useQuery(api.blog.all, { where: { own: true } })
|
|
399
|
+
// Skip
|
|
400
|
+
const data = useQuery(api.product.all, isOpen ? {} : 'skip')
|
|
401
|
+
// Pagination
|
|
402
|
+
const { results, loadMore, status } = usePaginatedQuery(api.product.list, {}, { initialNumItems: 20 })
|
|
403
|
+
// Indexed queries (fast lookups)
|
|
404
|
+
const bySlug = useQuery(api.blog.pubIndexed, { index: 'by_slug', key: 'slug', value: 'my-post' })
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Forms (Create)
|
|
408
|
+
|
|
409
|
+
`values` is optional — defaults are derived from the schema (strings → `''`, numbers → `0`, booleans → `false`, arrays → `[]`, files → `null`, enums → first option). Empty optional strings are auto-coerced to `undefined` on submit.
|
|
410
|
+
|
|
411
|
+
```tsx
|
|
412
|
+
import { useForm } from 'lazyconvex/react'
|
|
413
|
+
import { Form } from 'lazyconvex/components'
|
|
414
|
+
|
|
415
|
+
const form = useForm({
|
|
416
|
+
schema: owned.product,
|
|
417
|
+
onSubmit: data => create(data),
|
|
418
|
+
onSuccess: () => toast.success('Created')
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
<Form form={form} render={({ Text, Num, File, Submit }) => (
|
|
422
|
+
<>
|
|
423
|
+
<Text name='title' label='Title' />
|
|
424
|
+
<Num name='price' label='Price' />
|
|
425
|
+
<File name='photo' label='Photo' accept='image/*' />
|
|
426
|
+
<Submit>Save</Submit>
|
|
427
|
+
</>
|
|
428
|
+
)} />
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
**Field Components:** `Text`, `Num`, `Choose`, `MultiSelect`, `Arr`, `Toggle`, `Datepick`, `File`, `Files`, `Colorpick`, `Slider`, `Rating`, `Timepick`, `Combobox`, `Submit`
|
|
432
|
+
|
|
433
|
+
### `useFormMutation`
|
|
434
|
+
|
|
435
|
+
Combines `useMutation` + `useForm` — avoids separate wiring:
|
|
436
|
+
|
|
437
|
+
```tsx
|
|
438
|
+
const form = useFormMutation({
|
|
439
|
+
mutation: api.wiki.update,
|
|
440
|
+
schema: orgScoped.wiki,
|
|
441
|
+
values: wiki ? pickValues(orgScoped.wiki, wiki) : undefined,
|
|
442
|
+
transform: d => ({ ...d, id: wikiId, orgId: org._id }),
|
|
443
|
+
onSuccess: () => toast.success('Updated'),
|
|
444
|
+
resetOnSuccess: true
|
|
445
|
+
})
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Options: `mutation`, `schema`, `values?`, `transform?`, `onSuccess?`, `onError?`, `onConflict?`, `resetOnSuccess?`.
|
|
449
|
+
|
|
450
|
+
### Forms (Update)
|
|
451
|
+
|
|
452
|
+
Use `pickValues` to extract schema-matching fields from an existing doc:
|
|
453
|
+
|
|
454
|
+
```tsx
|
|
455
|
+
import { pickValues } from 'lazyconvex/zod'
|
|
456
|
+
|
|
457
|
+
const doc = useQuery(api.product.read, { id })
|
|
458
|
+
const form = useForm({
|
|
459
|
+
schema: editProduct,
|
|
460
|
+
values: doc ? pickValues(editProduct, doc) : undefined,
|
|
461
|
+
onSubmit: async data => {
|
|
462
|
+
await update({ id, ...data })
|
|
463
|
+
return data
|
|
464
|
+
},
|
|
465
|
+
onSuccess: () => toast.success('Saved')
|
|
466
|
+
})
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**Conflict Detection**: Pass `expectedUpdatedAt` to detect concurrent edits:
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
onSubmit: async data => {
|
|
473
|
+
await update({ id, ...data, expectedUpdatedAt: doc?.updatedAt })
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
If another user modified the record, a conflict dialog appears with options to cancel, reload, or overwrite.
|
|
478
|
+
|
|
479
|
+
**Navigation Guard**: Forms automatically warn about unsaved changes when navigating away.
|
|
480
|
+
|
|
481
|
+
### Async Validation
|
|
482
|
+
|
|
483
|
+
Validate against the backend (e.g., check uniqueness):
|
|
484
|
+
|
|
485
|
+
```tsx
|
|
486
|
+
// Backend
|
|
487
|
+
export const isSlugAvailable = uniqueCheck('blog', 'slug')
|
|
488
|
+
|
|
489
|
+
// Frontend
|
|
490
|
+
<Text
|
|
491
|
+
name='slug'
|
|
492
|
+
label='Slug'
|
|
493
|
+
asyncValidate={async (value) => {
|
|
494
|
+
const available = await isSlugAvailable({ value, exclude: id })
|
|
495
|
+
return available ? undefined : 'Slug already taken'
|
|
496
|
+
}}
|
|
497
|
+
asyncDebounceMs={500}
|
|
498
|
+
/>
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
`uniqueCheck` is fully typesafe — only valid string fields are accepted.
|
|
502
|
+
|
|
503
|
+
### Auto-Save
|
|
504
|
+
|
|
505
|
+
```tsx
|
|
506
|
+
const form = useForm({
|
|
507
|
+
schema: owned.product,
|
|
508
|
+
onSubmit: data => update({ id, ...data }),
|
|
509
|
+
autoSave: { enabled: true, debounceMs: 1000 }
|
|
510
|
+
})
|
|
511
|
+
<AutoSaveIndicator lastSaved={form.lastSaved} />
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### File Upload
|
|
515
|
+
|
|
516
|
+
```tsx
|
|
517
|
+
import { useUpload } from 'lazyconvex/react'
|
|
518
|
+
|
|
519
|
+
const { upload, isUploading, progress } = useUpload()
|
|
520
|
+
const result = await upload(file)
|
|
521
|
+
if (result.ok) await create({ photo: result.storageId })
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Image fields auto-compress by default (max 1920px, 0.8 quality):
|
|
525
|
+
|
|
526
|
+
```tsx
|
|
527
|
+
<File name='photo' /> // compressed (default)
|
|
528
|
+
<File name='photo' compressImg={false} /> // original quality
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
`cvFile()` for single file, `cvFiles()` for arrays. Auto-cleanup on doc update/delete. Max 10MB, rate limited 10 uploads/min.
|
|
532
|
+
|
|
533
|
+
**Auto URL resolution:** Query results include resolved URLs — `photo` (storage ID) → `photoUrl` (string URL), `attachments` (ID array) → `attachmentsUrls` (URL array). No manual `storage.getUrl()` calls.
|
|
534
|
+
|
|
535
|
+
### Optimistic Updates
|
|
536
|
+
|
|
537
|
+
```tsx
|
|
538
|
+
import { useOptimisticMutation } from 'lazyconvex/react'
|
|
539
|
+
|
|
540
|
+
// Delete
|
|
541
|
+
const { execute, isPending, error } = useOptimisticMutation({
|
|
542
|
+
mutation: api.product.rm,
|
|
543
|
+
onOptimistic: ({ id }) => setProducts(p => p.filter(x => x._id !== id)),
|
|
544
|
+
onSuccess: (result, args) => toast.success('Deleted'),
|
|
545
|
+
onRollback: (args, error) => toast.error('Failed to delete')
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// Update
|
|
549
|
+
const { execute } = useOptimisticMutation({
|
|
550
|
+
mutation: api.product.update,
|
|
551
|
+
onOptimistic: ({ id, title }) => {
|
|
552
|
+
setProducts(p => p.map(x => x._id === id ? { ...x, title } : x))
|
|
553
|
+
},
|
|
554
|
+
onRollback: () => refetch()
|
|
555
|
+
})
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
## Custom Queries
|
|
559
|
+
|
|
560
|
+
When the CRUD factory doesn't cover your access pattern (indexed lookups, joins, aggregations):
|
|
561
|
+
|
|
562
|
+
```tsx
|
|
563
|
+
// pq = public query (optional auth)
|
|
564
|
+
// q = auth required query
|
|
565
|
+
// m = auth required mutation
|
|
566
|
+
const bySlug = pq({
|
|
567
|
+
args: { slug: z.string() },
|
|
568
|
+
handler: async (c, { slug }) => {
|
|
569
|
+
const doc = await c.db.query('blog')
|
|
570
|
+
.withIndex('by_slug', q => q.eq('slug', slug))
|
|
571
|
+
.unique()
|
|
572
|
+
return doc ? (await c.withAuthor([doc]))[0] : null
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
const publish = m({
|
|
576
|
+
args: { id: zid('blog') },
|
|
577
|
+
handler: async (c, { id }) => {
|
|
578
|
+
await c.get(id) // throws if not owner
|
|
579
|
+
return c.patch(id, { published: true })
|
|
580
|
+
}
|
|
581
|
+
})
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
## Relations
|
|
585
|
+
|
|
586
|
+
Owned tables automatically include author info via `withAuthor`:
|
|
587
|
+
|
|
588
|
+
```tsx
|
|
589
|
+
const posts = useQuery(api.blog.all)
|
|
590
|
+
// Each post has: { ...data, author: { name, email, ... }, own: boolean }
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
For custom relations in handlers:
|
|
594
|
+
|
|
595
|
+
```tsx
|
|
596
|
+
const bySlug = pq({
|
|
597
|
+
args: { slug: z.string() },
|
|
598
|
+
handler: async (c, { slug }) => {
|
|
599
|
+
const doc = await c.db.query('blog')
|
|
600
|
+
.withIndex('by_slug', q => q.eq('slug', slug))
|
|
601
|
+
.unique()
|
|
602
|
+
if (!doc) return null
|
|
603
|
+
const author = await c.db.get(doc.userId)
|
|
604
|
+
return { ...doc, author }
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
## Where Clause Reference
|
|
610
|
+
|
|
611
|
+
### Comparison Operators
|
|
612
|
+
|
|
613
|
+
```tsx
|
|
614
|
+
{ where: { price: { $gt: 100 } } } // greater than
|
|
615
|
+
{ where: { price: { $gte: 100 } } } // greater than or equal
|
|
616
|
+
{ where: { price: { $lt: 50 } } } // less than
|
|
617
|
+
{ where: { price: { $lte: 50 } } } // less than or equal
|
|
618
|
+
{ where: { price: { $between: [10, 100] } } } // inclusive range
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
### Combining Conditions
|
|
622
|
+
|
|
623
|
+
```tsx
|
|
624
|
+
{ where: { category: 'tech', published: true } } // AND
|
|
625
|
+
{ where: { or: [{ category: 'tech' }, { category: 'life' }] } } // OR
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### Ownership Filter
|
|
629
|
+
|
|
630
|
+
```tsx
|
|
631
|
+
const myPosts = useQuery(api.blog.all, { where: { own: true } })
|
|
632
|
+
const myPublished = useQuery(api.blog.all, { where: { published: true, own: true } })
|
|
633
|
+
// Single doc ownership check
|
|
634
|
+
const post = useQuery(api.blog.read, { id, own: true }) // null if not owner
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
| Query | Unauthenticated | Authenticated (owner) | Authenticated (not owner) |
|
|
638
|
+
|-------|:---:|:---:|:---:|
|
|
639
|
+
| `read({ id })` | Returns doc | Returns doc | Returns doc |
|
|
640
|
+
| `read({ id, own: true })` | `null` | Returns doc | `null` |
|
|
641
|
+
| `all({ where: { own: true } })` | `[]` | User's docs | User's docs |
|
|
642
|
+
|
|
643
|
+
`{ own: true }` automatically uses the `by_user` index for fast lookups. Combinable with other filters.
|
|
644
|
+
|
|
645
|
+
### Default Where (Factory-Level)
|
|
646
|
+
|
|
647
|
+
Pre-filter endpoints so consumers only see relevant data:
|
|
648
|
+
|
|
649
|
+
```tsx
|
|
650
|
+
crud('blog', owned.blog, {
|
|
651
|
+
pub: { where: { published: true } },
|
|
652
|
+
auth: { where: { published: true } }
|
|
653
|
+
})
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
## Error Handling
|
|
657
|
+
|
|
658
|
+
### Error Codes
|
|
659
|
+
|
|
660
|
+
| Code | Meaning |
|
|
661
|
+
|------|---------|
|
|
662
|
+
| `NOT_AUTHENTICATED` | User not logged in |
|
|
663
|
+
| `NOT_FOUND` | Doc doesn't exist or not owned |
|
|
664
|
+
| `NOT_AUTHORIZED` | No permission |
|
|
665
|
+
| `CONFLICT` | Concurrent edit detected |
|
|
666
|
+
| `INVALID_WHERE` | Invalid filter syntax |
|
|
667
|
+
| `RATE_LIMITED` | Too many requests |
|
|
668
|
+
| `FILE_TOO_LARGE` | File exceeds 10MB |
|
|
669
|
+
| `INVALID_FILE_TYPE` | File type not allowed |
|
|
670
|
+
| `EDITOR_REQUIRED` | ACL edit permission required |
|
|
671
|
+
|
|
672
|
+
### Frontend Error Handling
|
|
673
|
+
|
|
674
|
+
```tsx
|
|
675
|
+
import { handleConvexError, getErrorCode, getErrorMessage } from 'lazyconvex/server'
|
|
676
|
+
|
|
677
|
+
// Pattern matching
|
|
678
|
+
handleConvexError(error, {
|
|
679
|
+
NOT_AUTHENTICATED: () => router.push('/login'),
|
|
680
|
+
CONFLICT: () => toast.error('Someone else edited this'),
|
|
681
|
+
default: () => toast.error('Something went wrong')
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
// Or extract info
|
|
685
|
+
const code = getErrorCode(error) // ErrorCode | undefined
|
|
686
|
+
const msg = getErrorMessage(error) // human-readable string
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
`<Form>` shows submit errors automatically. Override with `showError={false}`.
|
|
690
|
+
|
|
691
|
+
## Organizations
|
|
692
|
+
|
|
693
|
+
Multi-tenant support with org-scoped data, membership, roles, and invites.
|
|
694
|
+
|
|
695
|
+
### Roles
|
|
696
|
+
|
|
697
|
+
- **owner** — stored as `org.userId`, full control
|
|
698
|
+
- **admin** — `orgMember.isAdmin = true`, manage members + bulk ops
|
|
699
|
+
- **member** — read/write own org data
|
|
700
|
+
|
|
701
|
+
Use `getOrgRole(org, userId, member)` to derive role.
|
|
702
|
+
|
|
703
|
+
### Permission Model
|
|
704
|
+
|
|
705
|
+
| Operation | Member (own) | Member (other's) | Editor (`acl: true`) | Admin | Owner |
|
|
706
|
+
|-----------|:---:|:---:|:---:|:---:|:---:|
|
|
707
|
+
| `create` | Y | - | - | Y | Y |
|
|
708
|
+
| `list` / `read` / `all` | Y | Y | Y | Y | Y |
|
|
709
|
+
| `update` | Y | N | Y | Y any | Y any |
|
|
710
|
+
| `rm` | Y | N | Y | Y any | Y any |
|
|
711
|
+
| `bulkUpdate` / `bulkRm` | N | N | N | Y | Y |
|
|
712
|
+
| `addEditor` / `removeEditor` / `setEditors` | N | N | N | Y | Y |
|
|
713
|
+
|
|
714
|
+
### Define Org-Scoped Table
|
|
715
|
+
|
|
716
|
+
```tsx
|
|
717
|
+
// Schema
|
|
718
|
+
orgScoped = {
|
|
719
|
+
project: object({
|
|
720
|
+
name: string().min(1),
|
|
721
|
+
editors: array(zid('users')).max(100).optional(),
|
|
722
|
+
status: zenum(['active', 'archived', 'completed']).optional()
|
|
723
|
+
})
|
|
724
|
+
}
|
|
725
|
+
// Register: project: orgTable(orgScoped.project)
|
|
726
|
+
// Endpoints — with ACL + cascade delete tasks
|
|
727
|
+
const ops = orgCrud('project', orgScoped.project, {
|
|
728
|
+
acl: true,
|
|
729
|
+
cascade: orgCascade({ foreignKey: 'projectId', table: 'task' })
|
|
730
|
+
})
|
|
731
|
+
export const { addEditor, bulkRm, create, editors, list, read, removeEditor, rm, setEditors, update } = ops
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
### Frontend Usage
|
|
735
|
+
|
|
736
|
+
```tsx
|
|
737
|
+
import { OrgProvider, useOrg, useOrgQuery, useOrgMutation, canEditResource } from 'lazyconvex/react'
|
|
738
|
+
|
|
739
|
+
// Wrap org routes in layout
|
|
740
|
+
<OrgProvider membership={membership} org={org} role={role}>
|
|
741
|
+
{children}
|
|
742
|
+
</OrgProvider>
|
|
743
|
+
|
|
744
|
+
// Inside org pages
|
|
745
|
+
const { org, role, isAdmin, isOwner, canManageMembers } = useOrg()
|
|
746
|
+
const projects = useOrgQuery(api.project.list, { paginationOpts: { cursor: null, numItems: 20 } })
|
|
747
|
+
const members = useOrgQuery(api.org.members)
|
|
748
|
+
const remove = useOrgMutation(api.project.rm)
|
|
749
|
+
await remove({ id: projectId }) // orgId auto-injected
|
|
750
|
+
|
|
751
|
+
// Non-org queries still use raw useQuery/useMutation
|
|
752
|
+
const me = useQuery(api.user.me, {})
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
### Active Org (Cookie-Based)
|
|
756
|
+
|
|
757
|
+
Active org stored in cookie for SSR/SSG. OrgSwitcher can render anywhere.
|
|
758
|
+
|
|
759
|
+
```tsx
|
|
760
|
+
import { getActiveOrg } from 'lazyconvex/next'
|
|
761
|
+
import { setActiveOrgCookieClient } from 'lazyconvex/react'
|
|
762
|
+
|
|
763
|
+
// Server component
|
|
764
|
+
const org = await getActiveOrg(token)
|
|
765
|
+
|
|
766
|
+
// Client component
|
|
767
|
+
const { activeOrg, setActiveOrg } = useActiveOrg()
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Org Helpers (Server)
|
|
771
|
+
|
|
772
|
+
```tsx
|
|
773
|
+
import { getOrgMember, getOrgRole, requireOrgMember, requireOrgRole } from 'lazyconvex/server'
|
|
774
|
+
|
|
775
|
+
const role = getOrgRole(org, userId, member) // 'owner' | 'admin' | 'member' | null
|
|
776
|
+
const member = await getOrgMember(db, orgId, userId) // membership record
|
|
777
|
+
const ctx = await requireOrgMember(db, orgId, userId) // throws if not member
|
|
778
|
+
const ctx = await requireOrgRole({ db, orgId, userId, minRole: 'admin' }) // throws if insufficient
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
### Org API
|
|
782
|
+
|
|
783
|
+
```tsx
|
|
784
|
+
// Management
|
|
785
|
+
api.org.create({ name, slug })
|
|
786
|
+
api.org.update({ orgId, name, slug })
|
|
787
|
+
api.org.get({ orgId })
|
|
788
|
+
api.org.getBySlug({ slug })
|
|
789
|
+
api.org.myOrgs
|
|
790
|
+
api.org.remove({ orgId })
|
|
791
|
+
// Membership
|
|
792
|
+
api.org.membership({ orgId })
|
|
793
|
+
api.org.members({ orgId })
|
|
794
|
+
api.org.setAdmin({ orgId, targetUserId, isAdmin })
|
|
795
|
+
api.org.removeMember({ orgId, targetUserId })
|
|
796
|
+
api.org.leave({ orgId })
|
|
797
|
+
api.org.transferOwnership({ orgId, targetUserId })
|
|
798
|
+
// Invites (7-day expiry, single-use)
|
|
799
|
+
api.org.invite({ orgId, email, isAdmin })
|
|
800
|
+
api.org.acceptInvite({ token })
|
|
801
|
+
api.org.revokeInvite({ inviteId })
|
|
802
|
+
api.org.pendingInvites({ orgId })
|
|
803
|
+
// Join requests
|
|
804
|
+
api.org.requestJoin({ orgId, message })
|
|
805
|
+
api.org.approveJoinRequest({ requestId })
|
|
806
|
+
api.org.rejectJoinRequest({ requestId })
|
|
807
|
+
api.org.pendingJoinRequests({ orgId })
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
## ACL (Editors)
|
|
811
|
+
|
|
812
|
+
Grant specific org members edit permission on individual items. Other members can only view.
|
|
813
|
+
|
|
814
|
+
### Enable
|
|
815
|
+
|
|
816
|
+
Pass `acl: true` to `orgCrud`. The `editors` field (`array(zid('users')).optional()`) must be in your schema.
|
|
817
|
+
|
|
818
|
+
```tsx
|
|
819
|
+
const ops = orgCrud('wiki', orgScoped.wiki, { acl: true })
|
|
820
|
+
export const { addEditor, all, create, editors, list, read, removeEditor, rm, setEditors, update } = ops
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
### Auto-Generated Endpoints
|
|
824
|
+
|
|
825
|
+
The item ID arg is named `${table}Id` (e.g., `projectId`, `wikiId`):
|
|
826
|
+
|
|
827
|
+
| Endpoint | Args | Access | Returns |
|
|
828
|
+
|----------|------|--------|---------|
|
|
829
|
+
| `addEditor` | `{ orgId, ${table}Id, editorId }` | Admin only | Updated doc |
|
|
830
|
+
| `removeEditor` | `{ orgId, ${table}Id, editorId }` | Admin only | Updated doc |
|
|
831
|
+
| `editors` | `{ orgId, ${table}Id }` | Any member | `{ userId, name, email }[]` |
|
|
832
|
+
| `setEditors` | `{ orgId, ${table}Id, editorIds }` | Admin only | Updated doc |
|
|
833
|
+
|
|
834
|
+
### Permission Hierarchy
|
|
835
|
+
|
|
836
|
+
| Role | Can Edit? |
|
|
837
|
+
|------|-----------|
|
|
838
|
+
| Org owner/admin | Always |
|
|
839
|
+
| Item creator | Always (own docs) |
|
|
840
|
+
| In `editors[]` | Yes (when `acl: true`) |
|
|
841
|
+
| Regular member | View only |
|
|
842
|
+
|
|
843
|
+
### ACL Inheritance (`aclFrom`)
|
|
844
|
+
|
|
845
|
+
Child tables can inherit ACL from a parent — project editors can edit tasks in that project:
|
|
846
|
+
|
|
847
|
+
```tsx
|
|
848
|
+
const ops = orgCrud('task', orgScoped.task, {
|
|
849
|
+
aclFrom: { field: 'projectId', table: 'project' }
|
|
850
|
+
})
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
`field` is typed against `Doc<T>` — a typo raises a compile error. No ACL endpoints are generated for `aclFrom` tables (editors are managed on the parent).
|
|
854
|
+
|
|
855
|
+
### `canEditResource` (Frontend)
|
|
856
|
+
|
|
857
|
+
```tsx
|
|
858
|
+
import { canEditResource } from 'lazyconvex/react'
|
|
859
|
+
|
|
860
|
+
const canEdit = canEditResource({ editorsList, isAdmin, resource: doc, userId: me._id })
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
### `canEdit` (Server)
|
|
864
|
+
|
|
865
|
+
```tsx
|
|
866
|
+
import { canEdit } from 'lazyconvex/server'
|
|
867
|
+
|
|
868
|
+
const allowed = canEdit({ acl: true, doc: { editors: project.editors, userId: project.userId }, role, userId })
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
### Components
|
|
872
|
+
|
|
873
|
+
```tsx
|
|
874
|
+
import { EditorsSection, PermissionGuard } from 'lazyconvex/components'
|
|
875
|
+
|
|
876
|
+
// Editor management UI
|
|
877
|
+
<EditorsSection
|
|
878
|
+
editorsList={editorsList}
|
|
879
|
+
members={members}
|
|
880
|
+
onAdd={userId => addEditor({ wikiId, editorId: userId })}
|
|
881
|
+
onRemove={userId => removeEditor({ wikiId, editorId: userId })}
|
|
882
|
+
/>
|
|
883
|
+
|
|
884
|
+
// Permission gate for edit pages
|
|
885
|
+
<PermissionGuard canAccess={canEdit} backHref={backUrl} backLabel='wiki' resource='wiki'>
|
|
886
|
+
<EditForm />
|
|
887
|
+
</PermissionGuard>
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
### Bulk Selection
|
|
891
|
+
|
|
892
|
+
```tsx
|
|
893
|
+
import { useBulkSelection } from 'lazyconvex/react'
|
|
894
|
+
|
|
895
|
+
const { clear, handleBulkDelete, selected, toggleSelect, toggleSelectAll } = useBulkSelection({
|
|
896
|
+
bulkRm, items: wikis?.page ?? [], label: 'wiki page(s)', orgId: org._id
|
|
897
|
+
})
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
## Imports Reference
|
|
901
|
+
|
|
902
|
+
| Module | Key Exports |
|
|
903
|
+
|--------|------------|
|
|
904
|
+
| `lazyconvex/server` | `setup`, `ownedTable`, `orgTable`, `baseTable`, `orgChildTable`, `orgTables`, `uploadTables`, `orgCascade`, `canEdit`, `getOrgMember`, `getOrgRole`, `requireOrgMember`, `requireOrgRole`, `handleConvexError`, `getErrorCode`, `getErrorMessage`, `makeOrg`, `makeFileUpload`, `makeTestAuth` |
|
|
905
|
+
| `lazyconvex/react` | `useForm`, `useFormMutation`, `useOptimisticMutation`, `useUpload`, `useBulkSelection`, `useOnlineStatus`, `OrgProvider`, `useOrg`, `useOrgQuery`, `useOrgMutation`, `useMyOrgs`, `useActiveOrg`, `canEditResource`, `setActiveOrgCookieClient`, `buildMeta` |
|
|
906
|
+
| `lazyconvex/components` | `Form`, `EditorsSection`, `PermissionGuard`, `OfflineIndicator`, `OrgAvatar`, `RoleBadge`, `AutoSaveIndicator`, `ConflictDialog`, `FileFieldImpl`, `fields` |
|
|
907
|
+
| `lazyconvex/schema` | `child`, `cvFile`, `cvFiles`, `orgSchema` |
|
|
908
|
+
| `lazyconvex/zod` | `pickValues`, `defaultValues`, `enumToOptions`, `unwrapZod`, `isStringType`, `isNumberType`, `isBooleanType`, `isArrayType`, `isDateType`, `isOptionalField`, `cvMetaOf`, `cvFileKindOf` |
|
|
909
|
+
| `lazyconvex/next` | `getActiveOrg`, `setActiveOrgCookie`, `clearActiveOrgCookie`, `getToken`, `isAuthenticated`, `makeImageRoute` |
|
|
910
|
+
| `lazyconvex/retry` | `withRetry`, `fetchWithRetry` |
|
|
911
|
+
|
|
912
|
+
## Known Limitations
|
|
913
|
+
|
|
914
|
+
### Where Clauses Use Runtime Filtering
|
|
915
|
+
|
|
916
|
+
Most `where` filtering (`$gt`, `$gte`, `$lt`, `$lte`, `$between`, `or`) uses Convex `.filter()` — runtime scan, not index lookup. Exception: `{ own: true }` uses the `by_user` index automatically. Fine for hundreds of docs; for high-volume tables (thousands+), use indexed queries via `pubIndexed`/`authIndexed`.
|
|
917
|
+
|
|
918
|
+
**Runtime warnings**: `all()` and `count()` log `crud:large_result` / `crud:large_count` when results exceed 1,000 docs. `search()` logs `crud:search_fallback` when falling back to full-text scan. Check server logs.
|
|
919
|
+
|
|
920
|
+
### Generic Type Boundaries
|
|
921
|
+
|
|
922
|
+
CRUD factories use `as never` casts at the Zod ↔ Convex type boundary. Localized inside factory internals — consumer code is fully typesafe with no casts needed.
|
|
923
|
+
|
|
924
|
+
### Bulk Operations
|
|
925
|
+
|
|
926
|
+
`bulkUpdate` and `bulkRm` cap at 100 items per call. No built-in rate limiting on call frequency — add application-level throttling if needed for public-facing endpoints.
|