lazyconvex 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +926 -0
- package/dist/components/index.mjs +937 -0
- package/dist/error-D4GuI0ot.mjs +71 -0
- package/dist/file-field-BqVgy8xY.mjs +205 -0
- package/dist/form-BXJK_j10.d.mts +99 -0
- package/dist/index.d.mts +433 -0
- package/dist/index.mjs +1 -0
- package/dist/index2.d.mts +5 -0
- package/dist/index3.d.mts +35 -0
- package/dist/index4.d.mts +101 -0
- package/dist/index5.d.mts +842 -0
- package/dist/next/index.mjs +151 -0
- package/dist/org-CmJBb8z-.d.mts +56 -0
- package/dist/react/index.mjs +158 -0
- package/dist/retry.d.mts +12 -0
- package/dist/retry.mjs +35 -0
- package/dist/schema.d.mts +23 -0
- package/dist/schema.mjs +15 -0
- package/dist/server/index.mjs +2572 -0
- package/dist/types-DWBVRtit.d.mts +322 -0
- package/dist/use-online-status-CMr73Jlk.mjs +155 -0
- package/dist/use-upload-DtELytQi.mjs +95 -0
- package/dist/zod.d.mts +18 -0
- package/dist/zod.mjs +87 -0
- package/package.json +40 -0
- package/src/components/editors-section.tsx +86 -0
- package/src/components/fields.tsx +884 -0
- package/src/components/file-field.tsx +234 -0
- package/src/components/form.tsx +191 -0
- package/src/components/index.ts +11 -0
- package/src/components/offline-indicator.tsx +15 -0
- package/src/components/org-avatar.tsx +13 -0
- package/src/components/permission-guard.tsx +36 -0
- package/src/components/role-badge.tsx +14 -0
- package/src/components/suspense-wrap.tsx +8 -0
- package/src/index.ts +40 -0
- package/src/next/active-org.ts +33 -0
- package/src/next/auth.ts +9 -0
- package/src/next/image.ts +134 -0
- package/src/next/index.ts +3 -0
- package/src/react/form-meta.ts +53 -0
- package/src/react/form.ts +201 -0
- package/src/react/index.ts +8 -0
- package/src/react/org.tsx +96 -0
- package/src/react/use-active-org.ts +48 -0
- package/src/react/use-bulk-selection.ts +47 -0
- package/src/react/use-online-status.ts +21 -0
- package/src/react/use-optimistic.ts +54 -0
- package/src/react/use-upload.ts +101 -0
- package/src/retry.ts +47 -0
- package/src/schema.ts +30 -0
- package/src/server/cache-crud.ts +175 -0
- package/src/server/check-schema.ts +29 -0
- package/src/server/child.ts +98 -0
- package/src/server/crud.ts +384 -0
- package/src/server/db.ts +7 -0
- package/src/server/error.ts +39 -0
- package/src/server/file.ts +372 -0
- package/src/server/helpers.ts +214 -0
- package/src/server/index.ts +12 -0
- package/src/server/org-crud.ts +307 -0
- package/src/server/org-helpers.ts +54 -0
- package/src/server/org.ts +572 -0
- package/src/server/schema-helpers.ts +107 -0
- package/src/server/setup.ts +138 -0
- package/src/server/test-crud.ts +211 -0
- package/src/server/test.ts +554 -0
- package/src/server/types.ts +392 -0
- package/src/server/unique.ts +28 -0
- package/src/zod.ts +141 -0
|
@@ -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'
|