convex-cms 0.0.1 → 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 (267) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +99 -0
  3. package/admin-dist/nitro.json +15 -0
  4. package/admin-dist/public/assets/CmsEmptyState-CRswfTzk.js +5 -0
  5. package/admin-dist/public/assets/CmsPageHeader-CirpXndm.js +1 -0
  6. package/admin-dist/public/assets/CmsStatusBadge-CbEUpQu-.js +1 -0
  7. package/admin-dist/public/assets/CmsToolbar-BI2nZOXp.js +1 -0
  8. package/admin-dist/public/assets/ContentEntryEditor-CBeCyK_m.js +4 -0
  9. package/admin-dist/public/assets/ErrorState-BIVaWmom.js +1 -0
  10. package/admin-dist/public/assets/TaxonomyFilter-ChaY6Y_x.js +1 -0
  11. package/admin-dist/public/assets/_contentTypeId-DQ8k_Rvw.js +1 -0
  12. package/admin-dist/public/assets/_entryId-CKU_glsK.js +1 -0
  13. package/admin-dist/public/assets/alert-BXjTqrwQ.js +1 -0
  14. package/admin-dist/public/assets/badge-hvUOzpVZ.js +1 -0
  15. package/admin-dist/public/assets/circle-check-big-CF_pR17r.js +1 -0
  16. package/admin-dist/public/assets/command-DU82cJlt.js +1 -0
  17. package/admin-dist/public/assets/content-_LXl3pp7.js +1 -0
  18. package/admin-dist/public/assets/content-types-KjxaXGxY.js +2 -0
  19. package/admin-dist/public/assets/globals-CS6BZ0zp.css +1 -0
  20. package/admin-dist/public/assets/index-DNGIZHL-.js +1 -0
  21. package/admin-dist/public/assets/label-KNtpL71g.js +1 -0
  22. package/admin-dist/public/assets/link-2-Bw2aI4V4.js +1 -0
  23. package/admin-dist/public/assets/list-sYepHjt_.js +1 -0
  24. package/admin-dist/public/assets/main-CKj5yfEi.js +97 -0
  25. package/admin-dist/public/assets/media-Bkrkffm7.js +1 -0
  26. package/admin-dist/public/assets/new._contentTypeId-C3LstjNs.js +1 -0
  27. package/admin-dist/public/assets/plus-DUn8v_Xf.js +1 -0
  28. package/admin-dist/public/assets/rotate-ccw-DJEoHcRI.js +1 -0
  29. package/admin-dist/public/assets/scroll-area-DfIlT0in.js +1 -0
  30. package/admin-dist/public/assets/search-MuAUDJKR.js +1 -0
  31. package/admin-dist/public/assets/select-BD29IXCI.js +1 -0
  32. package/admin-dist/public/assets/settings-DmMyn_6A.js +1 -0
  33. package/admin-dist/public/assets/switch-h3Rrnl5i.js +1 -0
  34. package/admin-dist/public/assets/tabs-imc8h-Dp.js +1 -0
  35. package/admin-dist/public/assets/taxonomies-dAsrT65H.js +1 -0
  36. package/admin-dist/public/assets/textarea-BTy7nwzR.js +1 -0
  37. package/admin-dist/public/assets/trash-SAWKZZHv.js +1 -0
  38. package/admin-dist/public/assets/triangle-alert-E52Vfeuh.js +1 -0
  39. package/admin-dist/public/assets/useBreadcrumbLabel-BECBMCzM.js +1 -0
  40. package/admin-dist/public/assets/usePermissions-Basjs9BT.js +1 -0
  41. package/admin-dist/public/favicon.ico +0 -0
  42. package/admin-dist/server/_chunks/_libs/@date-fns/tz.mjs +217 -0
  43. package/admin-dist/server/_chunks/_libs/@floating-ui/core.mjs +719 -0
  44. package/admin-dist/server/_chunks/_libs/@floating-ui/dom.mjs +622 -0
  45. package/admin-dist/server/_chunks/_libs/@floating-ui/react-dom.mjs +292 -0
  46. package/admin-dist/server/_chunks/_libs/@floating-ui/utils.mjs +320 -0
  47. package/admin-dist/server/_chunks/_libs/@radix-ui/number.mjs +6 -0
  48. package/admin-dist/server/_chunks/_libs/@radix-ui/primitive.mjs +11 -0
  49. package/admin-dist/server/_chunks/_libs/@radix-ui/react-arrow.mjs +23 -0
  50. package/admin-dist/server/_chunks/_libs/@radix-ui/react-avatar.mjs +119 -0
  51. package/admin-dist/server/_chunks/_libs/@radix-ui/react-checkbox.mjs +270 -0
  52. package/admin-dist/server/_chunks/_libs/@radix-ui/react-collection.mjs +69 -0
  53. package/admin-dist/server/_chunks/_libs/@radix-ui/react-compose-refs.mjs +39 -0
  54. package/admin-dist/server/_chunks/_libs/@radix-ui/react-context.mjs +137 -0
  55. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dialog.mjs +325 -0
  56. package/admin-dist/server/_chunks/_libs/@radix-ui/react-direction.mjs +9 -0
  57. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dismissable-layer.mjs +210 -0
  58. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dropdown-menu.mjs +253 -0
  59. package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-guards.mjs +29 -0
  60. package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-scope.mjs +206 -0
  61. package/admin-dist/server/_chunks/_libs/@radix-ui/react-id.mjs +14 -0
  62. package/admin-dist/server/_chunks/_libs/@radix-ui/react-label.mjs +23 -0
  63. package/admin-dist/server/_chunks/_libs/@radix-ui/react-menu.mjs +812 -0
  64. package/admin-dist/server/_chunks/_libs/@radix-ui/react-popover.mjs +300 -0
  65. package/admin-dist/server/_chunks/_libs/@radix-ui/react-popper.mjs +286 -0
  66. package/admin-dist/server/_chunks/_libs/@radix-ui/react-portal.mjs +16 -0
  67. package/admin-dist/server/_chunks/_libs/@radix-ui/react-presence.mjs +128 -0
  68. package/admin-dist/server/_chunks/_libs/@radix-ui/react-primitive.mjs +141 -0
  69. package/admin-dist/server/_chunks/_libs/@radix-ui/react-roving-focus.mjs +224 -0
  70. package/admin-dist/server/_chunks/_libs/@radix-ui/react-scroll-area.mjs +721 -0
  71. package/admin-dist/server/_chunks/_libs/@radix-ui/react-select.mjs +1163 -0
  72. package/admin-dist/server/_chunks/_libs/@radix-ui/react-separator.mjs +28 -0
  73. package/admin-dist/server/_chunks/_libs/@radix-ui/react-slot.mjs +601 -0
  74. package/admin-dist/server/_chunks/_libs/@radix-ui/react-switch.mjs +152 -0
  75. package/admin-dist/server/_chunks/_libs/@radix-ui/react-tabs.mjs +189 -0
  76. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-callback-ref.mjs +11 -0
  77. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-controllable-state.mjs +69 -0
  78. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-effect-event.mjs +1 -0
  79. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-escape-keydown.mjs +17 -0
  80. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-is-hydrated.mjs +15 -0
  81. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-layout-effect.mjs +6 -0
  82. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-previous.mjs +14 -0
  83. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-size.mjs +39 -0
  84. package/admin-dist/server/_chunks/_libs/@radix-ui/react-visually-hidden.mjs +33 -0
  85. package/admin-dist/server/_chunks/_libs/@tanstack/history.mjs +409 -0
  86. package/admin-dist/server/_chunks/_libs/@tanstack/react-router.mjs +1711 -0
  87. package/admin-dist/server/_chunks/_libs/@tanstack/react-store.mjs +56 -0
  88. package/admin-dist/server/_chunks/_libs/@tanstack/router-core.mjs +4829 -0
  89. package/admin-dist/server/_chunks/_libs/@tanstack/store.mjs +134 -0
  90. package/admin-dist/server/_chunks/_libs/react-dom.mjs +10781 -0
  91. package/admin-dist/server/_chunks/_libs/react.mjs +513 -0
  92. package/admin-dist/server/_libs/aria-hidden.mjs +122 -0
  93. package/admin-dist/server/_libs/class-variance-authority.mjs +44 -0
  94. package/admin-dist/server/_libs/clsx.mjs +16 -0
  95. package/admin-dist/server/_libs/cmdk.mjs +315 -0
  96. package/admin-dist/server/_libs/convex.mjs +4841 -0
  97. package/admin-dist/server/_libs/cookie-es.mjs +58 -0
  98. package/admin-dist/server/_libs/croner.mjs +1 -0
  99. package/admin-dist/server/_libs/crossws.mjs +1 -0
  100. package/admin-dist/server/_libs/date-fns.mjs +1716 -0
  101. package/admin-dist/server/_libs/detect-node-es.mjs +1 -0
  102. package/admin-dist/server/_libs/get-nonce.mjs +9 -0
  103. package/admin-dist/server/_libs/h3-v2.mjs +277 -0
  104. package/admin-dist/server/_libs/h3.mjs +401 -0
  105. package/admin-dist/server/_libs/hookable.mjs +1 -0
  106. package/admin-dist/server/_libs/isbot.mjs +20 -0
  107. package/admin-dist/server/_libs/lucide-react.mjs +850 -0
  108. package/admin-dist/server/_libs/ohash.mjs +1 -0
  109. package/admin-dist/server/_libs/react-day-picker.mjs +2201 -0
  110. package/admin-dist/server/_libs/react-remove-scroll-bar.mjs +82 -0
  111. package/admin-dist/server/_libs/react-remove-scroll.mjs +328 -0
  112. package/admin-dist/server/_libs/react-style-singleton.mjs +69 -0
  113. package/admin-dist/server/_libs/rou3.mjs +8 -0
  114. package/admin-dist/server/_libs/seroval-plugins.mjs +58 -0
  115. package/admin-dist/server/_libs/seroval.mjs +1765 -0
  116. package/admin-dist/server/_libs/srvx.mjs +719 -0
  117. package/admin-dist/server/_libs/tailwind-merge.mjs +3010 -0
  118. package/admin-dist/server/_libs/tiny-invariant.mjs +12 -0
  119. package/admin-dist/server/_libs/tiny-warning.mjs +5 -0
  120. package/admin-dist/server/_libs/tslib.mjs +39 -0
  121. package/admin-dist/server/_libs/ufo.mjs +54 -0
  122. package/admin-dist/server/_libs/unctx.mjs +1 -0
  123. package/admin-dist/server/_libs/unstorage.mjs +1 -0
  124. package/admin-dist/server/_libs/use-callback-ref.mjs +66 -0
  125. package/admin-dist/server/_libs/use-sidecar.mjs +106 -0
  126. package/admin-dist/server/_libs/use-sync-external-store.mjs +139 -0
  127. package/admin-dist/server/_libs/zod.mjs +4223 -0
  128. package/admin-dist/server/_ssr/CmsEmptyState-DU7-7-mV.mjs +290 -0
  129. package/admin-dist/server/_ssr/CmsPageHeader-CseW0AHm.mjs +24 -0
  130. package/admin-dist/server/_ssr/CmsStatusBadge-B_pi4KCp.mjs +127 -0
  131. package/admin-dist/server/_ssr/CmsToolbar-X75ex6ek.mjs +49 -0
  132. package/admin-dist/server/_ssr/ContentEntryEditor-CepusRsA.mjs +3720 -0
  133. package/admin-dist/server/_ssr/ErrorState-cI-bKLez.mjs +89 -0
  134. package/admin-dist/server/_ssr/TaxonomyFilter-Bwrq0-cz.mjs +188 -0
  135. package/admin-dist/server/_ssr/_contentTypeId-BqYKEcLr.mjs +379 -0
  136. package/admin-dist/server/_ssr/_entryId-CRfnqeDf.mjs +161 -0
  137. package/admin-dist/server/_ssr/_tanstack-start-manifest_v-BwDlABVk.mjs +4 -0
  138. package/admin-dist/server/_ssr/alert-CVt45UUP.mjs +92 -0
  139. package/admin-dist/server/_ssr/badge-6BsP37vG.mjs +125 -0
  140. package/admin-dist/server/_ssr/command-fy8epIKf.mjs +128 -0
  141. package/admin-dist/server/_ssr/config.server-D7JHDcDv.mjs +117 -0
  142. package/admin-dist/server/_ssr/content-B5RhL7uW.mjs +532 -0
  143. package/admin-dist/server/_ssr/content-types-BIOqCQYN.mjs +1166 -0
  144. package/admin-dist/server/_ssr/index-DHSHDPt1.mjs +193 -0
  145. package/admin-dist/server/_ssr/index.mjs +1275 -0
  146. package/admin-dist/server/_ssr/label-C8Dko1j7.mjs +22 -0
  147. package/admin-dist/server/_ssr/media-CSx3XttC.mjs +1832 -0
  148. package/admin-dist/server/_ssr/new._contentTypeId-DzanEZQM.mjs +144 -0
  149. package/admin-dist/server/_ssr/router-DDWcF-kt.mjs +1556 -0
  150. package/admin-dist/server/_ssr/scroll-area-bjPYwhXN.mjs +59 -0
  151. package/admin-dist/server/_ssr/select-BUhDDf4T.mjs +142 -0
  152. package/admin-dist/server/_ssr/settings-DAsxnw2q.mjs +348 -0
  153. package/admin-dist/server/_ssr/start-HYkvq4Ni.mjs +4 -0
  154. package/admin-dist/server/_ssr/switch-BgyRtQ1Z.mjs +31 -0
  155. package/admin-dist/server/_ssr/tabs-DzMdRB1A.mjs +628 -0
  156. package/admin-dist/server/_ssr/taxonomies-C8j8g5Q5.mjs +915 -0
  157. package/admin-dist/server/_ssr/textarea-9jNeYJSc.mjs +18 -0
  158. package/admin-dist/server/_ssr/trash-DYMxwhZB.mjs +291 -0
  159. package/admin-dist/server/_ssr/useBreadcrumbLabel-FNSAr2Ha.mjs +16 -0
  160. package/admin-dist/server/_ssr/usePermissions-BJGGahrJ.mjs +68 -0
  161. package/admin-dist/server/favicon.ico +0 -0
  162. package/admin-dist/server/index.mjs +627 -0
  163. package/dist/cli/index.js +0 -0
  164. package/dist/client/admin-config.d.ts +0 -1
  165. package/dist/client/admin-config.d.ts.map +1 -1
  166. package/dist/client/admin-config.js +0 -1
  167. package/dist/client/admin-config.js.map +1 -1
  168. package/dist/client/adminApi.d.ts.map +1 -1
  169. package/dist/client/agentTools.d.ts +1237 -135
  170. package/dist/client/agentTools.d.ts.map +1 -1
  171. package/dist/client/agentTools.js +33 -9
  172. package/dist/client/agentTools.js.map +1 -1
  173. package/dist/client/index.d.ts +1 -1
  174. package/dist/client/index.d.ts.map +1 -1
  175. package/dist/client/index.js.map +1 -1
  176. package/dist/component/_generated/component.d.ts +9 -0
  177. package/dist/component/_generated/component.d.ts.map +1 -1
  178. package/dist/component/mediaAssets.d.ts +35 -0
  179. package/dist/component/mediaAssets.d.ts.map +1 -1
  180. package/dist/component/mediaAssets.js +81 -0
  181. package/dist/component/mediaAssets.js.map +1 -1
  182. package/dist/test.d.ts.map +1 -1
  183. package/dist/test.js +2 -1
  184. package/dist/test.js.map +1 -1
  185. package/package.json +24 -9
  186. package/dist/component/auditLog.d.ts +0 -410
  187. package/dist/component/auditLog.d.ts.map +0 -1
  188. package/dist/component/auditLog.js +0 -607
  189. package/dist/component/auditLog.js.map +0 -1
  190. package/dist/component/types.d.ts +0 -4
  191. package/dist/component/types.d.ts.map +0 -1
  192. package/dist/component/types.js +0 -2
  193. package/dist/component/types.js.map +0 -1
  194. package/src/cli/commands/admin.ts +0 -104
  195. package/src/cli/index.ts +0 -21
  196. package/src/cli/utils/detectConvexUrl.ts +0 -54
  197. package/src/cli/utils/openBrowser.ts +0 -16
  198. package/src/client/admin-config.ts +0 -138
  199. package/src/client/adminApi.ts +0 -942
  200. package/src/client/agentTools.ts +0 -1311
  201. package/src/client/argTypes.ts +0 -316
  202. package/src/client/field-types.ts +0 -187
  203. package/src/client/index.ts +0 -1301
  204. package/src/client/queryBuilder.ts +0 -1100
  205. package/src/client/schema/codegen.ts +0 -500
  206. package/src/client/schema/defineContentType.ts +0 -501
  207. package/src/client/schema/index.ts +0 -169
  208. package/src/client/schema/schemaDrift.ts +0 -574
  209. package/src/client/schema/typedClient.ts +0 -688
  210. package/src/client/schema/types.ts +0 -666
  211. package/src/client/types.ts +0 -723
  212. package/src/client/workflows.ts +0 -141
  213. package/src/client/wrapper.ts +0 -4304
  214. package/src/component/_generated/api.ts +0 -140
  215. package/src/component/_generated/component.ts +0 -5029
  216. package/src/component/_generated/dataModel.ts +0 -60
  217. package/src/component/_generated/server.ts +0 -156
  218. package/src/component/authorization.ts +0 -647
  219. package/src/component/authorizationHooks.ts +0 -668
  220. package/src/component/bulkOperations.ts +0 -687
  221. package/src/component/contentEntries.ts +0 -1976
  222. package/src/component/contentEntryMutations.ts +0 -1223
  223. package/src/component/contentEntryValidation.ts +0 -707
  224. package/src/component/contentLock.ts +0 -550
  225. package/src/component/contentTypeMigration.ts +0 -1064
  226. package/src/component/contentTypeMutations.ts +0 -969
  227. package/src/component/contentTypes.ts +0 -346
  228. package/src/component/convex.config.ts +0 -44
  229. package/src/component/documentTypes.ts +0 -240
  230. package/src/component/eventEmitter.ts +0 -485
  231. package/src/component/exportImport.ts +0 -1169
  232. package/src/component/index.ts +0 -491
  233. package/src/component/lib/deepReferenceResolver.ts +0 -999
  234. package/src/component/lib/errors.ts +0 -816
  235. package/src/component/lib/index.ts +0 -145
  236. package/src/component/lib/mediaReferenceResolver.ts +0 -495
  237. package/src/component/lib/metadataExtractor.ts +0 -792
  238. package/src/component/lib/mutationAuth.ts +0 -199
  239. package/src/component/lib/queries.ts +0 -79
  240. package/src/component/lib/ragContentChunker.ts +0 -1371
  241. package/src/component/lib/referenceResolver.ts +0 -430
  242. package/src/component/lib/slugGenerator.ts +0 -262
  243. package/src/component/lib/slugUniqueness.ts +0 -333
  244. package/src/component/lib/softDelete.ts +0 -44
  245. package/src/component/localeFallbackChain.ts +0 -673
  246. package/src/component/localeFields.ts +0 -896
  247. package/src/component/mediaAssetMutations.ts +0 -725
  248. package/src/component/mediaAssets.ts +0 -932
  249. package/src/component/mediaFolderMutations.ts +0 -1046
  250. package/src/component/mediaUploadMutations.ts +0 -224
  251. package/src/component/mediaVariantMutations.ts +0 -900
  252. package/src/component/mediaVariants.ts +0 -793
  253. package/src/component/ragContentIndexer.ts +0 -1067
  254. package/src/component/rateLimitHooks.ts +0 -572
  255. package/src/component/roles.ts +0 -1360
  256. package/src/component/scheduledPublish.ts +0 -358
  257. package/src/component/schema.ts +0 -617
  258. package/src/component/taxonomies.ts +0 -949
  259. package/src/component/taxonomyMutations.ts +0 -1210
  260. package/src/component/trash.ts +0 -724
  261. package/src/component/userContext.ts +0 -898
  262. package/src/component/validation.ts +0 -1388
  263. package/src/component/validators.ts +0 -949
  264. package/src/component/versionMutations.ts +0 -392
  265. package/src/component/webhookTrigger.ts +0 -1922
  266. package/src/react/index.ts +0 -898
  267. package/src/test.ts +0 -1580
