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,307 @@
1
+ /* eslint-disable no-await-in-loop, no-continue, max-statements, @typescript-eslint/max-params, @typescript-eslint/no-unnecessary-condition */
2
+ // biome-ignore-all lint/performance/noAwaitInLoops: x
3
+ // biome-ignore-all lint/nursery/noContinue: x
4
+ import type { ZodObject, ZodRawShape } from 'zod/v4'
5
+
6
+ import { zid } from 'convex-helpers/server/zod4'
7
+ import { array, number } from 'zod/v4'
8
+
9
+ import type {
10
+ BaseBuilders,
11
+ DbLike,
12
+ FilterLike,
13
+ IndexLike,
14
+ MutCtx,
15
+ OrgCrudResult,
16
+ OrgEnrichedDoc,
17
+ ReadCtx,
18
+ Rec
19
+ } from './types'
20
+
21
+ import { dbDelete, dbInsert, dbPatch } from './db'
22
+ import { addUrls, cleanFiles, detectFiles, err, log, pgOpts, time } from './helpers'
23
+ import { canEdit, getOrgMember, requireOrgMember, requireOrgRole } from './org-helpers'
24
+
25
+ interface CascadeOption {
26
+ foreignKey: string
27
+ table: string
28
+ }
29
+
30
+ interface OrgCrudOptions {
31
+ acl?: boolean
32
+ aclFrom?: { field: string; table: string }
33
+ cascade?: CascadeOption
34
+ }
35
+
36
+ const getEditors = (doc: Rec): string[] => (doc.editors as string[] | undefined) ?? [],
37
+ requireOrgDoc = (doc: null | Rec, orgId: string, debug?: string): Rec => {
38
+ if (doc?.orgId !== orgId) return err('NOT_FOUND', debug)
39
+ return doc
40
+ },
41
+ resolveAclDoc = async (
42
+ db: DbLike,
43
+ doc: Rec,
44
+ opt?: OrgCrudOptions
45
+ ): Promise<{
46
+ editors?: string[]
47
+ userId: string
48
+ }> => {
49
+ if (opt?.aclFrom) {
50
+ const parentId = doc[opt.aclFrom.field] as string,
51
+ parent = parentId ? await db.get(parentId) : null
52
+ return {
53
+ editors: parent ? getEditors(parent) : [],
54
+ userId: doc.userId as string
55
+ }
56
+ }
57
+ return doc as { userId: string }
58
+ },
59
+ makeOrgCrud = <S extends ZodRawShape>({
60
+ builders,
61
+ options: opt,
62
+ schema,
63
+ table
64
+ }: {
65
+ builders: BaseBuilders
66
+ options?: OrgCrudOptions
67
+ schema: ZodObject<S>
68
+ table: string
69
+ }): OrgCrudResult<S> => {
70
+ const { m, q } = builders,
71
+ partial = schema.partial(),
72
+ bulkIdsSchema = array(zid(table)).max(100),
73
+ fileFs = detectFiles(schema.shape),
74
+ idArgs = { id: zid(table) },
75
+ orgIdArg = { orgId: zid('org') },
76
+ useAcl = Boolean(opt?.acl) || Boolean(opt?.aclFrom),
77
+ enrich = async (c: ReadCtx, docs: Rec[]) =>
78
+ // oxlint-disable-next-line promise/prefer-await-to-then
79
+ Promise.all(
80
+ (await c.withAuthor(docs as { userId: string }[])).map(async d =>
81
+ addUrls({ doc: d, fileFields: fileFs, storage: c.storage })
82
+ )
83
+ ) as Promise<OrgEnrichedDoc<S>[]>,
84
+ cascadeDelete = async (db: DbLike, id: string) => {
85
+ if (!opt?.cascade) return
86
+ const { foreignKey, table: tbl } = opt.cascade,
87
+ kids = await db
88
+ .query(tbl)
89
+ .filter((f: FilterLike) => f.eq(f.field(foreignKey), id))
90
+ .collect()
91
+ for (const kid of kids) await dbDelete(db, kid._id as string)
92
+ },
93
+ create = m({
94
+ args: { ...orgIdArg, ...schema.shape },
95
+ handler: (async (c: MutCtx, a: Rec) => {
96
+ const { orgId, ...data } = a as Rec & { orgId: string }
97
+ await requireOrgMember({ db: c.db, orgId, userId: c.user._id as string })
98
+ return dbInsert(c.db, table, { ...data, orgId, userId: c.user._id, ...time() })
99
+ }) as never
100
+ }),
101
+ list = q({
102
+ args: { ...orgIdArg, paginationOpts: pgOpts },
103
+ handler: (async (c: MutCtx & ReadCtx, { orgId, paginationOpts }: { orgId: string; paginationOpts: Rec }) => {
104
+ await requireOrgMember({ db: c.db, orgId, userId: c.user._id as string })
105
+ const { page, ...rest } = await c.db
106
+ .query(table)
107
+ .withIndex('by_org', ((o: IndexLike) => o.eq('orgId', orgId)) as never)
108
+ .order('desc')
109
+ .paginate(paginationOpts)
110
+ return { ...rest, page: await enrich(c, page) }
111
+ }) as never
112
+ }),
113
+ all = q({
114
+ args: { ...orgIdArg },
115
+ handler: (async (c: MutCtx & ReadCtx, { orgId }: { orgId: string }) => {
116
+ await requireOrgMember({ db: c.db, orgId, userId: c.user._id as string })
117
+ const docs = await c.db
118
+ .query(table)
119
+ .withIndex('by_org', ((o: IndexLike) => o.eq('orgId', orgId)) as never)
120
+ .order('desc')
121
+ .collect()
122
+ return enrich(c, docs)
123
+ }) as never
124
+ }),
125
+ count = q({
126
+ args: { ...orgIdArg },
127
+ handler: (async (c: MutCtx & ReadCtx, { orgId }: { orgId: string }) => {
128
+ await requireOrgMember({ db: c.db, orgId, userId: c.user._id as string })
129
+ const docs = await c.db
130
+ .query(table)
131
+ .withIndex('by_org', ((o: IndexLike) => o.eq('orgId', orgId)) as never)
132
+ .collect()
133
+ if (docs.length > 1000)
134
+ log('warn', 'crud:large_count', { count: docs.length, table, tip: 'Use indexed queries for large tables' })
135
+ return docs.length
136
+ }) as never
137
+ }),
138
+ read = q({
139
+ args: { ...orgIdArg, ...idArgs },
140
+ handler: (async (c: MutCtx & ReadCtx, { id, orgId }: { id: string; orgId: string }) => {
141
+ await requireOrgMember({ db: c.db, orgId, userId: c.user._id as string })
142
+ const doc = requireOrgDoc(await c.db.get(id), orgId)
143
+ return (await enrich(c, [doc]))[0]
144
+ }) as never
145
+ }),
146
+ update = m({
147
+ args: { ...orgIdArg, ...idArgs, ...partial.shape, expectedUpdatedAt: number().optional() },
148
+ handler: (async (c: MutCtx, a: Rec) => {
149
+ const { expectedUpdatedAt, id, orgId, ...patch } = a as Rec & {
150
+ expectedUpdatedAt?: number
151
+ id: string
152
+ orgId: string
153
+ },
154
+ { role } = await requireOrgMember({ db: c.db, orgId, userId: c.user._id as string }),
155
+ doc = requireOrgDoc(await c.db.get(id), orgId),
156
+ aclDoc = await resolveAclDoc(c.db, doc, opt)
157
+ if (
158
+ !canEdit({
159
+ acl: useAcl,
160
+ doc: aclDoc as { editors?: string[]; userId: string },
161
+ role,
162
+ userId: c.user._id as string
163
+ })
164
+ )
165
+ return err('FORBIDDEN', `${table}:update`)
166
+ if (expectedUpdatedAt !== undefined && doc.updatedAt !== expectedUpdatedAt)
167
+ return err('CONFLICT', `${table}:update`)
168
+ await dbPatch(c.db, id, { ...patch, ...time() })
169
+ return c.db.get(id)
170
+ }) as never
171
+ }),
172
+ rm = m({
173
+ args: { ...orgIdArg, ...idArgs },
174
+ handler: (async (c: MutCtx, { id, orgId }: { id: string; orgId: string }) => {
175
+ const { role } = await requireOrgMember({ db: c.db, orgId, userId: c.user._id as string }),
176
+ doc = requireOrgDoc(await c.db.get(id), orgId),
177
+ aclDoc = await resolveAclDoc(c.db, doc, opt)
178
+ if (
179
+ !canEdit({
180
+ acl: useAcl,
181
+ doc: aclDoc as { editors?: string[]; userId: string },
182
+ role,
183
+ userId: c.user._id as string
184
+ })
185
+ )
186
+ return err('FORBIDDEN', `${table}:rm`)
187
+ await cascadeDelete(c.db, id)
188
+ await dbDelete(c.db, id)
189
+ await cleanFiles({ doc, fileFields: fileFs, storage: c.storage })
190
+ return doc
191
+ }) as never
192
+ }),
193
+ bulkUpdate = m({
194
+ args: { ...orgIdArg, data: partial, ids: bulkIdsSchema },
195
+ handler: (async (c: MutCtx, a: Rec) => {
196
+ const { data, ids, orgId } = a as { data: Rec; ids: string[]; orgId: string }
197
+ if (ids.length > 100) return err('LIMIT_EXCEEDED', `${table}:bulkUpdate`)
198
+ await requireOrgRole({ db: c.db, minRole: 'admin', orgId, userId: c.user._id as string })
199
+ const results: Rec[] = []
200
+ for (const id of ids) {
201
+ const doc = await c.db.get(id)
202
+ if (doc?.orgId !== orgId) continue
203
+ await dbPatch(c.db, id, { ...data, ...time() })
204
+ const updated = await c.db.get(id)
205
+ if (updated) results.push(updated)
206
+ }
207
+ return results
208
+ }) as never
209
+ }),
210
+ bulkRm = m({
211
+ args: { ...orgIdArg, ids: bulkIdsSchema },
212
+ handler: (async (c: MutCtx, a: Rec) => {
213
+ const { ids, orgId } = a as { ids: string[]; orgId: string }
214
+ if (ids.length > 100) return err('LIMIT_EXCEEDED', `${table}:bulkRm`)
215
+ await requireOrgRole({ db: c.db, minRole: 'admin', orgId, userId: c.user._id as string })
216
+ let deleted = 0
217
+ for (const id of ids) {
218
+ const doc = await c.db.get(id)
219
+ if (doc?.orgId !== orgId) continue
220
+ await cascadeDelete(c.db, id)
221
+ await dbDelete(c.db, id)
222
+ await cleanFiles({ doc, fileFields: fileFs, storage: c.storage })
223
+ deleted += 1
224
+ }
225
+ return deleted
226
+ }) as never
227
+ }),
228
+ base = { all, bulkRm, bulkUpdate, count, create, list, read, rm, update },
229
+ itemIdKey = `${table}Id` as const,
230
+ itemIdArg = { [itemIdKey]: zid(table) },
231
+ aclArgs = (a: unknown) => {
232
+ const args = a as Rec
233
+ return {
234
+ editorId: args.editorId as string,
235
+ editorIds: args.editorIds as string[] | undefined,
236
+ itemId: args[itemIdKey] as string,
237
+ orgId: args.orgId as string
238
+ }
239
+ },
240
+ addEditor = m({
241
+ args: { editorId: zid('users'), ...orgIdArg, ...itemIdArg },
242
+ handler: (async (c: MutCtx, a: Rec) => {
243
+ const { editorId, itemId, orgId } = aclArgs(a)
244
+ await requireOrgRole({ db: c.db, minRole: 'admin', orgId, userId: c.user._id as string })
245
+ const doc = requireOrgDoc(await c.db.get(itemId), orgId),
246
+ editorIsOwner = (await c.db.get(orgId))?.userId === editorId,
247
+ editorMember = await getOrgMember({ db: c.db, orgId, userId: editorId })
248
+ if (!(editorIsOwner || editorMember)) return err('NOT_ORG_MEMBER')
249
+ const eds = getEditors(doc),
250
+ already = eds.some((eid: string) => eid === editorId)
251
+ if (already) return doc
252
+ if (eds.length >= 100) return err('LIMIT_EXCEEDED')
253
+ await dbPatch(c.db, itemId, { editors: [...eds, editorId], ...time() })
254
+ return c.db.get(itemId)
255
+ }) as never
256
+ }),
257
+ removeEditor = m({
258
+ args: { editorId: zid('users'), ...orgIdArg, ...itemIdArg },
259
+ handler: (async (c: MutCtx, a: Rec) => {
260
+ const { editorId, itemId, orgId } = aclArgs(a)
261
+ await requireOrgRole({ db: c.db, minRole: 'admin', orgId, userId: c.user._id as string })
262
+ const doc = requireOrgDoc(await c.db.get(itemId), orgId),
263
+ eds = getEditors(doc),
264
+ filtered = eds.filter((eid: string) => eid !== editorId)
265
+ await dbPatch(c.db, itemId, { editors: filtered, ...time() })
266
+ return c.db.get(itemId)
267
+ }) as never
268
+ }),
269
+ editors = q({
270
+ args: { ...orgIdArg, ...itemIdArg },
271
+ handler: (async (c: MutCtx, a: Rec) => {
272
+ const { itemId, orgId } = aclArgs(a)
273
+ await requireOrgMember({ db: c.db, orgId, userId: c.user._id as string })
274
+ const doc = requireOrgDoc(await c.db.get(itemId), orgId),
275
+ editorIds = getEditors(doc),
276
+ users = await Promise.all(editorIds.map(async (eid: string) => c.db.get(eid))),
277
+ result: { email: string; name: string; userId: string }[] = []
278
+ for (let i = 0; i < editorIds.length; i += 1) {
279
+ const u = users[i] as null | Rec,
280
+ eid = editorIds[i]
281
+ if (u && eid) result.push({ email: (u.email as string) ?? '', name: (u.name as string) ?? '', userId: eid })
282
+ }
283
+ return result
284
+ }) as never
285
+ }),
286
+ setEditors = m({
287
+ args: { editorIds: array(zid('users')).max(100), ...orgIdArg, ...itemIdArg },
288
+ handler: (async (c: MutCtx, a: Rec) => {
289
+ const { editorIds, itemId, orgId } = aclArgs(a)
290
+ await requireOrgRole({ db: c.db, minRole: 'admin', orgId, userId: c.user._id as string })
291
+ requireOrgDoc(await c.db.get(itemId), orgId)
292
+ if (editorIds)
293
+ for (const editorId of editorIds) {
294
+ const isOwner = (await c.db.get(orgId))?.userId === editorId,
295
+ member = await getOrgMember({ db: c.db, orgId, userId: editorId })
296
+ if (!(isOwner || member)) return err('NOT_ORG_MEMBER')
297
+ }
298
+ await dbPatch(c.db, itemId, { editors: editorIds ?? [], ...time() })
299
+ return c.db.get(itemId)
300
+ }) as never
301
+ })
302
+ return { ...base, addEditor, editors, removeEditor, setEditors } as unknown as OrgCrudResult<S>
303
+ },
304
+ orgCascade = (config: { foreignKey: string; table: string }): CascadeOption => config
305
+
306
+ export type { CascadeOption, OrgCrudOptions }
307
+ export { makeOrgCrud, orgCascade }
@@ -0,0 +1,54 @@
1
+ import type { CanEditOpts, DbReadLike, FilterLike, IndexLike, OrgRole } from './types'
2
+
3
+ import { err } from './helpers'
4
+
5
+ const ROLE_LEVEL: Record<OrgRole, number> = { admin: 2, member: 1, owner: 3 },
6
+ getOrgRole = ({
7
+ member,
8
+ org,
9
+ userId
10
+ }: {
11
+ member: null | Record<string, unknown>
12
+ org: Record<string, unknown>
13
+ userId: string
14
+ }): null | OrgRole => {
15
+ if (org.userId === userId) return 'owner'
16
+ if (!member) return null
17
+ return (member as { isAdmin?: boolean }).isAdmin ? 'admin' : 'member'
18
+ },
19
+ getOrgMember = async ({ db, orgId, userId }: { db: unknown; orgId: string; userId: string }) =>
20
+ (db as DbReadLike)
21
+ .query('orgMember')
22
+ .withIndex('by_org_user', ((q: IndexLike) => q.eq('orgId', orgId)) as never)
23
+ .filter((f: FilterLike) => f.eq(f.field('userId'), userId))
24
+ .unique() as Promise<null | Record<string, unknown>>,
25
+ requireOrgMember = async ({ db, orgId, userId }: { db: unknown; orgId: string; userId: string }) => {
26
+ const dbl = db as DbReadLike,
27
+ org = await dbl.get(orgId)
28
+ if (!org) return err('NOT_FOUND')
29
+ const member = await getOrgMember({ db, orgId, userId }),
30
+ role = getOrgRole({ member, org, userId })
31
+ if (!role) return err('NOT_ORG_MEMBER')
32
+ return { member, org, role }
33
+ }
34
+
35
+ interface RequireOrgRoleArgs {
36
+ db: unknown
37
+ minRole: OrgRole
38
+ orgId: string
39
+ userId: string
40
+ }
41
+
42
+ const requireOrgRole = async ({ db, minRole, orgId, userId }: RequireOrgRoleArgs) => {
43
+ const result = await requireOrgMember({ db, orgId, userId })
44
+ if (ROLE_LEVEL[result.role] < ROLE_LEVEL[minRole]) return err('INSUFFICIENT_ORG_ROLE')
45
+ return result
46
+ },
47
+ canEdit = ({ acl, doc, role, userId }: CanEditOpts): boolean => {
48
+ if (role === 'owner' || role === 'admin') return true
49
+ if (doc.userId === userId) return true
50
+ if (acl && doc.editors?.includes(userId)) return true
51
+ return false
52
+ }
53
+
54
+ export { canEdit, getOrgMember, getOrgRole, requireOrgMember, requireOrgRole, ROLE_LEVEL }