create-vexcms 0.0.3

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 (116) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +2409 -0
  3. package/package.json +37 -0
  4. package/templates/base-nextjs/.prettierignore +26 -0
  5. package/templates/base-nextjs/.prettierrc +7 -0
  6. package/templates/base-nextjs/README.md +256 -0
  7. package/templates/base-nextjs/_gitignore +2 -0
  8. package/templates/base-nextjs/components.json +24 -0
  9. package/templates/base-nextjs/convex/_generated/api.d.ts +125 -0
  10. package/templates/base-nextjs/convex/_generated/api.js +23 -0
  11. package/templates/base-nextjs/convex/_generated/dataModel.d.ts +60 -0
  12. package/templates/base-nextjs/convex/_generated/server.d.ts +143 -0
  13. package/templates/base-nextjs/convex/_generated/server.js +93 -0
  14. package/templates/base-nextjs/convex/auth/adapter/index.ts +230 -0
  15. package/templates/base-nextjs/convex/auth/adapter/utils.ts +547 -0
  16. package/templates/base-nextjs/convex/auth/api.ts +8 -0
  17. package/templates/base-nextjs/convex/auth/config.ts +10 -0
  18. package/templates/base-nextjs/convex/auth/db.ts +303 -0
  19. package/templates/base-nextjs/convex/auth/index.ts +14 -0
  20. package/templates/base-nextjs/convex/auth/options.ts +63 -0
  21. package/templates/base-nextjs/convex/auth/plugins/index.ts +20 -0
  22. package/templates/base-nextjs/convex/auth/sessions.ts +60 -0
  23. package/templates/base-nextjs/convex/auth.config.ts +7 -0
  24. package/templates/base-nextjs/convex/convex.config.ts +5 -0
  25. package/templates/base-nextjs/convex/http.ts +28 -0
  26. package/templates/base-nextjs/convex/schema.ts +3 -0
  27. package/templates/base-nextjs/convex/vex/auth.ts +38 -0
  28. package/templates/base-nextjs/convex/vex/collections.ts +321 -0
  29. package/templates/base-nextjs/convex/vex/firstUser.ts +134 -0
  30. package/templates/base-nextjs/convex/vex/helpers.ts +33 -0
  31. package/templates/base-nextjs/convex/vex/impersonation.ts +51 -0
  32. package/templates/base-nextjs/convex/vex/media.ts +246 -0
  33. package/templates/base-nextjs/convex/vex/migrate.ts +74 -0
  34. package/templates/base-nextjs/convex/vex/model/collections.ts +196 -0
  35. package/templates/base-nextjs/convex/vex/model/media.ts +87 -0
  36. package/templates/base-nextjs/convex/vex/model/versions.ts +254 -0
  37. package/templates/base-nextjs/convex/vex/previewSnapshot.ts +53 -0
  38. package/templates/base-nextjs/convex/vex/versions.ts +588 -0
  39. package/templates/base-nextjs/eslint.config.mjs +164 -0
  40. package/templates/base-nextjs/next.config.ts +10 -0
  41. package/templates/base-nextjs/package.json +67 -0
  42. package/templates/base-nextjs/postcss.config.mjs +7 -0
  43. package/templates/base-nextjs/public/favicons/favicon.ico +0 -0
  44. package/templates/base-nextjs/public/file.svg +1 -0
  45. package/templates/base-nextjs/public/globe.svg +1 -0
  46. package/templates/base-nextjs/public/next.svg +1 -0
  47. package/templates/base-nextjs/public/vercel.svg +1 -0
  48. package/templates/base-nextjs/public/window.svg +1 -0
  49. package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/page.tsx +9 -0
  50. package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +23 -0
  51. package/templates/base-nextjs/src/app/(frontend)/@auth/default.tsx +3 -0
  52. package/templates/base-nextjs/src/app/(frontend)/@auth/page.tsx +3 -0
  53. package/templates/base-nextjs/src/app/(frontend)/auth/[pathname]/page.tsx +9 -0
  54. package/templates/base-nextjs/src/app/(frontend)/auth/[pathname]/view.tsx +11 -0
  55. package/templates/base-nextjs/src/app/(frontend)/layout.tsx +14 -0
  56. package/templates/base-nextjs/src/app/(frontend)/page.tsx +116 -0
  57. package/templates/base-nextjs/src/app/admin/AdminLayoutWrapper.tsx +33 -0
  58. package/templates/base-nextjs/src/app/admin/AdminPageWrapper.tsx +38 -0
  59. package/templates/base-nextjs/src/app/admin/RichTextFieldWithMedia.tsx +101 -0
  60. package/templates/base-nextjs/src/app/admin/[[...path]]/page.tsx +15 -0
  61. package/templates/base-nextjs/src/app/admin/layout.tsx +33 -0
  62. package/templates/base-nextjs/src/app/api/auth/[...all]/route.ts +6 -0
  63. package/templates/base-nextjs/src/app/favicon.ico +0 -0
  64. package/templates/base-nextjs/src/app/globals.css +130 -0
  65. package/templates/base-nextjs/src/app/layout.tsx +30 -0
  66. package/templates/base-nextjs/src/auth/client.tsx +41 -0
  67. package/templates/base-nextjs/src/auth/permissions.ts +102 -0
  68. package/templates/base-nextjs/src/auth/server.ts +16 -0
  69. package/templates/base-nextjs/src/auth/serverUtils.ts +64 -0
  70. package/templates/base-nextjs/src/auth/types.ts +3 -0
  71. package/templates/base-nextjs/src/components/component-example.tsx +474 -0
  72. package/templates/base-nextjs/src/components/example.tsx +52 -0
  73. package/templates/base-nextjs/src/components/providers/client.tsx +9 -0
  74. package/templates/base-nextjs/src/components/providers/convex.tsx +32 -0
  75. package/templates/base-nextjs/src/components/providers/server.tsx +15 -0
  76. package/templates/base-nextjs/src/components/providers/theme.tsx +11 -0
  77. package/templates/base-nextjs/src/components/ui/alert-dialog.tsx +162 -0
  78. package/templates/base-nextjs/src/components/ui/badge.tsx +52 -0
  79. package/templates/base-nextjs/src/components/ui/button.tsx +60 -0
  80. package/templates/base-nextjs/src/components/ui/card.tsx +92 -0
  81. package/templates/base-nextjs/src/components/ui/combobox.tsx +271 -0
  82. package/templates/base-nextjs/src/components/ui/dialog.tsx +135 -0
  83. package/templates/base-nextjs/src/components/ui/dropdown-menu.tsx +246 -0
  84. package/templates/base-nextjs/src/components/ui/field.tsx +224 -0
  85. package/templates/base-nextjs/src/components/ui/input-group.tsx +146 -0
  86. package/templates/base-nextjs/src/components/ui/input.tsx +20 -0
  87. package/templates/base-nextjs/src/components/ui/label.tsx +20 -0
  88. package/templates/base-nextjs/src/components/ui/select.tsx +189 -0
  89. package/templates/base-nextjs/src/components/ui/separator.tsx +21 -0
  90. package/templates/base-nextjs/src/components/ui/textarea.tsx +18 -0
  91. package/templates/base-nextjs/src/components/ui/theme-toggle.tsx +67 -0
  92. package/templates/base-nextjs/src/db/constants/auth.ts +8 -0
  93. package/templates/base-nextjs/src/db/constants/index.ts +44 -0
  94. package/templates/base-nextjs/src/db/types.ts +6 -0
  95. package/templates/base-nextjs/src/env.mjs +48 -0
  96. package/templates/base-nextjs/src/lib/utils.ts +6 -0
  97. package/templates/base-nextjs/src/proxy.ts +23 -0
  98. package/templates/base-nextjs/src/vexcms/access.ts +28 -0
  99. package/templates/base-nextjs/src/vexcms/auth.ts +4 -0
  100. package/templates/base-nextjs/src/vexcms/collections/index.ts +1 -0
  101. package/templates/base-nextjs/src/vexcms/collections/media.ts +15 -0
  102. package/templates/base-nextjs/src/vexcms/collections/users.ts +46 -0
  103. package/templates/base-nextjs/tsconfig.json +60 -0
  104. package/templates/base-nextjs/vex.config.ts +23 -0
  105. package/templates/marketing-site/convex/pages.ts +46 -0
  106. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +58 -0
  107. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +69 -0
  108. package/templates/marketing-site/src/app/admin/AdminLayoutWrapper.tsx +71 -0
  109. package/templates/marketing-site/src/db/constants/index.ts +52 -0
  110. package/templates/marketing-site/src/vexcms/collections/footers.ts +28 -0
  111. package/templates/marketing-site/src/vexcms/collections/headers.ts +35 -0
  112. package/templates/marketing-site/src/vexcms/collections/index.ts +7 -0
  113. package/templates/marketing-site/src/vexcms/collections/pages.ts +40 -0
  114. package/templates/marketing-site/src/vexcms/collections/site_settings.ts +31 -0
  115. package/templates/marketing-site/src/vexcms/collections/themes.ts +37 -0
  116. package/templates/marketing-site/vex.config.ts +32 -0
