Zum Inhalt springen

Paginierung

Drei Möglichkeiten, eine Collection zu durchlaufen: ein Offset, eine Seitenzahl und ein Cursor. Die ersten beiden sind positionsbasiert und die dritte nicht, was den entscheidenden Unterschied ausmacht – eine positionsbasierte Seite liest erneut, indem sie vom Anfang an zählt, sodass Zeilen, die während des Paginierens geschrieben werden, die Bedeutung von „Zeile 20“ verschieben.

// Offset-based pagination
const page1 = await client.data.products.find({ limit: 20, offset: 0 });
const page2 = await client.data.products.find({ limit: 20, offset: 20 });
// Check if more pages exist
if (page1.meta.hasMore) {
// fetch next page
}
// Page-number pagination (1-indexed)
const page = await client.data.products.find({ page: 2, limit: 20 });

Verwenden Sie das eine oder das andere. Seit 0.25 page und offset zusammen beschreiben dasselbe Fenster doppelt, und der Read wird mit PAGE_WITH_OFFSET abgelehnt, anstatt stillschweigend page anzuwenden. Das SDK lehnt es vor jeder Anfrage ab, über HTTP, In-Process und offline; der Server antwortet auf eine Roh-Anfrage, die beides sendet, mit einem 400.

limit muss eine ganze Zahl zwischen 1 und 1000 sein. Ein größerer Wert – oder eine Null, ein negativer Wert oder ein Bruch – wird mit 400 INVALID_LIMIT abgelehnt, anstatt begrenzt zu werden, da eine stillschweigend kleinere Seite nicht von der letzten Seite unterschieden werden kann. Um über diese Obergrenze hinaus zu lesen, durchlaufen Sie die Seiten mit iterate() oder findAll().

Jede Listenantwort enthält ein meta.nextCursor, solange eine weitere Seite vorhanden ist. Übergeben Sie diesen als after zurück, und die nächste Seite setzt strikt nach der zuletzt ausgelieferten Zeile an, anstatt bei einer Zeilenanzahl, die durch gleichzeitige Schreibvorgänge bereits verschoben wurde:

let after: string | undefined;
do {
const { data, meta } = await client.data.orders.find({
orderBy: ["createdAt", "desc"],
limit: 100,
after
});
for (const order of data) await handle(order);
after = meta.nextCursor;
} while (after);

nextCursor fehlt auf der letzten Seite, und auf einer Seite, die kein Cursor beschreiben kann: einer nach Relevanz sortierten Suche (_score), einer Vektorsuche und einer fields-Projektion, die eines der orderBy-Felder auslässt — der Cursor wird aus den Sortierwerten der letzten Zeile gebaut, und diese Zeile trägt sie dann nicht. Dort kann meta.hasMore true sein, ohne dass es ein nextCursor gibt, und die obige Schleife stoppt vorzeitig: Fügen Sie die Sortierfelder zu fields hinzu, oder paginieren Sie per offset. iterate() mit einem cursor weist eine solche Auflistung mit cursor-missing ab, statt einfach zu stoppen. Eine Sortierung nach einem Aggregat über eine Relation paginiert dagegen per Cursor.

Der Cursor ist opak. Er kodiert die Sortierschlüssel und die Werte der letzten Zeile für diese Schlüssel, sodass er nur die Auflistung fortsetzen kann, aus der er stammt: Behalten Sie orderBy über alle Seiten hinweg identisch bei, andernfalls wird die Anfrage mit CURSOR_ORDER_MISMATCH abgelehnt, anstatt in einer Reihenfolge zu suchen, die niemand angefordert hat. Eine Anfrage, die überhaupt kein orderBy angibt, übernimmt die Sortierung des Cursors, sodass Sie ihn direkt zurückgeben können, ohne die Sortierung erneut anzugeben.

Parsen Sie ihn nicht und erstellen Sie keinen eigenen: Die Kodierung existiert, um geändert werden zu können, und alles andere führt zu INVALID_CURSOR.

Aus der Funktionsweise eines Cursors ergeben sich vier Dinge:

  • after kann nicht mit offset oder page kombiniert werden (400 CURSOR_WITH_OFFSET). Beide geben an, wo die Seite beginnt, und die Berücksichtigung beider würde Zeilen überspringen.
  • Sortierungen über mehrere Schlüssel und null-fähige Schlüssel funktionieren beide. Der Vergleich wird über jeden Schlüssel der Reihe nach aufgebaut, mit der NULL-Platzierung, die die Sortierung deklariert hat – nicht ein einzelnes > auf einer Spalte.
  • Relevanz kann kein Cursor sein. Ein _score wird pro Abfrage berechnet und nirgendwo gespeichert, und zwei Abfragen mit unterschiedlichen Suchbegriffen erzeugen Scores, die nicht auf derselben Skala liegen. Eine solche Auflistung enthält schlicht kein nextCursor; paginieren Sie diese mit offset. Mit der Distanz einer Vektorsuche verhält es sich genauso, und ein Cursor, der mit einer gesendet wird, wird mit VECTOR_CURSOR_UNSUPPORTED abgelehnt.
  • Ein Abonnement kann nicht an einem Cursor fortsetzen. listen() führt seine Abfrage bei jedem Schreibvorgang erneut aus, und after bezeichnet eine Stelle in einem Durchlauf davon, daher wird listen({ after }) mit CURSOR_NOT_LIVE abgelehnt, bevor etwas gesendet wird. Verwenden Sie offset oder page für ein Live-Fenster, oder find({ after }), um die Seite einmal zu lesen.

Über HTTP ist es ein Parameter:

GET /api/data/orders?orderBy=createdAt:desc&limit=100
GET /api/data/orders?orderBy=createdAt:desc&limit=100&after=eyJrIjpbWyJ…

