convex-cms 0.0.2 → 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 (265) hide show
  1. package/admin-dist/nitro.json +15 -0
  2. package/admin-dist/public/assets/CmsEmptyState-CRswfTzk.js +5 -0
  3. package/admin-dist/public/assets/CmsPageHeader-CirpXndm.js +1 -0
  4. package/admin-dist/public/assets/CmsStatusBadge-CbEUpQu-.js +1 -0
  5. package/admin-dist/public/assets/CmsToolbar-BI2nZOXp.js +1 -0
  6. package/admin-dist/public/assets/ContentEntryEditor-CBeCyK_m.js +4 -0
  7. package/admin-dist/public/assets/ErrorState-BIVaWmom.js +1 -0
  8. package/admin-dist/public/assets/TaxonomyFilter-ChaY6Y_x.js +1 -0
  9. package/admin-dist/public/assets/_contentTypeId-DQ8k_Rvw.js +1 -0
  10. package/admin-dist/public/assets/_entryId-CKU_glsK.js +1 -0
  11. package/admin-dist/public/assets/alert-BXjTqrwQ.js +1 -0
  12. package/admin-dist/public/assets/badge-hvUOzpVZ.js +1 -0
  13. package/admin-dist/public/assets/circle-check-big-CF_pR17r.js +1 -0
  14. package/admin-dist/public/assets/command-DU82cJlt.js +1 -0
  15. package/admin-dist/public/assets/content-_LXl3pp7.js +1 -0
  16. package/admin-dist/public/assets/content-types-KjxaXGxY.js +2 -0
  17. package/admin-dist/public/assets/globals-CS6BZ0zp.css +1 -0
  18. package/admin-dist/public/assets/index-DNGIZHL-.js +1 -0
  19. package/admin-dist/public/assets/label-KNtpL71g.js +1 -0
  20. package/admin-dist/public/assets/link-2-Bw2aI4V4.js +1 -0
  21. package/admin-dist/public/assets/list-sYepHjt_.js +1 -0
  22. package/admin-dist/public/assets/main-CKj5yfEi.js +97 -0
  23. package/admin-dist/public/assets/media-Bkrkffm7.js +1 -0
  24. package/admin-dist/public/assets/new._contentTypeId-C3LstjNs.js +1 -0
  25. package/admin-dist/public/assets/plus-DUn8v_Xf.js +1 -0
  26. package/admin-dist/public/assets/rotate-ccw-DJEoHcRI.js +1 -0
  27. package/admin-dist/public/assets/scroll-area-DfIlT0in.js +1 -0
  28. package/admin-dist/public/assets/search-MuAUDJKR.js +1 -0
  29. package/admin-dist/public/assets/select-BD29IXCI.js +1 -0
  30. package/admin-dist/public/assets/settings-DmMyn_6A.js +1 -0
  31. package/admin-dist/public/assets/switch-h3Rrnl5i.js +1 -0
  32. package/admin-dist/public/assets/tabs-imc8h-Dp.js +1 -0
  33. package/admin-dist/public/assets/taxonomies-dAsrT65H.js +1 -0
  34. package/admin-dist/public/assets/textarea-BTy7nwzR.js +1 -0
  35. package/admin-dist/public/assets/trash-SAWKZZHv.js +1 -0
  36. package/admin-dist/public/assets/triangle-alert-E52Vfeuh.js +1 -0
  37. package/admin-dist/public/assets/useBreadcrumbLabel-BECBMCzM.js +1 -0
  38. package/admin-dist/public/assets/usePermissions-Basjs9BT.js +1 -0
  39. package/admin-dist/public/favicon.ico +0 -0
  40. package/admin-dist/server/_chunks/_libs/@date-fns/tz.mjs +217 -0
  41. package/admin-dist/server/_chunks/_libs/@floating-ui/core.mjs +719 -0
  42. package/admin-dist/server/_chunks/_libs/@floating-ui/dom.mjs +622 -0
  43. package/admin-dist/server/_chunks/_libs/@floating-ui/react-dom.mjs +292 -0
  44. package/admin-dist/server/_chunks/_libs/@floating-ui/utils.mjs +320 -0
  45. package/admin-dist/server/_chunks/_libs/@radix-ui/number.mjs +6 -0
  46. package/admin-dist/server/_chunks/_libs/@radix-ui/primitive.mjs +11 -0
  47. package/admin-dist/server/_chunks/_libs/@radix-ui/react-arrow.mjs +23 -0
  48. package/admin-dist/server/_chunks/_libs/@radix-ui/react-avatar.mjs +119 -0
  49. package/admin-dist/server/_chunks/_libs/@radix-ui/react-checkbox.mjs +270 -0
  50. package/admin-dist/server/_chunks/_libs/@radix-ui/react-collection.mjs +69 -0
  51. package/admin-dist/server/_chunks/_libs/@radix-ui/react-compose-refs.mjs +39 -0
  52. package/admin-dist/server/_chunks/_libs/@radix-ui/react-context.mjs +137 -0
  53. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dialog.mjs +325 -0
  54. package/admin-dist/server/_chunks/_libs/@radix-ui/react-direction.mjs +9 -0
  55. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dismissable-layer.mjs +210 -0
  56. package/admin-dist/server/_chunks/_libs/@radix-ui/react-dropdown-menu.mjs +253 -0
  57. package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-guards.mjs +29 -0
  58. package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-scope.mjs +206 -0
  59. package/admin-dist/server/_chunks/_libs/@radix-ui/react-id.mjs +14 -0
  60. package/admin-dist/server/_chunks/_libs/@radix-ui/react-label.mjs +23 -0
  61. package/admin-dist/server/_chunks/_libs/@radix-ui/react-menu.mjs +812 -0
  62. package/admin-dist/server/_chunks/_libs/@radix-ui/react-popover.mjs +300 -0
  63. package/admin-dist/server/_chunks/_libs/@radix-ui/react-popper.mjs +286 -0
  64. package/admin-dist/server/_chunks/_libs/@radix-ui/react-portal.mjs +16 -0
  65. package/admin-dist/server/_chunks/_libs/@radix-ui/react-presence.mjs +128 -0
  66. package/admin-dist/server/_chunks/_libs/@radix-ui/react-primitive.mjs +141 -0
  67. package/admin-dist/server/_chunks/_libs/@radix-ui/react-roving-focus.mjs +224 -0
  68. package/admin-dist/server/_chunks/_libs/@radix-ui/react-scroll-area.mjs +721 -0
  69. package/admin-dist/server/_chunks/_libs/@radix-ui/react-select.mjs +1163 -0
  70. package/admin-dist/server/_chunks/_libs/@radix-ui/react-separator.mjs +28 -0
  71. package/admin-dist/server/_chunks/_libs/@radix-ui/react-slot.mjs +601 -0
  72. package/admin-dist/server/_chunks/_libs/@radix-ui/react-switch.mjs +152 -0
  73. package/admin-dist/server/_chunks/_libs/@radix-ui/react-tabs.mjs +189 -0
  74. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-callback-ref.mjs +11 -0
  75. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-controllable-state.mjs +69 -0
  76. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-effect-event.mjs +1 -0
  77. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-escape-keydown.mjs +17 -0
  78. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-is-hydrated.mjs +15 -0
  79. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-layout-effect.mjs +6 -0
  80. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-previous.mjs +14 -0
  81. package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-size.mjs +39 -0
  82. package/admin-dist/server/_chunks/_libs/@radix-ui/react-visually-hidden.mjs +33 -0
  83. package/admin-dist/server/_chunks/_libs/@tanstack/history.mjs +409 -0
  84. package/admin-dist/server/_chunks/_libs/@tanstack/react-router.mjs +1711 -0
  85. package/admin-dist/server/_chunks/_libs/@tanstack/react-store.mjs +56 -0
  86. package/admin-dist/server/_chunks/_libs/@tanstack/router-core.mjs +4829 -0
  87. package/admin-dist/server/_chunks/_libs/@tanstack/store.mjs +134 -0
  88. package/admin-dist/server/_chunks/_libs/react-dom.mjs +10781 -0
  89. package/admin-dist/server/_chunks/_libs/react.mjs +513 -0
  90. package/admin-dist/server/_libs/aria-hidden.mjs +122 -0
  91. package/admin-dist/server/_libs/class-variance-authority.mjs +44 -0
  92. package/admin-dist/server/_libs/clsx.mjs +16 -0
  93. package/admin-dist/server/_libs/cmdk.mjs +315 -0
  94. package/admin-dist/server/_libs/convex.mjs +4841 -0
  95. package/admin-dist/server/_libs/cookie-es.mjs +58 -0
  96. package/admin-dist/server/_libs/croner.mjs +1 -0
  97. package/admin-dist/server/_libs/crossws.mjs +1 -0
  98. package/admin-dist/server/_libs/date-fns.mjs +1716 -0
  99. package/admin-dist/server/_libs/detect-node-es.mjs +1 -0
  100. package/admin-dist/server/_libs/get-nonce.mjs +9 -0
  101. package/admin-dist/server/_libs/h3-v2.mjs +277 -0
  102. package/admin-dist/server/_libs/h3.mjs +401 -0
  103. package/admin-dist/server/_libs/hookable.mjs +1 -0
  104. package/admin-dist/server/_libs/isbot.mjs +20 -0
  105. package/admin-dist/server/_libs/lucide-react.mjs +850 -0
  106. package/admin-dist/server/_libs/ohash.mjs +1 -0
  107. package/admin-dist/server/_libs/react-day-picker.mjs +2201 -0
  108. package/admin-dist/server/_libs/react-remove-scroll-bar.mjs +82 -0
  109. package/admin-dist/server/_libs/react-remove-scroll.mjs +328 -0
  110. package/admin-dist/server/_libs/react-style-singleton.mjs +69 -0
  111. package/admin-dist/server/_libs/rou3.mjs +8 -0
  112. package/admin-dist/server/_libs/seroval-plugins.mjs +58 -0
  113. package/admin-dist/server/_libs/seroval.mjs +1765 -0
  114. package/admin-dist/server/_libs/srvx.mjs +719 -0
  115. package/admin-dist/server/_libs/tailwind-merge.mjs +3010 -0
  116. package/admin-dist/server/_libs/tiny-invariant.mjs +12 -0
  117. package/admin-dist/server/_libs/tiny-warning.mjs +5 -0
  118. package/admin-dist/server/_libs/tslib.mjs +39 -0
  119. package/admin-dist/server/_libs/ufo.mjs +54 -0
  120. package/admin-dist/server/_libs/unctx.mjs +1 -0
  121. package/admin-dist/server/_libs/unstorage.mjs +1 -0
  122. package/admin-dist/server/_libs/use-callback-ref.mjs +66 -0
  123. package/admin-dist/server/_libs/use-sidecar.mjs +106 -0
  124. package/admin-dist/server/_libs/use-sync-external-store.mjs +139 -0
  125. package/admin-dist/server/_libs/zod.mjs +4223 -0
  126. package/admin-dist/server/_ssr/CmsEmptyState-DU7-7-mV.mjs +290 -0
  127. package/admin-dist/server/_ssr/CmsPageHeader-CseW0AHm.mjs +24 -0
  128. package/admin-dist/server/_ssr/CmsStatusBadge-B_pi4KCp.mjs +127 -0
  129. package/admin-dist/server/_ssr/CmsToolbar-X75ex6ek.mjs +49 -0
  130. package/admin-dist/server/_ssr/ContentEntryEditor-CepusRsA.mjs +3720 -0
  131. package/admin-dist/server/_ssr/ErrorState-cI-bKLez.mjs +89 -0
  132. package/admin-dist/server/_ssr/TaxonomyFilter-Bwrq0-cz.mjs +188 -0
  133. package/admin-dist/server/_ssr/_contentTypeId-BqYKEcLr.mjs +379 -0
  134. package/admin-dist/server/_ssr/_entryId-CRfnqeDf.mjs +161 -0
  135. package/admin-dist/server/_ssr/_tanstack-start-manifest_v-BwDlABVk.mjs +4 -0
  136. package/admin-dist/server/_ssr/alert-CVt45UUP.mjs +92 -0
  137. package/admin-dist/server/_ssr/badge-6BsP37vG.mjs +125 -0
  138. package/admin-dist/server/_ssr/command-fy8epIKf.mjs +128 -0
  139. package/admin-dist/server/_ssr/config.server-D7JHDcDv.mjs +117 -0
  140. package/admin-dist/server/_ssr/content-B5RhL7uW.mjs +532 -0
  141. package/admin-dist/server/_ssr/content-types-BIOqCQYN.mjs +1166 -0
  142. package/admin-dist/server/_ssr/index-DHSHDPt1.mjs +193 -0
  143. package/admin-dist/server/_ssr/index.mjs +1275 -0
  144. package/admin-dist/server/_ssr/label-C8Dko1j7.mjs +22 -0
  145. package/admin-dist/server/_ssr/media-CSx3XttC.mjs +1832 -0
  146. package/admin-dist/server/_ssr/new._contentTypeId-DzanEZQM.mjs +144 -0
  147. package/admin-dist/server/_ssr/router-DDWcF-kt.mjs +1556 -0
  148. package/admin-dist/server/_ssr/scroll-area-bjPYwhXN.mjs +59 -0
  149. package/admin-dist/server/_ssr/select-BUhDDf4T.mjs +142 -0
  150. package/admin-dist/server/_ssr/settings-DAsxnw2q.mjs +348 -0
  151. package/admin-dist/server/_ssr/start-HYkvq4Ni.mjs +4 -0
  152. package/admin-dist/server/_ssr/switch-BgyRtQ1Z.mjs +31 -0
  153. package/admin-dist/server/_ssr/tabs-DzMdRB1A.mjs +628 -0
  154. package/admin-dist/server/_ssr/taxonomies-C8j8g5Q5.mjs +915 -0
  155. package/admin-dist/server/_ssr/textarea-9jNeYJSc.mjs +18 -0
  156. package/admin-dist/server/_ssr/trash-DYMxwhZB.mjs +291 -0
  157. package/admin-dist/server/_ssr/useBreadcrumbLabel-FNSAr2Ha.mjs +16 -0
  158. package/admin-dist/server/_ssr/usePermissions-BJGGahrJ.mjs +68 -0
  159. package/admin-dist/server/favicon.ico +0 -0
  160. package/admin-dist/server/index.mjs +627 -0
  161. package/dist/cli/index.js +0 -0
  162. package/dist/client/admin-config.d.ts +0 -1
  163. package/dist/client/admin-config.d.ts.map +1 -1
  164. package/dist/client/admin-config.js +0 -1
  165. package/dist/client/admin-config.js.map +1 -1
  166. package/dist/client/adminApi.d.ts.map +1 -1
  167. package/dist/client/agentTools.d.ts +1237 -135
  168. package/dist/client/agentTools.d.ts.map +1 -1
  169. package/dist/client/agentTools.js +33 -9
  170. package/dist/client/agentTools.js.map +1 -1
  171. package/dist/client/index.d.ts +1 -1
  172. package/dist/client/index.d.ts.map +1 -1
  173. package/dist/client/index.js.map +1 -1
  174. package/dist/component/_generated/component.d.ts +9 -0
  175. package/dist/component/_generated/component.d.ts.map +1 -1
  176. package/dist/component/mediaAssets.d.ts +35 -0
  177. package/dist/component/mediaAssets.d.ts.map +1 -1
  178. package/dist/component/mediaAssets.js +81 -0
  179. package/dist/component/mediaAssets.js.map +1 -1
  180. package/dist/test.d.ts.map +1 -1
  181. package/dist/test.js +2 -1
  182. package/dist/test.js.map +1 -1
  183. package/package.json +9 -5
  184. package/dist/component/auditLog.d.ts +0 -410
  185. package/dist/component/auditLog.d.ts.map +0 -1
  186. package/dist/component/auditLog.js +0 -607
  187. package/dist/component/auditLog.js.map +0 -1
  188. package/dist/component/types.d.ts +0 -4
  189. package/dist/component/types.d.ts.map +0 -1
  190. package/dist/component/types.js +0 -2
  191. package/dist/component/types.js.map +0 -1
  192. package/src/cli/commands/admin.ts +0 -104
  193. package/src/cli/index.ts +0 -21
  194. package/src/cli/utils/detectConvexUrl.ts +0 -54
  195. package/src/cli/utils/openBrowser.ts +0 -16
  196. package/src/client/admin-config.ts +0 -138
  197. package/src/client/adminApi.ts +0 -942
  198. package/src/client/agentTools.ts +0 -1311
  199. package/src/client/argTypes.ts +0 -316
  200. package/src/client/field-types.ts +0 -187
  201. package/src/client/index.ts +0 -1301
  202. package/src/client/queryBuilder.ts +0 -1100
  203. package/src/client/schema/codegen.ts +0 -500
  204. package/src/client/schema/defineContentType.ts +0 -501
  205. package/src/client/schema/index.ts +0 -169
  206. package/src/client/schema/schemaDrift.ts +0 -574
  207. package/src/client/schema/typedClient.ts +0 -688
  208. package/src/client/schema/types.ts +0 -666
  209. package/src/client/types.ts +0 -723
  210. package/src/client/workflows.ts +0 -141
  211. package/src/client/wrapper.ts +0 -4304
  212. package/src/component/_generated/api.ts +0 -140
  213. package/src/component/_generated/component.ts +0 -5029
  214. package/src/component/_generated/dataModel.ts +0 -60
  215. package/src/component/_generated/server.ts +0 -156
  216. package/src/component/authorization.ts +0 -647
  217. package/src/component/authorizationHooks.ts +0 -668
  218. package/src/component/bulkOperations.ts +0 -687
  219. package/src/component/contentEntries.ts +0 -1976
  220. package/src/component/contentEntryMutations.ts +0 -1223
  221. package/src/component/contentEntryValidation.ts +0 -707
  222. package/src/component/contentLock.ts +0 -550
  223. package/src/component/contentTypeMigration.ts +0 -1064
  224. package/src/component/contentTypeMutations.ts +0 -969
  225. package/src/component/contentTypes.ts +0 -346
  226. package/src/component/convex.config.ts +0 -44
  227. package/src/component/documentTypes.ts +0 -240
  228. package/src/component/eventEmitter.ts +0 -485
  229. package/src/component/exportImport.ts +0 -1169
  230. package/src/component/index.ts +0 -491
  231. package/src/component/lib/deepReferenceResolver.ts +0 -999
  232. package/src/component/lib/errors.ts +0 -816
  233. package/src/component/lib/index.ts +0 -145
  234. package/src/component/lib/mediaReferenceResolver.ts +0 -495
  235. package/src/component/lib/metadataExtractor.ts +0 -792
  236. package/src/component/lib/mutationAuth.ts +0 -199
  237. package/src/component/lib/queries.ts +0 -79
  238. package/src/component/lib/ragContentChunker.ts +0 -1371
  239. package/src/component/lib/referenceResolver.ts +0 -430
  240. package/src/component/lib/slugGenerator.ts +0 -262
  241. package/src/component/lib/slugUniqueness.ts +0 -333
  242. package/src/component/lib/softDelete.ts +0 -44
  243. package/src/component/localeFallbackChain.ts +0 -673
  244. package/src/component/localeFields.ts +0 -896
  245. package/src/component/mediaAssetMutations.ts +0 -725
  246. package/src/component/mediaAssets.ts +0 -932
  247. package/src/component/mediaFolderMutations.ts +0 -1046
  248. package/src/component/mediaUploadMutations.ts +0 -224
  249. package/src/component/mediaVariantMutations.ts +0 -900
  250. package/src/component/mediaVariants.ts +0 -793
  251. package/src/component/ragContentIndexer.ts +0 -1067
  252. package/src/component/rateLimitHooks.ts +0 -572
  253. package/src/component/roles.ts +0 -1360
  254. package/src/component/scheduledPublish.ts +0 -358
  255. package/src/component/schema.ts +0 -617
  256. package/src/component/taxonomies.ts +0 -949
  257. package/src/component/taxonomyMutations.ts +0 -1210
  258. package/src/component/trash.ts +0 -724
  259. package/src/component/userContext.ts +0 -898
  260. package/src/component/validation.ts +0 -1388
  261. package/src/component/validators.ts +0 -949
  262. package/src/component/versionMutations.ts +0 -392
  263. package/src/component/webhookTrigger.ts +0 -1922
  264. package/src/react/index.ts +0 -898
  265. package/src/test.ts +0 -1580
