From 4dff30f676938d9166c31c2722eb9c27d383c707 Mon Sep 17 00:00:00 2001 From: Priyanshu Bartwal Date: Mon, 1 Jun 2026 14:20:14 +0530 Subject: [PATCH] Twenty server:Fix REST pagination issues (#20980) Fixes #20109 The entry was repeating because in the database we store DateTime fields with microsecond precision (timestamptz), but when JS parses timestamptz into a Date object it only keeps millisecond precision. ### Example If previous cursor was: ``` { name: "Quick Lead", createdAt: "2026-05-21T15:33:00.708Z", } ``` The resulting query look something like: ``` ... WHERE ( "workflow"."name" > "Quick Lead" OR ( "workflow"."name" = "Quick Lead" AND "workflow"."createdAt" > "2026-05-21T15:33:00.708Z" ) OR ( "workflow"."name" = "Quick Lead" AND "workflow"."createdAt" = "2026-05-21T15:33:00.708Z" AND "workflow"."id" > "8b213cac-a68b-4ffe-817a-3ec994e9932d" ) ) ``` So, when comparing the 2nd condition the `"workflow"."createdAt" > "2026-05-21T15:33:00.708Z"` would always result to true because in db the data for createdAt is `2026-05-21 21:03:00.708 +0530` which will always be greater than `2026-05-21T15:33:00.708Z` The second condition `"workflow"."createdAt" > "2026-05-21T15:33:00.708Z"` always evaluates to true, because the value actually stored in the DB for createdAt is something like `2026-05-21 21:03:00.708264 +0530`, which is always greater than `2026-05-21T15:33:00.708Z` in the cursor. The row used to generate the cursor therefore reappears on the next page. ### My solution Truncate the column to milliseconds in the comparison so both sides have the same precision: `date_trunc('milliseconds', ${fieldReference})`. For the issue of nested sorting filters, when ordering by a composite field (e.g. `createdBy.name`), `encodeCursor` stored the entire composite object (`source`, `workspaceMemberId`, `name`, `context`). The where-condition builder later iterated those sub-keys and threw "Invalid cursor" because only name had an orderBy direction. P.S: Duplicate of #20867 because last fork got polluted. --------- Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> --- .../common-find-many-query-runner.service.ts | 2 +- .../utils/compute-where-condition-parts.ts | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts index 27d69594cd..f1f0c13916 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts @@ -124,7 +124,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi isForwardPagination, ); - appliedFilters = (args.filter + appliedFilters = (args.filter && Object.keys(args.filter).length > 0 ? { and: [args.filter, { or: cursorArgFilter }], } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts index 84a174b35d..957bf9efea 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'crypto'; -import { type FieldMetadataType } from 'twenty-shared/types'; +import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type ObjectLiteral } from 'typeorm'; @@ -43,6 +43,8 @@ export const computeWhereConditionParts = ({ ? `"${key}"` : `"${objectNameSingular}"."${key}"`; + const isDateTimeField = fieldMetadataType === FieldMetadataType.DATE_TIME; + //TODO : Remove filter null equivalence injection once feature flag removed + null equivalence transformation added in ORM const nullEquivalentFieldValue = findPostgresDefaultNullEquivalentValue( value, @@ -59,16 +61,37 @@ export const computeWhereConditionParts = ({ params: {}, }; case 'eq': + if (isDateTimeField) { + return { + sql: `(${fieldReference} >= :${key}${paramSuffix} AND ${fieldReference} < :${key}${paramSuffix}::timestamptz + interval '1 millisecond')${hasNullEquivalentFieldValue ? ` OR ${fieldReference} IS NULL` : ''}`, + params: { [`${key}${paramSuffix}`]: value }, + }; + } + return { sql: `${fieldReference} = :${key}${paramSuffix}${hasNullEquivalentFieldValue ? ` OR ${fieldReference} IS NULL` : ''}`, params: { [`${key}${paramSuffix}`]: value }, }; case 'neq': + if (isDateTimeField) { + return { + sql: `(${fieldReference} < :${key}${paramSuffix} OR ${fieldReference} >= :${key}${paramSuffix}::timestamptz + interval '1 millisecond')${hasNullEquivalentFieldValue ? ` AND ${fieldReference} IS NOT NULL` : ''}`, + params: { [`${key}${paramSuffix}`]: value }, + }; + } + return { sql: `${fieldReference} != :${key}${paramSuffix}${hasNullEquivalentFieldValue ? ` AND ${fieldReference} IS NOT NULL` : ''}`, params: { [`${key}${paramSuffix}`]: value }, }; case 'gt': + if (isDateTimeField) { + return { + sql: `${fieldReference} >= :${key}${paramSuffix}::timestamptz + interval '1 millisecond'`, + params: { [`${key}${paramSuffix}`]: value }, + }; + } + return { sql: `${fieldReference} > :${key}${paramSuffix}`, params: { [`${key}${paramSuffix}`]: value }, @@ -84,6 +107,13 @@ export const computeWhereConditionParts = ({ params: { [`${key}${paramSuffix}`]: value }, }; case 'lte': + if (isDateTimeField) { + return { + sql: `${fieldReference} < :${key}${paramSuffix}::timestamptz + interval '1 millisecond'`, + params: { [`${key}${paramSuffix}`]: value }, + }; + } + return { sql: `${fieldReference} <= :${key}${paramSuffix}`, params: { [`${key}${paramSuffix}`]: value },