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,372 @@
1
+ import type { ActionBuilder, GenericDataModel, MutationBuilder, QueryBuilder } from 'convex/server'
2
+
3
+ import { anyApi } from 'convex/server'
4
+ import { ConvexError, v } from 'convex/values'
5
+
6
+ import type { DbLike, ErrorCode, FilterLike, IndexLike, Rec } from './types'
7
+
8
+ import { isTestMode } from './test'
9
+
10
+ interface FileActionCtx {
11
+ runMutation: (...a: unknown[]) => Promise<unknown>
12
+ runQuery: (...a: unknown[]) => Promise<unknown>
13
+ storage: FileStor
14
+ }
15
+
16
+ interface FileCtx {
17
+ db: DbLike
18
+ storage: FileStor
19
+ }
20
+ interface FileStor {
21
+ delete: (id: string) => Promise<void>
22
+ generateUploadUrl: () => Promise<string>
23
+ get: (id: string) => Promise<Blob | null>
24
+ getUrl: (id: string) => Promise<null | string>
25
+ store: (blob: Blob) => Promise<string>
26
+ }
27
+
28
+ interface FileUploadConfig<DM extends GenericDataModel = GenericDataModel> {
29
+ action: ActionBuilder<DM, 'public'>
30
+ allowedTypes?: Set<string>
31
+ getAuthUserId: (ctx: unknown) => Promise<null | string>
32
+ internalMutation: MutationBuilder<DM, 'internal'>
33
+ internalQuery: QueryBuilder<DM, 'internal'>
34
+ maxFileSize?: number
35
+ mutation: MutationBuilder<DM, 'public'>
36
+ namespace: string
37
+ query: QueryBuilder<DM, 'public'>
38
+ }
39
+
40
+ const DEFAULT_ALLOWED_TYPES = new Set([
41
+ 'application/json',
42
+ 'application/msword',
43
+ 'application/pdf',
44
+ 'application/vnd.ms-excel',
45
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
46
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
47
+ 'image/gif',
48
+ 'image/jpeg',
49
+ 'image/png',
50
+ 'image/svg+xml',
51
+ 'image/webp',
52
+ 'text/csv',
53
+ 'text/plain'
54
+ ]),
55
+ DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024,
56
+ CHUNK_SIZE = 5 * 1024 * 1024,
57
+ RATE_LIMIT_WINDOW = 60 * 1000,
58
+ MAX_UPLOADS_PER_WINDOW = 10,
59
+ cvErr = (code: ErrorCode, message?: string) => new ConvexError(message ? { code, message } : { code }),
60
+ makeFileUpload = <DM extends GenericDataModel>(config: FileUploadConfig<DM>) => {
61
+ const {
62
+ action,
63
+ allowedTypes = DEFAULT_ALLOWED_TYPES,
64
+ getAuthUserId,
65
+ internalMutation,
66
+ internalQuery,
67
+ maxFileSize = DEFAULT_MAX_FILE_SIZE,
68
+ mutation,
69
+ namespace,
70
+ query
71
+ } = config,
72
+ tPath = (anyApi as Rec)[namespace] as Rec,
73
+ authUserId = async (ctx: unknown) => getAuthUserId(ctx),
74
+ validateFileType = async (
75
+ storage: { delete: (id: string) => Promise<void> },
76
+ id: string,
77
+ contentType: string | undefined
78
+ ) => {
79
+ if (!allowedTypes.has(contentType ?? '')) {
80
+ await storage.delete(id)
81
+ throw cvErr('INVALID_FILE_TYPE', `File type ${contentType} not allowed`)
82
+ }
83
+ },
84
+ validateFileSize = async (storage: { delete: (id: string) => Promise<void> }, id: string, size: number) => {
85
+ if (size > maxFileSize) {
86
+ await storage.delete(id)
87
+ throw cvErr('FILE_TOO_LARGE', `File size ${size} exceeds ${maxFileSize} bytes`)
88
+ }
89
+ },
90
+ checkRateLimit = async (db: DbLike, userId: string) => {
91
+ const now = Date.now(),
92
+ cutoff = now - RATE_LIMIT_WINDOW,
93
+ recent = await db
94
+ .query('uploadRateLimit')
95
+ .withIndex('by_user', ((q: IndexLike) => q.eq('userId', userId)) as never)
96
+ .filter((q: FilterLike) => q.gte(q.field('timestamp'), cutoff))
97
+ .collect()
98
+ if (recent.length >= MAX_UPLOADS_PER_WINDOW) throw cvErr('RATE_LIMITED')
99
+ await db.insert('uploadRateLimit', { timestamp: now, userId })
100
+ const old = await db
101
+ .query('uploadRateLimit')
102
+ .withIndex('by_user', ((q: IndexLike) => q.eq('userId', userId)) as never)
103
+ .filter((q: FilterLike) => q.lt(q.field('timestamp'), cutoff))
104
+ .collect()
105
+ await Promise.all(old.map(async (r: Rec) => db.delete(r._id as string)))
106
+ },
107
+ upload = mutation({
108
+ handler: async (c: FileCtx) => {
109
+ const userId = await authUserId(c)
110
+ if (!userId) throw cvErr('NOT_AUTHENTICATED')
111
+ if (!isTestMode()) await checkRateLimit(c.db, userId)
112
+ return c.storage.generateUploadUrl()
113
+ }
114
+ } as never),
115
+ validate = mutation({
116
+ args: { id: v.id('_storage') },
117
+ handler: async (c: FileCtx, { id }: { id: string }) => {
118
+ const userId = await authUserId(c)
119
+ if (!userId) throw cvErr('NOT_AUTHENTICATED')
120
+ const meta = await c.db.system.get(id)
121
+ if (!meta) throw cvErr('FILE_NOT_FOUND')
122
+ await validateFileType(c.storage, id, meta.contentType as string)
123
+ await validateFileSize(c.storage, id, meta.size as number)
124
+ return { contentType: meta.contentType, size: meta.size, valid: true }
125
+ }
126
+ } as never),
127
+ info = query({
128
+ args: { id: v.id('_storage') },
129
+ handler: async (c: FileCtx, { id }: { id: string }) => {
130
+ const userId = await authUserId(c)
131
+ if (!userId) throw cvErr('NOT_AUTHENTICATED')
132
+ const [meta, url] = await Promise.all([c.db.system.get(id), c.storage.getUrl(id)])
133
+ return meta ? { ...meta, url } : null
134
+ }
135
+ } as never),
136
+ startChunkedUpload = mutation({
137
+ args: {
138
+ contentType: v.string(),
139
+ fileName: v.string(),
140
+ totalChunks: v.number(),
141
+ totalSize: v.number()
142
+ },
143
+ handler: async (
144
+ c: FileCtx,
145
+ {
146
+ contentType,
147
+ fileName,
148
+ totalChunks,
149
+ totalSize
150
+ }: { contentType: string; fileName: string; totalChunks: number; totalSize: number }
151
+ ) => {
152
+ const userId = await authUserId(c)
153
+ if (!userId) throw cvErr('NOT_AUTHENTICATED')
154
+ if (!isTestMode()) await checkRateLimit(c.db, userId)
155
+ if (!allowedTypes.has(contentType)) throw cvErr('INVALID_FILE_TYPE', `File type ${contentType} not allowed`)
156
+ if (totalSize > maxFileSize) throw cvErr('FILE_TOO_LARGE', `File size ${totalSize} exceeds ${maxFileSize} bytes`)
157
+ const uploadId = `${userId}_${Date.now()}_${Math.random().toString(36).slice(2)}`
158
+ await c.db.insert('uploadSession', {
159
+ completedChunks: 0,
160
+ contentType,
161
+ fileName,
162
+ status: 'pending',
163
+ totalChunks,
164
+ totalSize,
165
+ uploadId,
166
+ userId
167
+ })
168
+ return { uploadId }
169
+ }
170
+ } as never),
171
+ uploadChunk = mutation({
172
+ args: {
173
+ chunkIndex: v.number(),
174
+ uploadId: v.string()
175
+ },
176
+ handler: async (c: FileCtx, { chunkIndex, uploadId }: { chunkIndex: number; uploadId: string }) => {
177
+ const userId = await authUserId(c)
178
+ if (!userId) throw cvErr('NOT_AUTHENTICATED')
179
+ const session = await c.db
180
+ .query('uploadSession')
181
+ .withIndex('by_upload_id', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
182
+ .unique()
183
+ if (!session) throw cvErr('SESSION_NOT_FOUND')
184
+ if (session.userId !== userId) throw cvErr('UNAUTHORIZED')
185
+ if (session.status !== 'pending') throw cvErr('INVALID_SESSION_STATE')
186
+ const existing = await c.db
187
+ .query('uploadChunk')
188
+ .withIndex('by_upload', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
189
+ .filter((q: FilterLike) => q.eq(q.field('chunkIndex'), chunkIndex))
190
+ .unique()
191
+ if (existing) throw cvErr('CHUNK_ALREADY_UPLOADED')
192
+ return c.storage.generateUploadUrl()
193
+ }
194
+ } as never),
195
+ confirmChunk = mutation({
196
+ args: {
197
+ chunkIndex: v.number(),
198
+ storageId: v.id('_storage'),
199
+ uploadId: v.string()
200
+ },
201
+ handler: async (
202
+ c: FileCtx,
203
+ { chunkIndex, storageId, uploadId }: { chunkIndex: number; storageId: string; uploadId: string }
204
+ ) => {
205
+ const userId = await authUserId(c)
206
+ if (!userId) throw cvErr('NOT_AUTHENTICATED')
207
+ const session = await c.db
208
+ .query('uploadSession')
209
+ .withIndex('by_upload_id', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
210
+ .unique()
211
+ if (!session) throw cvErr('SESSION_NOT_FOUND')
212
+ if (session.userId !== userId) throw cvErr('UNAUTHORIZED')
213
+ await c.db.insert('uploadChunk', {
214
+ chunkIndex,
215
+ storageId,
216
+ totalChunks: session.totalChunks,
217
+ uploadId,
218
+ userId
219
+ })
220
+ const chunks = await c.db
221
+ .query('uploadChunk')
222
+ .withIndex('by_upload', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
223
+ .collect()
224
+ await c.db.patch(session._id as string, {
225
+ completedChunks: chunks.length
226
+ })
227
+ const allUploaded = chunks.length === session.totalChunks
228
+ return {
229
+ allUploaded,
230
+ completedChunks: chunks.length,
231
+ totalChunks: session.totalChunks
232
+ }
233
+ }
234
+ } as never),
235
+ getSessionForAssembly = internalQuery({
236
+ args: { uploadId: v.string() },
237
+ handler: async (c: { db: DbLike }, { uploadId }: { uploadId: string }) => {
238
+ const session = await c.db
239
+ .query('uploadSession')
240
+ .withIndex('by_upload_id', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
241
+ .unique()
242
+ if (!session) return null
243
+ const chunks = await c.db
244
+ .query('uploadChunk')
245
+ .withIndex('by_upload', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
246
+ .collect()
247
+ if (chunks.length !== session.totalChunks) throw cvErr('INCOMPLETE_UPLOAD')
248
+ return { ...session, chunks }
249
+ }
250
+ } as never),
251
+ finalizeAssembly = internalMutation({
252
+ args: {
253
+ chunkStorageIds: v.array(v.id('_storage')),
254
+ finalStorageId: v.id('_storage'),
255
+ uploadId: v.string()
256
+ },
257
+ handler: async (
258
+ c: FileCtx,
259
+ {
260
+ chunkStorageIds,
261
+ finalStorageId,
262
+ uploadId
263
+ }: { chunkStorageIds: string[]; finalStorageId: string; uploadId: string }
264
+ ) => {
265
+ const session = await c.db
266
+ .query('uploadSession')
267
+ .withIndex('by_upload_id', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
268
+ .unique()
269
+ if (!session) throw cvErr('SESSION_NOT_FOUND')
270
+ await c.db.patch(session._id as string, { finalStorageId, status: 'completed' })
271
+ const chunks = await c.db
272
+ .query('uploadChunk')
273
+ .withIndex('by_upload', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
274
+ .collect()
275
+ await Promise.all([
276
+ ...chunkStorageIds.map(async (id: string) => c.storage.delete(id)),
277
+ ...chunks.map(async (chunk: Rec) => c.db.delete(chunk._id as string))
278
+ ])
279
+ }
280
+ } as never),
281
+ assembleChunks = action({
282
+ args: { uploadId: v.string() },
283
+ handler: async (
284
+ c: FileActionCtx,
285
+ { uploadId }: { uploadId: string }
286
+ ): Promise<{ contentType: string; size: number; storageId: string }> => {
287
+ const session = (await c.runQuery(tPath.getSessionForAssembly, { uploadId })) as null | Rec
288
+ if (!session) throw cvErr('SESSION_NOT_FOUND')
289
+ if (session.status !== 'pending') throw cvErr('INVALID_SESSION_STATE')
290
+ const sortedChunks = (session.chunks as Rec[]).toSorted(
291
+ (a: Rec, b: Rec) => (a.chunkIndex as number) - (b.chunkIndex as number)
292
+ ),
293
+ chunkBlobs = await Promise.all(
294
+ sortedChunks.map(async (chunk: Rec) => {
295
+ const blob = await c.storage.get(chunk.storageId as string)
296
+ if (!blob) throw cvErr('CHUNK_NOT_FOUND')
297
+ return blob
298
+ })
299
+ ),
300
+ combinedBlob = new Blob(chunkBlobs, { type: session.contentType as string }),
301
+ finalStorageId = await c.storage.store(combinedBlob)
302
+ await c.runMutation(tPath.finalizeAssembly, {
303
+ chunkStorageIds: sortedChunks.map((ch: Rec) => ch.storageId),
304
+ finalStorageId,
305
+ uploadId
306
+ })
307
+ return {
308
+ contentType: session.contentType as string,
309
+ size: session.totalSize as number,
310
+ storageId: finalStorageId
311
+ }
312
+ }
313
+ } as never),
314
+ cancelChunkedUpload = mutation({
315
+ args: { uploadId: v.string() },
316
+ handler: async (c: FileCtx, { uploadId }: { uploadId: string }) => {
317
+ const userId = await authUserId(c)
318
+ if (!userId) throw cvErr('NOT_AUTHENTICATED')
319
+ const session = await c.db
320
+ .query('uploadSession')
321
+ .withIndex('by_upload_id', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
322
+ .unique()
323
+ if (!session) throw cvErr('SESSION_NOT_FOUND')
324
+ if (session.userId !== userId) throw cvErr('UNAUTHORIZED')
325
+ const chunks = await c.db
326
+ .query('uploadChunk')
327
+ .withIndex('by_upload', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
328
+ .collect()
329
+ await Promise.all(chunks.map(async (chunk: Rec) => c.storage.delete(chunk.storageId as string)))
330
+ await Promise.all(chunks.map(async (chunk: Rec) => c.db.delete(chunk._id as string)))
331
+ await c.db.patch(session._id as string, { status: 'failed' })
332
+ return { cancelled: true }
333
+ }
334
+ } as never),
335
+ getUploadProgress = query({
336
+ args: { uploadId: v.string() },
337
+ handler: async (c: { db: DbLike }, { uploadId }: { uploadId: string }) => {
338
+ const userId = await authUserId(c)
339
+ if (!userId) throw cvErr('NOT_AUTHENTICATED')
340
+ const session = await c.db
341
+ .query('uploadSession')
342
+ .withIndex('by_upload_id', ((q: IndexLike) => q.eq('uploadId', uploadId)) as never)
343
+ .unique()
344
+ if (!session) return null
345
+ if (session.userId !== userId) throw cvErr('UNAUTHORIZED')
346
+ return {
347
+ completedChunks: session.completedChunks,
348
+ finalStorageId: session.finalStorageId,
349
+ progress: Math.round(((session.completedChunks as number) / (session.totalChunks as number)) * 100),
350
+ status: session.status,
351
+ totalChunks: session.totalChunks
352
+ }
353
+ }
354
+ } as never)
355
+ return {
356
+ assembleChunks,
357
+ cancelChunkedUpload,
358
+ CHUNK_SIZE,
359
+ confirmChunk,
360
+ finalizeAssembly,
361
+ getSessionForAssembly,
362
+ getUploadProgress,
363
+ info,
364
+ startChunkedUpload,
365
+ upload,
366
+ uploadChunk,
367
+ validate
368
+ }
369
+ }
370
+
371
+ export type { FileUploadConfig }
372
+ export { CHUNK_SIZE, makeFileUpload }
@@ -0,0 +1,214 @@
1
+ /* eslint-disable no-await-in-loop, no-continue, max-statements */
2
+ // biome-ignore-all lint/performance/noAwaitInLoops: x
3
+ // biome-ignore-all lint/nursery/noContinue: x
4
+ import type { ZodRawShape } from 'zod/v4'
5
+
6
+ import { ConvexError } from 'convex/values'
7
+ import { nullable, number, object, string } from 'zod/v4'
8
+
9
+ import type {
10
+ ChildConfig,
11
+ ComparisonOp,
12
+ DbLike,
13
+ ErrorCode,
14
+ FID,
15
+ MutationCtxLike,
16
+ PaginationOptsShape,
17
+ QueryCtxLike,
18
+ StorageLike,
19
+ WithUrls
20
+ } from './types'
21
+
22
+ import { cvFileKindOf } from '../zod'
23
+
24
+ const log = (level: 'debug' | 'error' | 'info' | 'warn', msg: string, data?: Record<string, unknown>) => {
25
+ console[level](JSON.stringify({ level, msg, ts: Date.now(), ...data }))
26
+ },
27
+ isRecord = (v: unknown): v is Record<string, unknown> => Boolean(v) && typeof v === 'object',
28
+ isComparisonOp = (val: unknown): val is ComparisonOp<unknown> =>
29
+ typeof val === 'object' &&
30
+ val !== null &&
31
+ ('$gt' in val || '$gte' in val || '$lt' in val || '$lte' in val || '$between' in val),
32
+ pgOpts = object({
33
+ cursor: nullable(string()),
34
+ endCursor: nullable(string()).optional(),
35
+ id: number().optional(),
36
+ maximumBytesRead: number().optional(),
37
+ maximumRowsRead: number().optional(),
38
+ numItems: number()
39
+ } satisfies PaginationOptsShape),
40
+ cascadeFor = (
41
+ parent: string,
42
+ children: Record<string, ChildConfig>
43
+ ): {
44
+ foreignKey: string
45
+ table: string
46
+ }[] => {
47
+ const result: { foreignKey: string; table: string }[] = []
48
+ for (const [table, c] of Object.entries(children))
49
+ if (c.parent === parent) result.push({ foreignKey: c.foreignKey, table })
50
+ return result
51
+ },
52
+ detectFiles = <S extends ZodRawShape>(s: S) => (Object.keys(s) as (keyof S & string)[]).filter(k => cvFileKindOf(s[k])),
53
+ err = (code: ErrorCode, debug?: string): never => {
54
+ throw new ConvexError(debug ? { code, debug } : { code })
55
+ },
56
+ noFetcher = (): never => err('NO_FETCHER'),
57
+ time = () => ({ updatedAt: Date.now() }),
58
+ getUser = async ({
59
+ ctx,
60
+ db,
61
+ getAuthUserId
62
+ }: {
63
+ ctx: MutationCtxLike | QueryCtxLike
64
+ db: DbLike
65
+ getAuthUserId: (c: never) => Promise<null | string>
66
+ }): Promise<Record<string, unknown> & { _id: string }> => {
67
+ const uid = await getAuthUserId(ctx as never)
68
+ if (!uid) return err('NOT_AUTHENTICATED')
69
+ const u = await db.get(uid)
70
+ return (u ?? err('USER_NOT_FOUND')) as Record<string, unknown> & { _id: string }
71
+ },
72
+ ownGet =
73
+ (db: DbLike, userId: string) =>
74
+ async (id: string): Promise<Record<string, unknown>> => {
75
+ const d = await db.get(id)
76
+ return d && (d as { userId?: string }).userId === userId ? d : err('NOT_FOUND')
77
+ },
78
+ readCtx = <D = DbLike, S = StorageLike>({ db, storage, viewerId }: { db: D; storage: S; viewerId: null | string }) => ({
79
+ db,
80
+ storage,
81
+ viewerId,
82
+ withAuthor: async <T extends { userId: string }>(docs: T[]) => {
83
+ const ids = [...new Set(docs.map(d => d.userId))],
84
+ users = await Promise.all(ids.map(async id => (db as DbLike).get(id))),
85
+ map = new Map(ids.map((id, i) => [id, users[i]] as const))
86
+ return docs.map(d => ({ ...d, author: map.get(d.userId) ?? null, own: viewerId ? viewerId === d.userId : null }))
87
+ }
88
+ }),
89
+ toId = (x: unknown): FID | null => (typeof x === 'string' ? (x as FID) : null),
90
+ cleanFiles = async (opts: {
91
+ doc: Record<string, unknown>
92
+ fileFields: string[]
93
+ next?: Record<string, unknown>
94
+ storage: StorageLike
95
+ }) => {
96
+ const { doc, fileFields, next, storage } = opts
97
+ if (!fileFields.length) return
98
+ const del = new Set<FID>()
99
+ for (const f of fileFields) {
100
+ const prev = doc[f]
101
+ if (prev === null) continue
102
+ const pArr = Array.isArray(prev) ? prev : [prev]
103
+ if (!next) {
104
+ for (const p of pArr) {
105
+ const id = toId(p)
106
+ if (id) del.add(id)
107
+ }
108
+ continue
109
+ }
110
+ if (!Object.hasOwn(next, f)) continue
111
+ const nv = next[f],
112
+ keep = new Set(Array.isArray(nv) ? nv : nv ? [nv] : [])
113
+ for (const p of pArr)
114
+ if (!keep.has(p as FID)) {
115
+ const id = toId(p)
116
+ if (id) del.add(id)
117
+ }
118
+ }
119
+ if (del.size) await Promise.all([...del].map(async id => storage.delete(id)))
120
+ },
121
+ addUrls = async <D extends Record<string, unknown>>({
122
+ doc,
123
+ fileFields,
124
+ storage
125
+ }: {
126
+ doc: D
127
+ fileFields: string[]
128
+ storage: StorageLike
129
+ }): Promise<WithUrls<D>> => {
130
+ if (!fileFields.length) return doc as WithUrls<D>
131
+ const o = { ...doc } as Record<string, unknown>,
132
+ getUrl = async (x: unknown) => {
133
+ const id = toId(x)
134
+ return id ? storage.getUrl(id) : null
135
+ }
136
+ for (const f of fileFields) {
137
+ const fv = doc[f]
138
+ if (fv !== null)
139
+ o[Array.isArray(fv) ? `${f}Urls` : `${f}Url`] = Array.isArray(fv)
140
+ ? await Promise.all(fv.map(getUrl))
141
+ : await getUrl(fv)
142
+ }
143
+ return o as WithUrls<D>
144
+ },
145
+ matchField = (docVal: unknown, filterVal: unknown): boolean => {
146
+ if (isComparisonOp(filterVal)) {
147
+ const dv = docVal as number
148
+ if (filterVal.$gt !== undefined && !(dv > (filterVal.$gt as number))) return false
149
+ if (filterVal.$gte !== undefined && !(dv >= (filterVal.$gte as number))) return false
150
+ if (filterVal.$lt !== undefined && !(dv < (filterVal.$lt as number))) return false
151
+ if (filterVal.$lte !== undefined && !(dv <= (filterVal.$lte as number))) return false
152
+ if (filterVal.$between !== undefined) {
153
+ const [min, max] = filterVal.$between as [number, number]
154
+ if (!(dv >= min && dv <= max)) return false
155
+ }
156
+ return true
157
+ }
158
+ return Object.is(docVal, filterVal)
159
+ },
160
+ groupList = <WG extends Record<string, unknown> & { own?: boolean }>(w?: WG & { or?: WG[] }): WG[] =>
161
+ w
162
+ ? [{ ...w, or: undefined } as WG, ...(w.or ?? [])].filter(
163
+ g => g.own ?? Object.keys(g).some(k => k !== 'own' && g[k] !== undefined)
164
+ )
165
+ : [],
166
+ matchW = <WG extends Record<string, unknown> & { own?: boolean }>(
167
+ doc: Record<string, unknown>,
168
+ w: undefined | (WG & { or?: WG[] }),
169
+ vid?: null | string
170
+ ) => {
171
+ const gs = groupList(w)
172
+ if (!gs.length) return true
173
+ for (const g of gs) {
174
+ const ok = Object.entries(g).every(
175
+ ([k, vl]: [string, unknown]) => k === 'own' || vl === undefined || matchField(doc[k], vl)
176
+ )
177
+ if (ok && (!g.own || vid === (doc as { userId?: string }).userId)) return true
178
+ }
179
+ return false
180
+ },
181
+ pickFields = (data: Record<string, unknown>, keys: string[]): Record<string, unknown> => {
182
+ const result: Record<string, unknown> = {}
183
+ for (const k of keys) if (k in data) result[k] = data[k]
184
+ return result
185
+ },
186
+ errValidation = (
187
+ code: ErrorCode,
188
+ zodError: { flatten: () => { fieldErrors: Record<string, string[] | undefined> } }
189
+ ): never => {
190
+ const { fieldErrors } = zodError.flatten(),
191
+ fields = Object.keys(fieldErrors)
192
+ throw new ConvexError({ code, fields, message: fields.length ? `Invalid: ${fields.join(', ')}` : 'Validation failed' })
193
+ }
194
+
195
+ export {
196
+ addUrls,
197
+ cascadeFor,
198
+ cleanFiles,
199
+ detectFiles,
200
+ err,
201
+ errValidation,
202
+ getUser,
203
+ groupList,
204
+ isComparisonOp,
205
+ isRecord,
206
+ log,
207
+ matchW,
208
+ noFetcher,
209
+ ownGet,
210
+ pgOpts,
211
+ pickFields,
212
+ readCtx,
213
+ time
214
+ }
@@ -0,0 +1,12 @@
1
+ export { checkSchema } from './check-schema'
2
+ export { getErrorCode, getErrorMessage, isRecord } from './error'
3
+ export { makeFileUpload } from './file'
4
+ export { err, time } from './helpers'
5
+ export { makeOrg } from './org'
6
+ export type { InviteDocLike, JoinRequestItem, OrgDocLike, OrgMemberItem, OrgUserLike } from './org'
7
+ export { orgCascade } from './org-crud'
8
+ export { canEdit, getOrgMember, getOrgRole, requireOrgMember, requireOrgRole } from './org-helpers'
9
+ export { baseTable, orgChildTable, orgTable, orgTables, ownedTable, uploadTables } from './schema-helpers'
10
+ export { setup } from './setup'
11
+ export { makeTestAuth } from './test'
12
+ export { getOrgMembership, makeOrgTestCrud } from './test-crud'