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>
This commit is contained in:
Priyanshu Bartwal
2026-06-01 14:20:14 +05:30
committed by GitHub
parent 71c377484e
commit 4dff30f676
2 changed files with 32 additions and 2 deletions
@@ -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 }],
}
@@ -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 },