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
@@ -0,0 +1,384 @@
1
+ // oxlint-disable promise/prefer-await-to-then
2
+ /* eslint-disable no-await-in-loop, no-continue, max-statements, @typescript-eslint/max-params */
3
+ // biome-ignore-all lint/suspicious/useAwait: x
4
+ // biome-ignore-all lint/performance/noAwaitInLoops: x
5
+ // biome-ignore-all lint/nursery/noContinue: x
6
+ import type { ZodObject, ZodRawShape } from 'zod/v4'
7
+
8
+ import { zid } from 'convex-helpers/server/zod4'
9
+ import { array, boolean, number, string } from 'zod/v4'
10
+
11
+ import type {
12
+ CrudBuilders,
13
+ CrudOptions,
14
+ CrudResult,
15
+ DbLike,
16
+ EnrichedDoc,
17
+ FilterLike,
18
+ IndexLike,
19
+ MutCtx,
20
+ Qb,
21
+ ReadCtx,
22
+ Rec,
23
+ SearchLike,
24
+ StorageLike
25
+ } from './types'
26
+
27
+ import { isStringType, unwrapZod } from '../zod'
28
+ import { dbDelete, dbPatch } from './db'
29
+ import {
30
+ addUrls,
31
+ cascadeFor,
32
+ cleanFiles,
33
+ detectFiles,
34
+ err,
35
+ errValidation,
36
+ groupList,
37
+ isComparisonOp,
38
+ log,
39
+ matchW,
40
+ pgOpts
41
+ } from './helpers'
42
+
43
+ interface CrudMCtx extends MutCtx {
44
+ create: (t: string, d: Rec) => Promise<string>
45
+ delete: (id: string) => Promise<unknown>
46
+ get: (id: string) => Promise<Rec>
47
+ patch: (id: string, data: Rec, expectedUpdatedAt?: number) => Promise<Rec>
48
+ }
49
+
50
+ const makeCrud = <S extends ZodRawShape>({
51
+ builders,
52
+ options: opt,
53
+ schema,
54
+ table
55
+ }: {
56
+ builders: CrudBuilders
57
+ options?: CrudOptions<S>
58
+ schema: ZodObject<S>
59
+ table: string
60
+ }) => {
61
+ type WG = Rec & { own?: boolean }
62
+ type W = WG & { or?: WG[] }
63
+ const { m, pq, q } = builders,
64
+ stringFields: string[] = []
65
+ // biome-ignore lint/nursery/noForIn: iterating shape keys with hasOwn guard
66
+ for (const k in schema.shape)
67
+ if (Object.hasOwn(schema.shape, k) && isStringType(unwrapZod(schema.shape[k]).type)) stringFields.push(k)
68
+ const partial = schema.partial(),
69
+ bulkIdsSchema = array(zid(table)).max(100),
70
+ fileFs = detectFiles(schema.shape),
71
+ wgSchema = partial.extend({ own: boolean().optional() }),
72
+ wSchema = wgSchema.extend({ or: array(wgSchema).optional() }),
73
+ wArgs = { where: wSchema.optional() },
74
+ ownArg = { own: boolean().optional() },
75
+ idArgs = { id: zid(table) },
76
+ parseW = (i: unknown, fb?: W): undefined | W => {
77
+ if (i === undefined) return fb
78
+ const r = wSchema.safeParse(i)
79
+ return r.success ? (r.data as W) : errValidation('INVALID_WHERE', r.error)
80
+ },
81
+ defaults = { auth: parseW(opt?.auth?.where), pub: parseW(opt?.pub?.where) },
82
+ enrich = async (c: ReadCtx, docs: Rec[]) =>
83
+ Promise.all(
84
+ (await c.withAuthor(docs as { userId: string }[])).map(async d =>
85
+ addUrls({ doc: d, fileFields: fileFs, storage: c.storage })
86
+ )
87
+ ) as Promise<EnrichedDoc<S>[]>,
88
+ buildExpr = (fb: FilterLike, w: WG, vid: null | string) => {
89
+ let e: unknown = null
90
+ const and = (x: unknown) => {
91
+ e = e ? fb.and(e, x) : x
92
+ }
93
+ // biome-ignore lint/nursery/noForIn: x
94
+ for (const k in w) {
95
+ if (k === 'own') continue
96
+ const fv = w[k]
97
+ if (fv === undefined) continue
98
+ const field = fb.field(k)
99
+ if (isComparisonOp(fv)) {
100
+ if (fv.$gt !== undefined) and(fb.gt(field, fv.$gt))
101
+ if (fv.$gte !== undefined) and(fb.gte(field, fv.$gte))
102
+ if (fv.$lt !== undefined) and(fb.lt(field, fv.$lt))
103
+ if (fv.$lte !== undefined) and(fb.lte(field, fv.$lte))
104
+ if (fv.$between !== undefined) {
105
+ and(fb.gte(field, fv.$between[0]))
106
+ and(fb.lte(field, fv.$between[1]))
107
+ }
108
+ } else and(fb.eq(field, fv))
109
+ }
110
+ if (w.own) and(vid ? fb.eq(fb.field('userId'), vid) : fb.eq(true, false))
111
+ return e
112
+ },
113
+ canUseOwnIndex = (w: undefined | W): boolean => {
114
+ if (!w || w.or?.length) return false
115
+ const gs = groupList(w)
116
+ return gs.length === 1 && gs[0]?.own === true
117
+ },
118
+ startQ = (c: ReadCtx, w: undefined | W) =>
119
+ canUseOwnIndex(w) && c.viewerId
120
+ ? c.db.query(table).withIndex('by_user', ((o: IndexLike) => o.eq('userId', c.viewerId)) as never)
121
+ : c.db.query(table),
122
+ applyW = (qr: ReturnType<ReadCtx['db']['query']>, w: undefined | W, vid: null | string) => {
123
+ let qry = qr
124
+ if (opt?.softDelete) qry = qry.filter((fb: FilterLike) => fb.eq(fb.field('deletedAt'), null))
125
+ const gs = groupList(w)
126
+ if (!gs.length) return qry
127
+ return qry.filter((f: FilterLike) => {
128
+ let e: unknown = null
129
+ for (const g of gs) {
130
+ const ge = buildExpr(f, g, vid)
131
+ if (ge) e = e ? f.or(e, ge) : ge
132
+ }
133
+ return e ?? true
134
+ })
135
+ },
136
+ allH =
137
+ (fb?: W) =>
138
+ async (c: ReadCtx, { where }: { where?: unknown }) => {
139
+ const w = parseW(where, fb),
140
+ docs = await applyW(startQ(c, w), w, c.viewerId).order('desc').collect()
141
+ if (docs.length > 1000)
142
+ log('warn', 'crud:large_result', { count: docs.length, table, tip: 'Use pagination or indexed queries' })
143
+ return enrich(c, docs)
144
+ },
145
+ listH =
146
+ (fb?: W) =>
147
+ async (
148
+ c: ReadCtx,
149
+ {
150
+ paginationOpts: op,
151
+ where
152
+ }: {
153
+ paginationOpts: unknown
154
+ where?: unknown
155
+ }
156
+ ) => {
157
+ const w = parseW(where, fb),
158
+ { page, ...rest } = await applyW(startQ(c, w), w, c.viewerId)
159
+ .order('desc')
160
+ .paginate(op as Rec)
161
+ return { ...rest, page: await enrich(c, page) }
162
+ },
163
+ readH =
164
+ (fb?: W) =>
165
+ async (
166
+ c: ReadCtx,
167
+ {
168
+ id,
169
+ own,
170
+ where
171
+ }: {
172
+ id: string
173
+ own?: boolean
174
+ where?: unknown
175
+ }
176
+ ) => {
177
+ const doc = await c.db.get(id),
178
+ w = parseW(where, fb)
179
+ if (!doc) return null
180
+ if (!matchW(doc, w, c.viewerId)) return null
181
+ if (own) {
182
+ if (!c.viewerId) return null
183
+ if ((doc as { userId?: string }).userId !== c.viewerId) return null
184
+ }
185
+ return (await enrich(c, [doc]))[0] ?? null
186
+ },
187
+ countH =
188
+ (fb?: W) =>
189
+ async (c: ReadCtx, { where }: { where?: unknown }) => {
190
+ const w = parseW(where, fb),
191
+ docs = await applyW(startQ(c, w), w, c.viewerId).collect()
192
+ if (docs.length > 1000)
193
+ log('warn', 'crud:large_count', { count: docs.length, table, tip: 'Use indexed queries for large tables' })
194
+ return docs.length
195
+ },
196
+ searchIndexed = async (c: ReadCtx, qry: string, w: undefined | W) => {
197
+ const results = await c.db
198
+ .query(table)
199
+ .withSearchIndex('search_field', ((sb: SearchLike) => sb.search('text', qry)) as never)
200
+ .collect(),
201
+ filtered = results.filter(d => matchW(d, w, c.viewerId))
202
+ if (opt?.softDelete)
203
+ return enrich(
204
+ c,
205
+ filtered.filter((d: Rec) => !d.deletedAt)
206
+ )
207
+ return enrich(c, filtered)
208
+ },
209
+ searchH =
210
+ (fb?: W) =>
211
+ async (
212
+ c: ReadCtx,
213
+ {
214
+ fields: fs,
215
+ query: qry,
216
+ where
217
+ }: {
218
+ fields?: string[]
219
+ query: string
220
+ where?: unknown
221
+ }
222
+ ) => {
223
+ const w = parseW(where, fb)
224
+ if (opt?.search === 'index') return searchIndexed(c, qry, w)
225
+ const searchFs = fs?.length ? fs : stringFields,
226
+ lower = qry.toLowerCase(),
227
+ docs = await applyW(startQ(c, w), w, c.viewerId).order('desc').collect(),
228
+ matches = docs.filter((d: Rec) => {
229
+ const rec = d
230
+ return searchFs.some(f => {
231
+ const val = rec[f]
232
+ if (typeof val !== 'string') return false
233
+ return val.toLowerCase().includes(lower)
234
+ })
235
+ })
236
+ if (docs.length > 1000)
237
+ log('warn', 'crud:search_scan', { count: docs.length, table, tip: 'Add search: "index" for large tables' })
238
+ return enrich(c, matches)
239
+ },
240
+ readApi = (wrap: Qb, fb?: W) => ({
241
+ all: wrap({ args: wArgs, handler: allH(fb) as never }),
242
+ count: wrap({ args: wArgs, handler: countH(fb) as never }),
243
+ list: wrap({ args: { paginationOpts: pgOpts, ...wArgs }, handler: listH(fb) as never }),
244
+ read: wrap({ args: { ...idArgs, ...ownArg, ...wArgs }, handler: readH(fb) as never }),
245
+ search: wrap({
246
+ args: { fields: array(string()).optional(), query: string(), ...wArgs },
247
+ handler: searchH(fb) as never
248
+ })
249
+ }),
250
+ indexedH =
251
+ (fb?: W) =>
252
+ async (
253
+ c: ReadCtx,
254
+ {
255
+ index,
256
+ key,
257
+ value,
258
+ where
259
+ }: {
260
+ index: string
261
+ key: string
262
+ value: string
263
+ where?: unknown
264
+ }
265
+ ) => {
266
+ const w = parseW(where, fb),
267
+ docs = await applyW(
268
+ c.db.query(table).withIndex(index, ((i: IndexLike) => i.eq(key, value)) as never),
269
+ w,
270
+ c.viewerId
271
+ )
272
+ .order('desc')
273
+ .collect()
274
+ return enrich(c, docs)
275
+ },
276
+ rmHandler = async (
277
+ c: {
278
+ db: DbLike
279
+ delete: (id: string) => Promise<unknown>
280
+ storage: StorageLike
281
+ },
282
+ { id }: { id: string }
283
+ ) => {
284
+ if (opt?.cascade !== false)
285
+ for (const { foreignKey: fk, table: tbl } of cascadeFor(table, builders.children))
286
+ for (const r of await c.db
287
+ .query(tbl)
288
+ .filter((f: FilterLike) => f.eq(f.field(fk), id))
289
+ .collect())
290
+ await dbDelete(c.db, r._id as string)
291
+ if (opt?.softDelete) {
292
+ const doc = await c.db.get(id)
293
+ if (!doc) {
294
+ log('warn', 'crud:not_found', { id, table })
295
+ return err('NOT_FOUND', `${table}:rm`)
296
+ }
297
+ await dbPatch(c.db, id, { deletedAt: Date.now() })
298
+ log('info', 'crud:delete', { id, soft: true, table })
299
+ return doc
300
+ }
301
+ const d = await c.delete(id)
302
+ await cleanFiles({ doc: d as Rec, fileFields: fileFs, storage: c.storage })
303
+ log('info', 'crud:delete', { id, table })
304
+ return d
305
+ }
306
+ return {
307
+ auth: readApi(q, defaults.auth),
308
+ authIndexed: q({
309
+ args: { index: string(), key: string(), value: string(), ...wArgs },
310
+ handler: indexedH(defaults.auth) as never
311
+ }),
312
+ bulkRm: m({
313
+ args: { ids: bulkIdsSchema },
314
+ handler: (async (c: CrudMCtx, { ids }: { ids: string[] }) => {
315
+ if (ids.length > 100) return err('LIMIT_EXCEEDED', `${table}:bulkRm`)
316
+ let deleted = 0
317
+ for (const id of ids) {
318
+ await rmHandler(c as never, { id })
319
+ deleted += 1
320
+ }
321
+ return deleted
322
+ }) as never
323
+ }),
324
+ bulkUpdate: m({
325
+ args: { data: partial, ids: bulkIdsSchema },
326
+ handler: (async (c: CrudMCtx, args: Rec) => {
327
+ const { data, ids } = args as { data: Rec; ids: string[] }
328
+ if (ids.length > 100) return err('LIMIT_EXCEEDED', `${table}:bulkUpdate`)
329
+ const results: unknown[] = []
330
+ for (const id of ids) {
331
+ const prev = await c.get(id),
332
+ ret = await c.patch(id, data)
333
+ await cleanFiles({ doc: prev, fileFields: fileFs, next: data, storage: c.storage })
334
+ results.push(ret)
335
+ }
336
+ return results
337
+ }) as never
338
+ }),
339
+ create: m({
340
+ args: schema.shape,
341
+ handler: (async (c: CrudMCtx, a: Rec) => {
342
+ const id = await c.create(table, a)
343
+ log('info', 'crud:create', { table, userId: c.user._id })
344
+ return id
345
+ }) as never
346
+ }),
347
+ pub: readApi(pq, defaults.pub),
348
+ pubIndexed: pq({
349
+ args: { index: string(), key: string(), value: string(), ...wArgs },
350
+ handler: indexedH(defaults.pub) as never
351
+ }),
352
+ restore: opt?.softDelete
353
+ ? m({
354
+ args: idArgs,
355
+ handler: (async (c: CrudMCtx, { id }: { id: string }) => {
356
+ const doc = await c.get(id)
357
+ await dbPatch(c.db, id, { deletedAt: null })
358
+ return { ...doc, deletedAt: null }
359
+ }) as never
360
+ })
361
+ : undefined,
362
+ rm: m({
363
+ args: idArgs,
364
+ handler: (async (c: CrudMCtx, { id }: { id: string }) => rmHandler(c as never, { id })) as never
365
+ }),
366
+ update: m({
367
+ args: { ...idArgs, ...partial.shape, expectedUpdatedAt: number().optional() },
368
+ handler: (async (c: CrudMCtx, a: Rec) => {
369
+ const { expectedUpdatedAt, id, ...rest } = a as Rec & {
370
+ expectedUpdatedAt?: number
371
+ id: string
372
+ },
373
+ patch = rest as Rec,
374
+ prev = await c.get(id),
375
+ ret = await c.patch(id, patch, expectedUpdatedAt)
376
+ await cleanFiles({ doc: prev, fileFields: fileFs, next: patch, storage: c.storage })
377
+ log('info', 'crud:update', { id, table })
378
+ return ret
379
+ }) as never
380
+ })
381
+ } as unknown as CrudResult<S>
382
+ }
383
+
384
+ export { makeCrud }
@@ -0,0 +1,7 @@
1
+ import type { DbLike } from './types'
2
+
3
+ const dbInsert = async (db: DbLike, table: string, data: Record<string, unknown>) => db.insert(table, data),
4
+ dbPatch = async (db: DbLike, id: string, data: Record<string, unknown>) => db.patch(id, data),
5
+ dbDelete = async (db: DbLike, id: string) => db.delete(id)
6
+
7
+ export { dbDelete, dbInsert, dbPatch }
@@ -0,0 +1,39 @@
1
+ import { ConvexError } from 'convex/values'
2
+
3
+ import type { ErrorCode } from './types'
4
+
5
+ import { ERROR_MESSAGES } from './types'
6
+
7
+ type ErrorHandler = Partial<Record<ErrorCode, () => void>> & { default?: () => void }
8
+
9
+ const isRecord = (v: unknown): v is Record<string, unknown> => Boolean(v) && typeof v === 'object',
10
+ getErrorCode = (e: unknown): ErrorCode | undefined => {
11
+ if (!(e instanceof ConvexError)) return
12
+ const { data } = e as { data?: unknown }
13
+ if (!isRecord(data)) return
14
+ const { code } = data
15
+ return typeof code === 'string' && code in ERROR_MESSAGES ? (code as ErrorCode) : undefined
16
+ },
17
+ getErrorMessage = (e: unknown): string => {
18
+ if (e instanceof ConvexError) {
19
+ const { data } = e as { data?: unknown }
20
+ if (isRecord(data)) {
21
+ if (typeof data.message === 'string') return data.message
22
+ const { code } = data
23
+ if (typeof code === 'string' && code in ERROR_MESSAGES) return ERROR_MESSAGES[code as ErrorCode]
24
+ }
25
+ }
26
+ if (e instanceof Error) return e.message
27
+ return 'Unknown error'
28
+ },
29
+ handleConvexError = (e: unknown, handlers: ErrorHandler): void => {
30
+ const code = getErrorCode(e),
31
+ handler = code ? handlers[code] : undefined
32
+ if (handler) {
33
+ handler()
34
+ return
35
+ }
36
+ handlers.default?.()
37
+ }
38
+
39
+ export { getErrorCode, getErrorMessage, handleConvexError, isRecord }