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.
- package/dist/cli/commands/admin.d.ts +16 -0
- package/dist/cli/commands/admin.d.ts.map +1 -0
- package/dist/cli/commands/admin.js +88 -0
- package/dist/cli/commands/admin.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/utils/detectConvexUrl.d.ts +13 -0
- package/dist/cli/utils/detectConvexUrl.d.ts.map +1 -0
- package/dist/cli/utils/detectConvexUrl.js +48 -0
- package/dist/cli/utils/detectConvexUrl.js.map +1 -0
- package/dist/cli/utils/openBrowser.d.ts +7 -0
- package/dist/cli/utils/openBrowser.d.ts.map +1 -0
- package/dist/cli/utils/openBrowser.js +17 -0
- package/dist/cli/utils/openBrowser.js.map +1 -0
- package/dist/client/admin-config.d.ts +126 -0
- package/dist/client/admin-config.d.ts.map +1 -0
- package/dist/client/admin-config.js +117 -0
- package/dist/client/admin-config.js.map +1 -0
- package/dist/client/adminApi.d.ts +2273 -0
- package/dist/client/adminApi.d.ts.map +1 -0
- package/dist/client/adminApi.js +716 -0
- package/dist/client/adminApi.js.map +1 -0
- package/dist/client/agentTools.d.ts +933 -0
- package/dist/client/agentTools.d.ts.map +1 -0
- package/dist/client/agentTools.js +1004 -0
- package/dist/client/agentTools.js.map +1 -0
- package/dist/client/argTypes.d.ts +212 -0
- package/dist/client/argTypes.d.ts.map +1 -0
- package/dist/client/argTypes.js +5 -0
- package/dist/client/argTypes.js.map +1 -0
- package/dist/client/field-types.d.ts +55 -0
- package/dist/client/field-types.d.ts.map +1 -0
- package/dist/client/field-types.js +152 -0
- package/dist/client/field-types.js.map +1 -0
- package/dist/client/index.d.ts +189 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +668 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/queryBuilder.d.ts +765 -0
- package/dist/client/queryBuilder.d.ts.map +1 -0
- package/dist/client/queryBuilder.js +970 -0
- package/dist/client/queryBuilder.js.map +1 -0
- package/dist/client/schema/codegen.d.ts +128 -0
- package/dist/client/schema/codegen.d.ts.map +1 -0
- package/dist/client/schema/codegen.js +318 -0
- package/dist/client/schema/codegen.js.map +1 -0
- package/dist/client/schema/defineContentType.d.ts +221 -0
- package/dist/client/schema/defineContentType.d.ts.map +1 -0
- package/dist/client/schema/defineContentType.js +380 -0
- package/dist/client/schema/defineContentType.js.map +1 -0
- package/dist/client/schema/index.d.ts +85 -0
- package/dist/client/schema/index.d.ts.map +1 -0
- package/dist/client/schema/index.js +92 -0
- package/dist/client/schema/index.js.map +1 -0
- package/dist/client/schema/schemaDrift.d.ts +199 -0
- package/dist/client/schema/schemaDrift.d.ts.map +1 -0
- package/dist/client/schema/schemaDrift.js +340 -0
- package/dist/client/schema/schemaDrift.js.map +1 -0
- package/dist/client/schema/typedClient.d.ts +401 -0
- package/dist/client/schema/typedClient.d.ts.map +1 -0
- package/dist/client/schema/typedClient.js +269 -0
- package/dist/client/schema/typedClient.js.map +1 -0
- package/dist/client/schema/types.d.ts +477 -0
- package/dist/client/schema/types.d.ts.map +1 -0
- package/dist/client/schema/types.js +39 -0
- package/dist/client/schema/types.js.map +1 -0
- package/dist/client/types.d.ts +449 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +149 -0
- package/dist/client/types.js.map +1 -0
- package/dist/client/workflows.d.ts +51 -0
- package/dist/client/workflows.d.ts.map +1 -0
- package/dist/client/workflows.js +103 -0
- package/dist/client/workflows.js.map +1 -0
- package/dist/client/wrapper.d.ts +2198 -0
- package/dist/client/wrapper.d.ts.map +1 -0
- package/dist/client/wrapper.js +2651 -0
- package/dist/client/wrapper.js.map +1 -0
- package/dist/component/_generated/api.d.ts +124 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +4321 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/auditLog.d.ts +410 -0
- package/dist/component/auditLog.d.ts.map +1 -0
- package/dist/component/auditLog.js +607 -0
- package/dist/component/auditLog.js.map +1 -0
- package/dist/component/authorization.d.ts +323 -0
- package/dist/component/authorization.d.ts.map +1 -0
- package/dist/component/authorization.js +464 -0
- package/dist/component/authorization.js.map +1 -0
- package/dist/component/authorizationHooks.d.ts +184 -0
- package/dist/component/authorizationHooks.d.ts.map +1 -0
- package/dist/component/authorizationHooks.js +521 -0
- package/dist/component/authorizationHooks.js.map +1 -0
- package/dist/component/bulkOperations.d.ts +200 -0
- package/dist/component/bulkOperations.d.ts.map +1 -0
- package/dist/component/bulkOperations.js +568 -0
- package/dist/component/bulkOperations.js.map +1 -0
- package/dist/component/contentEntries.d.ts +719 -0
- package/dist/component/contentEntries.d.ts.map +1 -0
- package/dist/component/contentEntries.js +1617 -0
- package/dist/component/contentEntries.js.map +1 -0
- package/dist/component/contentEntryMutations.d.ts +505 -0
- package/dist/component/contentEntryMutations.d.ts.map +1 -0
- package/dist/component/contentEntryMutations.js +1009 -0
- package/dist/component/contentEntryMutations.js.map +1 -0
- package/dist/component/contentEntryValidation.d.ts +115 -0
- package/dist/component/contentEntryValidation.d.ts.map +1 -0
- package/dist/component/contentEntryValidation.js +546 -0
- package/dist/component/contentEntryValidation.js.map +1 -0
- package/dist/component/contentLock.d.ts +328 -0
- package/dist/component/contentLock.d.ts.map +1 -0
- package/dist/component/contentLock.js +471 -0
- package/dist/component/contentLock.js.map +1 -0
- package/dist/component/contentTypeMigration.d.ts +411 -0
- package/dist/component/contentTypeMigration.d.ts.map +1 -0
- package/dist/component/contentTypeMigration.js +805 -0
- package/dist/component/contentTypeMigration.js.map +1 -0
- package/dist/component/contentTypeMutations.d.ts +975 -0
- package/dist/component/contentTypeMutations.d.ts.map +1 -0
- package/dist/component/contentTypeMutations.js +768 -0
- package/dist/component/contentTypeMutations.js.map +1 -0
- package/dist/component/contentTypes.d.ts +538 -0
- package/dist/component/contentTypes.d.ts.map +1 -0
- package/dist/component/contentTypes.js +304 -0
- package/dist/component/contentTypes.js.map +1 -0
- package/dist/component/convex.config.d.ts +42 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +43 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/documentTypes.d.ts +186 -0
- package/dist/component/documentTypes.d.ts.map +1 -0
- package/dist/component/documentTypes.js +23 -0
- package/dist/component/documentTypes.js.map +1 -0
- package/dist/component/eventEmitter.d.ts +281 -0
- package/dist/component/eventEmitter.d.ts.map +1 -0
- package/dist/component/eventEmitter.js +300 -0
- package/dist/component/eventEmitter.js.map +1 -0
- package/dist/component/exportImport.d.ts +1120 -0
- package/dist/component/exportImport.d.ts.map +1 -0
- package/dist/component/exportImport.js +931 -0
- package/dist/component/exportImport.js.map +1 -0
- package/dist/component/index.d.ts +28 -0
- package/dist/component/index.d.ts.map +1 -0
- package/dist/component/index.js +142 -0
- package/dist/component/index.js.map +1 -0
- package/dist/component/lib/deepReferenceResolver.d.ts +252 -0
- package/dist/component/lib/deepReferenceResolver.d.ts.map +1 -0
- package/dist/component/lib/deepReferenceResolver.js +601 -0
- package/dist/component/lib/deepReferenceResolver.js.map +1 -0
- package/dist/component/lib/errors.d.ts +306 -0
- package/dist/component/lib/errors.d.ts.map +1 -0
- package/dist/component/lib/errors.js +407 -0
- package/dist/component/lib/errors.js.map +1 -0
- package/dist/component/lib/index.d.ts +10 -0
- package/dist/component/lib/index.d.ts.map +1 -0
- package/dist/component/lib/index.js +33 -0
- package/dist/component/lib/index.js.map +1 -0
- package/dist/component/lib/mediaReferenceResolver.d.ts +217 -0
- package/dist/component/lib/mediaReferenceResolver.d.ts.map +1 -0
- package/dist/component/lib/mediaReferenceResolver.js +326 -0
- package/dist/component/lib/mediaReferenceResolver.js.map +1 -0
- package/dist/component/lib/metadataExtractor.d.ts +245 -0
- package/dist/component/lib/metadataExtractor.d.ts.map +1 -0
- package/dist/component/lib/metadataExtractor.js +548 -0
- package/dist/component/lib/metadataExtractor.js.map +1 -0
- package/dist/component/lib/mutationAuth.d.ts +95 -0
- package/dist/component/lib/mutationAuth.d.ts.map +1 -0
- package/dist/component/lib/mutationAuth.js +146 -0
- package/dist/component/lib/mutationAuth.js.map +1 -0
- package/dist/component/lib/queries.d.ts +17 -0
- package/dist/component/lib/queries.d.ts.map +1 -0
- package/dist/component/lib/queries.js +49 -0
- package/dist/component/lib/queries.js.map +1 -0
- package/dist/component/lib/ragContentChunker.d.ts +423 -0
- package/dist/component/lib/ragContentChunker.d.ts.map +1 -0
- package/dist/component/lib/ragContentChunker.js +897 -0
- package/dist/component/lib/ragContentChunker.js.map +1 -0
- package/dist/component/lib/referenceResolver.d.ts +175 -0
- package/dist/component/lib/referenceResolver.d.ts.map +1 -0
- package/dist/component/lib/referenceResolver.js +293 -0
- package/dist/component/lib/referenceResolver.js.map +1 -0
- package/dist/component/lib/slugGenerator.d.ts +71 -0
- package/dist/component/lib/slugGenerator.d.ts.map +1 -0
- package/dist/component/lib/slugGenerator.js +207 -0
- package/dist/component/lib/slugGenerator.js.map +1 -0
- package/dist/component/lib/slugUniqueness.d.ts +131 -0
- package/dist/component/lib/slugUniqueness.d.ts.map +1 -0
- package/dist/component/lib/slugUniqueness.js +229 -0
- package/dist/component/lib/slugUniqueness.js.map +1 -0
- package/dist/component/lib/softDelete.d.ts +18 -0
- package/dist/component/lib/softDelete.d.ts.map +1 -0
- package/dist/component/lib/softDelete.js +29 -0
- package/dist/component/lib/softDelete.js.map +1 -0
- package/dist/component/localeFallbackChain.d.ts +410 -0
- package/dist/component/localeFallbackChain.d.ts.map +1 -0
- package/dist/component/localeFallbackChain.js +467 -0
- package/dist/component/localeFallbackChain.js.map +1 -0
- package/dist/component/localeFields.d.ts +508 -0
- package/dist/component/localeFields.d.ts.map +1 -0
- package/dist/component/localeFields.js +592 -0
- package/dist/component/localeFields.js.map +1 -0
- package/dist/component/mediaAssetMutations.d.ts +235 -0
- package/dist/component/mediaAssetMutations.d.ts.map +1 -0
- package/dist/component/mediaAssetMutations.js +558 -0
- package/dist/component/mediaAssetMutations.js.map +1 -0
- package/dist/component/mediaAssets.d.ts +168 -0
- package/dist/component/mediaAssets.d.ts.map +1 -0
- package/dist/component/mediaAssets.js +618 -0
- package/dist/component/mediaAssets.js.map +1 -0
- package/dist/component/mediaFolderMutations.d.ts +642 -0
- package/dist/component/mediaFolderMutations.d.ts.map +1 -0
- package/dist/component/mediaFolderMutations.js +849 -0
- package/dist/component/mediaFolderMutations.js.map +1 -0
- package/dist/component/mediaUploadMutations.d.ts +136 -0
- package/dist/component/mediaUploadMutations.d.ts.map +1 -0
- package/dist/component/mediaUploadMutations.js +205 -0
- package/dist/component/mediaUploadMutations.js.map +1 -0
- package/dist/component/mediaVariantMutations.d.ts +468 -0
- package/dist/component/mediaVariantMutations.d.ts.map +1 -0
- package/dist/component/mediaVariantMutations.js +737 -0
- package/dist/component/mediaVariantMutations.js.map +1 -0
- package/dist/component/mediaVariants.d.ts +525 -0
- package/dist/component/mediaVariants.d.ts.map +1 -0
- package/dist/component/mediaVariants.js +661 -0
- package/dist/component/mediaVariants.js.map +1 -0
- package/dist/component/ragContentIndexer.d.ts +595 -0
- package/dist/component/ragContentIndexer.d.ts.map +1 -0
- package/dist/component/ragContentIndexer.js +794 -0
- package/dist/component/ragContentIndexer.js.map +1 -0
- package/dist/component/rateLimitHooks.d.ts +266 -0
- package/dist/component/rateLimitHooks.d.ts.map +1 -0
- package/dist/component/rateLimitHooks.js +412 -0
- package/dist/component/rateLimitHooks.js.map +1 -0
- package/dist/component/roles.d.ts +649 -0
- package/dist/component/roles.d.ts.map +1 -0
- package/dist/component/roles.js +884 -0
- package/dist/component/roles.js.map +1 -0
- package/dist/component/scheduledPublish.d.ts +182 -0
- package/dist/component/scheduledPublish.d.ts.map +1 -0
- package/dist/component/scheduledPublish.js +304 -0
- package/dist/component/scheduledPublish.js.map +1 -0
- package/dist/component/schema.d.ts +4114 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +469 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/taxonomies.d.ts +476 -0
- package/dist/component/taxonomies.d.ts.map +1 -0
- package/dist/component/taxonomies.js +785 -0
- package/dist/component/taxonomies.js.map +1 -0
- package/dist/component/taxonomyMutations.d.ts +206 -0
- package/dist/component/taxonomyMutations.d.ts.map +1 -0
- package/dist/component/taxonomyMutations.js +1001 -0
- package/dist/component/taxonomyMutations.js.map +1 -0
- package/dist/component/trash.d.ts +265 -0
- package/dist/component/trash.d.ts.map +1 -0
- package/dist/component/trash.js +621 -0
- package/dist/component/trash.js.map +1 -0
- package/dist/component/types.d.ts +4 -0
- package/dist/component/types.d.ts.map +1 -0
- package/dist/component/types.js +2 -0
- package/dist/component/types.js.map +1 -0
- package/dist/component/userContext.d.ts +508 -0
- package/dist/component/userContext.d.ts.map +1 -0
- package/dist/component/userContext.js +615 -0
- package/dist/component/userContext.js.map +1 -0
- package/dist/component/validation.d.ts +387 -0
- package/dist/component/validation.d.ts.map +1 -0
- package/dist/component/validation.js +1052 -0
- package/dist/component/validation.js.map +1 -0
- package/dist/component/validators.d.ts +4645 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +641 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/component/versionMutations.d.ts +216 -0
- package/dist/component/versionMutations.d.ts.map +1 -0
- package/dist/component/versionMutations.js +321 -0
- package/dist/component/versionMutations.js.map +1 -0
- package/dist/component/webhookTrigger.d.ts +770 -0
- package/dist/component/webhookTrigger.d.ts.map +1 -0
- package/dist/component/webhookTrigger.js +1413 -0
- package/dist/component/webhookTrigger.js.map +1 -0
- package/dist/react/index.d.ts +316 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +558 -0
- package/dist/react/index.js.map +1 -0
- package/dist/test.d.ts +2230 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +1107 -0
- package/dist/test.js.map +1 -0
- package/package.json +95 -0
- package/src/cli/commands/admin.ts +104 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/utils/detectConvexUrl.ts +54 -0
- package/src/cli/utils/openBrowser.ts +16 -0
- package/src/client/admin-config.ts +138 -0
- package/src/client/adminApi.ts +942 -0
- package/src/client/agentTools.ts +1311 -0
- package/src/client/argTypes.ts +316 -0
- package/src/client/field-types.ts +187 -0
- package/src/client/index.ts +1301 -0
- package/src/client/queryBuilder.ts +1100 -0
- package/src/client/schema/codegen.ts +500 -0
- package/src/client/schema/defineContentType.ts +501 -0
- package/src/client/schema/index.ts +169 -0
- package/src/client/schema/schemaDrift.ts +574 -0
- package/src/client/schema/typedClient.ts +688 -0
- package/src/client/schema/types.ts +666 -0
- package/src/client/types.ts +723 -0
- package/src/client/workflows.ts +141 -0
- package/src/client/wrapper.ts +4304 -0
- package/src/component/_generated/api.ts +140 -0
- package/src/component/_generated/component.ts +5029 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/authorization.ts +647 -0
- package/src/component/authorizationHooks.ts +668 -0
- package/src/component/bulkOperations.ts +687 -0
- package/src/component/contentEntries.ts +1976 -0
- package/src/component/contentEntryMutations.ts +1223 -0
- package/src/component/contentEntryValidation.ts +707 -0
- package/src/component/contentLock.ts +550 -0
- package/src/component/contentTypeMigration.ts +1064 -0
- package/src/component/contentTypeMutations.ts +969 -0
- package/src/component/contentTypes.ts +346 -0
- package/src/component/convex.config.ts +44 -0
- package/src/component/documentTypes.ts +240 -0
- package/src/component/eventEmitter.ts +485 -0
- package/src/component/exportImport.ts +1169 -0
- package/src/component/index.ts +491 -0
- package/src/component/lib/deepReferenceResolver.ts +999 -0
- package/src/component/lib/errors.ts +816 -0
- package/src/component/lib/index.ts +145 -0
- package/src/component/lib/mediaReferenceResolver.ts +495 -0
- package/src/component/lib/metadataExtractor.ts +792 -0
- package/src/component/lib/mutationAuth.ts +199 -0
- package/src/component/lib/queries.ts +79 -0
- package/src/component/lib/ragContentChunker.ts +1371 -0
- package/src/component/lib/referenceResolver.ts +430 -0
- package/src/component/lib/slugGenerator.ts +262 -0
- package/src/component/lib/slugUniqueness.ts +333 -0
- package/src/component/lib/softDelete.ts +44 -0
- package/src/component/localeFallbackChain.ts +673 -0
- package/src/component/localeFields.ts +896 -0
- package/src/component/mediaAssetMutations.ts +725 -0
- package/src/component/mediaAssets.ts +932 -0
- package/src/component/mediaFolderMutations.ts +1046 -0
- package/src/component/mediaUploadMutations.ts +224 -0
- package/src/component/mediaVariantMutations.ts +900 -0
- package/src/component/mediaVariants.ts +793 -0
- package/src/component/ragContentIndexer.ts +1067 -0
- package/src/component/rateLimitHooks.ts +572 -0
- package/src/component/roles.ts +1360 -0
- package/src/component/scheduledPublish.ts +358 -0
- package/src/component/schema.ts +617 -0
- package/src/component/taxonomies.ts +949 -0
- package/src/component/taxonomyMutations.ts +1210 -0
- package/src/component/trash.ts +724 -0
- package/src/component/userContext.ts +898 -0
- package/src/component/validation.ts +1388 -0
- package/src/component/validators.ts +949 -0
- package/src/component/versionMutations.ts +392 -0
- package/src/component/webhookTrigger.ts +1922 -0
- package/src/react/index.ts +898 -0
- 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
|