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.
Files changed (70) hide show
  1. package/README.md +926 -0
  2. package/dist/components/index.mjs +937 -0
  3. package/dist/error-D4GuI0ot.mjs +71 -0
  4. package/dist/file-field-BqVgy8xY.mjs +205 -0
  5. package/dist/form-BXJK_j10.d.mts +99 -0
  6. package/dist/index.d.mts +433 -0
  7. package/dist/index.mjs +1 -0
  8. package/dist/index2.d.mts +5 -0
  9. package/dist/index3.d.mts +35 -0
  10. package/dist/index4.d.mts +101 -0
  11. package/dist/index5.d.mts +842 -0
  12. package/dist/next/index.mjs +151 -0
  13. package/dist/org-CmJBb8z-.d.mts +56 -0
  14. package/dist/react/index.mjs +158 -0
  15. package/dist/retry.d.mts +12 -0
  16. package/dist/retry.mjs +35 -0
  17. package/dist/schema.d.mts +23 -0
  18. package/dist/schema.mjs +15 -0
  19. package/dist/server/index.mjs +2572 -0
  20. package/dist/types-DWBVRtit.d.mts +322 -0
  21. package/dist/use-online-status-CMr73Jlk.mjs +155 -0
  22. package/dist/use-upload-DtELytQi.mjs +95 -0
  23. package/dist/zod.d.mts +18 -0
  24. package/dist/zod.mjs +87 -0
  25. package/package.json +40 -0
  26. package/src/components/editors-section.tsx +86 -0
  27. package/src/components/fields.tsx +884 -0
  28. package/src/components/file-field.tsx +234 -0
  29. package/src/components/form.tsx +191 -0
  30. package/src/components/index.ts +11 -0
  31. package/src/components/offline-indicator.tsx +15 -0
  32. package/src/components/org-avatar.tsx +13 -0
  33. package/src/components/permission-guard.tsx +36 -0
  34. package/src/components/role-badge.tsx +14 -0
  35. package/src/components/suspense-wrap.tsx +8 -0
  36. package/src/index.ts +40 -0
  37. package/src/next/active-org.ts +33 -0
  38. package/src/next/auth.ts +9 -0
  39. package/src/next/image.ts +134 -0
  40. package/src/next/index.ts +3 -0
  41. package/src/react/form-meta.ts +53 -0
  42. package/src/react/form.ts +201 -0
  43. package/src/react/index.ts +8 -0
  44. package/src/react/org.tsx +96 -0
  45. package/src/react/use-active-org.ts +48 -0
  46. package/src/react/use-bulk-selection.ts +47 -0
  47. package/src/react/use-online-status.ts +21 -0
  48. package/src/react/use-optimistic.ts +54 -0
  49. package/src/react/use-upload.ts +101 -0
  50. package/src/retry.ts +47 -0
  51. package/src/schema.ts +30 -0
  52. package/src/server/cache-crud.ts +175 -0
  53. package/src/server/check-schema.ts +29 -0
  54. package/src/server/child.ts +98 -0
  55. package/src/server/crud.ts +384 -0
  56. package/src/server/db.ts +7 -0
  57. package/src/server/error.ts +39 -0
  58. package/src/server/file.ts +372 -0
  59. package/src/server/helpers.ts +214 -0
  60. package/src/server/index.ts +12 -0
  61. package/src/server/org-crud.ts +307 -0
  62. package/src/server/org-helpers.ts +54 -0
  63. package/src/server/org.ts +572 -0
  64. package/src/server/schema-helpers.ts +107 -0
  65. package/src/server/setup.ts +138 -0
  66. package/src/server/test-crud.ts +211 -0
  67. package/src/server/test.ts +554 -0
  68. package/src/server/types.ts +392 -0
  69. package/src/server/unique.ts +28 -0
  70. 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.