@@ -1,1976 +0,0 @@
1
- /**
2
- * Content Entry Query Functions
3
- *
4
- * Provides query functions for retrieving content entries from the CMS.
5
- * Content entries are instances of content types that hold the actual content data.
6
- *
7
- * Uses convex-helpers paginator for robust cursor-based pagination.
8
- */
9
-
10
- import { v, type Infer } from "convex/values";
11
- import { isDeleted } from "./lib/softDelete.js";
12
- import { paginationOptsValidator } from "convex/server";
13
- import { stream } from "convex-helpers/server/stream";
14
- import { query, type QueryCtx } from "./_generated/server.js";
15
- import type { Id } from "./_generated/dataModel.js";
16
- import {
17
- contentEntryDoc,
18
- contentVersionDoc,
19
- compareVersionsArgs,
20
- compareVersionsResult,
21
- versionFieldDiff,
22
- } from "./validators.js";
23
- import { contentStatusValidator } from "./schema.js";
24
- import schema from "./schema.js";
25
-
26
- // =============================================================================
27
- // Field Filter Types and Operators
28
- // =============================================================================
29
-
30
- /**
31
- * Comparison operators for field filtering.
32
- *
33
- * - `eq`: Exact equality (works with all field types)
34
- * - `ne`: Not equal (works with all field types)
35
- * - `gt`: Greater than (numbers, dates)
36
- * - `gte`: Greater than or equal (numbers, dates)
37
- * - `lt`: Less than (numbers, dates)
38
- * - `lte`: Less than or equal (numbers, dates)
39
- * - `contains`: String contains substring, or array contains value
40
- * - `startsWith`: String starts with prefix
41
- * - `endsWith`: String ends with suffix
42
- * - `in`: Value is in array of allowed values
43
- * - `notIn`: Value is not in array of disallowed values
44
- */
45
- export const filterOperatorValidator = v.union(
46
- v.literal("eq"),
47
- v.literal("ne"),
48
- v.literal("gt"),
49
- v.literal("gte"),
50
- v.literal("lt"),
51
- v.literal("lte"),
52
- v.literal("contains"),
53
- v.literal("startsWith"),
54
- v.literal("endsWith"),
55
- v.literal("in"),
56
- v.literal("notIn")
57
- );
58
-
59
- export type FilterOperator = Infer<typeof filterOperatorValidator>;
60
-
61
- /**
62
- * A single field filter condition.
63
- *
64
- * @example
65
- * ```typescript
66
- * // Filter by exact title match
67
- * { field: "title", operator: "eq", value: "My Post" }
68
- *
69
- * // Filter by price range
70
- * { field: "price", operator: "gte", value: 100 }
71
- *
72
- * // Filter by category (in list)
73
- * { field: "category", operator: "in", value: ["tech", "science"] }
74
- *
75
- * // Filter by tag contains
76
- * { field: "tags", operator: "contains", value: "javascript" }
77
- * ```
78
- */
79
- export const fieldFilterValidator = v.object({
80
- /** The name of the field in the content entry's data object */
81
- field: v.string(),
82
- /** The comparison operator to use */
83
- operator: filterOperatorValidator,
84
- /** The value to compare against (type depends on field type and operator) */
85
- value: v.any(),
86
- });
87
-
88
- export type FieldFilter = Infer<typeof fieldFilterValidator>;
89
-
90
- // =============================================================================
91
- // Sort Types and Validators
92
- // =============================================================================
93
-
94
- /**
95
- * Sort direction for query results.
96
- */
97
- export const sortDirectionValidator = v.union(
98
- v.literal("asc"),
99
- v.literal("desc")
100
- );
101
-
102
- export type SortDirection = Infer<typeof sortDirectionValidator>;
103
-
104
- /**
105
- * Sortable system fields for content entries.
106
- * These are fields that exist on all content entries.
107
- */
108
- export const systemSortFieldValidator = v.union(
109
- v.literal("_creationTime"),
110
- v.literal("_id"),
111
- v.literal("firstPublishedAt"),
112
- v.literal("lastPublishedAt"),
113
- v.literal("scheduledPublishAt"),
114
- v.literal("version")
115
- );
116
-
117
- export type SystemSortField = Infer<typeof systemSortFieldValidator>;
118
-
119
- /**
120
- * Sort field can be a system field or a custom data field (prefixed with "data.").
121
- *
122
- * @example
123
- * ```typescript
124
- * // System field sorting
125
- * sortField: "_creationTime"
126
- * sortField: "firstPublishedAt"
127
- *
128
- * // Custom data field sorting (prefix with "data.")
129
- * sortField: "data.title"
130
- * sortField: "data.price"
131
- * sortField: "data.sortOrder"
132
- * ```
133
- */
134
- export const sortFieldValidator = v.string();
135
-
136
- export type SortField = string;
137
-
138
- /**
139
- * Sort options for content entry queries.
140
- *
141
- * @example
142
- * ```typescript
143
- * // Sort by creation time (newest first)
144
- * { sortField: "_creationTime", sortDirection: "desc" }
145
- *
146
- * // Sort by publish date (oldest published first)
147
- * { sortField: "firstPublishedAt", sortDirection: "asc" }
148
- *
149
- * // Sort by custom field (e.g., price low to high)
150
- * { sortField: "data.price", sortDirection: "asc" }
151
- * ```
152
- */
153
- export const sortOptionsValidator = v.object({
154
- /** The field to sort by (system field or "data.fieldName" for custom fields) */
155
- sortField: sortFieldValidator,
156
- /** The sort direction ("asc" for ascending, "desc" for descending) */
157
- sortDirection: sortDirectionValidator,
158
- });
159
-
160
- export type SortOptions = Infer<typeof sortOptionsValidator>;
161
-
162
- /**
163
- * Apply a single field filter to a content entry.
164
- *
165
- * @param entryData - The content entry's data object
166
- * @param filter - The filter condition to apply
167
- * @returns true if the entry matches the filter, false otherwise
168
- */
169
- export function matchesFieldFilter(
170
- entryData: Record<string, unknown>,
171
- filter: FieldFilter
172
- ): boolean {
173
- const { field, operator, value } = filter;
174
- const fieldValue = entryData[field];
175
-
176
- // Handle null/undefined field values
177
- if (fieldValue === undefined || fieldValue === null) {
178
- // Only eq and ne operators can match null/undefined
179
- if (operator === "eq") {
180
- return value === null || value === undefined;
181
- }
182
- if (operator === "ne") {
183
- return value !== null && value !== undefined;
184
- }
185
- // All other operators return false for null/undefined
186
- return false;
187
- }
188
-
189
- switch (operator) {
190
- case "eq":
191
- return deepEquals(fieldValue, value);
192
-
193
- case "ne":
194
- return !deepEquals(fieldValue, value);
195
-
196
- case "gt":
197
- if (typeof fieldValue === "number" && typeof value === "number") {
198
- return fieldValue > value;
199
- }
200
- // Support date comparison (stored as timestamps)
201
- if (typeof fieldValue === "number" && value instanceof Date) {
202
- return fieldValue > value.getTime();
203
- }
204
- return false;
205
-
206
- case "gte":
207
- if (typeof fieldValue === "number" && typeof value === "number") {
208
- return fieldValue >= value;
209
- }
210
- if (typeof fieldValue === "number" && value instanceof Date) {
211
- return fieldValue >= value.getTime();
212
- }
213
- return false;
214
-
215
- case "lt":
216
- if (typeof fieldValue === "number" && typeof value === "number") {
217
- return fieldValue < value;
218
- }
219
- if (typeof fieldValue === "number" && value instanceof Date) {
220
- return fieldValue < value.getTime();
221
- }
222
- return false;
223
-
224
- case "lte":
225
- if (typeof fieldValue === "number" && typeof value === "number") {
226
- return fieldValue <= value;
227
- }
228
- if (typeof fieldValue === "number" && value instanceof Date) {
229
- return fieldValue <= value.getTime();
230
- }
231
- return false;
232
-
233
- case "contains":
234
- // String contains substring
235
- if (typeof fieldValue === "string" && typeof value === "string") {
236
- return fieldValue.toLowerCase().includes(value.toLowerCase());
237
- }
238
- // Array contains value
239
- if (Array.isArray(fieldValue)) {
240
- return fieldValue.some((item) => deepEquals(item, value));
241
- }
242
- return false;
243
-
244
- case "startsWith":
245
- if (typeof fieldValue === "string" && typeof value === "string") {
246
- return fieldValue.toLowerCase().startsWith(value.toLowerCase());
247
- }
248
- return false;
249
-
250
- case "endsWith":
251
- if (typeof fieldValue === "string" && typeof value === "string") {
252
- return fieldValue.toLowerCase().endsWith(value.toLowerCase());
253
- }
254
- return false;
255
-
256
- case "in":
257
- if (Array.isArray(value)) {
258
- return value.some((v) => deepEquals(fieldValue, v));
259
- }
260
- return false;
261
-
262
- case "notIn":
263
- if (Array.isArray(value)) {
264
- return !value.some((v) => deepEquals(fieldValue, v));
265
- }
266
- return true;
267
-
268
- default:
269
- return false;
270
- }
271
- }
272
-
273
- /**
274
- * Apply multiple field filters to a content entry.
275
- * All filters must match (AND logic).
276
- *
277
- * @param entryData - The content entry's data object
278
- * @param filters - Array of filter conditions
279
- * @returns true if the entry matches all filters, false otherwise
280
- */
281
- export function matchesAllFieldFilters(
282
- entryData: Record<string, unknown>,
283
- filters: FieldFilter[]
284
- ): boolean {
285
- if (!filters || filters.length === 0) {
286
- return true;
287
- }
288
- return filters.every((filter) => matchesFieldFilter(entryData, filter));
289
- }
290
-
291
- /**
292
- * Deep equality check for comparing field values.
293
- * Handles primitives, arrays, and objects.
294
- */
295
- function deepEquals(a: unknown, b: unknown): boolean {
296
- if (a === b) return true;
297
- if (a === null || b === null) return false;
298
- if (typeof a !== typeof b) return false;
299
-
300
- if (Array.isArray(a) && Array.isArray(b)) {
301
- if (a.length !== b.length) return false;
302
- return a.every((item, index) => deepEquals(item, b[index]));
303
- }
304
-
305
- if (typeof a === "object" && typeof b === "object") {
306
- const aObj = a as Record<string, unknown>;
307
- const bObj = b as Record<string, unknown>;
308
- const aKeys = Object.keys(aObj);
309
- const bKeys = Object.keys(bObj);
310
- if (aKeys.length !== bKeys.length) return false;
311
- return aKeys.every((key) => deepEquals(aObj[key], bObj[key]));
312
- }
313
-
314
- return false;
315
- }
316
-
317
- /**
318
- * Arguments for retrieving a single content entry.
319
- */
320
- const getContentEntryArgs = v.object({
321
- /** The ID of the content entry to retrieve */
322
- id: v.id("contentEntries"),
323
- /** Whether to include the latest version info in the response */
324
- includeVersion: v.optional(v.boolean()),
325
- });
326
-
327
- /**
328
- * Return type for the get query when includeVersion is true.
329
- * Extends the base content entry document with optional version information.
330
- */
331
- const contentEntryWithVersionDoc = v.object({
332
- ...contentEntryDoc.fields,
333
- /** The latest version snapshot (included when includeVersion is true) */
334
- latestVersion: v.optional(contentVersionDoc),
335
- });
336
-
337
- /**
338
- * Query to retrieve a single content entry by ID.
339
- *
340
- * Returns full content data including metadata and status.
341
- * Optionally includes the latest version info when `includeVersion` is true.
342
- *
343
- * @param id - The content entry ID to retrieve
344
- * @param includeVersion - Whether to include version info (default: false)
345
- * @returns The content entry document, or null if not found or deleted
346
- *
347
- * @example
348
- * ```typescript
349
- * // Basic usage - get entry by ID
350
- * const entry = await ctx.runQuery(api.contentEntries.get, {
351
- * id: entryId,
352
- * });
353
- *
354
- * // With version info
355
- * const entryWithVersion = await ctx.runQuery(api.contentEntries.get, {
356
- * id: entryId,
357
- * includeVersion: true,
358
- * });
359
- * if (entryWithVersion?.latestVersion) {
360
- * console.log("Current version:", entryWithVersion.latestVersion.versionNumber);
361
- * }
362
- * ```
363
- */
364
- export const get = query({
365
- args: getContentEntryArgs.fields,
366
- returns: v.union(contentEntryWithVersionDoc, v.null()),
367
- handler: async (ctx, args) => {
368
- const entry = await ctx.db.get(args.id);
369
-
370
- // Return null if entry doesn't exist
371
- if (!entry) {
372
- return null;
373
- }
374
-
375
- // Return null if entry has been soft-deleted
376
- // (respects the soft delete feature - deleted entries should not be returned)
377
- if (isDeleted(entry)) {
378
- return null;
379
- }
380
-
381
- // If version info is requested, fetch the latest version
382
- if (args.includeVersion) {
383
- const latestVersion = await ctx.db
384
- .query("contentVersions")
385
- .withIndex("by_entry_and_version", (q) =>
386
- q.eq("entryId", args.id).eq("versionNumber", entry.version)
387
- )
388
- .first();
389
-
390
- return {
391
- ...entry,
392
- latestVersion: latestVersion ?? undefined,
393
- };
394
- }
395
-
396
- // Return the entry without version info
397
- return {
398
- ...entry,
399
- latestVersion: undefined,
400
- };
401
- },
402
- });
403
-
404
- // =============================================================================
405
- // Slug-Based Queries
406
- // =============================================================================
407
-
408
- /**
409
- * Arguments for retrieving a content entry by slug.
410
- */
411
- const getBySlugArgs = v.object({
412
- /** The ID of the content type to search within */
413
- contentTypeId: v.id("contentTypes"),
414
- /** The URL-friendly slug to look up */
415
- slug: v.string(),
416
- /** Optional status filter (e.g., "published" for public content) */
417
- status: v.optional(contentStatusValidator),
418
- /** Whether to include soft-deleted entries (default: false) */
419
- includeDeleted: v.optional(v.boolean()),
420
- });
421
-
422
- /**
423
- * Arguments for retrieving a content entry by slug and content type name.
424
- */
425
- const getBySlugAndTypeNameArgs = v.object({
426
- /** The machine-readable name of the content type (e.g., "blog_post") */
427
- contentTypeName: v.string(),
428
- /** The URL-friendly slug to look up */
429
- slug: v.string(),
430
- /** Optional status filter (e.g., "published" for public content) */
431
- status: v.optional(contentStatusValidator),
432
- /** Whether to include soft-deleted entries (default: false) */
433
- includeDeleted: v.optional(v.boolean()),
434
- });
435
-
436
- /**
437
- * Query to retrieve a content entry by its slug and content type ID.
438
- *
439
- * This is the primary lookup function for frontend routing and SEO-friendly URLs.
440
- * It uses the `by_content_type_and_slug` index for efficient O(1) lookups.
441
- *
442
- * @param contentTypeId - The ID of the content type to search within
443
- * @param slug - The URL-friendly slug to look up
444
- * @param status - Optional status filter (defaults to returning any status)
445
- * @param includeDeleted - Whether to include soft-deleted entries (defaults to false)
446
- *
447
- * @returns The content entry if found, or null if not found
448
- *
449
- * @example
450
- * ```typescript
451
- * // From parent app - basic usage:
452
- * const blogPost = await ctx.runQuery(components.convexCms.contentEntries.getBySlug, {
453
- * contentTypeId: blogTypeId,
454
- * slug: "my-first-post",
455
- * });
456
- *
457
- * // With status filter for published content only (common for public sites):
458
- * const publishedPost = await ctx.runQuery(components.convexCms.contentEntries.getBySlug, {
459
- * contentTypeId: blogTypeId,
460
- * slug: "my-first-post",
461
- * status: "published",
462
- * });
463
- *
464
- * // Frontend routing example:
465
- * // URL: /blog/my-first-post
466
- * // -> Extract slug "my-first-post" from URL
467
- * // -> Query: getBySlug({ contentTypeId: blogTypeId, slug: "my-first-post", status: "published" })
468
- * ```
469
- */
470
- export const getBySlug = query({
471
- args: getBySlugArgs.fields,
472
- returns: v.union(contentEntryDoc, v.null()),
473
- handler: async (ctx, args) => {
474
- const { contentTypeId, slug, status, includeDeleted = false } = args;
475
-
476
- // Query using the compound index for efficient lookup
477
- // The by_content_type_and_slug index enables O(1) lookups
478
- const entry = await ctx.db
479
- .query("contentEntries")
480
- .withIndex("by_content_type_and_slug", (q) =>
481
- q.eq("contentTypeId", contentTypeId).eq("slug", slug)
482
- )
483
- .first();
484
-
485
- // Return null if no entry found
486
- if (!entry) {
487
- return null;
488
- }
489
-
490
- // Filter out soft-deleted entries unless explicitly requested
491
- if (!includeDeleted && isDeleted(entry)) {
492
- return null;
493
- }
494
-
495
- // Filter by status if specified
496
- if (status !== undefined && entry.status !== status) {
497
- return null;
498
- }
499
-
500
- return entry;
501
- },
502
- });
503
-
504
- /**
505
- * Query to retrieve a content entry by its slug and content type name.
506
- *
507
- * This is a convenience function that looks up the content type by name first,
508
- * then retrieves the entry by slug. Useful when you have the content type name
509
- * (e.g., "blog_post") but not its ID.
510
- *
511
- * Note: This performs two index lookups (content type by name, then entry by slug),
512
- * so `getBySlug` is more efficient if you already have the content type ID cached.
513
- *
514
- * @param contentTypeName - The machine-readable name of the content type (e.g., "blog_post")
515
- * @param slug - The URL-friendly slug to look up
516
- * @param status - Optional status filter (defaults to returning any status)
517
- * @param includeDeleted - Whether to include soft-deleted entries (defaults to false)
518
- *
519
- * @returns The content entry if found, or null if not found (including if content type doesn't exist)
520
- *
521
- * @example
522
- * ```typescript
523
- * // From parent app - using content type name instead of ID:
524
- * const blogPost = await ctx.runQuery(components.convexCms.contentEntries.getBySlugAndTypeName, {
525
- * contentTypeName: "blog_post",
526
- * slug: "my-first-post",
527
- * status: "published",
528
- * });
529
- *
530
- * // Useful for static routes where content type is known at build time:
531
- * // /blog/[slug] -> contentTypeName: "blog_post"
532
- * // /products/[slug] -> contentTypeName: "product"
533
- * // /pages/[slug] -> contentTypeName: "page"
534
- * ```
535
- */
536
- export const getBySlugAndTypeName = query({
537
- args: getBySlugAndTypeNameArgs.fields,
538
- returns: v.union(contentEntryDoc, v.null()),
539
- handler: async (ctx, args) => {
540
- const { contentTypeName, slug, status, includeDeleted = false } = args;
541
-
542
- // First, look up the content type by name using the by_name index
543
- const contentType = await ctx.db
544
- .query("contentTypes")
545
- .withIndex("by_name", (q) => q.eq("name", contentTypeName))
546
- .first();
547
-
548
- // Return null if content type doesn't exist
549
- if (!contentType) {
550
- return null;
551
- }
552
-
553
- // Check if content type is active and not deleted
554
- // Inactive or deleted content types should not serve content
555
- if (!contentType.isActive || isDeleted(contentType)) {
556
- return null;
557
- }
558
-
559
- // Query the entry using the compound index
560
- const entry = await ctx.db
561
- .query("contentEntries")
562
- .withIndex("by_content_type_and_slug", (q) =>
563
- q.eq("contentTypeId", contentType._id).eq("slug", slug)
564
- )
565
- .first();
566
-
567
- // Return null if no entry found
568
- if (!entry) {
569
- return null;
570
- }
571
-
572
- // Filter out soft-deleted entries unless explicitly requested
573
- if (!includeDeleted && isDeleted(entry)) {
574
- return null;
575
- }
576
-
577
- // Filter by status if specified
578
- if (status !== undefined && entry.status !== status) {
579
- return null;
580
- }
581
-
582
- return entry;
583
- },
584
- });
585
-
586
- // =============================================================================
587
- // List Query with Cursor-Based Pagination
588
- // =============================================================================
589
-
590
- /**
591
- * Default number of items per page when not specified.
592
- */
593
- const DEFAULT_NUM_ITEMS = 50;
594
-
595
- /**
596
- * Maximum items per page to prevent excessive data fetching.
597
- */
598
- const MAX_NUM_ITEMS = 250;
599
-
600
- /**
601
- * Arguments for listing content entries with filtering and pagination.
602
- * Uses convex-helpers paginator for robust cursor-based pagination.
603
- */
604
- const listContentEntriesArgs = v.object({
605
- /** Filter by content type ID */
606
- contentTypeId: v.optional(v.id("contentTypes")),
607
- /** Filter by content type name (alternative to contentTypeId) */
608
- contentTypeName: v.optional(v.string()),
609
- /** Filter by a single entry status (draft, published, archived, scheduled) */
610
- status: v.optional(contentStatusValidator),
611
- /** Filter by multiple statuses (e.g., ["draft", "scheduled"] for admin views) */
612
- statusIn: v.optional(v.array(contentStatusValidator)),
613
- /** Filter by locale code (e.g., "en-US") */
614
- locale: v.optional(v.string()),
615
- /** Full-text search query to match against entry content */
616
- search: v.optional(v.string()),
617
- /** Whether to include soft-deleted entries (default: false) */
618
- includeDeleted: v.optional(v.boolean()),
619
- /**
620
- * Field-level filters to apply to content entry data.
621
- * All filters are combined with AND logic.
622
- *
623
- * @example
624
- * ```typescript
625
- * // Filter by exact field value
626
- * fieldFilters: [{ field: "category", operator: "eq", value: "tech" }]
627
- *
628
- * // Filter by numeric range
629
- * fieldFilters: [
630
- * { field: "price", operator: "gte", value: 100 },
631
- * { field: "price", operator: "lte", value: 500 }
632
- * ]
633
- *
634
- * // Filter by array contains
635
- * fieldFilters: [{ field: "tags", operator: "contains", value: "featured" }]
636
- * ```
637
- */
638
- fieldFilters: v.optional(v.array(fieldFilterValidator)),
639
- /**
640
- * Field to sort results by.
641
- * Can be a system field (e.g., "_creationTime", "firstPublishedAt") or
642
- * a custom data field prefixed with "data." (e.g., "data.title", "data.price").
643
- *
644
- * @default "_creationTime"
645
- *
646
- * @example
647
- * ```typescript
648
- * // Sort by publish date
649
- * sortField: "firstPublishedAt"
650
- *
651
- * // Sort by custom field
652
- * sortField: "data.sortOrder"
653
- * ```
654
- */
655
- sortField: v.optional(sortFieldValidator),
656
- /**
657
- * Sort direction for results.
658
- *
659
- * @default "desc" (newest first)
660
- *
661
- * @example
662
- * ```typescript
663
- * sortDirection: "asc" // Ascending (oldest/lowest first)
664
- * sortDirection: "desc" // Descending (newest/highest first)
665
- * ```
666
- */
667
- sortDirection: v.optional(sortDirectionValidator),
668
- /**
669
- * Pagination options using standard Convex pagination format.
670
- * Compatible with usePaginatedQuery hook on the client.
671
- */
672
- paginationOpts: paginationOptsValidator,
673
- });
674
-
675
- /**
676
- * Paginated response using standard Convex PaginationResult format.
677
- *
678
- * This format is compatible with:
679
- * - Convex's usePaginatedQuery React hook
680
- * - convex-helpers paginator
681
- * - Standard Convex pagination patterns
682
- */
683
- const paginatedContentEntriesResponse = v.object({
684
- /** Array of content entry documents for this page */
685
- page: v.array(contentEntryDoc),
686
- /** Cursor for fetching the next page (pass to next query's paginationOpts.cursor) */
687
- continueCursor: v.union(v.string(), v.null()),
688
- /** Whether this is the last page (no more results) */
689
- isDone: v.boolean(),
690
- });
691
-
692
- /**
693
- * Query to list content entries with filtering, search, and cursor-based pagination.
694
- *
695
- * This is the primary function for retrieving multiple content entries.
696
- * It uses the convex-helpers paginator for robust cursor-based pagination that
697
- * integrates seamlessly with Convex's usePaginatedQuery hook.
698
- *
699
- * The query intelligently selects the most efficient index based on the
700
- * provided filters:
701
- * - Full-text search: Uses the `search_content` search index
702
- * - Type + Status filter: Uses the `by_content_type_and_status` index
703
- * - Type only: Uses the `by_content_type` index
704
- * - Status only: Uses the `by_status` index
705
- * - Locale filter: Uses the `by_locale` index
706
- * - Field filters: Applied as post-processing filters on entry data
707
- *
708
- * @param contentTypeId - Optional content type ID to filter by
709
- * @param contentTypeName - Optional content type name (resolved to ID internally)
710
- * @param status - Optional status filter (draft, published, archived, scheduled)
711
- * @param statusIn - Optional array of statuses to filter by (for admin views)
712
- * @param locale - Optional locale code to filter by
713
- * @param search - Optional full-text search query
714
- * @param fieldFilters - Optional array of field filters (combined with AND logic)
715
- * @param includeDeleted - Whether to include soft-deleted entries (default: false)
716
- * @param paginationOpts - Standard Convex pagination options (numItems, cursor)
717
- *
718
- * @returns PaginationResult with page, continueCursor, and isDone
719
- *
720
- * @example
721
- * ```typescript
722
- * // List all published blog posts (frontend use case)
723
- * const { page, continueCursor, isDone } = await ctx.runQuery(
724
- * components.convexCms.contentEntries.list,
725
- * {
726
- * contentTypeName: "blog_post",
727
- * status: "published",
728
- * paginationOpts: { numItems: 10 },
729
- * }
730
- * );
731
- *
732
- * // List entries with multiple statuses (admin use case)
733
- * // Shows draft and scheduled content for editorial workflow
734
- * const editorialContent = await ctx.runQuery(
735
- * components.convexCms.contentEntries.list,
736
- * {
737
- * contentTypeName: "blog_post",
738
- * statusIn: ["draft", "scheduled"],
739
- * paginationOpts: { numItems: 20 },
740
- * }
741
- * );
742
- *
743
- * // Filter by field values (e.g., category)
744
- * const techPosts = await ctx.runQuery(
745
- * components.convexCms.contentEntries.list,
746
- * {
747
- * contentTypeName: "blog_post",
748
- * status: "published",
749
- * fieldFilters: [
750
- * { field: "category", operator: "eq", value: "tech" }
751
- * ],
752
- * paginationOpts: { numItems: 10 },
753
- * }
754
- * );
755
- *
756
- * // Filter by numeric range (e.g., price)
757
- * const affordableProducts = await ctx.runQuery(
758
- * components.convexCms.contentEntries.list,
759
- * {
760
- * contentTypeName: "product",
761
- * status: "published",
762
- * fieldFilters: [
763
- * { field: "price", operator: "gte", value: 10 },
764
- * { field: "price", operator: "lte", value: 100 }
765
- * ],
766
- * paginationOpts: { numItems: 20 },
767
- * }
768
- * );
769
- *
770
- * // Filter by array contains (e.g., tags)
771
- * const featuredPosts = await ctx.runQuery(
772
- * components.convexCms.contentEntries.list,
773
- * {
774
- * contentTypeName: "blog_post",
775
- * fieldFilters: [
776
- * { field: "tags", operator: "contains", value: "featured" }
777
- * ],
778
- * paginationOpts: { numItems: 10 },
779
- * }
780
- * );
781
- *
782
- * // Paginate through results using continueCursor
783
- * const page2 = await ctx.runQuery(
784
- * components.convexCms.contentEntries.list,
785
- * {
786
- * contentTypeName: "blog_post",
787
- * paginationOpts: {
788
- * numItems: 10,
789
- * cursor: previousResult.continueCursor,
790
- * },
791
- * }
792
- * );
793
- *
794
- * // Full-text search with pagination
795
- * const results = await ctx.runQuery(
796
- * components.convexCms.contentEntries.list,
797
- * {
798
- * search: "typescript tutorial",
799
- * status: "published",
800
- * paginationOpts: { numItems: 10 },
801
- * }
802
- * );
803
- *
804
- * // Use with usePaginatedQuery React hook
805
- * const { results, status, loadMore } = usePaginatedQuery(
806
- * api.contentEntries.list,
807
- * { contentTypeName: "blog_post", status: "published" },
808
- * { initialNumItems: 10 }
809
- * );
810
- * ```
811
- */
812
- export const list = query({
813
- args: listContentEntriesArgs.fields,
814
- returns: paginatedContentEntriesResponse,
815
- handler: async (ctx, args) => {
816
- const {
817
- contentTypeId,
818
- contentTypeName,
819
- status,
820
- statusIn,
821
- locale,
822
- search,
823
- includeDeleted = false,
824
- fieldFilters,
825
- sortField = "_creationTime",
826
- sortDirection = "desc",
827
- paginationOpts,
828
- } = args;
829
-
830
- // Resolve status filter: statusIn takes precedence, then status
831
- // This allows filtering by multiple statuses (e.g., ["draft", "scheduled"])
832
- const resolvedStatuses: string[] | undefined = statusIn?.length
833
- ? statusIn
834
- : status
835
- ? [status]
836
- : undefined;
837
-
838
- // Clamp numItems to valid range
839
- const numItems = Math.min(
840
- Math.max(1, paginationOpts.numItems ?? DEFAULT_NUM_ITEMS),
841
- MAX_NUM_ITEMS
842
- );
843
-
844
- const clampedPaginationOpts = {
845
- ...paginationOpts,
846
- numItems,
847
- };
848
-
849
- // Resolve content type ID from name if provided
850
- let resolvedContentTypeId = contentTypeId;
851
- if (!resolvedContentTypeId && contentTypeName) {
852
- const contentType = await ctx.db
853
- .query("contentTypes")
854
- .withIndex("by_name", (q) => q.eq("name", contentTypeName))
855
- .first();
856
-
857
- // If content type not found or inactive, return empty result
858
- if (!contentType || !contentType.isActive || isDeleted(contentType)) {
859
- return { page: [], continueCursor: null, isDone: true };
860
- }
861
-
862
- resolvedContentTypeId = contentType._id;
863
- }
864
-
865
- // Build sort options
866
- const sortOptions: SortOptions = {
867
- sortField,
868
- sortDirection,
869
- };
870
-
871
- // Handle full-text search queries (cannot use paginator for search indexes)
872
- if (search && search.trim().length > 0) {
873
- return handleSearchQuery(ctx, {
874
- search: search.trim(),
875
- contentTypeId: resolvedContentTypeId,
876
- statuses: resolvedStatuses,
877
- locale,
878
- includeDeleted,
879
- fieldFilters,
880
- sortOptions,
881
- paginationOpts: clampedPaginationOpts,
882
- });
883
- }
884
-
885
- // Handle standard index-based queries with paginator
886
- return handlePaginatorQuery(ctx, {
887
- contentTypeId: resolvedContentTypeId,
888
- statuses: resolvedStatuses,
889
- locale,
890
- includeDeleted,
891
- fieldFilters,
892
- sortOptions,
893
- paginationOpts: clampedPaginationOpts,
894
- });
895
- },
896
- });
897
-
898
- // Type for pagination options
899
- type PaginationOpts = Infer<typeof paginationOptsValidator>;
900
-
901
- // Type for pagination result
902
- interface ContentEntryPaginationResult {
903
- page: any[];
904
- continueCursor: string | null;
905
- isDone: boolean;
906
- }
907
-
908
- /**
909
- * Get a sortable value from an entry based on the sort field.
910
- * Handles both system fields and custom data fields (prefixed with "data.").
911
- */
912
- function getSortValue(entry: any, sortField: string): unknown {
913
- if (sortField.startsWith("data.")) {
914
- const fieldName = sortField.slice(5); // Remove "data." prefix
915
- return entry.data?.[fieldName];
916
- }
917
- return entry[sortField];
918
- }
919
-
920
- /**
921
- * Compare two values for sorting.
922
- * Handles null/undefined by pushing them to the end.
923
- */
924
- function compareValues(a: unknown, b: unknown, direction: SortDirection): number {
925
- // Handle null/undefined - push them to the end
926
- if (a === null || a === undefined) {
927
- return direction === "asc" ? 1 : -1;
928
- }
929
- if (b === null || b === undefined) {
930
- return direction === "asc" ? -1 : 1;
931
- }
932
-
933
- // Compare numbers
934
- if (typeof a === "number" && typeof b === "number") {
935
- return direction === "asc" ? a - b : b - a;
936
- }
937
-
938
- // Compare strings (case-insensitive)
939
- if (typeof a === "string" && typeof b === "string") {
940
- const comparison = a.toLowerCase().localeCompare(b.toLowerCase());
941
- return direction === "asc" ? comparison : -comparison;
942
- }
943
-
944
- // Compare booleans (false < true)
945
- if (typeof a === "boolean" && typeof b === "boolean") {
946
- const aNum = a ? 1 : 0;
947
- const bNum = b ? 1 : 0;
948
- return direction === "asc" ? aNum - bNum : bNum - aNum;
949
- }
950
-
951
- // Fallback: convert to string and compare
952
- const aStr = String(a);
953
- const bStr = String(b);
954
- const comparison = aStr.localeCompare(bStr);
955
- return direction === "asc" ? comparison : -comparison;
956
- }
957
-
958
- /**
959
- * Sort an array of entries by the specified sort options.
960
- */
961
- function sortEntries(entries: any[], sortOptions: SortOptions): any[] {
962
- return [...entries].sort((a, b) => {
963
- const aValue = getSortValue(a, sortOptions.sortField);
964
- const bValue = getSortValue(b, sortOptions.sortField);
965
- return compareValues(aValue, bValue, sortOptions.sortDirection);
966
- });
967
- }
968
-
969
- /**
970
- * Internal helper to handle full-text search queries.
971
- * Uses the search_content search index for efficient text matching.
972
- *
973
- * Note: Convex search indexes don't support the paginator directly,
974
- * so we implement cursor-based pagination manually for search queries.
975
- * When filtering by multiple statuses, we query without status filter and
976
- * apply status filtering in post-processing.
977
- */
978
- async function handleSearchQuery(
979
- ctx: QueryCtx,
980
- args: {
981
- search: string;
982
- contentTypeId?: Id<"contentTypes">;
983
- statuses?: string[];
984
- locale?: string;
985
- includeDeleted: boolean;
986
- fieldFilters?: FieldFilter[];
987
- sortOptions: SortOptions;
988
- paginationOpts: PaginationOpts;
989
- }
990
- ): Promise<ContentEntryPaginationResult> {
991
- const { search, contentTypeId, statuses, locale, includeDeleted, fieldFilters, sortOptions, paginationOpts } = args;
992
- const { numItems, cursor } = paginationOpts;
993
-
994
- // Determine if we can use index-level status filtering
995
- // Only possible when filtering by exactly one status
996
- const singleStatus = statuses?.length === 1 ? statuses[0] : undefined;
997
-
998
- // Build search query with filter fields
999
- // The search_content index supports filtering by contentTypeId, status, and locale
1000
- const searchQuery = ctx.db
1001
- .query("contentEntries")
1002
- .withSearchIndex("search_content", (q: any) => {
1003
- let query = q.search("searchText", search);
1004
-
1005
- // Apply filter fields available in the search index
1006
- if (contentTypeId) {
1007
- query = query.eq("contentTypeId", contentTypeId);
1008
- }
1009
- // Only apply index-level status filter for single status
1010
- if (singleStatus) {
1011
- query = query.eq("status", singleStatus);
1012
- }
1013
- if (locale) {
1014
- query = query.eq("locale", locale);
1015
- }
1016
-
1017
- return query;
1018
- });
1019
-
1020
- // For multiple status filtering, soft-delete, and field filters we need to fetch more results
1021
- // to ensure we have enough after post-filtering
1022
- const hasFieldFilters = fieldFilters && fieldFilters.length > 0;
1023
- const fetchMultiplier = (statuses && statuses.length > 1) || !includeDeleted || hasFieldFilters ? 4 : 1;
1024
- const results = await searchQuery.take((numItems + 1) * fetchMultiplier);
1025
-
1026
- // Apply post-processing filters
1027
- let filteredResults = results;
1028
-
1029
- // Filter by soft-delete status
1030
- if (!includeDeleted) {
1031
- filteredResults = filteredResults.filter(
1032
- (entry: any) => !isDeleted(entry)
1033
- );
1034
- }
1035
-
1036
- // Filter by multiple statuses (when not using index-level filtering)
1037
- if (statuses && statuses.length > 1) {
1038
- filteredResults = filteredResults.filter((entry: any) =>
1039
- statuses.includes(entry.status)
1040
- );
1041
- }
1042
-
1043
- // Apply field-level filters to entry data
1044
- if (hasFieldFilters) {
1045
- filteredResults = filteredResults.filter((entry: any) =>
1046
- matchesAllFieldFilters(entry.data || {}, fieldFilters!)
1047
- );
1048
- }
1049
-
1050
- // Apply sorting to the filtered results
1051
- // Search results may not be in the desired order, so we always sort
1052
- const sortedResults = sortEntries(filteredResults, sortOptions);
1053
-
1054
- // Handle cursor-based pagination for search results
1055
- let startIndex = 0;
1056
- if (cursor) {
1057
- // Find the index of the cursor in results
1058
- const cursorIndex = sortedResults.findIndex(
1059
- (entry: any) => entry._id === cursor
1060
- );
1061
- if (cursorIndex !== -1) {
1062
- startIndex = cursorIndex + 1;
1063
- }
1064
- }
1065
-
1066
- // Get the page of results
1067
- const pageResults = sortedResults.slice(startIndex, startIndex + numItems + 1);
1068
- const isDone = pageResults.length <= numItems;
1069
- const page = isDone ? pageResults : pageResults.slice(0, numItems);
1070
-
1071
- // Get continuation cursor
1072
- const continueCursor =
1073
- !isDone && page.length > 0 ? page[page.length - 1]._id : null;
1074
-
1075
- return {
1076
- page,
1077
- continueCursor,
1078
- isDone,
1079
- };
1080
- }
1081
-
1082
- /**
1083
- * Internal helper to handle index-based queries using convex-helpers stream.
1084
- * Selects the optimal index based on provided filters and uses the stream
1085
- * helper for efficient cursor-based pagination with filtering support.
1086
- *
1087
- * When filtering by multiple statuses or field filters, uses filterWith for
1088
- * post-processing while maintaining efficient pagination.
1089
- *
1090
- * Sorting strategy:
1091
- * - For system fields (_creationTime, _id), we can use index-based ordering
1092
- * - For custom data fields or other system fields, we must use in-memory sorting
1093
- * which requires fetching more results upfront
1094
- */
1095
- async function handlePaginatorQuery(
1096
- ctx: QueryCtx,
1097
- args: {
1098
- contentTypeId?: Id<"contentTypes">;
1099
- statuses?: string[];
1100
- locale?: string;
1101
- includeDeleted: boolean;
1102
- fieldFilters?: FieldFilter[];
1103
- sortOptions: SortOptions;
1104
- paginationOpts: PaginationOpts;
1105
- }
1106
- ): Promise<ContentEntryPaginationResult> {
1107
- const { contentTypeId, statuses, locale, includeDeleted, fieldFilters, sortOptions, paginationOpts } = args;
1108
-
1109
- // Determine if we can use index-level status filtering
1110
- // Only possible when filtering by exactly one status
1111
- const singleStatus = statuses?.length === 1 ? statuses[0] : undefined;
1112
-
1113
- // Create stream with schema for type-safe pagination with filtering
1114
- const streamDb = stream(ctx.db, schema);
1115
-
1116
- // Build the base query using the most efficient index
1117
- let baseQuery;
1118
-
1119
- if (contentTypeId && singleStatus) {
1120
- // Use compound index for content type + single status filtering
1121
- baseQuery = streamDb
1122
- .query("contentEntries")
1123
- .withIndex("by_content_type_and_status", (q) =>
1124
- q.eq("contentTypeId", contentTypeId).eq("status", singleStatus as "draft" | "published" | "archived" | "scheduled")
1125
- );
1126
- } else if (contentTypeId) {
1127
- // Use content type index
1128
- baseQuery = streamDb
1129
- .query("contentEntries")
1130
- .withIndex("by_content_type", (q) =>
1131
- q.eq("contentTypeId", contentTypeId)
1132
- );
1133
- } else if (singleStatus) {
1134
- // Use status index for single status
1135
- baseQuery = streamDb
1136
- .query("contentEntries")
1137
- .withIndex("by_status", (q) => q.eq("status", singleStatus as "draft" | "published" | "archived" | "scheduled"));
1138
- } else if (locale) {
1139
- // Use locale index
1140
- baseQuery = streamDb
1141
- .query("contentEntries")
1142
- .withIndex("by_locale", (q) => q.eq("locale", locale));
1143
- } else {
1144
- // No specific filter - use creation time index (most efficient for full scans)
1145
- baseQuery = streamDb.query("contentEntries");
1146
- }
1147
-
1148
- // Check if field filters are present
1149
- const hasFieldFilters = fieldFilters && fieldFilters.length > 0;
1150
-
1151
- // Determine if we can use index-based sorting
1152
- // Only _creationTime supports index-based ordering in Convex
1153
- const canUseIndexSort = sortOptions.sortField === "_creationTime";
1154
- const needsCustomSort = !canUseIndexSort;
1155
-
1156
- // Determine if we need post-processing filters
1157
- const needsFiltering =
1158
- !includeDeleted ||
1159
- (statuses && statuses.length > 1) ||
1160
- (locale && !contentTypeId && !singleStatus) ||
1161
- hasFieldFilters;
1162
-
1163
- // Apply order based on sort direction (for _creationTime sorting)
1164
- const indexOrder = canUseIndexSort ? sortOptions.sortDirection : "desc";
1165
- const orderedQuery = baseQuery.order(indexOrder);
1166
-
1167
- // If custom sorting is needed, we must fetch all filtered results and sort in-memory
1168
- if (needsCustomSort) {
1169
- return handleCustomSortQuery(ctx, {
1170
- orderedQuery,
1171
- statuses,
1172
- locale,
1173
- contentTypeId,
1174
- singleStatus,
1175
- includeDeleted,
1176
- fieldFilters,
1177
- sortOptions,
1178
- paginationOpts,
1179
- });
1180
- }
1181
-
1182
- // If filtering is needed, use filterWith; otherwise use direct pagination
1183
- if (needsFiltering) {
1184
- const filteredQuery = orderedQuery.filterWith(async (entry: any) => {
1185
- // Filter out soft-deleted entries
1186
- if (!includeDeleted && isDeleted(entry)) {
1187
- return false;
1188
- }
1189
-
1190
- // Filter by multiple statuses (when not already filtered by index)
1191
- if (statuses && statuses.length > 1) {
1192
- if (!statuses.includes(entry.status)) {
1193
- return false;
1194
- }
1195
- }
1196
-
1197
- // Filter by locale if not already handled by index
1198
- if (locale && !contentTypeId && !singleStatus) {
1199
- if (entry.locale !== locale) {
1200
- return false;
1201
- }
1202
- }
1203
-
1204
- // Apply field-level filters to entry data
1205
- if (hasFieldFilters) {
1206
- if (!matchesAllFieldFilters(entry.data || {}, fieldFilters!)) {
1207
- return false;
1208
- }
1209
- }
1210
-
1211
- return true;
1212
- });
1213
-
1214
- // Execute pagination with maximumRowsRead for safety when filtering
1215
- // Increase the multiplier when field filters are present since they may filter out many entries
1216
- const maxRowsMultiplier = hasFieldFilters ? 20 : 10;
1217
- const result = await filteredQuery.paginate({
1218
- ...paginationOpts,
1219
- maximumRowsRead: paginationOpts.numItems * maxRowsMultiplier,
1220
- });
1221
-
1222
- return {
1223
- page: result.page,
1224
- continueCursor: result.continueCursor,
1225
- isDone: result.isDone,
1226
- };
1227
- }
1228
-
1229
- // No filtering needed - use direct pagination
1230
- const result = await orderedQuery.paginate(paginationOpts);
1231
-
1232
- return {
1233
- page: result.page,
1234
- continueCursor: result.continueCursor,
1235
- isDone: result.isDone,
1236
- };
1237
- }
1238
-
1239
- /**
1240
- * Internal helper to handle queries that require custom (in-memory) sorting.
1241
- * Used when sorting by fields other than _creationTime (e.g., firstPublishedAt,
1242
- * lastPublishedAt, or custom data fields like data.price).
1243
- *
1244
- * This fetches more results upfront, applies filtering, sorts them in-memory,
1245
- * and then implements cursor-based pagination on the sorted results.
1246
- */
1247
- async function handleCustomSortQuery(
1248
- _ctx: QueryCtx,
1249
- args: {
1250
- // Stream query from convex-helpers - complex generic type, kept untyped for simplicity
1251
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1252
- orderedQuery: any;
1253
- statuses?: string[];
1254
- locale?: string;
1255
- contentTypeId?: Id<"contentTypes">;
1256
- singleStatus?: string;
1257
- includeDeleted: boolean;
1258
- fieldFilters?: FieldFilter[];
1259
- sortOptions: SortOptions;
1260
- paginationOpts: PaginationOpts;
1261
- }
1262
- ): Promise<ContentEntryPaginationResult> {
1263
- const {
1264
- orderedQuery,
1265
- statuses,
1266
- locale,
1267
- contentTypeId,
1268
- singleStatus,
1269
- includeDeleted,
1270
- fieldFilters,
1271
- sortOptions,
1272
- paginationOpts,
1273
- } = args;
1274
-
1275
- const hasFieldFilters = fieldFilters && fieldFilters.length > 0;
1276
- const { numItems, cursor } = paginationOpts;
1277
-
1278
- // For custom sorting, we need to fetch more results since we can't rely on index ordering
1279
- // We fetch a multiplier of the requested items to ensure we have enough after filtering
1280
- const fetchMultiplier = hasFieldFilters ? 20 : 10;
1281
- const fetchLimit = (numItems + 1) * fetchMultiplier;
1282
-
1283
- // Collect results from the stream
1284
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1285
- const _allResults: any[] = [];
1286
- let hasMore = false;
1287
-
1288
- // Use filterWith to apply filters while collecting results
1289
- const filteredQuery = orderedQuery.filterWith(async (entry: any) => {
1290
- // Filter out soft-deleted entries
1291
- if (!includeDeleted && isDeleted(entry)) {
1292
- return false;
1293
- }
1294
-
1295
- // Filter by multiple statuses (when not already filtered by index)
1296
- if (statuses && statuses.length > 1) {
1297
- if (!statuses.includes(entry.status)) {
1298
- return false;
1299
- }
1300
- }
1301
-
1302
- // Filter by locale if not already handled by index
1303
- if (locale && !contentTypeId && !singleStatus) {
1304
- if (entry.locale !== locale) {
1305
- return false;
1306
- }
1307
- }
1308
-
1309
- // Apply field-level filters to entry data
1310
- if (hasFieldFilters) {
1311
- if (!matchesAllFieldFilters(entry.data || {}, fieldFilters!)) {
1312
- return false;
1313
- }
1314
- }
1315
-
1316
- return true;
1317
- });
1318
-
1319
- // Fetch limited results
1320
- const result = await filteredQuery.paginate({
1321
- numItems: fetchLimit,
1322
- cursor: null, // Always start from beginning for custom sort
1323
- maximumRowsRead: fetchLimit * 2,
1324
- });
1325
-
1326
- const filteredResults = result.page;
1327
- hasMore = !result.isDone;
1328
-
1329
- // Sort the filtered results in-memory
1330
- const sortedResults = sortEntries(filteredResults, sortOptions);
1331
-
1332
- // Handle cursor-based pagination on sorted results
1333
- let startIndex = 0;
1334
- if (cursor) {
1335
- // Find the index of the cursor in sorted results
1336
- const cursorIndex = sortedResults.findIndex(
1337
- (entry: any) => entry._id === cursor
1338
- );
1339
- if (cursorIndex !== -1) {
1340
- startIndex = cursorIndex + 1;
1341
- }
1342
- }
1343
-
1344
- // Get the page of results
1345
- const pageResults = sortedResults.slice(startIndex, startIndex + numItems + 1);
1346
- const pageIsDone = pageResults.length <= numItems && !hasMore;
1347
- const page = pageResults.length > numItems ? pageResults.slice(0, numItems) : pageResults;
1348
-
1349
- // Get continuation cursor
1350
- const continueCursor =
1351
- page.length > 0 && !pageIsDone ? page[page.length - 1]._id : null;
1352
-
1353
- return {
1354
- page,
1355
- continueCursor,
1356
- isDone: pageIsDone || page.length < numItems,
1357
- };
1358
- }
1359
-
1360
- // =============================================================================
1361
- // Version History Query
1362
- // =============================================================================
1363
-
1364
- /**
1365
- * Arguments for retrieving version history.
1366
- * Uses the existing getVersionHistoryArgs validator pattern.
1367
- */
1368
- const versionHistoryArgs = v.object({
1369
- /** The ID of the content entry to get version history for */
1370
- entryId: v.id("contentEntries"),
1371
- /** Standard pagination options */
1372
- paginationOpts: paginationOptsValidator,
1373
- });
1374
-
1375
- /**
1376
- * Paginated response for version history.
1377
- * Returns version documents ordered by version number descending (newest first).
1378
- */
1379
- const paginatedVersionHistoryResponse = v.object({
1380
- /** Array of version documents for this page */
1381
- page: v.array(contentVersionDoc),
1382
- /** Cursor for fetching the next page */
1383
- continueCursor: v.union(v.string(), v.null()),
1384
- /** Whether this is the last page */
1385
- isDone: v.boolean(),
1386
- });
1387
-
1388
- /**
1389
- * Query to retrieve version history for a content entry.
1390
- *
1391
- * Returns a paginated list of version snapshots ordered by version number
1392
- * descending (newest versions first). Each version includes:
1393
- * - versionNumber: The version at the time of the snapshot
1394
- * - data: Snapshot of the content data
1395
- * - slug: Snapshot of the slug
1396
- * - status: Status when the version was created
1397
- * - changeDescription: Optional description of changes
1398
- * - createdBy: User who created this version
1399
- * - wasPublished: Whether this version was published
1400
- * - publishedAt: When this version was published (if ever)
1401
- *
1402
- * @param entryId - The content entry ID to get version history for
1403
- * @param paginationOpts - Standard Convex pagination options (numItems, cursor)
1404
- *
1405
- * @returns PaginationResult with version documents, or null if entry not found
1406
- *
1407
- * @example
1408
- * ```typescript
1409
- * // Get first page of version history
1410
- * const history = await ctx.runQuery(
1411
- * components.convexCms.contentEntries.getVersionHistory,
1412
- * {
1413
- * entryId: entryId,
1414
- * paginationOpts: { numItems: 10 },
1415
- * }
1416
- * );
1417
- *
1418
- * // Get published versions only
1419
- * const publishedVersions = history?.page.filter(v => v.wasPublished);
1420
- *
1421
- * // Paginate through history
1422
- * if (!history.isDone) {
1423
- * const nextPage = await ctx.runQuery(
1424
- * components.convexCms.contentEntries.getVersionHistory,
1425
- * {
1426
- * entryId: entryId,
1427
- * paginationOpts: {
1428
- * numItems: 10,
1429
- * cursor: history.continueCursor,
1430
- * },
1431
- * }
1432
- * );
1433
- * }
1434
- *
1435
- * // Compare versions
1436
- * const [current, previous] = history.page;
1437
- * console.log("Changes from v" + previous.versionNumber + " to v" + current.versionNumber);
1438
- * ```
1439
- */
1440
- export const getVersionHistory = query({
1441
- args: versionHistoryArgs.fields,
1442
- returns: v.union(paginatedVersionHistoryResponse, v.null()),
1443
- handler: async (ctx, args) => {
1444
- const { entryId, paginationOpts } = args;
1445
-
1446
- // Verify the entry exists and is not deleted
1447
- const entry = await ctx.db.get(entryId);
1448
-
1449
- if (!entry) {
1450
- return null;
1451
- }
1452
-
1453
- // Return null if entry has been soft-deleted
1454
- // (deleted entries should not expose version history)
1455
- if (isDeleted(entry)) {
1456
- return null;
1457
- }
1458
-
1459
- // Clamp numItems to valid range
1460
- const numItems = Math.min(
1461
- Math.max(1, paginationOpts.numItems ?? DEFAULT_NUM_ITEMS),
1462
- MAX_NUM_ITEMS
1463
- );
1464
-
1465
- const clampedPaginationOpts = {
1466
- ...paginationOpts,
1467
- numItems,
1468
- };
1469
-
1470
- // Create stream with schema for type-safe pagination
1471
- const streamDb = stream(ctx.db, schema);
1472
-
1473
- // Query versions using the by_entry index, ordered by creation time descending
1474
- // This gives us newest versions first
1475
- const result = await streamDb
1476
- .query("contentVersions")
1477
- .withIndex("by_entry", (q) => q.eq("entryId", entryId))
1478
- .order("desc")
1479
- .paginate(clampedPaginationOpts);
1480
-
1481
- return {
1482
- page: result.page,
1483
- continueCursor: result.continueCursor,
1484
- isDone: result.isDone,
1485
- };
1486
- },
1487
- });
1488
-
1489
- // =============================================================================
1490
- // Get Specific Version Query
1491
- // =============================================================================
1492
-
1493
- /**
1494
- * Retrieve a specific version of a content entry by version ID or number.
1495
- *
1496
- * This query allows fetching the complete content state at a specific version,
1497
- * which is useful for:
1498
- * - Version comparison/diff views
1499
- * - Previewing historical content states
1500
- * - Rollback preparation (viewing what content looked like)
1501
- * - Audit trail investigation
1502
- *
1503
- * ## Lookup Methods
1504
- *
1505
- * You can retrieve a version by either:
1506
- * 1. **Version ID** (`versionId`): Direct document lookup using the `_id` field
1507
- * 2. **Version Number** (`versionNumber`): Uses the compound index for efficient lookup
1508
- *
1509
- * At least one of `versionId` or `versionNumber` must be provided.
1510
- * If both are provided, `versionId` takes precedence.
1511
- *
1512
- * ## Security
1513
- *
1514
- * - Returns `null` if the parent entry doesn't exist or has been soft-deleted
1515
- * - Validates that the version belongs to the specified entry (prevents cross-entry access)
1516
- *
1517
- * ## Example Usage
1518
- *
1519
- * ```typescript
1520
- * // Get version by version number
1521
- * const versionByNumber = await ctx.runQuery(
1522
- * api.contentEntries.getVersion,
1523
- * {
1524
- * entryId: entryId,
1525
- * versionNumber: 3
1526
- * }
1527
- * );
1528
- *
1529
- * // Get version by version ID
1530
- * const versionById = await ctx.runQuery(
1531
- * api.contentEntries.getVersion,
1532
- * {
1533
- * entryId: entryId,
1534
- * versionId: someVersionId
1535
- * }
1536
- * );
1537
- *
1538
- * // Access version data
1539
- * if (versionByNumber) {
1540
- * console.log("Content at v3:", versionByNumber.data);
1541
- * console.log("Slug at v3:", versionByNumber.slug);
1542
- * console.log("Status at v3:", versionByNumber.status);
1543
- * console.log("Was published:", versionByNumber.wasPublished);
1544
- * }
1545
- * ```
1546
- */
1547
- export const getVersion = query({
1548
- args: {
1549
- entryId: v.id("contentEntries"),
1550
- versionId: v.optional(v.id("contentVersions")),
1551
- versionNumber: v.optional(v.number()),
1552
- },
1553
- returns: v.union(contentVersionDoc, v.null()),
1554
- handler: async (ctx, args) => {
1555
- const { entryId, versionId, versionNumber } = args;
1556
-
1557
- // Validate that at least one lookup method is provided
1558
- if (versionId === undefined && versionNumber === undefined) {
1559
- // Return null instead of throwing to maintain consistent query behavior
1560
- return null;
1561
- }
1562
-
1563
- // Verify the entry exists and is not soft-deleted
1564
- const entry = await ctx.db.get(entryId);
1565
-
1566
- if (!entry) {
1567
- return null;
1568
- }
1569
-
1570
- // Return null for soft-deleted entries (they shouldn't expose version history)
1571
- if (isDeleted(entry)) {
1572
- return null;
1573
- }
1574
-
1575
- // Lookup by version ID (direct document fetch)
1576
- if (versionId !== undefined) {
1577
- const version = await ctx.db.get(versionId);
1578
-
1579
- // Validate version exists and belongs to the specified entry
1580
- if (!version || version.entryId !== entryId) {
1581
- return null;
1582
- }
1583
-
1584
- return version;
1585
- }
1586
-
1587
- // Lookup by version number (compound index query)
1588
- if (versionNumber !== undefined) {
1589
- const version = await ctx.db
1590
- .query("contentVersions")
1591
- .withIndex("by_entry_and_version", (q) =>
1592
- q.eq("entryId", entryId).eq("versionNumber", versionNumber)
1593
- )
1594
- .first();
1595
-
1596
- return version ?? null;
1597
- }
1598
-
1599
- return null;
1600
- },
1601
- });
1602
-
1603
- // =============================================================================
1604
- // Version Comparison Helper Functions
1605
- // =============================================================================
1606
-
1607
- /**
1608
- * Detect which fields changed between two data objects.
1609
- * Skips internal fields (starting with underscore).
1610
- *
1611
- * @internal
1612
- */
1613
- function detectChangedDataFields(
1614
- fromData: Record<string, unknown> | undefined | null,
1615
- toData: Record<string, unknown> | undefined | null
1616
- ): string[] {
1617
- if (!fromData && !toData) {
1618
- return [];
1619
- }
1620
-
1621
- const from = fromData ?? {};
1622
- const to = toData ?? {};
1623
-
1624
- const changedFields: string[] = [];
1625
- const allKeys = new Set([...Object.keys(from), ...Object.keys(to)]);
1626
-
1627
- for (const key of allKeys) {
1628
- // Skip internal fields
1629
- if (key.startsWith("_")) continue;
1630
-
1631
- const fromValue = from[key];
1632
- const toValue = to[key];
1633
-
1634
- // Deep comparison using JSON serialization
1635
- if (JSON.stringify(fromValue) !== JSON.stringify(toValue)) {
1636
- changedFields.push(key);
1637
- }
1638
- }
1639
-
1640
- return changedFields;
1641
- }
1642
-
1643
- /**
1644
- * Determine the type of change for a field.
1645
- *
1646
- * @internal
1647
- */
1648
- function getChangeType(
1649
- fromData: Record<string, unknown>,
1650
- toData: Record<string, unknown>,
1651
- field: string
1652
- ): "added" | "removed" | "modified" {
1653
- const hasInFrom = field in fromData;
1654
- const hasInTo = field in toData;
1655
-
1656
- if (!hasInFrom && hasInTo) {
1657
- return "added";
1658
- }
1659
- if (hasInFrom && !hasInTo) {
1660
- return "removed";
1661
- }
1662
- return "modified";
1663
- }
1664
-
1665
- /**
1666
- * Generate a human-readable summary of version changes.
1667
- *
1668
- * @internal
1669
- */
1670
- function generateVersionChangeSummary(
1671
- changedFields: string[],
1672
- slugChanged: boolean,
1673
- statusChanged: boolean
1674
- ): string {
1675
- const parts: string[] = [];
1676
-
1677
- if (changedFields.length > 0) {
1678
- if (changedFields.length <= 3) {
1679
- parts.push(`${changedFields.length} field${changedFields.length === 1 ? "" : "s"} changed: ${changedFields.join(", ")}`);
1680
- } else {
1681
- parts.push(`${changedFields.length} fields changed: ${changedFields.slice(0, 3).join(", ")} and ${changedFields.length - 3} more`);
1682
- }
1683
- }
1684
-
1685
- if (slugChanged) {
1686
- parts.push("slug changed");
1687
- }
1688
-
1689
- if (statusChanged) {
1690
- parts.push("status changed");
1691
- }
1692
-
1693
- if (parts.length === 0) {
1694
- return "No changes";
1695
- }
1696
-
1697
- return parts.join("; ");
1698
- }
1699
-
1700
- // =============================================================================
1701
- // Version Comparison Query
1702
- // =============================================================================
1703
-
1704
- /**
1705
- * Compare two versions of a content entry and return field-level differences.
1706
- *
1707
- * This query retrieves two version snapshots by version number and computes
1708
- * a detailed diff showing which fields changed, what the before/after values
1709
- * are, and whether metadata like slug and status also changed.
1710
- *
1711
- * @example
1712
- * ```typescript
1713
- * // Compare version 2 to version 5 of an entry
1714
- * const diff = await ctx.runQuery(api.contentEntries.compareVersions, {
1715
- * entryId: entryId,
1716
- * fromVersionNumber: 2,
1717
- * toVersionNumber: 5,
1718
- * });
1719
- *
1720
- * if (diff.hasChanges) {
1721
- * console.log("Changes:", diff.changeSummary);
1722
- * for (const fieldDiff of diff.fieldDiffs) {
1723
- * console.log(`Field: ${fieldDiff.field}`);
1724
- * console.log(` Change type: ${fieldDiff.changeType}`);
1725
- * console.log(` From: ${JSON.stringify(fieldDiff.fromValue)}`);
1726
- * console.log(` To: ${JSON.stringify(fieldDiff.toValue)}`);
1727
- * }
1728
- * }
1729
- * ```
1730
- *
1731
- * @param entryId - The ID of the content entry to compare versions for
1732
- * @param fromVersionNumber - The version number of the "from" (older/base) version
1733
- * @param toVersionNumber - The version number of the "to" (newer/target) version
1734
- * @returns Detailed comparison result or null if entry is deleted or versions don't exist
1735
- */
1736
- export const compareVersions = query({
1737
- args: compareVersionsArgs.fields,
1738
- returns: v.union(compareVersionsResult, v.null()),
1739
- handler: async (ctx, args) => {
1740
- const { entryId, fromVersionNumber, toVersionNumber } = args;
1741
-
1742
- // Verify the entry exists and is not soft-deleted
1743
- const entry = await ctx.db.get(entryId);
1744
- if (!entry || isDeleted(entry)) {
1745
- return null;
1746
- }
1747
-
1748
- // Fetch both versions using the compound index
1749
- const [fromVersion, toVersion] = await Promise.all([
1750
- ctx.db
1751
- .query("contentVersions")
1752
- .withIndex("by_entry_and_version", (q) =>
1753
- q.eq("entryId", entryId).eq("versionNumber", fromVersionNumber)
1754
- )
1755
- .first(),
1756
- ctx.db
1757
- .query("contentVersions")
1758
- .withIndex("by_entry_and_version", (q) =>
1759
- q.eq("entryId", entryId).eq("versionNumber", toVersionNumber)
1760
- )
1761
- .first(),
1762
- ]);
1763
-
1764
- // Return null if either version doesn't exist
1765
- if (!fromVersion || !toVersion) {
1766
- return null;
1767
- }
1768
-
1769
- // Extract data from both versions (content data is stored in `data` field)
1770
- const fromData = (fromVersion.data as Record<string, unknown>) ?? {};
1771
- const toData = (toVersion.data as Record<string, unknown>) ?? {};
1772
-
1773
- // Detect changed fields in the content data
1774
- const changedFields = detectChangedDataFields(fromData, toData);
1775
-
1776
- // Check if slug changed
1777
- const slugChanged = fromVersion.slug !== toVersion.slug;
1778
-
1779
- // Check if status changed
1780
- const statusChanged = fromVersion.status !== toVersion.status;
1781
-
1782
- // Build field diffs with before/after values
1783
- const fieldDiffs: Infer<typeof versionFieldDiff>[] = changedFields.map(
1784
- (field) => ({
1785
- field,
1786
- fromValue: fromData[field],
1787
- toValue: toData[field],
1788
- changeType: getChangeType(fromData, toData, field),
1789
- })
1790
- );
1791
-
1792
- // Generate human-readable summary
1793
- const changeSummary = generateVersionChangeSummary(
1794
- changedFields,
1795
- slugChanged,
1796
- statusChanged
1797
- );
1798
-
1799
- // Determine if there are any changes at all
1800
- const hasChanges =
1801
- changedFields.length > 0 || slugChanged || statusChanged;
1802
-
1803
- return {
1804
- hasChanges,
1805
- fromVersion: {
1806
- versionNumber: fromVersion.versionNumber,
1807
- status: fromVersion.status,
1808
- slug: fromVersion.slug,
1809
- wasPublished: fromVersion.wasPublished,
1810
- createdAt: fromVersion._creationTime,
1811
- },
1812
- toVersion: {
1813
- versionNumber: toVersion.versionNumber,
1814
- status: toVersion.status,
1815
- slug: toVersion.slug,
1816
- wasPublished: toVersion.wasPublished,
1817
- createdAt: toVersion._creationTime,
1818
- },
1819
- changedFields,
1820
- fieldDiffs,
1821
- slugChanged,
1822
- statusChanged,
1823
- changeSummary,
1824
- };
1825
- },
1826
- });
1827
-
1828
- // =============================================================================
1829
- // Count Query
1830
- // =============================================================================
1831
-
1832
- /**
1833
- * Arguments for counting content entries.
1834
- */
1835
- const countContentEntriesArgs = v.object({
1836
- /** Filter by content type ID */
1837
- contentTypeId: v.optional(v.id("contentTypes")),
1838
- /** Filter by content type name (alternative to contentTypeId) */
1839
- contentTypeName: v.optional(v.string()),
1840
- /** Filter by a single entry status */
1841
- status: v.optional(contentStatusValidator),
1842
- /** Filter by multiple statuses */
1843
- statusIn: v.optional(v.array(contentStatusValidator)),
1844
- /** Whether to include soft-deleted entries (default: false) */
1845
- includeDeleted: v.optional(v.boolean()),
1846
- });
1847
-
1848
- /**
1849
- * Query to count content entries matching the given filters.
1850
- *
1851
- * This query efficiently counts entries without loading all entry data.
1852
- * It uses database indexes for filtering and iterates through matching
1853
- * entries to provide an accurate count regardless of the number of entries.
1854
- *
1855
- * Unlike the `list` query which is limited by pagination, this query
1856
- * counts ALL matching entries and returns the total.
1857
- *
1858
- * @param contentTypeId - Optional content type ID to filter by
1859
- * @param contentTypeName - Optional content type name (resolved to ID internally)
1860
- * @param status - Optional single status filter
1861
- * @param statusIn - Optional array of statuses to filter by
1862
- * @param includeDeleted - Whether to include soft-deleted entries (default: false)
1863
- *
1864
- * @returns Object containing the count of matching entries
1865
- *
1866
- * @example
1867
- * ```typescript
1868
- * // Count all entries for a content type
1869
- * const { count } = await ctx.runQuery(
1870
- * components.convexCms.contentEntries.count,
1871
- * { contentTypeId: blogTypeId }
1872
- * );
1873
- * console.log(`Blog posts: ${count}`);
1874
- *
1875
- * // Count published entries only
1876
- * const { count: publishedCount } = await ctx.runQuery(
1877
- * components.convexCms.contentEntries.count,
1878
- * { contentTypeId: blogTypeId, status: "published" }
1879
- * );
1880
- *
1881
- * // Count entries by content type name
1882
- * const { count: productCount } = await ctx.runQuery(
1883
- * components.convexCms.contentEntries.count,
1884
- * { contentTypeName: "product" }
1885
- * );
1886
- * ```
1887
- */
1888
- export const count = query({
1889
- args: countContentEntriesArgs.fields,
1890
- returns: v.object({
1891
- count: v.number(),
1892
- }),
1893
- handler: async (ctx, args) => {
1894
- const {
1895
- contentTypeId,
1896
- contentTypeName,
1897
- status,
1898
- statusIn,
1899
- includeDeleted = false,
1900
- } = args;
1901
-
1902
- // Resolve status filter: statusIn takes precedence, then status
1903
- const resolvedStatuses: string[] | undefined = statusIn?.length
1904
- ? statusIn
1905
- : status
1906
- ? [status]
1907
- : undefined;
1908
-
1909
- // Resolve content type ID from name if provided
1910
- let resolvedContentTypeId = contentTypeId;
1911
- if (!resolvedContentTypeId && contentTypeName) {
1912
- const contentType = await ctx.db
1913
- .query("contentTypes")
1914
- .withIndex("by_name", (q) => q.eq("name", contentTypeName))
1915
- .first();
1916
-
1917
- // If content type not found or inactive, return 0 count
1918
- if (!contentType || !contentType.isActive || isDeleted(contentType)) {
1919
- return { count: 0 };
1920
- }
1921
-
1922
- resolvedContentTypeId = contentType._id;
1923
- }
1924
-
1925
- // Determine if we can use index-level status filtering
1926
- const singleStatus = resolvedStatuses?.length === 1 ? resolvedStatuses[0] : undefined;
1927
-
1928
- // Build and execute the query using the most efficient index
1929
- let queryBuilder;
1930
-
1931
- if (resolvedContentTypeId && singleStatus) {
1932
- // Use compound index for content type + single status filtering
1933
- queryBuilder = ctx.db
1934
- .query("contentEntries")
1935
- .withIndex("by_content_type_and_status", (q) =>
1936
- q.eq("contentTypeId", resolvedContentTypeId!).eq("status", singleStatus as "draft" | "published" | "archived" | "scheduled")
1937
- );
1938
- } else if (resolvedContentTypeId) {
1939
- // Use content type index
1940
- queryBuilder = ctx.db
1941
- .query("contentEntries")
1942
- .withIndex("by_content_type", (q) =>
1943
- q.eq("contentTypeId", resolvedContentTypeId!)
1944
- );
1945
- } else if (singleStatus) {
1946
- // Use status index for single status
1947
- queryBuilder = ctx.db
1948
- .query("contentEntries")
1949
- .withIndex("by_status", (q) => q.eq("status", singleStatus as "draft" | "published" | "archived" | "scheduled"));
1950
- } else {
1951
- // No specific filter - full table scan
1952
- queryBuilder = ctx.db.query("contentEntries");
1953
- }
1954
-
1955
- // Count entries by iterating through the query results
1956
- let count = 0;
1957
-
1958
- for await (const entry of queryBuilder) {
1959
- // Filter out soft-deleted entries unless explicitly requested
1960
- if (!includeDeleted && isDeleted(entry)) {
1961
- continue;
1962
- }
1963
-
1964
- // Filter by multiple statuses (when not already filtered by index)
1965
- if (resolvedStatuses && resolvedStatuses.length > 1) {
1966
- if (!resolvedStatuses.includes(entry.status)) {
1967
- continue;
1968
- }
1969
- }
1970
-
1971
- count++;
1972
- }
1973
-
1974
- return { count };
1975
- },
1976
- });