From 2d289cd3d57094213c279f204846dbc1cf40fbb0 Mon Sep 17 00:00:00 2001 From: Daniel Hiller Date: Wed, 26 Aug 2026 09:23:01 +0200 Subject: [PATCH] fix: Make endless list pagination deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filtered branch of `getByEndlessListId` applied `LIMIT` without an `ORDER BY`, so Postgres was free to hand back any matching rows. With a search over 64 cards the first page returned the oldest ones and a full cursor walk reached 60 of them across 149 rows — cards both skipped and repeated. The query now orders the way the cursor reads it. The cursor itself is validated as ISO 8601, which admits forms Postgres rejects as a timestamp (`2026`, `2026-W35-3`, a comma as the decimal separator), each of them a 500 from the adapter. It is normalized before the query, and stays a string because the equality half of the cursor reads a `Date` as an empty constraint. --- server/api/controllers/cards/index.js | 11 ++++++++++- server/api/hooks/query-methods/models/Card.js | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/server/api/controllers/cards/index.js b/server/api/controllers/cards/index.js index 8e19d51d..e01ad7b6 100644 --- a/server/api/controllers/cards/index.js +++ b/server/api/controllers/cards/index.js @@ -243,8 +243,17 @@ module.exports = { filterLabelIds = filterLabelIds.filter((labelId) => availableLabelIdsSet.has(labelId)); } + // ISO 8601 allows forms Postgres rejects as a timestamp (`2026`, `2026-W35-3`, + // a comma as the decimal separator), so the cursor is normalized here rather + // than reaching the query as the string the client sent. It stays a string + // because the equality branch of the cursor reads a `Date` as an empty constraint + const before = inputs.before && { + listChangedAt: moment.utc(inputs.before.listChangedAt, moment.ISO_8601, true).toISOString(), + id: inputs.before.id, + }; + const cards = await Card.qm.getByEndlessListId(list.id, { - before: inputs.before, + before, search: inputs.search, userIds: filterUserIds, labelIds: filterLabelIds, diff --git a/server/api/hooks/query-methods/models/Card.js b/server/api/hooks/query-methods/models/Card.js index cefb45ef..0dd7a6c2 100644 --- a/server/api/hooks/query-methods/models/Card.js +++ b/server/api/hooks/query-methods/models/Card.js @@ -108,6 +108,9 @@ const getByEndlessListId = async (listId, { before, search, userIds, labelIds }) query += ` AND card_label.label_id IN (${inValues.join(', ')})`; } + // Must match the cursor built from the last returned card, otherwise the + // limit cuts an arbitrary slice and pages skip or repeat cards + query += ' ORDER BY card.list_changed_at DESC, card.id DESC'; query += ` LIMIT ${LIMIT}`; let queryResult;