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,588 @@
1
+ import type { DataModel } from "@convex/_generated/dataModel"
2
+ import type { GenericQueryCtx, TableNamesInDataModel } from "convex/server"
3
+
4
+ import { mutation, query } from "@convex/_generated/server"
5
+ import {
6
+ hasPermission,
7
+ extractUserFields,
8
+ DEFAULT_MAX_VERSIONS_PER_DOC,
9
+ } from "@vexcms/core"
10
+ import { ConvexError, v } from "convex/values"
11
+
12
+ import { TABLE_SLUG_USERS } from "~/db/constants"
13
+
14
+ import config from "../../vex.config"
15
+ import { access } from "../../src/vexcms/access"
16
+ import * as Versions from "./model/versions"
17
+
18
+ function requireVersionedCollection(slug: string) {
19
+ const collections = [...config.collections, ...(config.media?.collections ?? [])]
20
+ const match = collections.find((c) => c.slug === slug)
21
+ if (!match) {
22
+ throw new ConvexError(`Collection not found: ${slug}`)
23
+ }
24
+ if (!match.versions?.drafts) {
25
+ throw new ConvexError(`Collection "${slug}" does not have versioning enabled`)
26
+ }
27
+ return match
28
+ }
29
+
30
+ async function getUser(ctx: GenericQueryCtx<DataModel>) {
31
+ const identity = await ctx.auth.getUserIdentity()
32
+ if (!identity?.email) return null
33
+ const user = await ctx.db
34
+ .query(TABLE_SLUG_USERS)
35
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
36
+ .first()
37
+ if (!user) return null
38
+ return {
39
+ user: user as Record<string, unknown>,
40
+ roles: (user.role as string[]) ?? [],
41
+ email: identity.email,
42
+ }
43
+ }
44
+
45
+ async function requireUser(ctx: GenericQueryCtx<DataModel>) {
46
+ const result = await getUser(ctx)
47
+ if (!result) throw new ConvexError("Not authenticated")
48
+ return result
49
+ }
50
+
51
+ function checkPermission(props: Parameters<typeof hasPermission>[0]) {
52
+ const result = hasPermission(props)
53
+ const denied =
54
+ result === false ||
55
+ (typeof result === "object" && !Object.values(result).some(Boolean))
56
+ if (denied) {
57
+ throw new ConvexError(`Access denied: "${props.action}" on "${props.resource}"`)
58
+ }
59
+ return result
60
+ }
61
+
62
+ /**
63
+ * Creates a new document in a versioned collection.
64
+ * The document starts with vex_status: "draft" and an initial version is created.
65
+ */
66
+ export const createDraftDocument = mutation({
67
+ args: {
68
+ collectionSlug: v.string(),
69
+ fields: v.any(),
70
+ },
71
+ handler: async (ctx, { collectionSlug, fields }) => {
72
+ const collection = requireVersionedCollection(collectionSlug)
73
+ const { user, roles, email } = await requireUser(ctx)
74
+
75
+ checkPermission({
76
+ access,
77
+ user,
78
+ userRoles: roles,
79
+ resource: collectionSlug,
80
+ action: "create",
81
+ data: fields as Record<string, unknown>,
82
+ })
83
+
84
+ const validatedFields = fields as Record<string, unknown>
85
+
86
+ const tableName = (collection.tableName ?? collectionSlug) as TableNamesInDataModel<DataModel>
87
+ const newDocId = await (ctx.db as any).insert(tableName, {
88
+ ...validatedFields,
89
+ vex_status: "draft",
90
+ vex_version: 1,
91
+ vex_publishedAt: undefined,
92
+ })
93
+
94
+ await Versions.createVersion<DataModel>({
95
+ ctx,
96
+ collection: collectionSlug,
97
+ documentId: newDocId as string,
98
+ snapshot: validatedFields,
99
+ status: "draft",
100
+ createdBy: email,
101
+ })
102
+
103
+ return { documentId: newDocId as string, version: 1 }
104
+ },
105
+ })
106
+
107
+ /**
108
+ * Saves a draft version without publishing.
109
+ * Creates a new version record in vex_versions.
110
+ * Does NOT update the main document's content fields.
111
+ */
112
+ export const saveDraft = mutation({
113
+ args: {
114
+ collectionSlug: v.string(),
115
+ documentId: v.string(),
116
+ fields: v.any(),
117
+ restoredFrom: v.optional(v.number()),
118
+ },
119
+ handler: async (ctx, { collectionSlug, documentId, fields, restoredFrom }) => {
120
+ const collection = requireVersionedCollection(collectionSlug)
121
+ const { user, roles, email } = await requireUser(ctx)
122
+
123
+ checkPermission({
124
+ access,
125
+ user,
126
+ userRoles: roles,
127
+ resource: collectionSlug,
128
+ action: "update",
129
+ data: fields as Record<string, unknown>,
130
+ })
131
+
132
+ const doc = await ctx.db.get(documentId as any)
133
+ if (!doc) throw new ConvexError("Document not found")
134
+
135
+ // If no versions exist yet, snapshot the current main doc as v1 published
136
+ // (preserves the original state before any edits)
137
+ const latestVersion = await Versions.getLatestVersion<DataModel>({
138
+ ctx,
139
+ collection: collectionSlug,
140
+ documentId,
141
+ })
142
+ if (!latestVersion) {
143
+ const originalSnapshot = extractUserFields({
144
+ document: doc as Record<string, unknown>,
145
+ })
146
+ await Versions.createVersion<DataModel>({
147
+ ctx,
148
+ collection: collectionSlug,
149
+ documentId,
150
+ snapshot: originalSnapshot,
151
+ status: "published",
152
+ createdBy: null,
153
+ })
154
+ }
155
+
156
+ // The frontend sends ALL field values as a complete snapshot
157
+ const snapshot = fields as Record<string, unknown>
158
+
159
+ const { version } = await Versions.createVersion<DataModel>({
160
+ ctx,
161
+ collection: collectionSlug,
162
+ documentId,
163
+ snapshot,
164
+ status: "draft",
165
+ createdBy: email,
166
+ restoredFrom,
167
+ })
168
+
169
+ await Versions.cleanupOldVersions<DataModel>({
170
+ ctx,
171
+ collection: collectionSlug,
172
+ documentId,
173
+ maxPerDoc: collection.versions?.maxPerDoc ?? DEFAULT_MAX_VERSIONS_PER_DOC,
174
+ })
175
+
176
+ return { version }
177
+ },
178
+ })
179
+
180
+ /**
181
+ * Publishes the latest draft version.
182
+ * Copies the latest version's snapshot to the main document's fields,
183
+ * sets vex_status to "published", and creates a published version record.
184
+ */
185
+ export const publish = mutation({
186
+ args: {
187
+ collectionSlug: v.string(),
188
+ documentId: v.string(),
189
+ fields: v.optional(v.any()),
190
+ },
191
+ handler: async (ctx, { collectionSlug, documentId, fields }) => {
192
+ const collection = requireVersionedCollection(collectionSlug)
193
+ const { user, roles, email } = await requireUser(ctx)
194
+
195
+ checkPermission({
196
+ access,
197
+ user,
198
+ userRoles: roles,
199
+ resource: collectionSlug,
200
+ action: "update",
201
+ })
202
+
203
+ const doc = await ctx.db.get(documentId as any)
204
+ if (!doc) throw new ConvexError("Document not found")
205
+
206
+ // Check if this document has any version history yet
207
+ const latestVersion = await Versions.getLatestVersion<DataModel>({
208
+ ctx,
209
+ collection: collectionSlug,
210
+ documentId,
211
+ })
212
+
213
+ // If no versions exist, snapshot the current main doc as v1 published
214
+ // (preserves the original state before any edits)
215
+ if (!latestVersion) {
216
+ const originalSnapshot = extractUserFields({
217
+ document: doc as Record<string, unknown>,
218
+ })
219
+ await Versions.createVersion<DataModel>({
220
+ ctx,
221
+ collection: collectionSlug,
222
+ documentId,
223
+ snapshot: originalSnapshot,
224
+ status: "published",
225
+ createdBy: null,
226
+ })
227
+ }
228
+
229
+ let snapshot: Record<string, unknown>
230
+
231
+ if (fields) {
232
+ snapshot = fields as Record<string, unknown>
233
+ } else if (latestVersion) {
234
+ snapshot = latestVersion.snapshot as Record<string, unknown>
235
+ } else {
236
+ // No fields provided and no prior versions — republish current doc as-is
237
+ snapshot = extractUserFields({
238
+ document: doc as Record<string, unknown>,
239
+ })
240
+ }
241
+
242
+ // If the latest version is already published with the same snapshot,
243
+ // don't create a duplicate — just ensure the main doc is up to date
244
+ if (
245
+ latestVersion &&
246
+ (latestVersion as any).status === "published" &&
247
+ JSON.stringify((latestVersion as any).snapshot) === JSON.stringify(snapshot)
248
+ ) {
249
+ const existingVersion = (latestVersion as any).version as number
250
+ // Ensure main doc reflects published state
251
+ if ((doc as any).vex_status !== "published") {
252
+ await (ctx.db as any).patch(documentId as any, {
253
+ vex_status: "published",
254
+ vex_publishedAt: Date.now(),
255
+ vex_version: existingVersion,
256
+ })
257
+ }
258
+ return { version: existingVersion }
259
+ }
260
+
261
+ // If the latest version is a draft with the same snapshot, promote it
262
+ // to published instead of creating a new version entry
263
+ if (
264
+ latestVersion &&
265
+ (latestVersion as any).status === "draft" &&
266
+ JSON.stringify((latestVersion as any).snapshot) === JSON.stringify(snapshot)
267
+ ) {
268
+ const existingVersion = (latestVersion as any).version as number
269
+ await (ctx.db as any).patch((latestVersion as any)._id, {
270
+ status: "published",
271
+ createdAt: Date.now(),
272
+ })
273
+ await (ctx.db as any).patch(documentId as any, {
274
+ ...snapshot,
275
+ vex_status: "published",
276
+ vex_publishedAt: Date.now(),
277
+ vex_version: existingVersion,
278
+ })
279
+ return { version: existingVersion }
280
+ }
281
+
282
+ // Create a new published version record
283
+ const { version } = await Versions.createVersion<DataModel>({
284
+ ctx,
285
+ collection: collectionSlug,
286
+ documentId,
287
+ snapshot,
288
+ status: "published",
289
+ createdBy: email,
290
+ })
291
+
292
+ // Update the main document
293
+ await (ctx.db as any).patch(documentId as any, {
294
+ ...snapshot,
295
+ vex_status: "published",
296
+ vex_publishedAt: Date.now(),
297
+ vex_version: version,
298
+ })
299
+
300
+ await Versions.cleanupOldVersions<DataModel>({
301
+ ctx,
302
+ collection: collectionSlug,
303
+ documentId,
304
+ maxPerDoc: collection.versions?.maxPerDoc ?? DEFAULT_MAX_VERSIONS_PER_DOC,
305
+ })
306
+
307
+ return { version }
308
+ },
309
+ })
310
+
311
+ /**
312
+ * Unpublishes a document by setting vex_status back to "draft".
313
+ */
314
+ export const unpublish = mutation({
315
+ args: {
316
+ collectionSlug: v.string(),
317
+ documentId: v.string(),
318
+ },
319
+ handler: async (ctx, { collectionSlug, documentId }) => {
320
+ requireVersionedCollection(collectionSlug)
321
+ const { user, roles, email } = await requireUser(ctx)
322
+
323
+ checkPermission({
324
+ access,
325
+ user,
326
+ userRoles: roles,
327
+ resource: collectionSlug,
328
+ action: "update",
329
+ })
330
+
331
+ const doc = await ctx.db.get(documentId as any)
332
+ if (!doc) throw new ConvexError("Document not found")
333
+
334
+ if ((doc as any).vex_status !== "published") {
335
+ throw new ConvexError("Document is not published")
336
+ }
337
+
338
+ // Patch the main document status back to draft
339
+ await (ctx.db as any).patch(documentId as any, {
340
+ vex_status: "draft",
341
+ })
342
+
343
+ // Patch the latest published version back to draft instead of creating a new one
344
+ const latestVersion = await Versions.getLatestVersion<DataModel>({
345
+ ctx,
346
+ collection: collectionSlug,
347
+ documentId,
348
+ })
349
+
350
+ if (latestVersion && (latestVersion as any).status === "published") {
351
+ await (ctx.db as any).patch((latestVersion as any)._id, {
352
+ status: "draft",
353
+ })
354
+ }
355
+
356
+ return { version: (latestVersion as any)?.version ?? 0 }
357
+ },
358
+ })
359
+
360
+ /**
361
+ * Autosave — coalesces into a single autosave version record.
362
+ */
363
+ export const autosave = mutation({
364
+ args: {
365
+ collectionSlug: v.string(),
366
+ documentId: v.string(),
367
+ fields: v.any(),
368
+ },
369
+ handler: async (ctx, { collectionSlug, documentId, fields }) => {
370
+ requireVersionedCollection(collectionSlug)
371
+ const { user, roles, email } = await requireUser(ctx)
372
+
373
+ checkPermission({
374
+ access,
375
+ user,
376
+ userRoles: roles,
377
+ resource: collectionSlug,
378
+ action: "update",
379
+ })
380
+
381
+ const doc = await ctx.db.get(documentId as any)
382
+ if (!doc) throw new ConvexError("Document not found")
383
+
384
+ const validatedFields = fields as Record<string, unknown>
385
+
386
+ // Get latest version to merge snapshot
387
+ const latestVersion = await Versions.getLatestVersion<DataModel>({
388
+ ctx,
389
+ collection: collectionSlug,
390
+ documentId,
391
+ })
392
+
393
+ const mergedSnapshot = {
394
+ ...((latestVersion?.snapshot as Record<string, unknown>) ?? {}),
395
+ ...validatedFields,
396
+ }
397
+
398
+ const { version } = await Versions.coalesceAutosave<DataModel>({
399
+ ctx,
400
+ collection: collectionSlug,
401
+ documentId,
402
+ snapshot: mergedSnapshot,
403
+ createdBy: email,
404
+ })
405
+
406
+ return { version }
407
+ },
408
+ })
409
+
410
+ /**
411
+ * Gets a version's snapshot for restoring into the edit form.
412
+ * Does NOT create a new version — the user must explicitly save
413
+ * after reviewing the restored values in the form.
414
+ */
415
+ export const getVersionSnapshot = query({
416
+ args: {
417
+ collectionSlug: v.string(),
418
+ documentId: v.string(),
419
+ version: v.number(),
420
+ },
421
+ handler: async (ctx, { collectionSlug, documentId, version: versionNum }) => {
422
+ requireVersionedCollection(collectionSlug)
423
+
424
+ const targetVersion = await Versions.getVersion<DataModel>({
425
+ ctx,
426
+ collection: collectionSlug,
427
+ documentId,
428
+ version: versionNum,
429
+ })
430
+ if (!targetVersion) throw new ConvexError("Version not found")
431
+
432
+ return {
433
+ version: targetVersion.version as number,
434
+ snapshot: targetVersion.snapshot as Record<string, unknown>,
435
+ }
436
+ },
437
+ })
438
+
439
+ /**
440
+ * Permanently deletes a specific version record.
441
+ */
442
+ export const deleteVersion = mutation({
443
+ args: {
444
+ collectionSlug: v.string(),
445
+ documentId: v.string(),
446
+ version: v.number(),
447
+ },
448
+ handler: async (ctx, { collectionSlug, documentId, version: versionNum }) => {
449
+ requireVersionedCollection(collectionSlug)
450
+ const { user, roles } = await requireUser(ctx)
451
+
452
+ checkPermission({
453
+ access,
454
+ user,
455
+ userRoles: roles,
456
+ resource: collectionSlug,
457
+ action: "update",
458
+ })
459
+
460
+ const targetVersion = await Versions.getVersion<DataModel>({
461
+ ctx,
462
+ collection: collectionSlug,
463
+ documentId,
464
+ version: versionNum,
465
+ })
466
+ if (!targetVersion) throw new ConvexError("Version not found")
467
+
468
+ await (ctx.db as any).delete(targetVersion._id)
469
+
470
+ return { deleted: versionNum }
471
+ },
472
+ })
473
+
474
+ /**
475
+ * Lists version history for a document.
476
+ */
477
+ export const listVersions = query({
478
+ args: {
479
+ collectionSlug: v.string(),
480
+ documentId: v.string(),
481
+ limit: v.optional(v.number()),
482
+ },
483
+ handler: async (ctx, { collectionSlug, documentId, limit }) => {
484
+ requireVersionedCollection(collectionSlug)
485
+
486
+ return await Versions.listVersions<DataModel>({
487
+ ctx,
488
+ collection: collectionSlug,
489
+ documentId,
490
+ limit,
491
+ })
492
+ },
493
+ })
494
+
495
+ /**
496
+ * Gets the document content for the admin edit view.
497
+ * Returns the latest version's snapshot if versions exist,
498
+ * otherwise falls back to the main document's fields.
499
+ */
500
+ export const getDocumentForEdit = query({
501
+ args: {
502
+ collectionSlug: v.string(),
503
+ documentId: v.string(),
504
+ },
505
+ handler: async (ctx, { collectionSlug, documentId }) => {
506
+ requireVersionedCollection(collectionSlug)
507
+
508
+ const mainDoc = await ctx.db.get(documentId as any)
509
+ if (!mainDoc) return null
510
+
511
+ const latestVersion = await Versions.getLatestVersion<DataModel>({
512
+ ctx,
513
+ collection: collectionSlug,
514
+ documentId,
515
+ })
516
+
517
+ if (latestVersion) {
518
+ return {
519
+ _id: mainDoc._id,
520
+ ...(latestVersion.snapshot as Record<string, unknown>),
521
+ // Use the latest version's status, not the main doc's — the main doc
522
+ // only updates on publish, but the latest version may be a draft
523
+ vex_status: (latestVersion as any).status ?? (mainDoc as any).vex_status ?? "draft",
524
+ vex_version: latestVersion.version,
525
+ vex_publishedAt: (mainDoc as any).vex_publishedAt,
526
+ _creationTime: mainDoc._creationTime,
527
+ }
528
+ }
529
+
530
+ // Fallback: return main doc as-is (e.g., versioning just enabled on existing collection)
531
+ // Pre-existing documents without vex_status are treated as published
532
+ const doc = mainDoc as Record<string, unknown>
533
+ if (!doc.vex_status) {
534
+ doc.vex_status = "published"
535
+ }
536
+ return doc
537
+ },
538
+ })
539
+
540
+ /**
541
+ * Backfills vex_status on existing documents in a versioned collection
542
+ * that don't have the field yet (e.g. versioning was just enabled on
543
+ * a collection with existing data, or VEX was added to an existing project).
544
+ *
545
+ * Only sets vex_status to "published" — does NOT create version records.
546
+ * Version records are created lazily on first saveDraft or publish, which
547
+ * avoids doubling the collection data in one go.
548
+ *
549
+ * Called by the VEX CLI in a paginated loop (cursor-based) so it can
550
+ * handle production tables with thousands of documents without hitting
551
+ * Convex mutation limits.
552
+ *
553
+ * Safe to run repeatedly — skips documents that already have vex_status.
554
+ */
555
+ export const backfillVersionStatus = mutation({
556
+ args: {
557
+ collectionSlug: v.string(),
558
+ cursor: v.optional(v.string()),
559
+ batchSize: v.optional(v.number()),
560
+ },
561
+ handler: async (ctx, { collectionSlug, cursor, batchSize = 100 }) => {
562
+ const collection = requireVersionedCollection(collectionSlug)
563
+
564
+ // No auth check — this is a system migration called by the VEX CLI,
565
+ // not a user-facing action. The ConvexHttpClient has no auth session.
566
+
567
+ const tableName = (collection.tableName ?? collectionSlug) as TableNamesInDataModel<DataModel>
568
+ const results = await (ctx.db as any)
569
+ .query(tableName)
570
+ .paginate({ cursor: cursor ?? null, numItems: batchSize })
571
+
572
+ let patched = 0
573
+ for (const doc of results.page) {
574
+ if (!doc.vex_status) {
575
+ await (ctx.db as any).patch(doc._id, {
576
+ vex_status: "published",
577
+ })
578
+ patched++
579
+ }
580
+ }
581
+
582
+ return {
583
+ patched,
584
+ isDone: results.isDone,
585
+ cursor: results.continueCursor,
586
+ }
587
+ },
588
+ })