@@ -0,0 +1,246 @@
1
+ import type { DataModel } from "@convex/_generated/dataModel"
2
+ import type { GenericQueryCtx, TableNamesInDataModel } from "convex/server"
3
+
4
+ import { ConvexError } from "convex/values"
5
+ import { mutation, query, action } from "@convex/_generated/server"
6
+ import { paginationOptsValidator } from "convex/server"
7
+ import { v } from "convex/values"
8
+
9
+ import { findCollectionBySlug, hasPermission } from "@vexcms/core"
10
+ import { TABLE_SLUG_USERS } from "~/db/constants"
11
+ import config from "../../vex.config"
12
+ import { access } from "../../src/vexcms/access"
13
+
14
+ import * as Media from "./model/media"
15
+
16
+ async function getUser(ctx: GenericQueryCtx<DataModel>) {
17
+ const identity = await ctx.auth.getUserIdentity()
18
+ if (!identity?.email) return null
19
+
20
+ const user = await ctx.db
21
+ .query(TABLE_SLUG_USERS)
22
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
23
+ .first()
24
+
25
+ if (!user) return null
26
+
27
+ return {
28
+ user: user as Record<string, unknown>,
29
+ roles: (user.role as string[]) ?? [],
30
+ }
31
+ }
32
+
33
+ async function requireUser(ctx: GenericQueryCtx<DataModel>) {
34
+ const result = await getUser(ctx)
35
+ if (!result) {
36
+ throw new ConvexError("Not authenticated")
37
+ }
38
+ return result
39
+ }
40
+
41
+ function checkPermission(props: Parameters<typeof hasPermission>[0]) {
42
+ const result = hasPermission(props)
43
+ const denied =
44
+ result === false ||
45
+ (typeof result === "object" && !Object.values(result).some(Boolean))
46
+
47
+ if (denied) {
48
+ const grantingRoles: string[] = []
49
+ if (props.access) {
50
+ for (const role of props.access.roles) {
51
+ const check = hasPermission({ ...props, userRoles: [role] })
52
+ const allowed =
53
+ check === true ||
54
+ (typeof check === "object" && Object.values(check).some(Boolean))
55
+ if (allowed) grantingRoles.push(role)
56
+ }
57
+ }
58
+
59
+ const rolesHint =
60
+ grantingRoles.length > 0
61
+ ? ` Requires one of: ${grantingRoles.join(", ")}`
62
+ : ""
63
+ throw new ConvexError(
64
+ `Access denied: "${props.action}" on "${props.resource}".${rolesHint}`,
65
+ )
66
+ }
67
+
68
+ return result
69
+ }
70
+
71
+ export const generateUploadUrl = mutation({
72
+ args: {},
73
+ handler: async (ctx) => {
74
+ return await ctx.storage.generateUploadUrl()
75
+ },
76
+ })
77
+
78
+ export const createMediaDocument = mutation({
79
+ args: {
80
+ collectionSlug: v.string(),
81
+ fields: v.any(),
82
+ },
83
+ handler: async (ctx, { collectionSlug, fields }) => {
84
+ const match = findCollectionBySlug({ slug: collectionSlug, config })
85
+ if (!match) {
86
+ throw new ConvexError(`Collection not found: ${collectionSlug}`)
87
+ }
88
+
89
+ const { user, roles } = await requireUser(ctx)
90
+
91
+ checkPermission({
92
+ access,
93
+ user,
94
+ userRoles: roles,
95
+ resource: collectionSlug,
96
+ action: "create",
97
+ data: fields as Record<string, unknown>,
98
+ })
99
+
100
+ return await Media.createMediaDocument<DataModel>({
101
+ ctx,
102
+ args: {
103
+ collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
104
+ fields: fields as Record<string, unknown>,
105
+ collectionFields: match.fields,
106
+ },
107
+ })
108
+ },
109
+ })
110
+
111
+ export const paginatedSearchDocuments = query({
112
+ args: {
113
+ collectionSlug: v.string(),
114
+ searchIndexName: v.string(),
115
+ searchField: v.string(),
116
+ query: v.string(),
117
+ paginationOpts: paginationOptsValidator,
118
+ },
119
+ handler: async (ctx, { collectionSlug, searchIndexName, searchField, query: searchQuery, paginationOpts }) => {
120
+ const result = await Media.paginatedSearchDocuments<DataModel>({
121
+ args: {
122
+ collectionSlug: collectionSlug as TableNamesInDataModel<DataModel>,
123
+ searchIndexName,
124
+ searchField,
125
+ query: searchQuery,
126
+ paginationOpts,
127
+ },
128
+ ctx,
129
+ })
130
+
131
+ // Filter by read permission if user is authenticated
132
+ const auth = await getUser(ctx)
133
+ if (!auth) return result
134
+
135
+ const filteredPage = result.page.filter((doc: Record<string, unknown>) => {
136
+ const readAllowed = hasPermission({
137
+ access,
138
+ user: auth.user,
139
+ userRoles: auth.roles,
140
+ resource: collectionSlug,
141
+ action: "read",
142
+ data: doc,
143
+ })
144
+ return readAllowed === true
145
+ })
146
+
147
+ return { ...result, page: filteredPage }
148
+ },
149
+ })
150
+
151
+ /**
152
+ * Downloads a file from a URL and stores it in Convex storage.
153
+ * Returns storageId + file metadata for the client to create a media document.
154
+ */
155
+ export const downloadAndStoreUrl = action({
156
+ args: {
157
+ url: v.string(),
158
+ maxSize: v.number(),
159
+ },
160
+ handler: async (ctx, { url, maxSize }) => {
161
+ // 1. Validate URL
162
+ let parsedUrl: URL
163
+ try {
164
+ parsedUrl = new URL(url)
165
+ } catch {
166
+ throw new ConvexError("Invalid URL")
167
+ }
168
+
169
+ // 2. Fetch with timeout
170
+ const controller = new AbortController()
171
+ const timeoutId = setTimeout(() => controller.abort(), 30000)
172
+
173
+ let response: Response
174
+ try {
175
+ response = await fetch(parsedUrl.href, { signal: controller.signal })
176
+ } catch (err: any) {
177
+ clearTimeout(timeoutId)
178
+ if (err.name === "AbortError") {
179
+ throw new ConvexError("URL fetch timed out")
180
+ }
181
+ throw new ConvexError(`Failed to fetch URL: ${err.message}`)
182
+ } finally {
183
+ clearTimeout(timeoutId)
184
+ }
185
+
186
+ if (!response.ok) {
187
+ throw new ConvexError(`Failed to fetch URL: ${response.statusText}`)
188
+ }
189
+
190
+ // 3. Reject HTML
191
+ const contentType = response.headers.get("Content-Type") || ""
192
+ if (contentType.includes("text/html")) {
193
+ throw new ConvexError("URL points to an HTML page, not a file")
194
+ }
195
+
196
+ // 4. Read body and check size
197
+ const arrayBuffer = await response.arrayBuffer()
198
+ if (arrayBuffer.byteLength > maxSize) {
199
+ throw new ConvexError(
200
+ `File size (${arrayBuffer.byteLength} bytes) exceeds maximum allowed (${maxSize} bytes)`,
201
+ )
202
+ }
203
+
204
+ // 5. Extract filename
205
+ let filename = "download"
206
+ try {
207
+ const pathname = decodeURIComponent(parsedUrl.pathname)
208
+ const segments = pathname.split("/").filter(Boolean)
209
+ if (segments.length > 0) {
210
+ const lastSegment = segments[segments.length - 1]
211
+ // Strip query-like suffixes that might leak through
212
+ const clean = lastSegment.split("?")[0].split("#")[0]
213
+ if (clean) filename = clean
214
+ }
215
+ } catch {
216
+ // Keep default filename
217
+ }
218
+
219
+ // Check Content-Disposition for filename
220
+ const disposition = response.headers.get("Content-Disposition")
221
+ if (disposition) {
222
+ const filenameMatch = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i)
223
+ if (filenameMatch?.[1]) {
224
+ filename = decodeURIComponent(filenameMatch[1].trim())
225
+ }
226
+ }
227
+
228
+ // 6. Extract mimeType
229
+ let mimeType = "application/octet-stream"
230
+ if (contentType) {
231
+ mimeType = contentType.split(";")[0].trim()
232
+ }
233
+
234
+ // 7. Store in Convex storage
235
+ const blob = new Blob([arrayBuffer], { type: mimeType })
236
+ const storageId = await ctx.storage.store(blob)
237
+
238
+ // 8. Return metadata
239
+ return {
240
+ storageId: storageId as string,
241
+ filename,
242
+ mimeType,
243
+ size: arrayBuffer.byteLength,
244
+ }
245
+ },
246
+ })
@@ -0,0 +1,74 @@
1
+ import { mutation } from "../_generated/server";
2
+ import { v } from "convex/values";
3
+
4
+ /**
5
+ * Generic backfill mutation called by the Vex CLI during auto-migration.
6
+ * Patches existing documents that are missing a field with a default value.
7
+ *
8
+ * The CLI calls this in a loop with cursor pagination until `isDone` is true.
9
+ */
10
+ export const backfillField = mutation({
11
+ args: {
12
+ table: v.string(),
13
+ field: v.string(),
14
+ value: v.any(),
15
+ cursor: v.optional(v.string()),
16
+ batchSize: v.optional(v.number()),
17
+ },
18
+ handler: async (ctx, { table, field, value, cursor, batchSize = 100 }) => {
19
+ const results = await ctx.db
20
+ .query(table as any)
21
+ .paginate({ cursor: cursor ?? null, numItems: batchSize });
22
+
23
+ let patched = 0;
24
+ for (const doc of results.page) {
25
+ if ((doc as any)[field] === undefined) {
26
+ await ctx.db.patch(doc._id, { [field]: value } as any);
27
+ patched++;
28
+ }
29
+ }
30
+
31
+ return {
32
+ patched,
33
+ isDone: results.isDone,
34
+ cursor: results.continueCursor,
35
+ };
36
+ },
37
+ });
38
+
39
+ /**
40
+ * Generic field removal mutation called by the Vex CLI during auto-migration.
41
+ * Unsets a field from existing documents so the new schema (without the field)
42
+ * can be deployed without validation errors.
43
+ *
44
+ * The CLI calls this in a loop with cursor pagination until `isDone` is true.
45
+ */
46
+ export const removeField = mutation({
47
+ args: {
48
+ table: v.string(),
49
+ field: v.string(),
50
+ cursor: v.optional(v.string()),
51
+ batchSize: v.optional(v.number()),
52
+ },
53
+ handler: async (ctx, { table, field, cursor, batchSize = 100 }) => {
54
+ const results = await ctx.db
55
+ .query(table as any)
56
+ .paginate({ cursor: cursor ?? null, numItems: batchSize });
57
+
58
+ let patched = 0;
59
+ for (const doc of results.page) {
60
+ if ((doc as any)[field] !== undefined) {
61
+ // Replace the entire document without the removed field
62
+ const { _id, _creationTime, [field]: _removed, ...rest } = doc as any;
63
+ await ctx.db.replace(_id, rest);
64
+ patched++;
65
+ }
66
+ }
67
+
68
+ return {
69
+ patched,
70
+ isDone: results.isDone,
71
+ cursor: results.continueCursor,
72
+ };
73
+ },
74
+ });
@@ -0,0 +1,196 @@
1
+ import type {
2
+ GenericDataModel,
3
+ GenericMutationCtx,
4
+ GenericQueryCtx,
5
+ PaginationOptions,
6
+ TableNamesInDataModel,
7
+ } from "convex/server"
8
+
9
+ import { ConvexError } from "convex/values"
10
+ import { generateFormSchema, getPreviewSnapshot } from "@vexcms/core"
11
+ import type { VexField, CollectionKind } from "@vexcms/core"
12
+
13
+ async function resolveStorageUrl(
14
+ ctx: { storage: { getUrl: (id: any) => Promise<string | null> } },
15
+ doc: any,
16
+ ) {
17
+ if (doc?.storageId) {
18
+ const url = await ctx.storage.getUrl(doc.storageId)
19
+ if (url) return { ...doc, url }
20
+ }
21
+ return doc
22
+ }
23
+
24
+ export async function listDocuments<DataModel extends GenericDataModel>(props: {
25
+ args: {
26
+ collectionSlug: TableNamesInDataModel<DataModel>
27
+ paginationOpts: PaginationOptions
28
+ order?: "asc" | "desc"
29
+ }
30
+ ctx: GenericQueryCtx<DataModel>
31
+ }) {
32
+ const { args, ctx } = props
33
+ const q = args.order === "desc"
34
+ ? ctx.db.query(args.collectionSlug).order("desc")
35
+ : ctx.db.query(args.collectionSlug)
36
+ const result = await q.paginate(args.paginationOpts)
37
+ const resolvedPage = await Promise.all(
38
+ result.page.map((doc: any) => resolveStorageUrl(ctx, doc)),
39
+ )
40
+ return { ...result, page: resolvedPage }
41
+ }
42
+
43
+ export async function countDocuments<DataModel extends GenericDataModel>(props: {
44
+ ctx: GenericQueryCtx<DataModel>
45
+ args: { collectionSlug: TableNamesInDataModel<DataModel> }
46
+ }): Promise<number> {
47
+ return await (props.ctx.db.query(props.args.collectionSlug) as any).count()
48
+ }
49
+
50
+ export async function getDocument<DataModel extends GenericDataModel>(props: {
51
+ ctx: GenericQueryCtx<DataModel>
52
+ args: {
53
+ collectionSlug: TableNamesInDataModel<DataModel>
54
+ documentId: string
55
+ /** When true, merges the transient preview snapshot (from admin live preview) */
56
+ preview?: boolean
57
+ }
58
+ }) {
59
+ const doc = await props.ctx.db.get(props.args.documentId as any)
60
+ if (!doc) return null
61
+ const resolved = await resolveStorageUrl(props.ctx, doc)
62
+
63
+ // Merge preview snapshot when explicitly requested (live preview iframe).
64
+ if (props.args.preview) {
65
+ const snapshot = await getPreviewSnapshot<DataModel>({
66
+ ctx: props.ctx,
67
+ collection: props.args.collectionSlug as string,
68
+ documentId: props.args.documentId,
69
+ })
70
+ if (snapshot) {
71
+ return { ...resolved, ...snapshot }
72
+ }
73
+ }
74
+
75
+ return resolved
76
+ }
77
+
78
+ export async function updateDocument<DataModel extends GenericDataModel>(props: {
79
+ ctx: GenericMutationCtx<DataModel>
80
+ args: {
81
+ collectionSlug: TableNamesInDataModel<DataModel>
82
+ documentId: string
83
+ fields: Record<string, unknown>
84
+ collectionFields: Record<string, VexField>
85
+ }
86
+ }) {
87
+ const f = { ...props.args.fields }
88
+
89
+ // Resolve the file URL from storageId when replacing a media file
90
+ if (f.storageId && f.url === "") {
91
+ const url = await props.ctx.storage.getUrl(f.storageId as any)
92
+ if (url) f.url = url
93
+ }
94
+
95
+ const schema = generateFormSchema({
96
+ fields: props.args.collectionFields,
97
+ }).partial()
98
+
99
+ const result = schema.safeParse(f)
100
+ if (!result.success) {
101
+ throw new ConvexError({
102
+ message: "Validation failed",
103
+ errors: result.error.flatten(),
104
+ })
105
+ }
106
+
107
+ await props.ctx.db.patch(props.args.documentId as any, result.data as any)
108
+ return props.args.documentId
109
+ }
110
+
111
+ export async function createDocument<DataModel extends GenericDataModel>(props: {
112
+ ctx: GenericMutationCtx<DataModel>
113
+ args: {
114
+ collectionSlug: TableNamesInDataModel<DataModel>
115
+ fields: Record<string, unknown>
116
+ collectionFields: Record<string, VexField>
117
+ kind: CollectionKind
118
+ }
119
+ }): Promise<string> {
120
+ if (props.args.kind === "global") {
121
+ const existing = await props.ctx.db.query(props.args.collectionSlug).first()
122
+ if (existing) {
123
+ throw new ConvexError(
124
+ `Global "${props.args.collectionSlug}" already exists. Globals can only have one document.`,
125
+ )
126
+ }
127
+ }
128
+
129
+ const schema = generateFormSchema({
130
+ fields: props.args.collectionFields,
131
+ })
132
+
133
+ const result = schema.safeParse(props.args.fields)
134
+ if (!result.success) {
135
+ throw new ConvexError({
136
+ message: "Validation failed",
137
+ errors: result.error.flatten(),
138
+ })
139
+ }
140
+
141
+ const data = result.data as Record<string, unknown>
142
+ // Default vex_status to "published" for all user collections
143
+ if (!data.vex_status) {
144
+ data.vex_status = "published"
145
+ }
146
+ const id = await props.ctx.db.insert(props.args.collectionSlug as any, data as any)
147
+ return id as string
148
+ }
149
+
150
+ export async function deleteDocument<DataModel extends GenericDataModel>(props: {
151
+ ctx: GenericMutationCtx<DataModel>
152
+ args: {
153
+ collectionSlug: TableNamesInDataModel<DataModel>
154
+ documentId: string
155
+ kind: CollectionKind
156
+ }
157
+ }): Promise<void> {
158
+ if (props.args.kind === "global") {
159
+ const existing = await props.ctx.db.get(props.args.documentId as any)
160
+ if (!existing) {
161
+ throw new ConvexError(
162
+ `Global "${props.args.collectionSlug}" document not found. Cannot delete a non-existent global.`,
163
+ )
164
+ }
165
+ }
166
+
167
+ await props.ctx.db.delete(props.args.documentId as any)
168
+ }
169
+
170
+ export async function bulkDeleteDocuments<DataModel extends GenericDataModel>(props: {
171
+ ctx: GenericMutationCtx<DataModel>
172
+ args: {
173
+ documentIds: string[]
174
+ }
175
+ }): Promise<{ deleted: number }> {
176
+ for (const id of props.args.documentIds) {
177
+ await props.ctx.db.delete(id as any)
178
+ }
179
+ return { deleted: props.args.documentIds.length }
180
+ }
181
+
182
+ export async function searchDocuments<DataModel extends GenericDataModel>(props: {
183
+ args: {
184
+ collectionSlug: TableNamesInDataModel<DataModel>
185
+ searchIndexName: string
186
+ searchField: string
187
+ query: string
188
+ }
189
+ ctx: GenericQueryCtx<DataModel>
190
+ }) {
191
+ const { args, ctx } = props
192
+ const docs = await (ctx.db.query(args.collectionSlug) as any)
193
+ .withSearchIndex(args.searchIndexName, (q: any) => q.search(args.searchField, args.query))
194
+ .take(50)
195
+ return Promise.all(docs.map((doc: any) => resolveStorageUrl(ctx, doc)))
196
+ }
@@ -0,0 +1,87 @@
1
+ import type {
2
+ GenericDataModel,
3
+ GenericMutationCtx,
4
+ GenericQueryCtx,
5
+ PaginationOptions,
6
+ TableNamesInDataModel,
7
+ } from "convex/server"
8
+
9
+ import { ConvexError } from "convex/values"
10
+ import { generateFormSchema } from "@vexcms/core"
11
+ import type { VexField } from "@vexcms/core"
12
+
13
+ export async function createMediaDocument<DataModel extends GenericDataModel>(props: {
14
+ ctx: GenericMutationCtx<DataModel>
15
+ args: {
16
+ collectionSlug: TableNamesInDataModel<DataModel>
17
+ fields: Record<string, unknown>
18
+ collectionFields: Record<string, VexField>
19
+ }
20
+ }): Promise<string> {
21
+ const f = { ...props.args.fields }
22
+
23
+ // Extract system fields that bypass form schema validation
24
+ const storageId = f.storageId
25
+ delete f.storageId
26
+
27
+ // Resolve the file URL from storageId before inserting
28
+ if (storageId && (!f.url || f.url === "")) {
29
+ const url = await props.ctx.storage.getUrl(storageId as any)
30
+ if (url) f.url = url
31
+ }
32
+
33
+ const schema = generateFormSchema({
34
+ fields: props.args.collectionFields,
35
+ })
36
+
37
+ const result = schema.safeParse(f)
38
+ if (!result.success) {
39
+ throw new ConvexError({
40
+ message: "Validation failed",
41
+ errors: result.error.flatten(),
42
+ })
43
+ }
44
+
45
+ // Re-attach storageId for the DB insert
46
+ const data = { ...result.data, ...(storageId ? { storageId } : {}) } as any
47
+ const id = await props.ctx.db.insert(props.args.collectionSlug as any, data)
48
+ return id as string
49
+ }
50
+
51
+ async function resolveStorageUrl(
52
+ ctx: { storage: { getUrl: (id: any) => Promise<string | null> } },
53
+ doc: any,
54
+ ) {
55
+ if (doc?.storageId) {
56
+ const url = await ctx.storage.getUrl(doc.storageId)
57
+ if (url) return { ...doc, url }
58
+ }
59
+ return doc
60
+ }
61
+
62
+ export async function paginatedSearchDocuments<DataModel extends GenericDataModel>(props: {
63
+ args: {
64
+ collectionSlug: TableNamesInDataModel<DataModel>
65
+ searchIndexName: string
66
+ searchField: string
67
+ query: string
68
+ paginationOpts: PaginationOptions
69
+ }
70
+ ctx: GenericQueryCtx<DataModel>
71
+ }) {
72
+ const { args, ctx } = props
73
+
74
+ let result
75
+ if (args.query === "") {
76
+ result = await ctx.db.query(args.collectionSlug).paginate(args.paginationOpts)
77
+ } else {
78
+ result = await (ctx.db.query(args.collectionSlug))
79
+ .withSearchIndex(args.searchIndexName, (q) => q.search(args.searchField, args.query))
80
+ .paginate(args.paginationOpts)
81
+ }
82
+
83
+ const resolvedPage = await Promise.all(
84
+ result.page.map((doc: any) => resolveStorageUrl(ctx, doc)),
85
+ )
86
+ return { ...result, page: resolvedPage }
87
+ }