Du lädst zwei Einträge, klickst auf „Weiter“ und siehst den letzten Eintrag erneut. Ein anderes Mal fehlt zwischen den Seiten plötzlich ein Eintrag. Stell dir eine kleine Aufgabenliste vor, die sich beim Lesen verändert: Der Code muss festlegen, was „weiter“ bedeutet, wenn Zeilen ihre Position wechseln.
Filter, Sortierung und Seitenauswahl zusammen lesen
Die Liste wird absteigend nach createdAt sortiert, dann absteigend nach einer eindeutigen numerischen id. Das Lehrbeispiel verwendet kleine Ganzzahlen als Zeitwerte. Seine sechs open-Einträge stehen in der Reihenfolge 6, 5, 4, 3, 2, 1; die Einträge 5 und 4 haben beide den Zeitwert 50. Zusätzlich gibt es den done-Eintrag 0 mit dem Zeitwert 70.
| Diesen Teil lesen | Nach dieser Regel suchen |
|---|---|
selected |
Vor der Seitenauswahl nach Status filtern |
compare |
Neuere Zeit zuerst; bei gleicher Zeit die größere ID zuerst |
offsetPage / cursorPage |
Welche Zeilen zur nächsten Seite gehören |
| Aufrufender Code | nextCursor speichern, gültige Ergebnisse anhängen und bei geänderter Abfrage zurücksetzen |
Beginne bei der Aktion „Weiter“ und verfolge ihre Argumente bis zur Auswahlfunktion. Wer nur den abschließenden Array-Ausschnitt liest, übersieht den Filter und die Sortierung, die diesen Positionen ihre Bedeutung geben.
Beobachten, wie derselbe Offset andere Zeilen trifft
Diese Implementierung überspringt eine Anzahl der aktuell passenden Zeilen:
const compare = (a, b) => b.createdAt - a.createdAt || b.id - a.id;
const selected = (rows, status) =>
rows.filter((row) => row.status === status).sort(compare);
function offsetPage(rows, offset, take = 2, status = 'open') {
return selected(rows, status).slice(offset, offset + take);
}
Die erste Seite enthält 6 und 5. Nach einer einzigen Änderung zwischen den Aufrufen liefert die zweite Seite mit offset = 2 unterschiedliche Ergebnisse:
| Änderung nach Seite eins | Aktuelle Reihenfolge passender Zeilen | Zweite Seite | Ergebnis |
|---|---|---|---|
| Keine | 6, 5, 4, 3, 2, 1 | 4, 3 | Erwartete Fortsetzung |
| 7 mit Zeitwert 70 einfügen | 7, 6, 5, 4, 3, 2, 1 | 5, 4 | 5 erscheint erneut |
| 6 löschen | 5, 4, 3, 2, 1 | 3, 2 | 4 wird übersprungen |
Jeder Fall beginnt wieder mit den ursprünglichen Beispieldaten. Die Zahl der zu überspringenden Zeilen bleibt gleich, ihre Positionen ändern sich. Doppelte IDs in der Oberfläche zu entfernen kann die Wiederholung verbergen; der übersprungene Eintrag kommt dadurch nicht zurück.
Den vollständigen Sortierschlüssel vergleichen
Ein Cursor enthält den Schlüssel des zuletzt zurückgegebenen Eintrags. Eine reine Zeitbedingung wie createdAt < cursor.createdAt verliert nach Eintrag 5 den Eintrag 4, denn beide haben den Zeitwert 50. Mit <= würde der vorherige Eintrag erneut zugelassen.
Speichere beide Werte in (createdAt, id) und verwende denselben Vergleich wie beim Sortieren:
function cursorPage(rows, after = null, take = 2, status = 'open') {
const items = selected(rows, status)
.filter((row) => after === null || compare(row, after) > 0)
.slice(0, take);
const last = items.at(-1);
return {
items,
nextCursor: last ? { createdAt: last.createdAt, id: last.id } : null,
};
}
In dieser absteigenden Reihenfolge bedeutet compare(row, after) > 0, dass die Zeile strikt hinter der gespeicherten Position liegt. Bei gleicher Zeit entscheidet die id. Sowohl nach dem vorherigen Einfügen als auch nach dem Löschen liefert die nächste Seite 4, 3.
Das Objekt nextCursor kopiert die Werte, statt die vorherige Zeile erneut zu suchen. Auch nach dem Löschen von Eintrag 5 lässt sich deshalb mit dessen gespeichertem Schlüssel weiterlesen. Eine leere Seite liefert null. Das Beispiel braucht gegebenenfalls eine zusätzliche Anfrage, um das Listenende festzustellen.
Die Änderungen als getrennte Tests ausführen
Lade pagination-reading.mjs herunter und führe die Datei mit Node.js 22 oder neuer aus:
node --test pagination-reading.mjs
Alle zehn Tests bestehen. Drei mit dem Namenspräfix reproduce: prüfen absichtlich die falschen Ergebnisse: das Offset-Duplikat, den durch Offset ausgelassenen Eintrag und den durch den reinen Zeit-Cursor ausgelassenen Eintrag. Die übrigen sieben prüfen das Einfügen, das Löschen vor dem Cursor, das Löschen seiner Ursprungszeile, den vollständigen Durchlauf mit gleichen Zeitwerten und leerer Abschlussseite, eine neu eingefügte spätere Zeile, das Filtern vor der Begrenzung und das Zurücksetzen beim Filterwechsel.
Die Tests verwenden neue Arrays und synchrone Änderungen zwischen den Aufrufen. Sie benötigen keinen Dienst, kein Konto, keine Timer und keine Paketinstallation.
Vor Abschluss der Korrektur den aufrufenden Code lesen
Wird der Filter auf done geändert und der alte Cursor beibehalten, liefert dieses Beispiel eine leere Seite: Eintrag 0 liegt vor diesem Cursor. Beginnt die geänderte Abfrage mit after = null, kommt Eintrag 0 zurück. Bei Änderungen am Filter oder an der Sortierung sollte der aufrufende Code die gesammelten Zeilen und den Cursor gemeinsam zurücksetzen.
Prüfe auch, wo nextCursor gespeichert und wann er verwendet wird. Hier bedeutet null nach einer leeren Antwort: aufhören. Wird dieser Wert direkt wieder an die Funktion übergeben, beginnt sie erneut mit Seite eins. Suche nach mehrfach ausgelösten Anfragen, vertauschter Antwortreihenfolge und doppelt angehängten Antworten. Dieses synchrone Beispiel deckt solche Oberflächenabläufe nicht ab; das Nachverfolgen asynchroner Auswahlwechsel behandelt veraltete Antworten gesondert.
Festlegen, welche Änderungen beim Lesen sichtbar werden
Ein Cursor liefert keinen Snapshot. Füge nach dem Lesen der ersten Seite Eintrag 8 mit Zeitwert 49 ein: Die nächste Seite enthält dann 4, 8. Die neue Zeile liegt hinter der gespeicherten Position und erscheint deshalb im laufenden Durchlauf. Eine neue Zeile vor dem Cursor wird erst nach einer Aktualisierung sichtbar.
Die geprüfte Fortsetzung setzt eindeutige IDs und unveränderte Sortierschlüssel der verbliebenen Einträge voraus. Geänderte Schlüssel oder Statuswerte und das Löschen ungelesener Einträge brauchen eine ausdrückliche Produktregel. Ein Export mit festem Inhalt kann eine gesonderte Snapshot-Strategie erfordern. Dieses Array-Beispiel prüft weder Datenbankisolation noch Indizes, Transport oder Cursor-Codierung. Vollständiges Filtern und Sortieren bei jedem Aufruf eignet sich außerdem nicht als Leistungsentwurf für große Datenmengen.
Lege beim Lesen deiner Implementierung den Filter, den Vergleich, den gespeicherten Schlüssel und das Zurücksetzen beim Aufrufer nebeneinander. In Yenpo kannst du die zugehörigen Dateien öffnen und ihren Referenzen folgen, um jeden Schritt im Code zu prüfen.