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:
committed by
GitHub
parent
71c377484e
commit
4dff30f676
+1
-1
@@ -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 }],
|
||||
}
|
||||
|
||||
+31
-1
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user