diff --git a/common/changes/@boostercloud/framework-core/fix-cosmos-pagination_3.x_2026-02-27-21-16.json b/common/changes/@boostercloud/framework-core/fix-cosmos-pagination_3.x_2026-02-27-21-16.json new file mode 100644 index 0000000000..085a97bd2d --- /dev/null +++ b/common/changes/@boostercloud/framework-core/fix-cosmos-pagination_3.x_2026-02-27-21-16.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@boostercloud/framework-core", + "comment": "Fix pagination issues with Cosmos DB", + "type": "patch" + } + ], + "packageName": "@boostercloud/framework-core" +} \ No newline at end of file diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index ed62137dde..716080190b 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: specifier: 3.7.13 version: 3.7.13(graphql@16.10.0)(react@17.0.2)(subscriptions-transport-ws@0.11.0(graphql@16.10.0)) '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -47,7 +47,7 @@ importers: version: 8.18.0 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@types/jsonwebtoken': specifier: 9.0.8 @@ -104,10 +104,10 @@ importers: ../../packages/cli: dependencies: '@boostercloud/framework-core': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-core '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -150,10 +150,10 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/application-tester': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../application-tester '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@oclif/test': specifier: ^4.1.10 @@ -264,7 +264,7 @@ importers: ../../packages/framework-common-helpers: dependencies: '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -280,7 +280,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -370,10 +370,10 @@ importers: ../../packages/framework-core: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-common-helpers '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect/cli': specifier: 0.56.2 @@ -437,10 +437,10 @@ importers: version: 8.18.0 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@boostercloud/metadata-booster': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../metadata-booster '@types/chai': specifier: 4.2.18 @@ -545,22 +545,22 @@ importers: ../../packages/framework-integration-tests: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-common-helpers '@boostercloud/framework-core': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-core '@boostercloud/framework-provider-aws': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-provider-aws '@boostercloud/framework-provider-azure': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-provider-azure '@boostercloud/framework-provider-local': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-provider-local '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -618,25 +618,25 @@ importers: specifier: 3.7.13 version: 3.7.13(graphql@16.10.0)(react@17.0.2)(subscriptions-transport-ws@0.11.0(graphql@16.10.0)) '@boostercloud/application-tester': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../application-tester '@boostercloud/cli': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../cli '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@boostercloud/framework-provider-aws-infrastructure': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-provider-aws-infrastructure '@boostercloud/framework-provider-azure-infrastructure': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-provider-azure-infrastructure '@boostercloud/framework-provider-local-infrastructure': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-provider-local-infrastructure '@boostercloud/metadata-booster': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../metadata-booster '@seald-io/nedb': specifier: 4.0.2 @@ -777,10 +777,10 @@ importers: ../../packages/framework-provider-aws: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-common-helpers '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -790,7 +790,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@types/aws-lambda': specifier: 8.10.48 @@ -943,13 +943,13 @@ importers: specifier: ^1.170.0 version: 1.204.0 '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-common-helpers '@boostercloud/framework-provider-aws': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-provider-aws '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -983,7 +983,7 @@ importers: version: 1.10.2 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@types/archiver': specifier: 5.1.0 @@ -1097,10 +1097,10 @@ importers: specifier: ~1.1.0 version: 1.1.3 '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-common-helpers '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -1110,7 +1110,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -1203,16 +1203,16 @@ importers: specifier: ~4.7.0 version: 4.7.0 '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-common-helpers '@boostercloud/framework-core': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-core '@boostercloud/framework-provider-azure': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-provider-azure '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@cdktf/provider-azurerm': specifier: 13.18.0 @@ -1279,7 +1279,7 @@ importers: version: 11.0.5 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -1360,10 +1360,10 @@ importers: ../../packages/framework-provider-local: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-common-helpers '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -1379,7 +1379,7 @@ importers: version: 8.18.0 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -1475,13 +1475,13 @@ importers: ../../packages/framework-provider-local-infrastructure: dependencies: '@boostercloud/framework-common-helpers': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-common-helpers '@boostercloud/framework-provider-local': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-provider-local '@boostercloud/framework-types': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../framework-types '@effect-ts/core': specifier: ^0.60.4 @@ -1500,7 +1500,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@types/chai': specifier: 4.2.18 @@ -1636,10 +1636,10 @@ importers: version: 8.18.0 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@boostercloud/metadata-booster': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../metadata-booster '@types/chai': specifier: 4.2.18 @@ -1733,7 +1733,7 @@ importers: version: 2.8.1 devDependencies: '@boostercloud/eslint-config': - specifier: workspace:^3.4.1 + specifier: workspace:^3.4.3 version: link:../../tools/eslint-config '@types/node': specifier: ^20.17.17 @@ -7395,8 +7395,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20250822: - resolution: {integrity: sha512-omHezTVn6vg+B/eFHkIzUGFvlbbkJdsdmdBohcsw8NMLyKOhKRMinE9aLu8f0EALT4R2YS41xak2KinK74/6Xg==} + typescript@6.0.0-dev.20260302: + resolution: {integrity: sha512-f1OyfRwerbx6t8dQdNRa3YLgPqAbmE3ugnqDvf7dJBq40ZSTN9NpibhfTcGuUBP0kB/o0MHFOlxv2Cznguk65Q==} engines: {node: '>=14.17'} hasBin: true @@ -10912,7 +10912,7 @@ snapshots: dependencies: semver: 7.6.3 shelljs: 0.8.5 - typescript: 6.0.0-dev.20250822 + typescript: 6.0.0-dev.20260302 dunder-proto@1.0.1: dependencies: @@ -14375,7 +14375,7 @@ snapshots: typescript@5.7.3: {} - typescript@6.0.0-dev.20250822: {} + typescript@6.0.0-dev.20260302: {} unbox-primitive@1.1.0: dependencies: diff --git a/packages/framework-integration-tests/integration/provider-unaware/end-to-end/read-models.integration.ts b/packages/framework-integration-tests/integration/provider-unaware/end-to-end/read-models.integration.ts index a383469ae8..469d609af6 100644 --- a/packages/framework-integration-tests/integration/provider-unaware/end-to-end/read-models.integration.ts +++ b/packages/framework-integration-tests/integration/provider-unaware/end-to-end/read-models.integration.ts @@ -1548,23 +1548,7 @@ describe('Read models end-to-end tests', () => { if (cursor) { if (process.env.TESTED_PROVIDER === 'AZURE' || process.env.TESTED_PROVIDER === 'LOCAL') { - // Cursor can be either continuation token format or legacy offset format - if (cursor.continuationToken) { - // New continuation token format - expect(cursor.continuationToken).to.be.a('string') - expect(cursor.continuationToken).to.not.be.empty - expect(cursor.id).to.be.undefined - } else if (cursor.id) { - expect(cursor.id).to.be.a('string') - expect(cursor.id).to.not.be.empty - expect(cursor.continuationToken).to.be.undefined - // If it's a numeric string (legacy format), verify it matches the expected sequence - if (/^\d+$/.test(cursor.id)) { - expect(cursor.id).to.equal((i + 1).toString()) - } - } else { - throw new Error('Cursor must have either continuationToken or id field') - } + expect(cursor.id).to.equal((i + 1).toString()) } else { expect(cursor.id).to.equal(currentPageCartData[0].id) } @@ -1621,7 +1605,7 @@ describe('Read models end-to-end tests', () => { if (result.cursor) { expect(result.cursor.id).to.be.a('string') expect(result.cursor.id).to.not.be.empty - // Should be '2' (1 + 1 result returned) based on our fixed logic: currentOffset + finalResources.length + // Should be '2' (offset 1 + limit 1) based page-based offset logic: offset + effectiveLimit expect(result.cursor.id).to.equal('2') // Legacy cursors don't have continuation tokens expect(result.cursor.continuationToken).to.be.undefined @@ -1751,16 +1735,8 @@ describe('Read models end-to-end tests', () => { }, ]) expect(cartShippingAddress.count).to.equal(1) - // Cursor may be undefined when there are no more pages (continuation token approach) - if (cartShippingAddress.cursor) { - expect(cartShippingAddress.cursor.id).to.be.a('string') - expect(cartShippingAddress.cursor.id).to.not.be.empty - // For Azure/Local with legacy pagination, verify it's "1" for the first page - if (process.env.TESTED_PROVIDER === 'AZURE' || process.env.TESTED_PROVIDER === 'LOCAL') { - if (/^\d+$/.test(cartShippingAddress.cursor.id)) { - expect(cartShippingAddress.cursor.id).to.equal('1') - } - } + if (process.env.TESTED_PROVIDER === 'AZURE' || process.env.TESTED_PROVIDER === 'LOCAL') { + expect(cartShippingAddress.cursor.id).to.equal('100') } }) @@ -1903,19 +1879,7 @@ describe('Read models end-to-end tests', () => { if (cursor) { if (process.env.TESTED_PROVIDER === 'AZURE' || process.env.TESTED_PROVIDER === 'LOCAL') { - // With continuation token, cursor.id can be either the legacy format (i + 1).toString() or a continuation token - // For legacy format, verify it matches the expected sequence; for continuation token, just verify it's valid - expect(cursor.id).to.be.a('string') - expect(cursor.id).to.not.be.empty - // If it's a numeric string (legacy format), verify it matches the expected sequence - if (/^\d+$/.test(cursor.id)) { - expect(cursor.id).to.equal((i + 1).toString()) - } - // If it has continuationToken property, it's the new format - verify it advances - if (cursor.continuationToken) { - expect(cursor.continuationToken).to.be.a('string') - expect(cursor.continuationToken).to.not.be.empty - } + expect(cursor.id).to.equal('100') } else { expect(cursor.id).to.equal(currentPageCartData[0].id) } @@ -2087,16 +2051,8 @@ describe('Read models end-to-end tests', () => { }, ]) expect(cartMyAddress.count).to.equal(1) - // Cursor may be undefined when there are no more pages (continuation token approach) - if (cartMyAddress.cursor) { - expect(cartMyAddress.cursor.id).to.be.a('string') - expect(cartMyAddress.cursor.id).to.not.be.empty - // For Azure/Local with legacy pagination, verify it's "1" for the first page - if (process.env.TESTED_PROVIDER === 'AZURE' || process.env.TESTED_PROVIDER === 'LOCAL') { - if (/^\d+$/.test(cartMyAddress.cursor.id)) { - expect(cartMyAddress.cursor.id).to.equal('1') - } - } + if (process.env.TESTED_PROVIDER === 'AZURE' || process.env.TESTED_PROVIDER === 'LOCAL') { + expect(cartMyAddress.cursor.id).to.equal('100') } }) }) diff --git a/packages/framework-provider-azure/src/helpers/query-helper.ts b/packages/framework-provider-azure/src/helpers/query-helper.ts index 21d1a92405..30852a5ee5 100644 --- a/packages/framework-provider-azure/src/helpers/query-helper.ts +++ b/packages/framework-provider-azure/src/helpers/query-helper.ts @@ -29,6 +29,8 @@ export async function replaceOrDeleteItem( } } +const DEFAULT_PAGE_SIZE = 100 + export async function search( cosmosDb: CosmosClient, config: BoosterConfig, @@ -43,9 +45,7 @@ export async function search( const logger = getLogger(config, 'query-helper#search') const filterExpression = buildFilterExpression(filters) const projectionsExpression = buildProjections(projections) - const queryDefinition = `SELECT ${projectionsExpression} FROM c ${ - filterExpression !== '' ? `WHERE ${filterExpression}` : filterExpression - }` + const queryDefinition = `SELECT ${projectionsExpression} FROM c ${filterExpression !== '' ? `WHERE ${filterExpression}` : filterExpression}` const finalQuery = queryDefinition + buildOrderExpression(order) const querySpec: SqlQuerySpec = { @@ -69,19 +69,25 @@ export async function search( // Use Cosmos DB's continuation token pagination const feedOptions: FeedOptions = {} - if (limit) { - feedOptions.maxItemCount = limit - } + // Extract continuation token from the cursor (backward compatibility) if (afterCursor?.continuationToken) { feedOptions.continuationToken = afterCursor.continuationToken - } else if (!canUseContinuationToken || hasLegacyCursor) { + } + + // Azure Cosmos DB requires maxItemCount when using continuation tokens + // Always set maxItemCount in the continuation token path to ensure consistent page sizes + if (limit || afterCursor?.continuationToken || canUseContinuationToken) { + feedOptions.maxItemCount = limit ?? DEFAULT_PAGE_SIZE + } + + if (!afterCursor?.continuationToken && (!canUseContinuationToken || hasLegacyCursor)) { // Legacy cursor format - fallback to OFFSET for backward compatibility - const offset = afterCursor?.id ? parseInt(afterCursor.id) : 0 - let legacyQuery = `${finalQuery} OFFSET ${offset}` - if (limit) { - legacyQuery += ` LIMIT ${limit} ` - } + const parsedLegacyId = afterCursor?.id ? parseInt(afterCursor.id, 10) : NaN + const offset = Number.isFinite(parsedLegacyId) ? parsedLegacyId : 0 + // Azure Cosmos DB requires LIMIT when using OFFSET + const effectiveLimit = limit ?? DEFAULT_PAGE_SIZE + const legacyQuery = `${finalQuery} OFFSET ${offset} LIMIT ${effectiveLimit} ` const legacyQuerySpec = { ...querySpec, query: legacyQuery } const { resources } = await container.items.query(legacyQuerySpec).fetchAll() @@ -91,7 +97,7 @@ export async function search( items: processedResources ?? [], count: processedResources.length, cursor: { - id: (offset + processedResources.length).toString(), + id: (offset + effectiveLimit).toString(), }, } } @@ -101,12 +107,17 @@ export async function search( const finalResources = processResources(resources || []) + // cursor.id advances by the page size (limit) to maintain consistent page-based offsets + // that frontends rely on (e.g., limit=5 produces cursors 5, 10 ,15, ...) + const parsedId = afterCursor?.id ? parseInt(afterCursor.id, 10) : NaN + const previousOffset = Number.isFinite(parsedId) ? parsedId : 0 + const effectiveLimit = limit ?? DEFAULT_PAGE_SIZE + let cursor: Record | undefined if (continuationToken) { - cursor = { continuationToken } + cursor = { continuationToken, id: (previousOffset + effectiveLimit).toString() } } else if (finalResources.length > 0) { - const currentOffset = afterCursor?.id && !isNaN(parseInt(afterCursor.id)) ? parseInt(afterCursor.id) : 0 - cursor = { id: (currentOffset + finalResources.length).toString() } // Use the length of the results to calculate the next id + cursor = { id: (previousOffset + effectiveLimit).toString() } } return {