Você carrega dois itens, clica em “Próxima” e vê o último item da página anterior outra vez. Em outra sessão, um item desaparece entre páginas. Imagine uma pequena lista de tarefas que muda durante a navegação: o código precisa definir o que significa “próxima” quando as linhas mudam de posição.

Leia o filtro, a ordenação e a página em conjunto

A lista é ordenada por createdAt decrescente e depois por um id numérico único, também decrescente. O exemplo usa inteiros pequenos como horários. Seus seis itens open aparecem na ordem 6, 5, 4, 3, 2, 1; os itens 5 e 4 têm o mesmo horário, 50. Há também um item done, o 0, com horário 70.

Trecho a ler Regra a procurar
selected Filtrar pelo status antes de extrair uma página
compare Horário mais recente primeiro; em caso de empate, maior ID primeiro
offsetPage / cursorPage Quais linhas entram na próxima página
Código chamador Guardar nextCursor, acrescentar resultados válidos e reiniciar quando a consulta mudar

Comece pela ação “Próxima” e siga os argumentos até a função de seleção. Ler apenas o recorte final do array esconde o filtro e a ordenação que dão sentido àquelas posições.

Veja o mesmo offset apontar para outras linhas

Esta implementação pula uma quantidade de linhas que atendem ao filtro naquele momento:

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

A primeira página contém 6 e 5. Pedir a segunda com offset = 2 produz resultados diferentes após uma única alteração entre as chamadas:

Alteração após a primeira página Ordem atual dos itens filtrados Segunda página Resultado
Nenhuma 6, 5, 4, 3, 2, 1 4, 3 Continuação esperada
Inserir 7 com horário 70 7, 6, 5, 4, 3, 2, 1 5, 4 5 se repete
Excluir 6 5, 4, 3, 2, 1 3, 2 4 é pulado

Cada caso começa novamente com os dados originais. A quantidade de linhas a pular continua igual, mas as posições mudam. Remover IDs duplicados na interface pode esconder a repetição; não recupera o item pulado.

Compare a chave de ordenação inteira

O cursor carrega a chave do último item retornado. Uma condição baseada apenas no horário, como createdAt < cursor.createdAt, perde o item 4 depois do 5, pois os dois têm horário 50. Trocar a condição por <= permite que o item anterior apareça novamente.

Guarde os dois valores de (createdAt, id) e reutilize a comparação da ordenação:

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,
  };
}

Nesta ordem decrescente, compare(row, after) > 0 significa que a linha fica estritamente depois da posição salva. Em horários iguais, o id desempata. A próxima página é 4, 3 tanto após a inserção anterior quanto após a exclusão.

O objeto nextCursor copia os valores, em vez de buscar novamente a linha anterior; assim, excluir o item 5 ainda permite continuar a partir da chave salva. Uma página vazia retorna null. O exemplo pode fazer uma requisição adicional para descobrir que a lista terminou.

Execute as alterações como testes separados

Baixe pagination-reading.mjs e execute com Node.js 22 ou mais recente:

node --test pagination-reading.mjs

Os dez testes passam. Os três cujo nome começa com reproduce: verificam propositalmente a duplicação com offset, a omissão com offset e a omissão do cursor que compara apenas o horário. Os outros sete verificam inserção, exclusão antes do cursor, exclusão da linha de origem, percurso completo com horários iguais e página final vazia, uma nova linha posterior, filtragem antes do limite e reinício do filtro.

Os testes usam arrays novos e mudanças síncronas entre chamadas. Não precisam de serviço, conta, temporizador ou instalação de pacotes.

Leia o chamador antes de considerar a correção concluída

Mudar o filtro para done mantendo o cursor antigo retorna uma página vazia neste exemplo: o item 0 fica antes desse cursor. Iniciar a nova consulta com after = null retorna o item 0. Quando o filtro ou a ordenação mudar, o chamador deve reiniciar tanto as linhas acumuladas quanto o cursor.

Confira também onde nextCursor é guardado e quando é usado. Aqui, null após uma resposta vazia significa parar; passá-lo diretamente à função reiniciaria a primeira página. Procure envios repetidos, respostas fora de ordem e a inclusão da mesma resposta duas vezes. Esses comportamentos da interface estão fora deste exemplo síncrono; acompanhar mudanças de seleção assíncronas oferece outro exemplo para respostas antigas.

Decida como as mudanças devem aparecer para quem lê

Um cursor não fornece um snapshot. Insira o item 8 com horário 49 após ler a primeira página: a próxima será 4, 8. A nova linha fica depois da posição salva e, por isso, aparece no percurso já iniciado. Uma nova linha anterior ao cursor espera uma atualização da lista.

Essas verificações pressupõem IDs únicos e chaves de ordenação inalteradas nos itens que continuam existindo. Editar uma chave, mudar o status ou excluir um item ainda não lido exige uma regra explícita do produto; uma exportação com conteúdo fixo pode precisar de uma estratégia de snapshot separada. Este exemplo com arrays não testa isolamento de banco de dados, índices, transporte ou codificação do cursor. Filtrar e ordenar todos os dados a cada chamada também não serve como projeto de desempenho para grandes volumes.

Ao ler sua implementação, confira lado a lado o filtro, a comparação, a chave salva e o reinício no chamador. No Yenpo, abra os arquivos relacionados e siga as referências para verificar cada etapa no código.