@@ -1,1169 +0,0 @@
1
- /**
2
- * Content Export/Import Functions
3
- *
4
- * Provides functions to export content entries to JSON format and import from JSON.
5
- * Supports selective export by type or filter, handles reference resolution, and
6
- * validates imports against schemas.
7
- *
8
- * ## Export Features
9
- * - Export all entries or filter by content type
10
- * - Optionally resolve references to include related content
11
- * - Support for status filtering (export only published, etc.)
12
- * - Include content type definitions for schema validation during import
13
- *
14
- * ## Import Features
15
- * - Validate all entries against content type schemas before import
16
- * - Handle reference ID mapping (old IDs to new IDs)
17
- * - Support for skip, update, or error on duplicate slugs
18
- * - Dry-run mode to validate without making changes
19
- *
20
- * @example
21
- * ```typescript
22
- * // Export all published blog posts
23
- * const exportData = await ctx.runQuery(api.exportImport.exportEntries, {
24
- * contentTypeName: "blog_post",
25
- * status: "published",
26
- * includeReferences: true,
27
- * });
28
- *
29
- * // Import entries with conflict resolution
30
- * const result = await ctx.runMutation(api.exportImport.importEntries, {
31
- * data: exportData,
32
- * onConflict: "skip",
33
- * importedBy: currentUserId,
34
- * });
35
- * ```
36
- */
37
-
38
- import { v } from "convex/values";
39
- import { isDeleted } from "./lib/softDelete.js";
40
- import { query, mutation } from "./_generated/server.js";
41
- import { Id, Doc } from "./_generated/dataModel.js";
42
- import {
43
- contentStatusValidator,
44
- // contentEntryDoc,
45
- // contentTypeDoc,
46
- fieldTypeValidator,
47
- } from "./validators.js";
48
- import {
49
- validateContentData,
50
- ContentTypeSchema,
51
- FieldDefinition,
52
- } from "./validation.js";
53
- import { ensureUniqueSlug } from "./lib/slugUniqueness.js";
54
-
55
- // =============================================================================
56
- // Export Types and Validators
57
- // =============================================================================
58
-
59
- /**
60
- * Field options validator for exported content types.
61
- * This is a specialized version that uses string for taxonomyId instead of
62
- * Id<"taxonomies"> since IDs are not portable across Convex deployments
63
- * during export/import operations.
64
- */
65
- const exportedFieldOptionsValidator = v.optional(
66
- v.object({
67
- // Text fields
68
- minLength: v.optional(v.number()),
69
- maxLength: v.optional(v.number()),
70
- pattern: v.optional(v.string()),
71
-
72
- // Number fields
73
- min: v.optional(v.number()),
74
- max: v.optional(v.number()),
75
- step: v.optional(v.number()),
76
- precision: v.optional(v.number()),
77
-
78
- // Reference fields
79
- allowedContentTypes: v.optional(v.array(v.string())),
80
- multiple: v.optional(v.boolean()),
81
- minItems: v.optional(v.number()),
82
-
83
- // Media fields
84
- allowedMimeTypes: v.optional(v.array(v.string())),
85
- maxFileSize: v.optional(v.number()),
86
-
87
- // Select fields
88
- options: v.optional(
89
- v.array(
90
- v.object({
91
- value: v.string(),
92
- label: v.string(),
93
- }),
94
- ),
95
- ),
96
-
97
- // Rich text fields
98
- allowedBlocks: v.optional(v.array(v.string())),
99
- allowedMarks: v.optional(v.array(v.string())),
100
-
101
- // Tag fields - taxonomyId as string for portability
102
- taxonomyId: v.optional(v.string()),
103
- allowCreate: v.optional(v.boolean()),
104
- maxTags: v.optional(v.number()),
105
- minTags: v.optional(v.number()),
106
-
107
- // Category fields
108
- allowMultiple: v.optional(v.boolean()),
109
- }),
110
- );
111
-
112
- /**
113
- * Field definition validator for exported content types.
114
- * Reuses fieldTypeValidator from schema but uses exportedFieldOptionsValidator
115
- * which has string taxonomyId for portability across deployments.
116
- */
117
- const exportedFieldDefinitionValidator = v.object({
118
- name: v.string(),
119
- label: v.string(),
120
- type: fieldTypeValidator,
121
- required: v.boolean(),
122
- searchable: v.optional(v.boolean()),
123
- localized: v.optional(v.boolean()),
124
- description: v.optional(v.string()),
125
- defaultValue: v.optional(v.any()),
126
- options: exportedFieldOptionsValidator,
127
- });
128
-
129
- /**
130
- * Structure for a single exported content entry.
131
- * Includes all data needed to recreate the entry on import.
132
- */
133
- export const exportedEntryValidator = v.object({
134
- /** Original entry ID (for reference mapping) */
135
- _originalId: v.string(),
136
- /** Content type name (machine-readable) */
137
- contentTypeName: v.string(),
138
- /** URL-friendly slug */
139
- slug: v.string(),
140
- /** Entry status at time of export */
141
- status: contentStatusValidator,
142
- /** Content data */
143
- data: v.any(),
144
- /** Locale code if localized */
145
- locale: v.optional(v.string()),
146
- /** Version number at time of export */
147
- version: v.number(),
148
- /** First published timestamp */
149
- firstPublishedAt: v.optional(v.number()),
150
- /** Last published timestamp */
151
- lastPublishedAt: v.optional(v.number()),
152
- /** Scheduled publish timestamp */
153
- scheduledPublishAt: v.optional(v.number()),
154
- /** User who created the entry */
155
- createdBy: v.optional(v.string()),
156
- /** Original creation timestamp */
157
- createdAt: v.number(),
158
- });
159
-
160
- export type ExportedEntry = {
161
- _originalId: string;
162
- contentTypeName: string;
163
- slug: string;
164
- /** Content status (supports custom workflow states) */
165
- status: string;
166
- data: Record<string, unknown>;
167
- locale?: string;
168
- version: number;
169
- firstPublishedAt?: number;
170
- lastPublishedAt?: number;
171
- scheduledPublishAt?: number;
172
- createdBy?: string;
173
- createdAt: number;
174
- };
175
-
176
- /**
177
- * Structure for an exported content type definition.
178
- * Allows importing schemas along with content.
179
- * Uses exportedFieldDefinitionValidator which has string taxonomyId
180
- * for portability across Convex deployments.
181
- */
182
- export const exportedContentTypeValidator = v.object({
183
- /** Content type name (machine-readable) */
184
- name: v.string(),
185
- /** Display name */
186
- displayName: v.string(),
187
- /** Description */
188
- description: v.optional(v.string()),
189
- /** Field definitions with portable types */
190
- fields: v.array(exportedFieldDefinitionValidator),
191
- /** Icon identifier */
192
- icon: v.optional(v.string()),
193
- /** Whether this is a singleton type */
194
- singleton: v.optional(v.boolean()),
195
- /** Field to generate slugs from */
196
- slugField: v.optional(v.string()),
197
- /** Field to use for display titles */
198
- titleField: v.optional(v.string()),
199
- });
200
-
201
- export type ExportedContentType = {
202
- name: string;
203
- displayName: string;
204
- description?: string;
205
- fields: FieldDefinition[];
206
- icon?: string;
207
- singleton?: boolean;
208
- slugField?: string;
209
- titleField?: string;
210
- };
211
-
212
- /**
213
- * Complete export package structure.
214
- * Contains all information needed to import content into another instance.
215
- */
216
- export const exportPackageValidator = v.object({
217
- /** Export format version for compatibility checking */
218
- version: v.literal("1.0"),
219
- /** Timestamp when export was created */
220
- exportedAt: v.number(),
221
- /** Content type definitions (optional, for schema validation) */
222
- contentTypes: v.optional(v.array(exportedContentTypeValidator)),
223
- /** Exported entries */
224
- entries: v.array(exportedEntryValidator),
225
- /** Metadata about the export */
226
- metadata: v.optional(
227
- v.object({
228
- /** Source system identifier */
229
- source: v.optional(v.string()),
230
- /** Export description */
231
- description: v.optional(v.string()),
232
- /** Total count of entries */
233
- totalEntries: v.number(),
234
- /** Breakdown by content type */
235
- entriesByType: v.optional(v.any()),
236
- }),
237
- ),
238
- });
239
-
240
- export type ExportPackage = {
241
- version: "1.0";
242
- exportedAt: number;
243
- contentTypes?: ExportedContentType[];
244
- entries: ExportedEntry[];
245
- metadata?: {
246
- source?: string;
247
- description?: string;
248
- totalEntries: number;
249
- entriesByType?: Record<string, number>;
250
- };
251
- };
252
-
253
- // =============================================================================
254
- // Import Types and Validators
255
- // =============================================================================
256
-
257
- /**
258
- * Conflict resolution strategy for imports.
259
- * - "skip": Skip entries with conflicting slugs
260
- * - "update": Update existing entries with new data
261
- * - "error": Fail the entire import if any conflicts exist
262
- */
263
- export const conflictStrategyValidator = v.union(
264
- v.literal("skip"),
265
- v.literal("update"),
266
- v.literal("error"),
267
- );
268
-
269
- export type ConflictStrategy = "skip" | "update" | "error";
270
-
271
- /**
272
- * Result for a single imported entry.
273
- */
274
- export const importEntryResultValidator = v.object({
275
- /** Original ID from export */
276
- originalId: v.string(),
277
- /** New ID after import (if created/updated) */
278
- newId: v.optional(v.id("contentEntries")),
279
- /** Import action taken */
280
- action: v.union(
281
- v.literal("created"),
282
- v.literal("updated"),
283
- v.literal("skipped"),
284
- v.literal("failed"),
285
- ),
286
- /** Error message if failed */
287
- error: v.optional(v.string()),
288
- /** Slug of the entry */
289
- slug: v.string(),
290
- /** Content type name */
291
- contentTypeName: v.string(),
292
- });
293
-
294
- export type ImportEntryResult = {
295
- originalId: string;
296
- newId?: Id<"contentEntries">;
297
- action: "created" | "updated" | "skipped" | "failed";
298
- error?: string;
299
- slug: string;
300
- contentTypeName: string;
301
- };
302
-
303
- /**
304
- * Complete import result.
305
- */
306
- export const importResultValidator = v.object({
307
- /** Whether import was successful */
308
- success: v.boolean(),
309
- /** Total entries processed */
310
- totalProcessed: v.number(),
311
- /** Number of entries created */
312
- created: v.number(),
313
- /** Number of entries updated */
314
- updated: v.number(),
315
- /** Number of entries skipped */
316
- skipped: v.number(),
317
- /** Number of entries failed */
318
- failed: v.number(),
319
- /** Detailed results for each entry */
320
- results: v.array(importEntryResultValidator),
321
- /** ID mapping from old to new IDs (for reference updates) */
322
- idMapping: v.any(),
323
- /** Validation errors encountered */
324
- validationErrors: v.optional(v.array(v.string())),
325
- });
326
-
327
- export type ImportResult = {
328
- success: boolean;
329
- totalProcessed: number;
330
- created: number;
331
- updated: number;
332
- skipped: number;
333
- failed: number;
334
- results: ImportEntryResult[];
335
- idMapping: Record<string, string>;
336
- validationErrors?: string[];
337
- };
338
-
339
- // =============================================================================
340
- // Export Function
341
- // =============================================================================
342
-
343
- /**
344
- * Arguments for the export function.
345
- */
346
- const exportEntriesArgs = v.object({
347
- /** Filter by content type ID */
348
- contentTypeId: v.optional(v.id("contentTypes")),
349
- /** Filter by content type name (alternative to contentTypeId) */
350
- contentTypeName: v.optional(v.string()),
351
- /** Filter by status */
352
- status: v.optional(contentStatusValidator),
353
- /** Filter by multiple statuses */
354
- statusIn: v.optional(v.array(contentStatusValidator)),
355
- /** Filter by locale */
356
- locale: v.optional(v.string()),
357
- /** Include content type definitions in export */
358
- includeContentTypes: v.optional(v.boolean()),
359
- /** Include soft-deleted entries */
360
- includeDeleted: v.optional(v.boolean()),
361
- /** Maximum number of entries to export (default: 1000) */
362
- limit: v.optional(v.number()),
363
- /** Export description for metadata */
364
- description: v.optional(v.string()),
365
- /** Source identifier for metadata */
366
- source: v.optional(v.string()),
367
- });
368
-
369
- /**
370
- * Export content entries to a JSON-serializable package.
371
- *
372
- * This query retrieves content entries matching the specified filters and
373
- * packages them into a format suitable for import into another system.
374
- *
375
- * ## Features
376
- * - Filter by content type, status, or locale
377
- * - Optionally include content type definitions for schema validation
378
- * - Preserves original IDs for reference mapping during import
379
- * - Includes metadata about the export for traceability
380
- *
381
- * @param contentTypeId - Filter by content type ID
382
- * @param contentTypeName - Filter by content type name (alternative to ID)
383
- * @param status - Filter by single status
384
- * @param statusIn - Filter by multiple statuses
385
- * @param locale - Filter by locale code
386
- * @param includeContentTypes - Include content type definitions (default: true)
387
- * @param includeDeleted - Include soft-deleted entries (default: false)
388
- * @param limit - Maximum entries to export (default: 1000)
389
- * @param description - Description for export metadata
390
- * @param source - Source identifier for export metadata
391
- *
392
- * @returns ExportPackage containing entries and optional content types
393
- *
394
- * @example
395
- * ```typescript
396
- * // Export all published blog posts
397
- * const exportData = await ctx.runQuery(api.exportImport.exportEntries, {
398
- * contentTypeName: "blog_post",
399
- * status: "published",
400
- * includeContentTypes: true,
401
- * });
402
- *
403
- * // Export all entries of all types
404
- * const allData = await ctx.runQuery(api.exportImport.exportEntries, {
405
- * limit: 5000,
406
- * description: "Full site backup",
407
- * });
408
- * ```
409
- */
410
- export const exportEntries = query({
411
- args: exportEntriesArgs.fields,
412
- returns: exportPackageValidator,
413
- handler: async (ctx, args) => {
414
- const {
415
- contentTypeId,
416
- contentTypeName,
417
- status,
418
- statusIn,
419
- locale,
420
- includeContentTypes = true,
421
- includeDeleted = false,
422
- limit = 1000,
423
- description,
424
- source,
425
- } = args;
426
-
427
- // Resolve status filter
428
- const resolvedStatuses = statusIn?.length
429
- ? statusIn
430
- : status
431
- ? [status]
432
- : undefined;
433
-
434
- // Resolve content type ID from name if needed
435
- let resolvedContentTypeId = contentTypeId;
436
- if (!resolvedContentTypeId && contentTypeName) {
437
- const contentType = await ctx.db
438
- .query("contentTypes")
439
- .withIndex("by_name", (q) => q.eq("name", contentTypeName))
440
- .first();
441
- if (contentType) {
442
- resolvedContentTypeId = contentType._id;
443
- }
444
- }
445
-
446
- // Build query for entries
447
- let entriesQuery;
448
- if (resolvedContentTypeId) {
449
- entriesQuery = ctx.db
450
- .query("contentEntries")
451
- .withIndex("by_content_type", (q) =>
452
- q.eq("contentTypeId", resolvedContentTypeId),
453
- );
454
- } else {
455
- entriesQuery = ctx.db.query("contentEntries");
456
- }
457
-
458
- // Fetch entries (we'll filter in memory for complex conditions)
459
- const allEntries = await entriesQuery.take(limit * 2);
460
-
461
- // Apply filters
462
- let filteredEntries = allEntries;
463
-
464
- // Filter by deleted status
465
- if (!includeDeleted) {
466
- filteredEntries = filteredEntries.filter(
467
- (e) => !isDeleted(e),
468
- );
469
- }
470
-
471
- // Filter by status
472
- if (resolvedStatuses && resolvedStatuses.length > 0) {
473
- filteredEntries = filteredEntries.filter((e) =>
474
- resolvedStatuses.includes(e.status),
475
- );
476
- }
477
-
478
- // Filter by locale
479
- if (locale) {
480
- filteredEntries = filteredEntries.filter((e) => e.locale === locale);
481
- }
482
-
483
- // Limit results
484
- filteredEntries = filteredEntries.slice(0, limit);
485
-
486
- // Get unique content type IDs from entries
487
- const contentTypeIdsSet = new Set<Id<"contentTypes">>();
488
- for (const entry of filteredEntries) {
489
- contentTypeIdsSet.add(entry.contentTypeId);
490
- }
491
- const contentTypeIds = Array.from(contentTypeIdsSet);
492
-
493
- // Fetch content types
494
- const contentTypesMap = new Map<string, Doc<"contentTypes">>();
495
- for (const typeId of contentTypeIds) {
496
- const contentType = await ctx.db.get(typeId);
497
- if (contentType) {
498
- contentTypesMap.set(typeId as string, contentType);
499
- }
500
- }
501
-
502
- // Build exported entries
503
- const exportedEntries: ExportedEntry[] = filteredEntries.map((entry) => {
504
- const contentType = contentTypesMap.get(entry.contentTypeId as string);
505
- return {
506
- _originalId: entry._id as string,
507
- contentTypeName: contentType?.name ?? "unknown",
508
- slug: entry.slug,
509
- status: entry.status,
510
- data: entry.data as Record<string, unknown>,
511
- locale: entry.locale,
512
- version: entry.version,
513
- firstPublishedAt: entry.firstPublishedAt,
514
- lastPublishedAt: entry.lastPublishedAt,
515
- scheduledPublishAt: entry.scheduledPublishAt,
516
- createdBy: entry.createdBy,
517
- createdAt: entry._creationTime,
518
- };
519
- });
520
-
521
- // Build exported content types if requested
522
- let exportedContentTypes: ExportedContentType[] | undefined;
523
- if (includeContentTypes) {
524
- exportedContentTypes = Array.from(contentTypesMap.values())
525
- .filter((ct) => !ct.deletedAt)
526
- .map((ct) => ({
527
- name: ct.name,
528
- displayName: ct.displayName,
529
- description: ct.description,
530
- fields: ct.fields as FieldDefinition[],
531
- icon: ct.icon,
532
- singleton: ct.singleton,
533
- slugField: ct.slugField,
534
- titleField: ct.titleField,
535
- }));
536
- }
537
-
538
- // Build entries by type count
539
- const entriesByType: Record<string, number> = {};
540
- for (const entry of exportedEntries) {
541
- entriesByType[entry.contentTypeName] =
542
- (entriesByType[entry.contentTypeName] ?? 0) + 1;
543
- }
544
-
545
- return {
546
- version: "1.0" as const,
547
- exportedAt: Date.now(),
548
- contentTypes: exportedContentTypes,
549
- entries: exportedEntries,
550
- metadata: {
551
- source,
552
- description,
553
- totalEntries: exportedEntries.length,
554
- entriesByType,
555
- },
556
- };
557
- },
558
- });
559
-
560
- // =============================================================================
561
- // Import Function
562
- // =============================================================================
563
-
564
- /**
565
- * Arguments for the import function.
566
- */
567
- const importEntriesArgs = v.object({
568
- /** The export package to import */
569
- data: exportPackageValidator,
570
- /** How to handle conflicting slugs */
571
- onConflict: v.optional(conflictStrategyValidator),
572
- /** Whether to preserve original status or set all to draft */
573
- preserveStatus: v.optional(v.boolean()),
574
- /** Whether to run validation only without making changes */
575
- dryRun: v.optional(v.boolean()),
576
- /** User ID for audit trail */
577
- importedBy: v.optional(v.string()),
578
- /** Filter which content types to import (by name) */
579
- contentTypeFilter: v.optional(v.array(v.string())),
580
- });
581
-
582
- /**
583
- * Import content entries from an export package.
584
- *
585
- * This mutation validates and imports content entries from an export package.
586
- * It supports conflict resolution, dry-run mode, and reference ID mapping.
587
- *
588
- * ## Features
589
- * - Validates all entries against content type schemas before import
590
- * - Maps old reference IDs to new IDs for reference fields
591
- * - Supports skip, update, or error on slug conflicts
592
- * - Dry-run mode validates without making changes
593
- * - Preserves or resets entry status
594
- *
595
- * ## Import Process
596
- * 1. Validate all entries against existing content type schemas
597
- * 2. Check for slug conflicts based on onConflict strategy
598
- * 3. Create or update entries, collecting ID mappings
599
- * 4. Update reference fields with new IDs (second pass)
600
- *
601
- * @param data - The export package to import
602
- * @param onConflict - How to handle slug conflicts (default: "skip")
603
- * @param preserveStatus - Keep original status or set to draft (default: false)
604
- * @param dryRun - Validate only without making changes (default: false)
605
- * @param importedBy - User ID for audit trail
606
- * @param contentTypeFilter - Only import entries of these content types
607
- *
608
- * @returns ImportResult with details of the import operation
609
- *
610
- * @example
611
- * ```typescript
612
- * // Dry run to validate import
613
- * const validation = await ctx.runMutation(api.exportImport.importEntries, {
614
- * data: exportPackage,
615
- * dryRun: true,
616
- * });
617
- *
618
- * // Import with skip on conflicts
619
- * const result = await ctx.runMutation(api.exportImport.importEntries, {
620
- * data: exportPackage,
621
- * onConflict: "skip",
622
- * preserveStatus: true,
623
- * importedBy: currentUserId,
624
- * });
625
- * ```
626
- */
627
- export const importEntries = mutation({
628
- args: importEntriesArgs.fields,
629
- returns: importResultValidator,
630
- handler: async (ctx, args) => {
631
- const {
632
- data,
633
- onConflict = "skip",
634
- preserveStatus = false,
635
- dryRun = false,
636
- importedBy,
637
- contentTypeFilter,
638
- } = args;
639
-
640
- const results: ImportEntryResult[] = [];
641
- const idMapping: Record<string, string> = {};
642
- const validationErrors: string[] = [];
643
-
644
- let created = 0;
645
- let updated = 0;
646
- let skipped = 0;
647
- let failed = 0;
648
-
649
- // Filter entries by content type if specified
650
- let entriesToImport = data.entries;
651
- if (contentTypeFilter && contentTypeFilter.length > 0) {
652
- entriesToImport = entriesToImport.filter((e) =>
653
- contentTypeFilter.includes(e.contentTypeName),
654
- );
655
- }
656
-
657
- // Build a map of content type name to content type document
658
- const contentTypeMap = new Map<string, Doc<"contentTypes">>();
659
- const contentTypeNamesSet = new Set<string>();
660
- for (const entry of entriesToImport) {
661
- contentTypeNamesSet.add(entry.contentTypeName);
662
- }
663
- const contentTypeNames = Array.from(contentTypeNamesSet);
664
-
665
- for (const typeName of contentTypeNames) {
666
- const contentType = await ctx.db
667
- .query("contentTypes")
668
- .withIndex("by_name", (q) => q.eq("name", typeName))
669
- .first();
670
-
671
- if (contentType && !contentType.deletedAt && contentType.isActive) {
672
- contentTypeMap.set(typeName, contentType);
673
- } else {
674
- validationErrors.push(
675
- `Content type "${typeName}" not found or not active`,
676
- );
677
- }
678
- }
679
-
680
- // Validate all entries first
681
- for (const entry of entriesToImport) {
682
- const contentType = contentTypeMap.get(entry.contentTypeName);
683
- if (!contentType) {
684
- results.push({
685
- originalId: entry._originalId,
686
- action: "failed",
687
- error: `Content type "${entry.contentTypeName}" not found`,
688
- slug: entry.slug,
689
- contentTypeName: entry.contentTypeName,
690
- });
691
- failed++;
692
- continue;
693
- }
694
-
695
- // Build schema for validation
696
- const schema: ContentTypeSchema = {
697
- name: contentType.name,
698
- displayName: contentType.displayName,
699
- description: contentType.description,
700
- fields: contentType.fields as FieldDefinition[],
701
- titleField: contentType.titleField,
702
- slugField: contentType.slugField,
703
- singleton: contentType.singleton,
704
- };
705
-
706
- // Validate content data
707
- const validationResult = validateContentData(
708
- entry.data as Record<string, unknown>,
709
- schema,
710
- );
711
- if (!validationResult.valid) {
712
- const errorMessages = validationResult.errors
713
- .map((e) => `${e.field}: ${e.message}`)
714
- .join("; ");
715
- validationErrors.push(
716
- `Entry "${entry.slug}" (${entry.contentTypeName}): ${errorMessages}`,
717
- );
718
- results.push({
719
- originalId: entry._originalId,
720
- action: "failed",
721
- error: `Validation failed: ${errorMessages}`,
722
- slug: entry.slug,
723
- contentTypeName: entry.contentTypeName,
724
- });
725
- failed++;
726
- continue;
727
- }
728
-
729
- // Check for existing entry with same slug
730
- const existingEntry = await ctx.db
731
- .query("contentEntries")
732
- .withIndex("by_content_type_and_slug", (q) =>
733
- q.eq("contentTypeId", contentType._id).eq("slug", entry.slug),
734
- )
735
- .filter((q) => q.eq(q.field("deletedAt"), undefined))
736
- .first();
737
-
738
- if (existingEntry) {
739
- // Handle conflict based on strategy
740
- switch (onConflict) {
741
- case "error":
742
- validationErrors.push(
743
- `Slug conflict: "${entry.slug}" already exists for type "${entry.contentTypeName}"`,
744
- );
745
- results.push({
746
- originalId: entry._originalId,
747
- action: "failed",
748
- error: `Slug "${entry.slug}" already exists`,
749
- slug: entry.slug,
750
- contentTypeName: entry.contentTypeName,
751
- });
752
- failed++;
753
- continue;
754
-
755
- case "skip":
756
- results.push({
757
- originalId: entry._originalId,
758
- newId: existingEntry._id,
759
- action: "skipped",
760
- slug: entry.slug,
761
- contentTypeName: entry.contentTypeName,
762
- });
763
- idMapping[entry._originalId] = existingEntry._id as string;
764
- skipped++;
765
- continue;
766
-
767
- case "update":
768
- if (!dryRun) {
769
- // Update existing entry
770
- const status = preserveStatus
771
- ? entry.status
772
- : existingEntry.status;
773
- await ctx.db.patch(existingEntry._id, {
774
- data: entry.data,
775
- status,
776
- version: existingEntry.version + 1,
777
- updatedBy: importedBy,
778
- });
779
- }
780
- results.push({
781
- originalId: entry._originalId,
782
- newId: existingEntry._id,
783
- action: "updated",
784
- slug: entry.slug,
785
- contentTypeName: entry.contentTypeName,
786
- });
787
- idMapping[entry._originalId] = existingEntry._id as string;
788
- updated++;
789
- continue;
790
- }
791
- }
792
-
793
- // Create new entry
794
- if (!dryRun) {
795
- // Generate search text from searchable fields
796
- let searchText = "";
797
- for (const field of contentType.fields) {
798
- const fieldData = entry.data as Record<string, unknown>;
799
- if (field.searchable && fieldData[field.name]) {
800
- const value = fieldData[field.name];
801
- if (typeof value === "string") {
802
- searchText += ` ${value}`;
803
- }
804
- }
805
- }
806
-
807
- // Ensure unique slug
808
- const queryFn = async (candidateSlug: string) => {
809
- return await ctx.db
810
- .query("contentEntries")
811
- .withIndex("by_content_type_and_slug", (q) =>
812
- q.eq("contentTypeId", contentType._id).eq("slug", candidateSlug),
813
- )
814
- .filter((q) => q.eq(q.field("deletedAt"), undefined))
815
- .first();
816
- };
817
-
818
- const uniqueSlug = await ensureUniqueSlug(entry.slug, queryFn);
819
-
820
- const newEntryId = await ctx.db.insert("contentEntries", {
821
- contentTypeId: contentType._id,
822
- slug: uniqueSlug,
823
- status: preserveStatus ? entry.status : "draft",
824
- data: entry.data,
825
- locale: entry.locale,
826
- version: 1,
827
- createdBy: importedBy ?? entry.createdBy,
828
- updatedBy: importedBy ?? entry.createdBy,
829
- searchText: searchText.trim() || undefined,
830
- // Only preserve publication timestamps if preserving status
831
- firstPublishedAt: preserveStatus ? entry.firstPublishedAt : undefined,
832
- lastPublishedAt: preserveStatus ? entry.lastPublishedAt : undefined,
833
- scheduledPublishAt: preserveStatus
834
- ? entry.scheduledPublishAt
835
- : undefined,
836
- });
837
-
838
- results.push({
839
- originalId: entry._originalId,
840
- newId: newEntryId,
841
- action: "created",
842
- slug: uniqueSlug,
843
- contentTypeName: entry.contentTypeName,
844
- });
845
- idMapping[entry._originalId] = newEntryId as string;
846
- created++;
847
- } else {
848
- // Dry run - simulate creation
849
- results.push({
850
- originalId: entry._originalId,
851
- action: "created",
852
- slug: entry.slug,
853
- contentTypeName: entry.contentTypeName,
854
- });
855
- created++;
856
- }
857
- }
858
-
859
- // Second pass: Update reference fields with new IDs
860
- if (!dryRun && Object.keys(idMapping).length > 0) {
861
- for (const result of results) {
862
- if (
863
- (result.action === "created" || result.action === "updated") &&
864
- result.newId
865
- ) {
866
- const entry = await ctx.db.get(result.newId);
867
- if (!entry) continue;
868
-
869
- const contentType = contentTypeMap.get(result.contentTypeName);
870
- if (!contentType) continue;
871
-
872
- const entryData = entry.data as Record<string, unknown>;
873
- let dataChanged = false;
874
- const updatedData = { ...entryData };
875
-
876
- // Find reference fields and update IDs
877
- for (const field of contentType.fields) {
878
- if (field.type === "reference") {
879
- const value = entryData[field.name];
880
- if (field.options?.multiple && Array.isArray(value)) {
881
- const newRefs = value.map(
882
- (refId: string) => idMapping[refId] ?? refId,
883
- );
884
- if (JSON.stringify(newRefs) !== JSON.stringify(value)) {
885
- updatedData[field.name] = newRefs;
886
- dataChanged = true;
887
- }
888
- } else if (typeof value === "string" && idMapping[value]) {
889
- updatedData[field.name] = idMapping[value];
890
- dataChanged = true;
891
- }
892
- }
893
- }
894
-
895
- if (dataChanged) {
896
- await ctx.db.patch(result.newId, {
897
- data: updatedData,
898
- });
899
- }
900
- }
901
- }
902
- }
903
-
904
- const success =
905
- failed === 0 &&
906
- validationErrors.filter((e) => !e.includes("Slug conflict")).length === 0;
907
-
908
- return {
909
- success,
910
- totalProcessed: entriesToImport.length,
911
- created,
912
- updated,
913
- skipped,
914
- failed,
915
- results,
916
- idMapping,
917
- validationErrors:
918
- validationErrors.length > 0 ? validationErrors : undefined,
919
- };
920
- },
921
- });
922
-
923
- // =============================================================================
924
- // Utility Functions
925
- // =============================================================================
926
-
927
- /**
928
- * Get a summary of content that would be exported without actually exporting.
929
- *
930
- * This is useful for previewing what an export would contain before
931
- * running the full export operation.
932
- */
933
- export const getExportPreview = query({
934
- args: {
935
- contentTypeId: v.optional(v.id("contentTypes")),
936
- contentTypeName: v.optional(v.string()),
937
- status: v.optional(contentStatusValidator),
938
- statusIn: v.optional(v.array(contentStatusValidator)),
939
- locale: v.optional(v.string()),
940
- includeDeleted: v.optional(v.boolean()),
941
- },
942
- returns: v.object({
943
- totalEntries: v.number(),
944
- entriesByType: v.any(),
945
- entriesByStatus: v.any(),
946
- contentTypes: v.array(v.string()),
947
- }),
948
- handler: async (ctx, args) => {
949
- const {
950
- contentTypeId,
951
- contentTypeName,
952
- status,
953
- statusIn,
954
- locale,
955
- includeDeleted = false,
956
- } = args;
957
-
958
- // Resolve status filter
959
- const resolvedStatuses = statusIn?.length
960
- ? statusIn
961
- : status
962
- ? [status]
963
- : undefined;
964
-
965
- // Resolve content type ID
966
- let resolvedContentTypeId = contentTypeId;
967
- if (!resolvedContentTypeId && contentTypeName) {
968
- const contentType = await ctx.db
969
- .query("contentTypes")
970
- .withIndex("by_name", (q) => q.eq("name", contentTypeName))
971
- .first();
972
- if (contentType) {
973
- resolvedContentTypeId = contentType._id;
974
- }
975
- }
976
-
977
- // Build query
978
- let entriesQuery;
979
- if (resolvedContentTypeId) {
980
- entriesQuery = ctx.db
981
- .query("contentEntries")
982
- .withIndex("by_content_type", (q) =>
983
- q.eq("contentTypeId", resolvedContentTypeId),
984
- );
985
- } else {
986
- entriesQuery = ctx.db.query("contentEntries");
987
- }
988
-
989
- // Fetch all matching entries
990
- const allEntries = await entriesQuery.collect();
991
-
992
- // Apply filters
993
- let filteredEntries = allEntries;
994
-
995
- if (!includeDeleted) {
996
- filteredEntries = filteredEntries.filter(
997
- (e) => !isDeleted(e),
998
- );
999
- }
1000
-
1001
- if (resolvedStatuses && resolvedStatuses.length > 0) {
1002
- filteredEntries = filteredEntries.filter((e) =>
1003
- resolvedStatuses.includes(e.status),
1004
- );
1005
- }
1006
-
1007
- if (locale) {
1008
- filteredEntries = filteredEntries.filter((e) => e.locale === locale);
1009
- }
1010
-
1011
- // Get content types
1012
- const contentTypeIdsSet = new Set<Id<"contentTypes">>();
1013
- for (const entry of filteredEntries) {
1014
- contentTypeIdsSet.add(entry.contentTypeId);
1015
- }
1016
- const contentTypeIds = Array.from(contentTypeIdsSet);
1017
- const contentTypeNames: string[] = [];
1018
- const contentTypeNameMap = new Map<string, string>();
1019
-
1020
- for (const typeId of contentTypeIds) {
1021
- const contentType = await ctx.db.get(typeId);
1022
- if (contentType) {
1023
- contentTypeNames.push(contentType.name);
1024
- contentTypeNameMap.set(typeId as string, contentType.name);
1025
- }
1026
- }
1027
-
1028
- // Count by type
1029
- const entriesByType: Record<string, number> = {};
1030
- for (const entry of filteredEntries) {
1031
- const typeName =
1032
- contentTypeNameMap.get(entry.contentTypeId as string) ?? "unknown";
1033
- entriesByType[typeName] = (entriesByType[typeName] ?? 0) + 1;
1034
- }
1035
-
1036
- // Count by status
1037
- const entriesByStatus: Record<string, number> = {};
1038
- for (const entry of filteredEntries) {
1039
- entriesByStatus[entry.status] = (entriesByStatus[entry.status] ?? 0) + 1;
1040
- }
1041
-
1042
- return {
1043
- totalEntries: filteredEntries.length,
1044
- entriesByType,
1045
- entriesByStatus,
1046
- contentTypes: contentTypeNames,
1047
- };
1048
- },
1049
- });
1050
-
1051
- /**
1052
- * Validate an export package without importing.
1053
- *
1054
- * Checks that all entries can be validated against existing content type
1055
- * schemas and reports any issues that would occur during import.
1056
- */
1057
- export const validateImportPackage = query({
1058
- args: {
1059
- data: exportPackageValidator,
1060
- contentTypeFilter: v.optional(v.array(v.string())),
1061
- },
1062
- returns: v.object({
1063
- valid: v.boolean(),
1064
- totalEntries: v.number(),
1065
- validEntries: v.number(),
1066
- invalidEntries: v.number(),
1067
- missingContentTypes: v.array(v.string()),
1068
- validationErrors: v.array(
1069
- v.object({
1070
- slug: v.string(),
1071
- contentTypeName: v.string(),
1072
- errors: v.array(v.string()),
1073
- }),
1074
- ),
1075
- }),
1076
- handler: async (ctx, args) => {
1077
- const { data, contentTypeFilter } = args;
1078
-
1079
- const missingContentTypes: string[] = [];
1080
- const validationErrors: Array<{
1081
- slug: string;
1082
- contentTypeName: string;
1083
- errors: string[];
1084
- }> = [];
1085
-
1086
- // Filter entries
1087
- let entriesToValidate = data.entries;
1088
- if (contentTypeFilter && contentTypeFilter.length > 0) {
1089
- entriesToValidate = entriesToValidate.filter((e) =>
1090
- contentTypeFilter.includes(e.contentTypeName),
1091
- );
1092
- }
1093
-
1094
- // Build content type map
1095
- const contentTypeMap = new Map<string, Doc<"contentTypes">>();
1096
- const contentTypeNamesSet = new Set<string>();
1097
- for (const entry of entriesToValidate) {
1098
- contentTypeNamesSet.add(entry.contentTypeName);
1099
- }
1100
- const contentTypeNames = Array.from(contentTypeNamesSet);
1101
-
1102
- for (const typeName of contentTypeNames) {
1103
- const contentType = await ctx.db
1104
- .query("contentTypes")
1105
- .withIndex("by_name", (q) => q.eq("name", typeName))
1106
- .first();
1107
-
1108
- if (contentType && !contentType.deletedAt && contentType.isActive) {
1109
- contentTypeMap.set(typeName, contentType);
1110
- } else {
1111
- missingContentTypes.push(typeName);
1112
- }
1113
- }
1114
-
1115
- let validEntries = 0;
1116
- let invalidEntries = 0;
1117
-
1118
- // Validate each entry
1119
- for (const entry of entriesToValidate) {
1120
- const contentType = contentTypeMap.get(entry.contentTypeName);
1121
- if (!contentType) {
1122
- invalidEntries++;
1123
- validationErrors.push({
1124
- slug: entry.slug,
1125
- contentTypeName: entry.contentTypeName,
1126
- errors: [`Content type "${entry.contentTypeName}" not found`],
1127
- });
1128
- continue;
1129
- }
1130
-
1131
- // Build schema
1132
- const schema: ContentTypeSchema = {
1133
- name: contentType.name,
1134
- displayName: contentType.displayName,
1135
- description: contentType.description,
1136
- fields: contentType.fields as FieldDefinition[],
1137
- titleField: contentType.titleField,
1138
- slugField: contentType.slugField,
1139
- singleton: contentType.singleton,
1140
- };
1141
-
1142
- // Validate
1143
- const result = validateContentData(
1144
- entry.data as Record<string, unknown>,
1145
- schema,
1146
- );
1147
-
1148
- if (result.valid) {
1149
- validEntries++;
1150
- } else {
1151
- invalidEntries++;
1152
- validationErrors.push({
1153
- slug: entry.slug,
1154
- contentTypeName: entry.contentTypeName,
1155
- errors: result.errors.map((e) => `${e.field}: ${e.message}`),
1156
- });
1157
- }
1158
- }
1159
-
1160
- return {
1161
- valid: invalidEntries === 0 && missingContentTypes.length === 0,
1162
- totalEntries: entriesToValidate.length,
1163
- validEntries,
1164
- invalidEntries,
1165
- missingContentTypes,
1166
- validationErrors,
1167
- };
1168
- },
1169
- });