Salta ai contenuti

Paginazione

Ci sono tre modi per scorrere una collezione: un offset, un numero di pagina e un cursore. I primi due sono posizionali e il terzo non lo è, ed è qui che risiede l’intera differenza: una pagina posizionale rilegge contando dall’inizio, quindi le righe scritte durante la paginazione modificano il significato di “riga 20”.

// 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 });

Usa l’uno o l’altro. Da 0.25 page e offset insieme descrivono la stessa finestra due volte, e la lettura viene rifiutata con PAGE_WITH_OFFSET anziché applicare in silenzio page. L’SDK la rifiuta prima di qualsiasi richiesta, via HTTP, in-process e offline; il server risponde con un 400 a una richiesta grezza che li invia entrambi.

limit deve essere un numero intero compreso tra 1 e 1000. Un valore maggiore — o pari a zero, negativo o frazionario — viene rifiutato con un 400 INVALID_LIMIT invece di essere bloccato a un valore limite (clamped), perché una pagina silenziosamente più piccola non può essere distinta dall’ultima. Per leggere oltre questa soglia, scorri le pagine con iterate() o findAll().

Ogni risposta di elenco contiene un meta.nextCursor quando è presente un’altra pagina. Rinvialo come after e la pagina successiva ripartirà strettamente dopo l’ultima riga restituita, invece che a un conteggio di righe che scritture concorrenti potrebbero aver già modificato:

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 è assente nell’ultima pagina, e in una pagina che nessun cursore può descrivere: una ricerca ordinata per rilevanza (_score), una ricerca vettoriale e una proiezione fields che esclude uno dei campi di orderBy — il cursore viene costruito dai valori di ordinamento dell’ultima riga, e quella riga non li porta con sé. In quel caso meta.hasMore può essere true senza nextCursor, e il ciclo sopra si interrompe in anticipo: aggiungi i campi di ordinamento a fields, oppure pagina con offset. iterate() invocato con un cursor rifiuta un simile elenco con cursor-missing invece di fermarsi. Un ordinamento per un aggregato su una relazione pagina invece correttamente tramite cursore.

Il cursore è opaco. Codifica le chiavi di ordinamento e i valori dell’ultima riga per esse, quindi può solo continuare l’elenco da cui proviene: mantieni orderBy identico tra le pagine, altrimenti la richiesta verrà rifiutata con CURSOR_ORDER_MISMATCH invece di eseguire una ricerca in un ordine non richiesto. Una richiesta che non specifica alcun orderBy adotta quello del cursore, permettendo di passarlo direttamente senza dover ribadire l’ordinamento.

Non analizzarlo (parse) e non costruirne uno: la codifica esiste per essere modificata e qualsiasi altra cosa produrrà INVALID_CURSOR.

Dalla natura del cursore derivano quattro conseguenze:

  • after non può essere combinato con offset o page (400 CURSOR_WITH_OFFSET). Entrambi indicano dove inizia la pagina, e rispettarli entrambi comporterebbe il salto di alcune righe.
  • Gli ordinamenti a più chiavi e le chiavi nullable funzionano entrambi. Il confronto viene costruito su ciascuna chiave nell’ordine specificato, con il posizionamento di NULL dichiarato dall’ordinamento — non un singolo > su una colonna.
  • La rilevanza non può essere un cursore. Un valore _score viene calcolato per query e non viene memorizzato da nessuna parte, e due query con stringhe di ricerca diverse producono punteggi che non sono sulla stessa scala. Un tale elenco semplicemente non include un nextCursor; paginalo con offset. Lo stesso vale per la distanza di una ricerca vettoriale, e un cursore inviato con una di esse viene rifiutato con VECTOR_CURSOR_UNSUPPORTED.
  • Una sottoscrizione non può proseguire da un cursore. listen() riesegue la sua query a ogni scrittura, e after indica un punto in una sola di queste esecuzioni, quindi listen({ after }) viene rifiutato con CURSOR_NOT_LIVE prima di inviare qualcosa. Usa offset o page per una finestra live, oppure find({ after }) per leggere la pagina una volta.

Su HTTP si tratta di un solo parametro:

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

