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,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 }