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,101 @@
|
|
|
1
|
+
// oxlint-disable unicorn/prefer-add-event-listener
|
|
2
|
+
/* eslint-disable no-await-in-loop, max-statements */
|
|
3
|
+
/** biome-ignore-all lint/performance/noAwaitInLoops: retry logic */
|
|
4
|
+
'use client'
|
|
5
|
+
|
|
6
|
+
import type { FunctionReference } from 'convex/server'
|
|
7
|
+
|
|
8
|
+
import { useMutation } from 'convex/react'
|
|
9
|
+
import { useRef, useState } from 'react'
|
|
10
|
+
|
|
11
|
+
interface UploadOptions {
|
|
12
|
+
retries?: number
|
|
13
|
+
retryDelay?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type UploadResult =
|
|
17
|
+
| { code: 'ABORTED' | 'INVALID_RESPONSE' | 'NETWORK' | 'URL'; ok: false }
|
|
18
|
+
| { code: 'HTTP'; ok: false; status: number }
|
|
19
|
+
| { ok: true; storageId: string }
|
|
20
|
+
|
|
21
|
+
const sleep = async (ms: number) =>
|
|
22
|
+
// oxlint-disable-next-line promise/avoid-new
|
|
23
|
+
new Promise<void>(resolve => {
|
|
24
|
+
setTimeout(resolve, ms)
|
|
25
|
+
}),
|
|
26
|
+
useUpload = (uploadMutation: FunctionReference<'mutation'>, options?: UploadOptions) => {
|
|
27
|
+
const { retries = 3, retryDelay = 1000 } = options ?? {},
|
|
28
|
+
getUrl = useMutation(uploadMutation),
|
|
29
|
+
[progress, setProgress] = useState(0),
|
|
30
|
+
[uploading, setUploading] = useState(false),
|
|
31
|
+
[attempt, setAttempt] = useState(0),
|
|
32
|
+
xhr = useRef<null | XMLHttpRequest>(null),
|
|
33
|
+
reset = () => {
|
|
34
|
+
setUploading(false)
|
|
35
|
+
setProgress(0)
|
|
36
|
+
setAttempt(0)
|
|
37
|
+
},
|
|
38
|
+
uploadOnce = async (file: File): Promise<UploadResult> => {
|
|
39
|
+
try {
|
|
40
|
+
const url = (await getUrl()) as string
|
|
41
|
+
// oxlint-disable-next-line promise/avoid-new
|
|
42
|
+
return await new Promise(res => {
|
|
43
|
+
const x = new XMLHttpRequest()
|
|
44
|
+
xhr.current = x
|
|
45
|
+
x.upload.onprogress = e => e.lengthComputable && setProgress(Math.round((e.loaded / e.total) * 100))
|
|
46
|
+
x.onload = () => {
|
|
47
|
+
if (x.status < 200 || x.status >= 300) return res({ code: 'HTTP', ok: false, status: x.status })
|
|
48
|
+
try {
|
|
49
|
+
const parsed: unknown = JSON.parse(x.responseText),
|
|
50
|
+
storageId =
|
|
51
|
+
typeof parsed === 'object' && parsed !== null && 'storageId' in parsed
|
|
52
|
+
? (parsed as { storageId: unknown }).storageId
|
|
53
|
+
: undefined
|
|
54
|
+
if (typeof storageId !== 'string') return res({ code: 'INVALID_RESPONSE', ok: false })
|
|
55
|
+
setProgress(100)
|
|
56
|
+
res({ ok: true, storageId })
|
|
57
|
+
} catch {
|
|
58
|
+
res({ code: 'INVALID_RESPONSE', ok: false })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
x.onerror = () => res({ code: 'NETWORK', ok: false })
|
|
62
|
+
x.onabort = () => res({ code: 'ABORTED', ok: false })
|
|
63
|
+
x.open('POST', url)
|
|
64
|
+
x.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
|
65
|
+
x.send(file)
|
|
66
|
+
})
|
|
67
|
+
} catch {
|
|
68
|
+
return { code: 'URL', ok: false }
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
upload = async (file: File): Promise<UploadResult> => {
|
|
72
|
+
setUploading(true)
|
|
73
|
+
setProgress(0)
|
|
74
|
+
setAttempt(0)
|
|
75
|
+
try {
|
|
76
|
+
for (let i = 0; i < retries; i += 1) {
|
|
77
|
+
setAttempt(i + 1)
|
|
78
|
+
const result = await uploadOnce(file)
|
|
79
|
+
if (result.ok || result.code === 'ABORTED') return result
|
|
80
|
+
if (i < retries - 1) await sleep(retryDelay * (i + 1))
|
|
81
|
+
}
|
|
82
|
+
return { code: 'NETWORK', ok: false }
|
|
83
|
+
} finally {
|
|
84
|
+
setUploading(false)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
attempt,
|
|
89
|
+
cancel: () => {
|
|
90
|
+
xhr.current?.abort()
|
|
91
|
+
reset()
|
|
92
|
+
},
|
|
93
|
+
isUploading: uploading,
|
|
94
|
+
progress,
|
|
95
|
+
reset,
|
|
96
|
+
upload
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type { UploadOptions, UploadResult }
|
|
101
|
+
export default useUpload
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
// biome-ignore-all lint/performance/noAwaitInLoops: x
|
|
3
|
+
// biome-ignore-all lint/suspicious/useAwait: x
|
|
4
|
+
|
|
5
|
+
interface RetryOptions {
|
|
6
|
+
base?: number
|
|
7
|
+
initialDelayMs?: number
|
|
8
|
+
maxAttempts?: number
|
|
9
|
+
maxDelayMs?: number
|
|
10
|
+
}
|
|
11
|
+
const DEFAULT_OPTIONS: Required<RetryOptions> = {
|
|
12
|
+
base: 2,
|
|
13
|
+
initialDelayMs: 500,
|
|
14
|
+
maxAttempts: 3,
|
|
15
|
+
maxDelayMs: 10_000
|
|
16
|
+
},
|
|
17
|
+
sleep = async (ms: number) =>
|
|
18
|
+
// oxlint-disable-next-line promise/avoid-new
|
|
19
|
+
new Promise<void>(resolve => {
|
|
20
|
+
setTimeout(resolve, ms)
|
|
21
|
+
}),
|
|
22
|
+
calculateDelay = (attempt: number, opts: Required<RetryOptions>) => {
|
|
23
|
+
const jitter = Math.random() * 0.3 + 0.85
|
|
24
|
+
return Math.min(opts.initialDelayMs * opts.base ** attempt * jitter, opts.maxDelayMs)
|
|
25
|
+
},
|
|
26
|
+
withRetry = async <T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> => {
|
|
27
|
+
const opts = { ...DEFAULT_OPTIONS, ...options }
|
|
28
|
+
let lastError: Error = new Error('Retry failed')
|
|
29
|
+
for (let attempt = 0; attempt < opts.maxAttempts; attempt += 1)
|
|
30
|
+
try {
|
|
31
|
+
return await fn()
|
|
32
|
+
} catch (error) {
|
|
33
|
+
lastError = error instanceof Error ? error : new Error(String(error))
|
|
34
|
+
if (attempt < opts.maxAttempts - 1) await sleep(calculateDelay(attempt, opts))
|
|
35
|
+
}
|
|
36
|
+
throw lastError
|
|
37
|
+
},
|
|
38
|
+
fetchWithRetry = async (url: string, options?: RequestInit & { retry?: RetryOptions }): Promise<Response> => {
|
|
39
|
+
const { retry, ...fetchOptions } = options ?? {}
|
|
40
|
+
return withRetry(async () => {
|
|
41
|
+
const response = await fetch(url, fetchOptions)
|
|
42
|
+
if (!response.ok && response.status >= 500) throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
43
|
+
return response
|
|
44
|
+
}, retry)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { fetchWithRetry, withRetry }
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ZodObject, ZodRawShape } from 'zod/v4'
|
|
2
|
+
|
|
3
|
+
import { zid } from 'convex-helpers/server/zod4'
|
|
4
|
+
import { array, object, string } from 'zod/v4'
|
|
5
|
+
|
|
6
|
+
const cvFile = () => zid('_storage').meta({ cv: 'file' as const }),
|
|
7
|
+
cvFiles = () => array(cvFile()).meta({ cv: 'files' as const }),
|
|
8
|
+
child = <const P extends string, const S extends ZodRawShape, const FK extends keyof S & string>(config: {
|
|
9
|
+
foreignKey: FK
|
|
10
|
+
index?: string
|
|
11
|
+
parent: P
|
|
12
|
+
schema: ZodObject<S>
|
|
13
|
+
}): {
|
|
14
|
+
foreignKey: FK
|
|
15
|
+
index: string
|
|
16
|
+
parent: P
|
|
17
|
+
schema: ZodObject<S>
|
|
18
|
+
} => ({
|
|
19
|
+
...config,
|
|
20
|
+
index: config.index ?? `by_${config.parent}`
|
|
21
|
+
}),
|
|
22
|
+
orgSchema = object({
|
|
23
|
+
avatarId: zid('_storage').nullable().optional(),
|
|
24
|
+
name: string().min(1),
|
|
25
|
+
slug: string()
|
|
26
|
+
.min(1)
|
|
27
|
+
.regex(/^[a-z0-9-]+$/u)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export { child, cvFile, cvFiles, orgSchema }
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop, @typescript-eslint/no-unnecessary-type-parameters */
|
|
2
|
+
import type { GenericDataModel } from 'convex/server'
|
|
3
|
+
import type { ZodObject, ZodRawShape } from 'zod/v4'
|
|
4
|
+
|
|
5
|
+
import { zodOutputToConvexFields as z2c, zid } from 'convex-helpers/server/zod4'
|
|
6
|
+
import { anyApi } from 'convex/server'
|
|
7
|
+
import { v } from 'convex/values'
|
|
8
|
+
import { boolean } from 'zod/v4'
|
|
9
|
+
|
|
10
|
+
import type { ActionCtxLike, CacheBuilders, CacheCrudResult, DbCtx, FilterLike, IndexLike, Rec } from './types'
|
|
11
|
+
|
|
12
|
+
import { dbDelete, dbInsert, dbPatch } from './db'
|
|
13
|
+
import { err, noFetcher, pgOpts, pickFields, time } from './helpers'
|
|
14
|
+
|
|
15
|
+
const makeCacheCrud = <S extends ZodRawShape, K extends string, DM extends GenericDataModel = GenericDataModel>({
|
|
16
|
+
builders: b,
|
|
17
|
+
fetcher,
|
|
18
|
+
key,
|
|
19
|
+
schema,
|
|
20
|
+
table,
|
|
21
|
+
ttl = 7 * 24 * 60 * 60 * 1000
|
|
22
|
+
}: {
|
|
23
|
+
builders: CacheBuilders<DM>
|
|
24
|
+
fetcher?: (c: unknown, key: unknown) => Promise<unknown>
|
|
25
|
+
key: K
|
|
26
|
+
schema: ZodObject<S>
|
|
27
|
+
table: string
|
|
28
|
+
ttl?: number
|
|
29
|
+
}): CacheCrudResult<S> => {
|
|
30
|
+
const keys = Object.keys(schema.shape),
|
|
31
|
+
pick = (d: Rec) => pickFields(d, keys),
|
|
32
|
+
valid = (d: Rec) => ((d.updatedAt as number | undefined) ?? (d._creationTime as number)) + ttl > Date.now(),
|
|
33
|
+
partial = schema.partial(),
|
|
34
|
+
idx = `by_${key}` as const,
|
|
35
|
+
kArgs = z2c({ [key]: schema.shape[key] } as never) as Rec,
|
|
36
|
+
idArgs = { id: zid(table) },
|
|
37
|
+
expArgs = { includeExpired: boolean().optional() },
|
|
38
|
+
listArgs = { includeExpired: boolean().optional(), paginationOpts: pgOpts },
|
|
39
|
+
retFields = z2c(schema.extend({ cacheHit: boolean() }).shape) as Rec,
|
|
40
|
+
kVal = kArgs[key] ?? err('INVALID_WHERE'),
|
|
41
|
+
byK = (x: unknown) => ((i: IndexLike) => i.eq(key, x)) as never,
|
|
42
|
+
getInt = b.internalQuery({
|
|
43
|
+
args: kArgs as never,
|
|
44
|
+
handler: (async (c: DbCtx, a: Rec) => c.db.query(table).withIndex(idx, byK(a[key])).first()) as never
|
|
45
|
+
}),
|
|
46
|
+
get = b.query({
|
|
47
|
+
args: kArgs as never,
|
|
48
|
+
handler: (async (c: DbCtx, a: Rec) => {
|
|
49
|
+
const d = await c.db.query(table).withIndex(idx, byK(a[key])).first()
|
|
50
|
+
return d && valid(d) ? { ...d, cacheHit: true } : null
|
|
51
|
+
}) as never
|
|
52
|
+
}),
|
|
53
|
+
read = b.cq({ args: idArgs, handler: (async (c: DbCtx, { id }: { id: string }) => c.db.get(id)) as never }),
|
|
54
|
+
all = b.cq({
|
|
55
|
+
args: expArgs,
|
|
56
|
+
handler: (async (c: DbCtx, { includeExpired: ie }: { includeExpired?: boolean }) => {
|
|
57
|
+
const d = await c.db.query(table).order('desc').collect()
|
|
58
|
+
return ie ? d : d.filter(valid)
|
|
59
|
+
}) as never
|
|
60
|
+
}),
|
|
61
|
+
list = b.cq({
|
|
62
|
+
args: listArgs,
|
|
63
|
+
handler: (async (
|
|
64
|
+
c: DbCtx,
|
|
65
|
+
{ includeExpired: ie, paginationOpts: op }: { includeExpired?: boolean; paginationOpts: Rec }
|
|
66
|
+
) => {
|
|
67
|
+
const qr = c.db.query(table).order('desc')
|
|
68
|
+
if (ie) return qr.paginate(op)
|
|
69
|
+
const { page, ...rest } = await qr.paginate({ ...op, numItems: (op.numItems as number) * 2 })
|
|
70
|
+
return { ...rest, page: page.filter(valid).slice(0, op.numItems as number) }
|
|
71
|
+
}) as never
|
|
72
|
+
}),
|
|
73
|
+
upsert = async (c: DbCtx, data: Rec) => {
|
|
74
|
+
const ex = await c.db.query(table).withIndex(idx, byK(data[key])).first(),
|
|
75
|
+
wt = { ...data, ...time() }
|
|
76
|
+
if (ex) {
|
|
77
|
+
await dbPatch(c.db, ex._id as string, wt)
|
|
78
|
+
return ex._id
|
|
79
|
+
}
|
|
80
|
+
return dbInsert(c.db, table, wt)
|
|
81
|
+
},
|
|
82
|
+
set = b.internalMutation({
|
|
83
|
+
args: { data: v.object(z2c(schema.shape) as never) },
|
|
84
|
+
handler: (async (c: DbCtx, { data }: { data: Rec }) => {
|
|
85
|
+
await upsert(c, pick(data))
|
|
86
|
+
}) as never
|
|
87
|
+
}),
|
|
88
|
+
create = b.cm({ args: schema.shape, handler: (async (c: DbCtx, d: Rec) => upsert(c, d)) as never }),
|
|
89
|
+
update = b.cm({
|
|
90
|
+
args: { ...idArgs, ...partial.shape },
|
|
91
|
+
handler: (async (c: DbCtx, a: Rec) => {
|
|
92
|
+
const { id, ...d } = a as Rec & { id: string },
|
|
93
|
+
ex = await c.db.get(id),
|
|
94
|
+
t = time()
|
|
95
|
+
if (!ex) return err('NOT_FOUND')
|
|
96
|
+
await dbPatch(c.db, id, { ...d, ...t })
|
|
97
|
+
return { ...ex, ...d, ...t }
|
|
98
|
+
}) as never
|
|
99
|
+
}),
|
|
100
|
+
rm = b.cm({
|
|
101
|
+
args: idArgs,
|
|
102
|
+
handler: (async (c: DbCtx, { id }: { id: string }) => {
|
|
103
|
+
const d = await c.db.get(id)
|
|
104
|
+
if (d) await c.db.delete(id)
|
|
105
|
+
return d
|
|
106
|
+
}) as never
|
|
107
|
+
}),
|
|
108
|
+
invalidate = b.mutation({
|
|
109
|
+
args: kArgs as never,
|
|
110
|
+
handler: (async (c: DbCtx, a: Rec) => {
|
|
111
|
+
const d = await c.db.query(table).withIndex(idx, byK(a[key])).first()
|
|
112
|
+
if (d) await dbDelete(c.db, d._id as string)
|
|
113
|
+
return d
|
|
114
|
+
}) as never
|
|
115
|
+
}),
|
|
116
|
+
purge = b.cm({
|
|
117
|
+
args: {},
|
|
118
|
+
handler: (async (c: DbCtx) => {
|
|
119
|
+
const cut = Date.now() - ttl,
|
|
120
|
+
exp = await c.db
|
|
121
|
+
.query(table)
|
|
122
|
+
.filter((qr: FilterLike) => qr.lt(qr.field('_creationTime'), cut))
|
|
123
|
+
.collect()
|
|
124
|
+
// biome-ignore lint/performance/noAwaitInLoops: x
|
|
125
|
+
for (const d of exp) await dbDelete(c.db, d._id as string)
|
|
126
|
+
return exp.length
|
|
127
|
+
}) as never
|
|
128
|
+
}),
|
|
129
|
+
tPath = (anyApi as Rec)[table] as Rec,
|
|
130
|
+
tKArgs = { [key]: kVal } as Rec,
|
|
131
|
+
doFetch = async (c: ActionCtxLike, kv: unknown) => {
|
|
132
|
+
const d = pick((await fetcher?.(c, kv)) as Rec)
|
|
133
|
+
await c.runMutation(tPath.set as string, { data: d })
|
|
134
|
+
return { ...d, cacheHit: false }
|
|
135
|
+
},
|
|
136
|
+
load = fetcher
|
|
137
|
+
? b.action({
|
|
138
|
+
args: tKArgs as never,
|
|
139
|
+
handler: (async (c: ActionCtxLike, a: Rec) => {
|
|
140
|
+
const kv = a[key],
|
|
141
|
+
d = await c.runQuery(tPath.getInternal as string, { [key]: kv })
|
|
142
|
+
return d && valid(d as Rec) ? { ...pick(d as Rec), cacheHit: true } : doFetch(c, kv)
|
|
143
|
+
}) as never,
|
|
144
|
+
returns: v.object(retFields as never)
|
|
145
|
+
})
|
|
146
|
+
: b.action(noFetcher as never),
|
|
147
|
+
refresh = fetcher
|
|
148
|
+
? b.action({
|
|
149
|
+
args: tKArgs as never,
|
|
150
|
+
handler: (async (c: ActionCtxLike, a: Rec) => {
|
|
151
|
+
const kv = a[key]
|
|
152
|
+
await c.runMutation(tPath.invalidate as string, { [key]: kv })
|
|
153
|
+
return doFetch(c, kv)
|
|
154
|
+
}) as never,
|
|
155
|
+
returns: v.object(retFields as never)
|
|
156
|
+
})
|
|
157
|
+
: b.action(noFetcher as never)
|
|
158
|
+
return {
|
|
159
|
+
all,
|
|
160
|
+
create,
|
|
161
|
+
get,
|
|
162
|
+
getInternal: getInt,
|
|
163
|
+
invalidate,
|
|
164
|
+
list,
|
|
165
|
+
load,
|
|
166
|
+
purge,
|
|
167
|
+
read,
|
|
168
|
+
refresh,
|
|
169
|
+
rm,
|
|
170
|
+
set,
|
|
171
|
+
update
|
|
172
|
+
} as unknown as CacheCrudResult<S>
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export { makeCacheCrud }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ZodObject, ZodRawShape } from 'zod/v4'
|
|
2
|
+
|
|
3
|
+
import { elementOf, isArrayType, unwrapZod } from '../zod'
|
|
4
|
+
import { isRecord } from './error'
|
|
5
|
+
|
|
6
|
+
interface Output {
|
|
7
|
+
path: string
|
|
8
|
+
zodType: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const unsupportedTypes = new Set(['pipe', 'transform']),
|
|
12
|
+
scan = (schema: unknown, path: string, out: Output[]) => {
|
|
13
|
+
const b = unwrapZod(schema)
|
|
14
|
+
if (b.type && unsupportedTypes.has(b.type)) out.push({ path, zodType: b.type })
|
|
15
|
+
if (isArrayType(b.type)) return scan(elementOf(b.schema), `${path}[]`, out)
|
|
16
|
+
if (b.type === 'object' && b.schema && isRecord((b.schema as unknown as { shape?: unknown }).shape))
|
|
17
|
+
for (const [k, v] of Object.entries((b.schema as unknown as { shape: Record<string, unknown> }).shape))
|
|
18
|
+
scan(v, path ? `${path}.${k}` : k, out)
|
|
19
|
+
},
|
|
20
|
+
checkSchema = (schemas: Record<string, ZodObject<ZodRawShape>>) => {
|
|
21
|
+
const res: Output[] = []
|
|
22
|
+
for (const [table, schema] of Object.entries(schemas)) scan(schema, table, res)
|
|
23
|
+
if (res.length) {
|
|
24
|
+
for (const f of res) process.stderr.write(`${f.path}: unsupported zod type "${f.zodType}"\n`)
|
|
25
|
+
process.exitCode = 1
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { checkSchema }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ZodObject, ZodRawShape } from 'zod/v4'
|
|
2
|
+
|
|
3
|
+
import { zid } from 'convex-helpers/server/zod4'
|
|
4
|
+
|
|
5
|
+
import type { BaseBuilders, ChildCrudResult, IndexLike, Rec, UserCtx } from './types'
|
|
6
|
+
|
|
7
|
+
import { dbDelete, dbInsert, dbPatch } from './db'
|
|
8
|
+
import { err, pickFields, time } from './helpers'
|
|
9
|
+
|
|
10
|
+
interface ChildMeta<S extends ZodRawShape = ZodRawShape> {
|
|
11
|
+
foreignKey: string
|
|
12
|
+
index: string
|
|
13
|
+
parent: string
|
|
14
|
+
schema: ZodObject<S>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const makeChildCrud = <S extends ZodRawShape>({
|
|
18
|
+
builders,
|
|
19
|
+
meta,
|
|
20
|
+
table
|
|
21
|
+
}: {
|
|
22
|
+
builders: BaseBuilders
|
|
23
|
+
meta: ChildMeta<S>
|
|
24
|
+
table: string
|
|
25
|
+
}): ChildCrudResult<S> => {
|
|
26
|
+
const { m, q } = builders,
|
|
27
|
+
{ foreignKey, index, parent, schema } = meta,
|
|
28
|
+
getFK = (doc: Rec): string => doc[foreignKey] as string,
|
|
29
|
+
schemaKeys = Object.keys(schema.shape),
|
|
30
|
+
partial = schema.partial(),
|
|
31
|
+
idArgs = { id: zid(table) },
|
|
32
|
+
// oxlint-disable-next-line unicorn/consistent-function-scoping
|
|
33
|
+
verifyParentOwnership = async (ctx: UserCtx, parentId: string) => {
|
|
34
|
+
const p = await ctx.db.get(parentId)
|
|
35
|
+
return p && p.userId === ctx.user._id ? p : null
|
|
36
|
+
},
|
|
37
|
+
create = m({
|
|
38
|
+
args: { ...schema.shape, [foreignKey]: zid(parent) },
|
|
39
|
+
handler: (async (ctx: UserCtx, a: Rec) => {
|
|
40
|
+
const args = a,
|
|
41
|
+
parentId = args[foreignKey] as string,
|
|
42
|
+
data = schema.parse(pickFields(args, schemaKeys))
|
|
43
|
+
if (!(await verifyParentOwnership(ctx, parentId))) return err('NOT_FOUND', `${table}:create`)
|
|
44
|
+
return dbInsert(ctx.db, table, { ...data, [foreignKey]: parentId, ...time() })
|
|
45
|
+
}) as never
|
|
46
|
+
}),
|
|
47
|
+
update = m({
|
|
48
|
+
args: { ...idArgs, ...partial.shape },
|
|
49
|
+
handler: (async (ctx: UserCtx, a: Rec) => {
|
|
50
|
+
const args = a as Rec & { id: string },
|
|
51
|
+
{ id, ...rest } = args,
|
|
52
|
+
data = partial.parse(pickFields(rest, schemaKeys)),
|
|
53
|
+
doc = await ctx.db.get(id)
|
|
54
|
+
if (!doc) return err('NOT_FOUND', `${table}:update`)
|
|
55
|
+
const parentId = getFK(doc)
|
|
56
|
+
if (!(await verifyParentOwnership(ctx, parentId))) return err('NOT_FOUND', `${table}:update`)
|
|
57
|
+
await dbPatch(ctx.db, id, { ...data, ...time() })
|
|
58
|
+
return ctx.db.get(id)
|
|
59
|
+
}) as never
|
|
60
|
+
}),
|
|
61
|
+
rm = m({
|
|
62
|
+
args: idArgs,
|
|
63
|
+
handler: (async (ctx: UserCtx, { id }: { id: string }) => {
|
|
64
|
+
const doc = await ctx.db.get(id)
|
|
65
|
+
if (!doc) return err('NOT_FOUND', `${table}:rm`)
|
|
66
|
+
const parentId = getFK(doc)
|
|
67
|
+
if (!(await verifyParentOwnership(ctx, parentId))) return err('NOT_FOUND', `${table}:rm`)
|
|
68
|
+
await dbDelete(ctx.db, id)
|
|
69
|
+
return doc
|
|
70
|
+
}) as never
|
|
71
|
+
}),
|
|
72
|
+
list = q({
|
|
73
|
+
args: { [foreignKey]: zid(parent) },
|
|
74
|
+
handler: (async (ctx: UserCtx, a: Rec) => {
|
|
75
|
+
const args = a,
|
|
76
|
+
parentId = args[foreignKey] as string
|
|
77
|
+
if (!(await verifyParentOwnership(ctx, parentId))) return err('NOT_AUTHORIZED', `${table}:list`)
|
|
78
|
+
return ctx.db
|
|
79
|
+
.query(table)
|
|
80
|
+
.withIndex(index, ((i: IndexLike) => i.eq(foreignKey, parentId)) as never)
|
|
81
|
+
.order('asc')
|
|
82
|
+
.collect()
|
|
83
|
+
}) as never
|
|
84
|
+
}),
|
|
85
|
+
get = q({
|
|
86
|
+
args: idArgs,
|
|
87
|
+
handler: (async (ctx: UserCtx, { id }: { id: string }) => {
|
|
88
|
+
const doc = await ctx.db.get(id)
|
|
89
|
+
if (!doc) return null
|
|
90
|
+
const parentId = getFK(doc)
|
|
91
|
+
if (!(await verifyParentOwnership(ctx, parentId))) return err('NOT_AUTHORIZED', `${table}:get`)
|
|
92
|
+
return doc
|
|
93
|
+
}) as never
|
|
94
|
+
})
|
|
95
|
+
return { create, get, list, rm, update } as unknown as ChildCrudResult<S>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { makeChildCrud }
|