convex-cms 0.0.1

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