Seit 0.25 meta.total ist ein COUNT(*) über jede Zeile, die die Abfrage trifft, neben der Seite ausgeführt — und bei einer großen Tabelle kostet es mehr als die Seite selbst. Ein Read, der nur vorwärts paginiert, braucht es nie, denn meta.hasMore hängt nicht davon ab: Übergeben Sie count: "none", und der Read fragt stattdessen eine Zeile über die Seite hinaus ab.

const { data, meta } = await client.data.orders.find({ limit: 100, count: "none" });
meta.hasMore; // exakt

Ohne Zählung gibt es keine Gesamtzahl zu melden: Über HTTP fehlt meta.total, und im Prozess sind es die Zeilen bis zum Ende der Seite (offset plus die Seite). Über HTTP lautet es ?count=none; ?count=exact ist der Standard, und jeder andere Wert ist ein 400 INVALID_COUNT. iterate() und findAll() senden es auf jeder Seite von selbst, da ein Durchlauf keine Gesamtzahl meldet.

Welche Leseoperationen gewrappt sind und welche nicht

Abschnitt betitelt „Welche Leseoperationen gewrappt sind und welche nicht“

Zwei Formen und eine Regel: Ein Ausschnitt ist gewrappt, eine vollständige Antwort nicht.

Methode Rückgabewert Warum
find(), listen() { data, meta } Eine Seite. meta.total / meta.hasMore sind die einzige Möglichkeit zu wissen, ob noch mehr vorhanden ist
findAll(), createMany(), updateMany() M[] Nichts übrig, was berichtet werden müsste – der Durchlauf ist beendet oder der Batch sind die Zeilen
iterate() eine Zeile nach der anderen Es wird überhaupt nichts materialisiert
findById(), get(), create(), update() eine Zeile Keine Liste

data ist kein Wrapper, den das SDK manchmal hinzufügt und manchmal vergisst. Hier befinden sich die Paginierungs-Metadaten, und es ist genau dann vorhanden, wenn es welche gibt.

iterate() streamt jede Zeile, die zu einer Abfrage passt, nacheinander und ruft im Hintergrund seitenweise Daten ab. Es sammelt sich nichts an, daher ist dies die Methode der Wahl für eine Collection, die Sie nicht vollständig im Speicher halten können:

for await (const order of client.data.orders.iterate({
where: { status: ["==", "pending"] }
})) {
await handleOrder(order);
}

findAll() ist derselbe Durchlauf, gesammelt in einem Array:

const stale = await client.data.sessions.findAll({
where: { expiresAt: ["<", cutoff] }
});

Beide sind auch auf dem Fluent Builder verfügbar, wo .limit() zur Seitengröße anstelle einer Gesamtzahl wird:

const rows = await client.data.orders
.where("status", "==", "pending")
.orderBy("createdAt", "asc")
.limit(500) // rows per request
.findAll();

Vier Optionen steuern den Durchlauf:

Option Standard Was sie bewirkt
pageSize 200 Zeilen pro Anfrage.
cursor — Seeking verlangen: einen Fehler werfen, statt auf Offset zurückzufallen. Siehe unten.
maxPages 10 000 Obergrenze für Anfragen, damit ein Server, der nie aufhört, hasMore zu melden, nicht endlos läuft.
maxRows 10 000 Nur findAll(). Ein Überschreiten wirft einen Fehler, anstatt ein abgeschnittenes Array zurückzugeben, als wäre es das vollständige Ergebnis. Übergeben Sie Infinity, um dies zu deaktivieren, oder verwenden Sie iterate().

Der Durchlauf arbeitet von selbst per Seek. Nach der ersten Seite gibt er den Cursor des Servers — meta.nextCursor — als after zurück, sodass jede Anfrage Zeilen strikt nach der zuletzt gesehenen anfordert. Gleichzeitige Schreibvorgänge vor dieser Zeile können das Fenster nicht verschieben, und die Datenbank muss nie Zeilen nach Anzahl überspringen, was lange Offset-Durchläufe langsam gemacht hat: Jede Seite kostete mehr als die vorherige.

Wo der Server keinen Cursor ausstellt — eine nach Relevanz sortierte Suche, eine Vektorsuche, ein distinct-Read, eine fields-Projektion, die ein Sortierfeld auslässt —, paginiert der Durchlauf stattdessen per Offset, und eine Zeile, die während des Durchlaufs eingefügt oder gelöscht wird, kann dazu führen, dass er eine Zeile überspringt oder wiederholt. Übergeben Sie cursor, um das Seeking von einer Präferenz zu einer Anforderung zu machen:

for await (const job of client.data.jobs.iterate({ cursor: "id" })) { /* … */ }

cursor gibt die Spalte an, nach der sortiert werden soll, wenn die Abfrage dies nicht bereits definiert, und macht den Offset-Fallback zu einem Fehler. Der Durchlauf baut keinen eigenen Vergleich auf, weshalb auch eine Sortierung über mehrere Schlüssel funktioniert –

for await (const job of client.data.jobs.iterate({
cursor: "id",
orderBy: [["priority", "desc"], ["createdAt", "asc"]]
})) { /* … */ }

— und warum auch ein null-fähiger Sortierschlüssel funktioniert.

Die Sortierung muss dennoch total sein, was in der Praxis eindeutig bedeutet: Die Zeilen-ID löst den finalen Gleichstand auf, sodass jede Spalte als Tie-Breaker funktioniert. Mit cursor wirft ein Durchlauf, dessen Cursor nicht mehr voranschreitet, cursor-stalled, anstatt in einer Endlosschleife zu verharren, und eine Abfrage, die kein Cursor beschreiben kann (Relevanz), wirft cursor-missing; entfernen Sie cursor, und der Durchlauf paginiert diese per Offset.