Da 0.25 meta.total è un COUNT(*) su ogni riga che la query trova, eseguito accanto alla pagina — e su una tabella grande costa più della pagina stessa. Una lettura che si limita a procedere in avanti non ne ha mai bisogno, perché meta.hasMore non ne dipende: passa count: "none" e la lettura chiede invece una riga oltre la pagina.

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

Senza conteggio non c’è alcun totale da riportare: su HTTP meta.total è assente, e in-process corrisponde alle righe fino alla fine della pagina (offset più la pagina). Su HTTP si usa ?count=none; ?count=exact è il valore predefinito, e qualsiasi altro valore è un 400 INVALID_COUNT. iterate() e findAll() lo inviano da soli a ogni pagina, dato che uno scorrimento non riporta alcun totale.

Due strutture e una regola: una finestra è incapsulata, una risposta completa no.

Metodo Restituisce Perché
find(), listen() { data, meta } Una pagina. meta.total / meta.hasMore sono l’unico modo per sapere se c’è dell’altro
findAll(), createMany(), updateMany() M[] Non resta nulla da segnalare: lo scorrimento è terminato, o il batch è l’insieme delle righe
iterate() una riga alla volta Nulla viene materializzato in memoria
findById(), get(), create(), update() una riga Non è una lista

data non è un wrapper che l’SDK a volte aggiunge e a volte dimentica. È il luogo in cui risiedono i metadati di paginazione, ed è presente esattamente quando ce ne sono.

iterate() trasmette in streaming ogni riga corrispondente a una query, una alla volta, recuperando una pagina alla volta dietro le quinte. Nulla si accumula, quindi è il metodo da utilizzare per una collezione che non puoi mantenere in memoria:

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

findAll() è lo stesso scorrimento raccolto in un array:

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

Entrambi sono disponibili anche sul fluent builder, dove .limit() diventa la dimensione della pagina piuttosto che un totale:

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

Quattro opzioni configurano lo scorrimento:

Opzione Predefinito Cosa fa
pageSize 200 Righe per richiesta.
cursor — Richiede la ricerca: genera un errore invece di ripiegare sull’offset. Vedi sotto.
maxPages 10 000 Limite massimo di richieste, per evitare che un server che restituisce continuamente hasMore rimanga bloccato in un ciclo infinito.
maxRows 10 000 Solo per findAll(). Il superamento del limite genera un errore invece di restituire un array troncato come se fosse la risposta completa. Passa Infinity per disattivarlo, oppure usa iterate().

Lo scorrimento cerca da solo. Dopo la prima pagina restituisce il cursore del server — meta.nextCursor — come after, quindi ogni richiesta chiede righe strettamente successive all’ultima visualizzata. Le scritture concorrenti precedenti a quella riga non possono spostare la finestra, e il database non deve mai saltare righe per conteggio, ed è questo che rendeva lenti i lunghi scorrimenti con offset: ogni pagina costava più della precedente.

Dove il server non emette alcun cursore — una ricerca ordinata per rilevanza, una ricerca vettoriale, una lettura distinct, una proiezione fields che esclude un campo di ordinamento — lo scorrimento pagina invece tramite offset, e una riga inserita o eliminata durante l’esecuzione dello scorrimento può causarne il salto o la ripetizione. Passa cursor per rendere la ricerca un requisito anziché una preferenza:

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

cursor specifica la colonna in base alla quale ordinare quando la query non lo dichiara già, e trasforma il ripiego sull’offset in un errore. Lo scorrimento non costruisce alcun confronto autonomamente, motivo per cui funziona anche un ordinamento a più chiavi —

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

— e per cui funziona anche una chiave di ordinamento nullable.

L’ordinamento deve comunque essere totale, il che in pratica significa univoco: l’ID di riga risolve l’eventuale parità finale, quindi qualsiasi colonna funziona come criterio di spareggio. Dato cursor, uno scorrimento il cui cursore smette di avanzare genera l’errore cursor-stalled invece di andare in loop infinito, e una query che nessun cursore può descrivere (rilevanza) genera cursor-missing; rimuovi cursor e lo scorrimento pagina queste tramite offset.