2 件読み込み、「次へ」を押すと、前のページの最後の項目がまた現れます。別のときには、ページを送る途中で 1 件消えてしまいます。閲覧中にも更新される小さなタスクリストを考えてみましょう。行が動いた後の「次」を、コードがどう決めているかが問題です。
絞り込み、並べ替え、ページの切り出しを続けて読む
まず createdAt の降順、次に一意な数値の id の降順に並べます。教材では小さな整数を時刻として使います。6 件の open 項目は 6、5、4、3、2、1 の順で、5 と 4 の時刻はいずれも 50 です。別に、時刻 70 の done 項目 0 があります。
| 読む場所 | 確認するルール |
|---|---|
selected |
ページを切り出す前にステータスで絞り込む |
compare |
新しい時刻を先にし、同じ時刻なら大きい ID を先にする |
offsetPage / cursorPage |
どの行が次のページに入るか |
| 呼び出し元 | nextCursor を保存し、有効な結果を追加し、検索条件の変更時にリセットする |
「次へ」の処理から始め、引数を選択関数までたどります。最後の配列切り出しだけを読んでも、その位置を決めた絞り込みと並び順は分かりません。
同じ offset が違う行を指す様子を見る
次の実装は、その時点で条件に一致する行を指定数だけ飛ばします。
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);
}
最初のページは 6 と 5 です。その後に変更を 1 回加え、offset = 2 で 2 ページ目を取得すると、結果が変わります。
| 最初のページ取得後の変更 | 現在の一致する行の順序 | 2 ページ目 | 結果 |
|---|---|---|---|
| なし | 6, 5, 4, 3, 2, 1 | 4, 3 | 予定どおり続く |
| 時刻 70 の 7 を挿入 | 7, 6, 5, 4, 3, 2, 1 | 5, 4 | 5 が重複する |
| 6 を削除 | 5, 4, 3, 2, 1 | 3, 2 | 4 が飛ばされる |
表の各ケースは元のデータから始めます。飛ばす件数は同じでも、行の位置が変わりました。画面側で ID の重複を除けば重複表示は隠せますが、飛ばした項目は戻りません。
並び順を決めるキー全体を比較する
カーソルには、最後に返した行のキーを保存します。createdAt < cursor.createdAt のように時刻だけを比較すると、5 の後の 4 が落ちます。どちらも時刻 50 だからです。<= に変えると、前の項目をまた選んでしまいます。
(createdAt, id) の両方を保存し、並べ替えと同じ比較を使います。
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,
};
}
この降順では、compare(row, after) > 0 が保存した位置より厳密に後の行を表します。同じ時刻なら id で順序を決めます。先ほどの挿入と削除のどちらが起きても、次のページは 4、3 になります。
nextCursor は前の行を探し直すのではなく、キーの値をコピーしたオブジェクトです。そのため、5 を削除しても保存済みのキーから続けられます。空のページは null を返します。この例では、終端を知るために追加のリクエストが 1 回必要になることがあります。
変更を別々のテストとして実行する
pagination-reading.mjs をダウンロードし、Node.js 22 以降で実行してください。
node --test pagination-reading.mjs
10 件すべてが通ります。reproduce: で始まる 3 件は、offset による重複、offset による欠落、時刻だけのカーソルによる欠落を、意図的に誤った結果として検査します。残る 7 件は、前方への挿入、前方の削除、カーソルの元の行の削除、同じ時刻と終端の空ページを含む全件走査、後方への新しい行の挿入、件数制限前の絞り込み、条件変更時のリセットを確認します。
各テストは新しい配列を使い、呼び出しの間に同期的に変更を加えます。サービス、アカウント、タイマー、パッケージのインストールは不要です。
修正できたと判断する前に呼び出し元を読む
古いカーソルを残したまま条件を done に変えると、この例は空のページを返します。項目 0 はそのカーソルより前にあるためです。条件を変えた検索を after = null で始めると、0 が返ります。絞り込みや並び順の変更時には、呼び出し元で蓄積した行とカーソルを同時にリセットします。
nextCursor の保存先と、次に使われるタイミングも確認してください。ここで空の応答に付く null は終了を意味します。そのまま関数に渡すと、最初のページから再開してしまいます。リクエストの重複、応答順序の逆転、同じ応答の二重追加も調べます。これらの画面の動作は同期的な教材の範囲外です。古い応答については、非同期の選択変更を追う例で扱っています。
更新中のリストで何を見せるか決める
カーソルはスナップショットを提供しません。最初のページを読んだ後に時刻 49 の項目 8 を挿入すると、次のページは 4、8 になります。新しい行が保存した位置より後に並ぶため、進行中の走査にも現れます。カーソルより前に入った新しい行は、再読み込みを待ちます。
この継続処理は、ID が一意で、残っている項目のソートキーが変わらないことを前提とします。キーの編集、ステータス変更、まだ読んでいない項目の削除には、別途製品としてのルールが必要です。内容を固定したエクスポートにはスナップショットの設計が必要かもしれません。この配列の教材では、データベースの分離性、インデックス、通信、カーソルの符号化は検証していません。毎回全件を絞り込み、並べ替えるので、大量データ向けの性能設計にもなりません。
自分の実装を読む際は、絞り込み、比較、保存したキー、呼び出し元のリセットを並べて確認します。Yenpo では、関連ファイルを開いて参照をたどり、各段階のコードを確かめられます。