IlmHamroh
JavaScript Full-stack/25-qism. HTTP, Express va REST API22/51-dars24 daqiqa
Mundarija (26)

API pagination: offset, cursor va keyset sahifalash, Link sarlavhasi

Qisqacha: Sahifalash (pagination) — katta ro'yxatni bo'laklab berish. Offset (?limit=20&offset=40) oddiy, istalgan sahifaga sakrash mumkin, lekin ro'yxat o'zgarsa qatorlar takrorlanadi yoki yo'qoladi. Kursor (keyset) — "oxirgi ko'rgan qatoringdan keyingisi": takror yo'q, bazada ham tez. Kanon o'lchovi: sahifalar orasida yangi vazifalar qo'shilganda offset 100 qatordan 20 tasini takrorladi, kursor — 0. Keyingi sahifa havolasi Link: <…>; rel="next" sarlavhasida keladi.

Bu darsda

  • Offset va kursor sahifalashni yozasiz va farqini o'lchov bilan tushuntirasiz.
  • Keyset g'oyasini — "qat'iy keyingi qator" — bilasiz.
  • Shaffof bo'lmagan (opaque) kursorni base64url bilan yasaysiz va uning sir emasligini tushunasiz.
  • total sonining narxini va hasNextPage ni qanday aniqlashni bilasiz.
  • Link sarlavhasini yozasiz va holat 22 ni commit qilasiz.

Oldin bilishingiz kerak: Filtrlash, saralash va maydon tanlash, Status kodlarni to'g'ri tanlash, Buffer va binar ma'lumot.

1. Nega bu kerak?

GET /vazifalar hozir hamma vazifani qaytaradi. 10 000 ta vazifa — bu bir necha megabayt JSON, serverda katta xotira va telefonda sekin ekran. Hech kim 10 000 qatorni bir vaqtda o'qimaydi — ekranda 20 tasi ko'rinadi.

Sahifalash — kitobga o'xshaydi. Butun kitobni bir varaqqa bosmaysiz, sahifalarga bo'lasiz. Savol — "keyingi sahifa" qanday aniqlanadi. Ikki yo'l bor:

  • Sahifa raqami bilan (offset): "41-qatordan boshlab 20 ta". Kitobning 3-sahifasini ochish kabi.
  • Xatcho'p bilan (kursor): "men shu qatorda to'xtadim, keyingisini ber". Kitobga xatcho'p qo'yish kabi.

Kitob o'zgarmasa, ikkalasi bir xil. Endi tasavvur qiling: siz o'qiyotganda kimdir kitob boshiga yangi sahifalar yopishtirib turibdi. Sahifa raqami bo'yicha ochsangiz — o'qigan joyingizni qayta o'qiysiz. Xatcho'p esa joyida qoladi. Vazifalar ro'yxati aynan shunday "tirik kitob": siz o'qiyotganda yangi vazifalar qo'shiladi.

2. Offset sahifalash

2.1 Qanday ishlaydi

?limit=20&offset=40 — "40 tasini o'tkazib yubor, keyingi 20 tasini ber". Xotirada bu slice(offset, offset + limit), SQL'da LIMIT 20 OFFSET 40.

Kanon curl (tekshiruv-22, besh vazifa):

text
$ curl -i $A/vazifalar?limit=2&offset=0&jami=true&fields=matn
HTTP/1.1 200 OK
Link: </vazifalar?limit=2&jami=true&fields=matn&offset=2>; rel="next"

{"vazifalar":[{"id":1,"matn":"Non olish #uy"},
  {"id":2,"matn":"Hisobot yozish #ish"}],
  "sahifa":{"limit":2,"offset":0,"jami":5}}

Afzalliklari: oddiy, jami bilan "5 sahifadan 1-si" ko'rsatish mumkin, istalgan sahifaga sakrash mumkin (offset=80).

2.2 Muammo: o'zgaruvchan ro'yxat

Ro'yxat yangisi oldin saralangan (sort=-yaratildi). Siz 1-sahifani o'qidingiz. Shu orada 5 ta yangi vazifa qo'shildi. Endi 2-sahifani so'raysiz (offset=20). Lekin hamma qatorlar 5 pozitsiyaga pastga surildi. Natijada 1-sahifaning oxirgi 5 qatori 2-sahifaning boshida takrorlanadi.

Buni kichik modelda ko'ramiz. Ro'yxat — 30 ta vazifa, yangisi oldin, sahifa 5 ta. Har sahifadan keyin 2 ta yangi vazifa qo'shiladi. «Ishga tushir» bilan sinang:

js
let keyingiId = 31;
const royxat = Array.from({ length: 30 }, (_, i) => ({ id: i + 1 }));
const tartiblangan = () => [...royxat].sort((a, b) => b.id - a.id);
const qosh = (n) => {
  for (let i = 0; i < n; i++) royxat.push({ id: keyingiId++ });
};

function offsetSahifa(offset, limit) {
  return tartiblangan().slice(offset, offset + limit);
}
// keyset: oxirgi ko'rilgan id dan QAT'IY keyingilar
// (kamayish tartibi)
function kursorSahifa(oxirgiId, limit) {
  const qolgan = oxirgiId === undefined
    ? tartiblangan()
    : tartiblangan().filter((v) => v.id < oxirgiId);
  return qolgan.slice(0, limit);
}

const korildi = { offset: [], kursor: [] };
let oxirgi;
for (let s = 0; s < 3; s++) {
  korildi.offset.push(...offsetSahifa(s * 5, 5).map((v) => v.id));
  const sahifa = kursorSahifa(oxirgi, 5);
  korildi.kursor.push(...sahifa.map((v) => v.id));
  oxirgi = sahifa.at(-1).id;
  qosh(2); // foydalanuvchi o'qiyotganda yangi vazifalar
}
for (const [usul, idlar] of Object.entries(korildi)) {
  const takror = idlar.length - new Set(idlar).size;
  console.log(`${usul}: ${idlar.join(" ")} | takror ${takror}`);
}

Konsolda:

text
offset: 30 29 28 27 26 27 26 25 24 23 24 23 22 21 20 | takror 4
kursor: 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 | takror 0

Offset satriga qarang: yangi vazifalar (31, 32, …) ro'yxat boshiga tushdi, eski qatorlar pastga surildi. Har yangi sahifada oldingi sahifaning oxirgi ikki qatori qaytadan keldi: 27 26, keyin 24 23 — 15 qatordan 4 tasi takror. Shu bilan birga yangi 31–34 vazifalar umuman ko'rinmadi. Kursor esa har safar "26 dan kichiklar", "21 dan kichiklar" deb davom etdi — 15 ta noyob qator. Yangi vazifalarni u keyingi to'liq yangilanishda ko'radi.

2.3 Kanon o'lchovi

Kanon xuddi shu tajribani haqiqiy vazifalar-api bilan qilgan (t22): 5 sahifa × 20, har sahifadan keyin 5 ta yangi vazifa, sort=-yaratildi:

5 sahifa × 20 qator, orada yangi vazifalar: takrorlangan qatorlar
  • offset100 dan 80 noyob20 qator
  • kursor100 noyob0 qator

Manba: IlmHamroh kanon o'lchovi t22, 2026-10-24: vazifalar-api holat 22, Node 24.21.0, Windows 11; har sahifadan keyin 5 ta yangi vazifa

Grafikdagi chap ustun: 100 qatordan 20 tasi takror — foydalanuvchi bir xil vazifani ikki marta ko'radi. O'chirishda teskarisi bo'ladi: qatorlar yuqoriga suriladi va bittasi o'tkazib yuboriladi. Kanon birlik testi buni ushlaydi: 2-qator o'chirilgandan keyin offset=2 3-qatorni o'tkazib yubordi, kursor esa 3 dan davom etdi.

3. Kursor va keyset

3.1 Keyset g'oyasi

Keyset sahifalash — "saralash kalitlari bo'yicha oxirgi qatordan qat'iy keyin turgan birinchi qator". Saralash -yaratildi bo'lsa va oxirgi ko'rgan qator yaratildi = 10:00:21, id = 21 bo'lsa, keyingi sahifa — "shundan keyin keladigan qatorlar".

Nega id ham kerak? Ikki vazifa bir soniyada yaratilgan bo'lishi mumkin. Faqat yaratildi bilan "qat'iy keyin" tenglarni o'tkazib yuboradi. id — oxirgi kalit, u har doim noyob (Filtrlash darsida kanon kalitlar() uni har saralashga qo'shishini ko'rgandik).

Kanon sahifala (royxat-mantiq.ts) — keyset qismi:

ts
const keyin = sorov.sahifa.keyin;
// Keyset: kursordan qat'iy keyingi birinchi qator
// (kursor qatori o'chirilgan bo'lsa ham ishlaydi)
const i = mos.findIndex(
  (v) => tartibda(kalitQiymatlari(v, k), keyin, k) > 0);
boshi = i === -1 ? mos.length : i;

Kursor qatorning o'zini emas, uning qiymatlarini saqlaydi. Shuning uchun o'sha qator o'chirilsa ham, "shu qiymatlardan keyingisi" hali ham aniq.

3.2 Bazada nega tezroq

Xotirada ikki usul bir xil tezlikda. Kanon t22 (10 000 vazifa, 40 o'lchov mediana): offset=0 3.96 ms, offset=5000 3.38 ms, offset=9980 3.13 ms, kursor bilan oxirgi sahifa 3.30 ms. Farq yo'q — xotiradagi ombor har so'rovda baribir 10 000 qatorni filtrlab saralaydi.

Postgres'da esa farq paydo bo'ladi. OFFSET 9980 — baza 9 980 qatorni o'qib, tashlab yuboradi. Keyset esa indeks bo'yicha kerakli joyga to'g'ridan sakraydi: WHERE (yaratildi, id) < ($1, $2) ORDER BY yaratildi DESC, id DESC LIMIT 20. Bu 26-qismda o'lchanadi — hozir raqam aytmaymiz.

3.3 Shaffof bo'lmagan kursor

Kursor mijozga qanday beriladi? Kanon qiymatlarni JSON qilib, base64url bilan kodlaydi: { v: [kalit qiymatlari], i: imzo }. Mijoz uni ochmasligi, faqat qaytarib yuborishi kerak — shuning uchun "shaffof bo'lmagan" (opaque).

Kanondagi Buffer.from(...).toString("base64url") brauzerda yo'q. Quyidagi blok xuddi shuni btoa bilan qiladi. «Ishga tushir» bilan sinang:

js
const b64url = (s) => btoa(String.fromCharCode(
  ...new TextEncoder().encode(s)))
  .replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
const b64urlOch = (s) => new TextDecoder().decode(Uint8Array.from(
  atob(s.replaceAll("-", "+").replaceAll("_", "/")),
  (c) => c.charCodeAt(0)));

const kursorYoz = (v, imzo) => b64url(JSON.stringify({ v, i: imzo }));
function kursorOqi(kursor, imzo) {
  try {
    const ichki = JSON.parse(b64urlOch(kursor));
    const yaroqli = ichki.i === imzo && Array.isArray(ichki.v);
    return yaroqli ? ichki.v : null;
  } catch {
    return null; // buzuq kursor
  }
}

const imzo = JSON.stringify([[], {}]); // saralash va filtr
const k = kursorYoz([2], imzo);
console.log(k);
console.log(kursorOqi(k, imzo));
console.log(kursorOqi(k, JSON.stringify([[{ maydon: "matn" }], {}])));
console.log(kursorOqi("buzuq", imzo));

Konsolda:

text
eyJ2IjpbMl0sImkiOiJbW10se31dIn0
[ 2 ]
null
null

Birinchi qator kanon curl dagi kursor bilan aynan bir xil: eyJ2IjpbMl0sImkiOiJbW10se31dIn0. Kanon chiqishi:

text
$ curl -i $A/vazifalar?limit=2&fields=matn
Link: </vazifalar?limit=2&fields=matn&cursor=eyJ2IjpbMl0sImkiOiJbW10se31dIn0>; rel="next"
{"vazifalar":[{"id":1,…},{"id":2,…}],
  "sahifa":{"limit":2,"keyingi":"eyJ2IjpbMl0sImkiOiJbW10se31dIn0"}}
$ curl $A/vazifalar?limit=2&sort=matn&cursor=eyJ2IjpbMl0sImkiOiJbW10se31dIn0
{"xato":"cursor yaroqsiz yoki boshqa saralash/filtr uchun"}

Imzo (i) — kursor qaysi saralash va filtr uchun yasalganini eslab qoladi. Mijoz sort=-yaratildi kursorini sort=matn so'rovida ishlatsa, qiymatlar boshqa kalitlarga tegishli bo'lib, natija ma'nosiz chiqardi. Imzo mos kelmasa — 400.

Ehtiyot bo'ling: bu "imzo" kriptografik imzo emas. U shunchaki saralash va filtrning JSON yozuvi ([[],{}]) — kursor qaysi so'rovga tegishli ekanini eslatuvchi yorliq. Uni soxtalashtirishdan himoya qilmaydi — buni keyingi bo'lim ko'rsatadi.

3.4 Halol eslatma: kursor sir emas

"Shaffof emas" — "maxfiy" degani emas. base64 — shifrlash emas, uni har kim ochadi. Kanon uzun kursorni ochib ko'rgan (t22, 127 belgi):

text
{"v":["2026-10-24T00:00:21.000Z",21],"i":"[[{\"maydon\":\"yaratildi\"…

Ichida vaqt va ID ochiq turibdi. Bu vazifalar-api uchun muammo emas — bu qiymatlar javobda baribir bor. Lekin mijoz kursorni qo'lda yasashi mumkin: {"v":["2099-01-01",1]}. Kanon himoyasi — format tekshiruvi (/^[\w-]{1,1000}$/, JSON, imzo, qiymat turlari). Kursorni o'zgartirib bo'lmaydigan qilish (HMAC imzo) — auth qismida, kanon qarori bo'yicha.

Tekshirib ko'ring: Nega kursor ichida offset raqamini ({"offset": 40}) saqlash yaxshi kursor emas?

Javob

Bu nomi "kursor", lekin aslida offset — ro'yxat o'zgarganda o'sha takrorlanish muammosi qoladi. Haqiqiy kursor pozitsiyani emas, oxirgi qatorning saralash kalitlari qiymatlarini saqlaydi: "shu qiymatlardan keyin". Shunda yangi qatorlar qo'shilsa yoki o'chirilsa ham, davom etish nuqtasi to'g'ri qoladi.

4.1 total — qimmat

"Jami 10 000 ta vazifa, 500 sahifa" — chiroyli, lekin har so'rovda butun ro'yxatni sanash kerak. Postgres'da SELECT count(*) katta jadvalda sekin. Shuning uchun kanon jami ni faqat so'ralganda qaytaradi: ?jami=true. Xotirada t22 da jami=true bilan birinchi sahifa 2.50 ms — farq sezilmaydi, bazada esa sezilishi mumkin.

Ko'p zamonaviy ilovalar umuman total ko'rsatmaydi — cheksiz aylantirish (infinite scroll) va "Yana yuklash" tugmasi bilan. Unga faqat "keyingisi bormi?" kerak.

4.2 hasNextPage — keyingisi bormi

Javobda keyingi: null — oxirgi sahifa. Kanon xotirada boshi + limit < mos.length bilan aniqlaydi. Bazada esa butun sonni bilish qimmat, shuning uchun hiyla ishlatiladi: LIMIT limit + 1. 21 ta qator kelsa — keyingi sahifa bor, mijozga faqat 20 tasi beriladi. Bu count(*) siz "bormi?" savoliga javob beradi.

Link (RFC 8288) — javobga bog'liq boshqa manzillar: rel="next", rel="prev", rel="first". Mijoz keyingi sahifa URL'ini o'zi yig'ishi shart emas — tayyorini oladi. GitHub API shunday ishlaydi.

Kanon funksiyasi joriy so'rovning parametrlarini nusxalaydi va faqat offset/cursor ni almashtiradi:

ts
function keyingiHavola(
  req: Request, ozgartir: Record<string, string>,
): string {
  const p = new URLSearchParams(req.originalUrl.split("?")[1] ?? "");
  p.delete("offset");
  p.delete("cursor");
  for (const [k, v] of Object.entries(ozgartir)) p.set(k, v);
  return `${req.baseUrl}?${p.toString()}`;
}
// …
if (keyingi !== undefined) res.links({ next: keyingi });

res.links() — Express'ning Link sarlavhasini yozadigan metodi. Kanon testidagi qiymat: </vazifalar?limit=4&jami=true&offset=4>; rel="next". jami=true saqlangan, offset almashgan.

Javob tanasida ham sahifa.keyingi bor. Ikkalasi ham kerakmi? Tana — JS'dan o'qish oson; Link — umumiy standart, CORS'da esa exposedHeaders talab qiladi. Kanon ikkalasini beradi.

sequenceDiagram
  participant F as Frontend
  participant A as API
  F->>A: GET /vazifalar?limit=20
  A-->>F: 20 ta + keyingi: K1<br/>Link: rel="next"
  F->>A: GET /vazifalar?limit=20&cursor=K1
  A-->>F: 20 ta + keyingi: K2
  F->>A: GET …&cursor=K2
  A-->>F: 7 ta + keyingi: null

Diagrammada har javob keyingi so'rov uchun kursor berishini kuzating. Server hech narsani eslab qolmaydi (holatsizlik) — butun "xatcho'p" mijozda, kursor ichida.

5. Qaysi birini tanlash

Offset Kursor (keyset)
Istalgan sahifaga sakrash ha yo'q
"Jami N sahifa" oson (count) qimmat, odatda yo'q
O'zgaruvchan ro'yxatda takror va yo'qolish izchil
Katta offset'da bazada sekinlashadi tez (indeks)
Murakkablik oddiy kalitlar, imzo, kodlash

Kanon qarori: parametrsiz GET /vazifalar — kursor rejimi (birinchi sahifa). Offset faqat aniq offset so'ralganda. offset va cursor birga — 400. offset chegarasi 10 000 (MAKS_OFFSET) — juda chuqur offset bazani og'irlashtirmasin. limit: sukut 20, chegara 1–100.

Qachon offset ma'qul? Admin jadvali — "5-sahifa" tugmalari kerak, ma'lumot kam o'zgaradi. Qachon kursor? Lenta, chat, bildirishnomalar, cheksiz aylantirish.

6. API qadami: sahifalash va commit (holat 22)

Holat 22 ning oxirgi qismi. Kanon fayllari to'liq — faqat telefon uchun uzun qatorlar bo'lingan, kod va ma'no aynan.

  1. Yangi fayl src/umumiy/kursor.ts:
ts
// kursor.ts — shaffof bo'lmagan (opaque) kursor: base64url(JSON).
// Mijoz ichini o'qimasligi, faqat qaytarib yuborishi kerak.
// Imzo (saralash + filtr) kursor bilan birga — boshqa saralash
// yoki filtr bilan ishlatilsa rad etiladi
import type { KursorQiymati } from "../vazifalar/ombor.ts";

type Ichki = { v: KursorQiymati; i: string };

export function kursorYoz(
  qiymatlar: KursorQiymati, imzo: string,
): string {
  return Buffer.from(
    JSON.stringify({ v: qiymatlar, i: imzo } satisfies Ichki),
  ).toString("base64url");
}

function qiymatmi(x: unknown): x is string | number | boolean {
  return typeof x === "string" || typeof x === "number" ||
    typeof x === "boolean";
}

/** @returns undefined — kursor buzilgan yoki boshqa so'rovniki */
export function kursorOqi(
  kursor: string, imzo: string,
): KursorQiymati | undefined {
  if (!/^[\w-]{1,1000}$/.test(kursor)) return undefined;
  let ichki: unknown;
  try {
    ichki = JSON.parse(
      Buffer.from(kursor, "base64url").toString("utf8"));
  } catch {
    return undefined;
  }
  if (typeof ichki !== "object" || ichki === null ||
    !("v" in ichki) || !("i" in ichki)) return undefined;
  const { v, i } = ichki;
  if (i !== imzo || !Array.isArray(v) || !v.every(qiymatmi)) {
    return undefined;
  }
  return v;
}
  1. src/vazifalar/ombor.ts ga sahifalash tiplari (Filtrlash darsidagi RoyxatSorovi shu tipni ishlatadi):
ts
/** Keyset kursori: oxirgi qatorning saralash kalitlari qiymatlari
 *  (saralash tartibida) + id */
export type KursorQiymati = (string | number | boolean)[];

export type Sahifalash =
  | { tur: "offset"; offset: number }
  | { tur: "kursor"; keyin?: KursorQiymati };

export type RoyxatNatija = {
  vazifalar: Vazifa[];
  /** keyingi sahifa bormi — kursor rejimida keyingi kursor
   *  qiymatlari */
  keyingi?: KursorQiymati;
  borKeyingi: boolean;
  jami?: number;
};
  1. src/vazifalar/royxat-mantiq.ts — filtrla va kalitlar Filtrlash darsida yozilgan; faylning qolgan qismi:
ts
function solishtir(a: string | number | boolean,
  b: string | number | boolean): number {
  // Satrlar — kod nuqtasi bo'yicha (localeCompare emas):
  // deterministik. Postgres collation boshqacha tartiblashi
  // mumkin — 26-qismda kursor DB tartibi bilan mos bo'lishi kerak
  if (a === b) return 0;
  return a < b ? -1 : 1;
}

function kalitQiymatlari(v: Vazifa,
  k: readonly Saralash[]): KursorQiymati {
  return k.map((s) => v[s.maydon]);
}

/** a kalitlari b dan saralash tartibida oldinmi (<0), tengmi (0),
 *  keyinmi (>0) */
function tartibda(a: KursorQiymati, b: KursorQiymati,
  k: readonly Saralash[]): number {
  for (const [i, s] of k.entries()) {
    const x = a[i];
    const y = b[i];
    if (x === undefined || y === undefined) return 0;
    const n = solishtir(x, y);
    if (n !== 0) return s.kamayish ? -n : n;
  }
  return 0;
}

export function sahifala(hammasi: readonly Vazifa[],
  sorov: RoyxatSorovi): RoyxatNatija {
  const k = kalitlar(sorov.saralash);
  const mos = filtrla(hammasi, sorov.filtr).sort((a, b) =>
    tartibda(kalitQiymatlari(a, k), kalitQiymatlari(b, k), k));
  let boshi = 0;
  if (sorov.sahifa.tur === "offset") {
    boshi = sorov.sahifa.offset;
  } else if (sorov.sahifa.keyin !== undefined) {
    const keyin = sorov.sahifa.keyin;
    // Keyset: kursordan qat'iy keyingi birinchi qator
    // (kursor qatori o'chirilgan bo'lsa ham ishlaydi)
    const i = mos.findIndex(
      (v) => tartibda(kalitQiymatlari(v, k), keyin, k) > 0);
    boshi = i === -1 ? mos.length : i;
  }
  const sahifa = mos.slice(boshi, boshi + sorov.limit);
  const borKeyingi = boshi + sorov.limit < mos.length;
  const oxirgi = sahifa.at(-1);
  const natija: RoyxatNatija = {
    vazifalar: structuredClone(sahifa), borKeyingi };
  if (borKeyingi && oxirgi !== undefined) {
    natija.keyingi = kalitQiymatlari(oxirgi, k);
  }
  if (sorov.jami === true) natija.jami = mos.length;
  return natija;
}

structuredClone — ombor ichki obyektlarini tashqariga bermaydi: mijoz kodi natijani o'zgartirsa, omborga ta'sir qilmaydi. XotiraOmbori.royxat shunchaki sahifala([...this.#vazifalar.values()], sorov) ni qaytaradi.

  1. src/vazifalar/sorov.ts — sahifalash qismi (faylning boshi Filtrlash darsida):
ts
export const SUKUT_LIMIT = 20;
export const MAKS_LIMIT = 100;
export const MAKS_OFFSET = 10_000;
// … royxatSorovi() ichida, filtr va saralashdan keyin:
  const limit = butun(q, "limit", 1, MAKS_LIMIT) ?? SUKUT_LIMIT;
  const offset = butun(q, "offset", 0, MAKS_OFFSET);
  const kursor = bitta(q, "cursor");
  // Imzo — kursor boshqa filtr/saralash bilan qayta ishlatilmasin
  const imzo = JSON.stringify([saralash, filtr]);
  let sahifa: RoyxatSorovi["sahifa"] =
    { tur: "offset", offset: offset ?? 0 };
  if (kursor !== undefined) {
    if (offset !== undefined) {
      throw new HttpXato(400, "offset va cursor birga berilmaydi");
    }
    const keyin = kursorOqi(kursor, imzo);
    if (keyin === undefined) {
      throw new HttpXato(400,
        "cursor yaroqsiz yoki boshqa saralash/filtr uchun");
    }
    sahifa = { tur: "kursor", keyin };
  } else if (offset === undefined) {
    // Parametrsiz — kursor rejimi (birinchi sahifa);
    // offset faqat aniq so'ralganda
    sahifa = { tur: "kursor" };
  }
  1. Router — GET /vazifalar handler'i to'liq (marshrut.ts; keyingiHavola — «Link sarlavhasi» bo'limida):
ts
.get(async (req, res) => {
  const { sorov, maydonlar, imzo } = royxatSorovi(req.query);
  const natija = await xizmat.royxat(sorov);
  const sahifa: Record<string, unknown> = { limit: sorov.limit };
  let keyingi: string | undefined;
  if (sorov.sahifa.tur === "offset") {
    sahifa.offset = sorov.sahifa.offset;
    if (natija.borKeyingi) {
      keyingi = keyingiHavola(req,
        { offset: String(sorov.sahifa.offset + sorov.limit) });
    }
  } else {
    const kursor = natija.keyingi && kursorYoz(natija.keyingi, imzo);
    sahifa.keyingi = kursor ?? null;
    if (kursor !== undefined) {
      keyingi = keyingiHavola(req, { cursor: kursor });
    }
  }
  if (natija.jami !== undefined) sahifa.jami = natija.jami;
  // Link sarlavhasi (RFC 8288) — mijoz URL'ni o'zi yig'masin
  if (keyingi !== undefined) res.links({ next: keyingi });
  res.json({
    vazifalar: natija.vazifalar.map((v) => proyeksiya(v, maydonlar)),
    sahifa,
  });
})

GET /vazifalar?limit=2&cursor=… so'rovi fayllar bo'ylab qanday yuradi:

Qadam Qayerda Natija
1 royxatSorovi(req.query) (sorov.ts) limit: 2; kursorOqi imzoni tekshiradi → keyin: [2]; buzuq bo'lsa 400
2 xizmat.royxat(sorov) omborga uzatadi — HTTP'ni bilmaydi
3 sahifala (royxat-mantiq.ts) [2] dan qat'iy keyingi 2 ta qator, borKeyingi, keyingi: [4]
4 router kursorYoz([4], imzo) → sahifa.keyingi va Link: rel="next"
5 res.json { vazifalar, sahifa }

Har qatlam faqat o'z ishini qiladi: kursorni o'qish va yozish — HTTP chegarasida, "qaysi qatordan" — omborda.

  1. Holat 22 dagi src/vazifalar/xotira-ombor.ts to'liq — oldingi to'rt darsdagi teglar, soni, royxat shu yerda birlashgan:
ts
// xotira-ombor.ts — VazifaOmbori ning xotiradagi amalga oshirilishi
// (jarayon to'xtasa — ma'lumot yo'qoladi)
import { teglariniOl } from "./vazifa.ts";
import type { Vazifa, VazifaOzgarishi, YangiVazifa } from "./vazifa.ts";
import { filtrla, sahifala } from "./royxat-mantiq.ts";
import type { Filtr, RoyxatNatija, RoyxatSorovi, TegSoni,
  VazifaOmbori } from "./ombor.ts";

export class XotiraOmbori implements VazifaOmbori {
  readonly #vazifalar = new Map<number, Vazifa>();
  #keyingiId = 1;
  readonly #soat: () => Date;

  constructor(soat: () => Date = () => new Date()) {
    this.#soat = soat;
  }

  // structuredClone (sahifala ichida ham): chaqiruvchi qaytgan
  // obyektni o'zgartirsa ombordagi nusxa buzilmaydi
  royxat(sorov: RoyxatSorovi): Promise<RoyxatNatija> {
    return Promise.resolve(
      sahifala([...this.#vazifalar.values()], sorov));
  }

  soni(filtr: Filtr): Promise<number> {
    return Promise.resolve(
      filtrla([...this.#vazifalar.values()], filtr).length);
  }

  ol(id: number): Promise<Vazifa | undefined> {
    const v = this.#vazifalar.get(id);
    return Promise.resolve(v && structuredClone(v));
  }

  yarat(yangi: YangiVazifa): Promise<Vazifa> {
    const hozir = this.#soat().toISOString();
    const v: Vazifa = {
      id: this.#keyingiId++,
      matn: yangi.matn,
      bajarildi: yangi.bajarildi ?? false,
      teglar: teglariniOl(yangi.matn),
      yaratildi: hozir,
      yangilandi: hozir,
    };
    this.#vazifalar.set(v.id, v);
    return Promise.resolve(structuredClone(v));
  }

  yangila(id: number,
    ozgarish: VazifaOzgarishi): Promise<Vazifa | undefined> {
    const v = this.#vazifalar.get(id);
    if (!v) return Promise.resolve(undefined);
    if (ozgarish.matn !== undefined) {
      v.matn = ozgarish.matn;
      v.teglar = teglariniOl(ozgarish.matn);
    }
    if (ozgarish.bajarildi !== undefined) {
      v.bajarildi = ozgarish.bajarildi;
    }
    v.yangilandi = this.#soat().toISOString();
    return Promise.resolve(structuredClone(v));
  }

  ochir(id: number): Promise<boolean> {
    return Promise.resolve(this.#vazifalar.delete(id));
  }

  teglar(): Promise<TegSoni[]> {
    const sanoq = new Map<string, number>();
    for (const v of this.#vazifalar.values()) {
      for (const t of v.teglar) sanoq.set(t, (sanoq.get(t) ?? 0) + 1);
    }
    return Promise.resolve(
      [...sanoq].map(([teg, soni]) => ({ teg, soni }))
        .sort((a, b) => b.soni - a.soni || (a.teg < b.teg ? -1 : 1)),
    );
  }
}

Va src/vazifalar/xizmat.ts to'liq (holat 22):

ts
// xizmat.ts — biznes qoidalari (service qatlami): HTTP'ni bilmaydi,
// omborni interfeys orqali oladi
import { HttpXato } from "../umumiy/http-xato.ts";
import type { RoyxatNatija, RoyxatSorovi, TegSoni, VazifaOmbori }
  from "./ombor.ts";
import type { ToliqVazifa, Vazifa, VazifaOzgarishi, YangiVazifa }
  from "./vazifa.ts";

export const MAKS_VAZIFA = 1000;

// DI framework'siz: factory funksiya bog'liqlikni parametr sifatida
// oladi (testda — boshqa ombor)
export function vazifaXizmati(ombor: VazifaOmbori) {
  async function topilsin(id: number): Promise<Vazifa> {
    const v = await ombor.ol(id);
    if (!v) throw new HttpXato(404, `Vazifa ${String(id)} topilmadi`);
    return v;
  }
  async function yangila(id: number,
    ozgarish: VazifaOzgarishi): Promise<Vazifa> {
    const v = await ombor.yangila(id, ozgarish);
    if (!v) throw new HttpXato(404, `Vazifa ${String(id)} topilmadi`);
    return v;
  }
  return {
    royxat: (sorov: RoyxatSorovi): Promise<RoyxatNatija> =>
      ombor.royxat(sorov),
    teglar: (): Promise<TegSoni[]> => ombor.teglar(),
    ol: topilsin,
    async yarat(yangi: YangiVazifa): Promise<Vazifa> {
      if ((await ombor.soni({})) >= MAKS_VAZIFA) {
        throw new HttpXato(409,
          `Ro'yxat to'lgan (${String(MAKS_VAZIFA)})`);
      }
      return ombor.yarat(yangi);
    },
    yangila,
    /** PUT: butun resurs (matn va bajarildi) almashtiriladi —
     *  idempotent */
    almashtir: (id: number, toliq: ToliqVazifa): Promise<Vazifa> =>
      yangila(id, toliq),
    async ochir(id: number): Promise<void> {
      if (!(await ombor.ochir(id))) {
        throw new HttpXato(404, `Vazifa ${String(id)} topilmadi`);
      }
    },
  };
}

export type VazifaXizmati = ReturnType<typeof vazifaXizmati>;
  1. GET /vazifalar endi { vazifalar, sahifa } qaytaradi. vazifalar frontend'i (22-qism) obyekt kutadi — { versiya, vazifalar }, shuning uchun shaklning o'zi muammo emas: ortiqcha sahifa maydonini uning Zod sxemasi jim tashlab yuboradi. Lekin holat 22 javobida majburiy versiya hali yo'q — sxema uni rad etardi. To'plam versiya si Bulk darsida qo'shiladi. Haqiqiy, jim muammo boshqa: sahifalash sukut bo'yicha yoqildi. Parametrsiz so'rov endi faqat 20 ta vazifa qaytaradi — 30 ta vazifasi bor foydalanuvchi oxirgi 10 tasini ko'rmaydi va hech qanday xato chiqmaydi. Hozircha frontend'ga tegmaymiz — API hali o'zgaradi (xato formati, /v1). Frontend'ni API'ga ulash — Coverage va test sifati metrikalari darsidagi «Frontend'ni ulash» bo'limida: o'sha yerda ro'yxat ?limit=100 bilan so'raladi.

Kanon testlari — kursor bilan hammasi bir martadan va o'chirilgan qator (test/rest.test.ts, qisqartirilgan):

ts
test("kursor: hammasi bir martadan", async () => {
  const korildi: number[] = [];
  let yol = "/vazifalar?limit=2&sort=-yaratildi";
  for (let i = 0; i < 10; i++) {
    const r = await ol(yol);
    korildi.push(...r.tana.vazifalar.map((v) => v.id ?? 0));
    if (r.tana.sahifa.keyingi === null) break;
    yol = "/vazifalar?limit=2&sort=-yaratildi" +
      `&cursor=${String(r.tana.sahifa.keyingi)}`;
  }
  assert.deepEqual([...korildi].sort((a, b) => a - b),
    [1, 2, 3, 4, 5, 6]);
});

test("sahifala: kursor qatori o'chirilsa ham davom etadi", () => {
  // … 1..5 vazifa, birinchi sahifa → keyingi [2]; 2 o'chiriladi
  assert.deepEqual(s2.vazifalar.map((x) => x.id), [3, 4]);
  // offset esa 3 ni o'tkazib yubordi:
  assert.deepEqual(o2.vazifalar.map((x) => x.id), [4, 5]);
});

Kanon tekshiruvi (tekshiruv-22, mashina yuklangan): tip 0, lint 0, testlar 17/17. Yuk (autocannon, band mashina, bitta o'lchov): ?limit=100 — 1 530 req/s, ?limit=20&teg=yuk&sort=-yaratildi — 2 886 req/s, /vazifalar/5 — 5 988 req/s. Kichik sahifa — tezroq javob: JSON serializatsiya hajmga bog'liq.

Holat 22 ni commit qiling (kanon xabari):

bash
npm run check
git add -A
git commit -m "REST: PUT/PATCH (merge-patch), filtr, saralash, fields, offset va kursor sahifalash, Link, /teglar"

26-qism uchun eslatma (kanondan). Xotiradagi ombor satrlarni kod nuqtasi bo'yicha solishtiradi, Postgres esa collation bo'yicha — kursor DB tartibi bilan mos bo'lishi kerak. Aralash yo'nalishli saralashda (-bajarildi,matn) (a, b) > ($1, $2) qator solishtiruvi ishlamaydi — OR zanjiri kerak bo'ladi.

7. Ko'p uchraydigan xatolar

Xato Oqibat To'g'risi
limit chegarasiz ?limit=1000000 — server xotirasi 1..100
Saralashsiz sahifalash Sahifalar tartibi tasodifiy Barqaror kalitlar + id
Kursorda faqat yaratildi Bir soniyadagi qatorlar yo'qoladi Oxirgi kalit — id
Kursor boshqa sort bilan Ma'nosiz natija Imzo → 400
Har so'rovda count(*) Katta jadvalda sekin ?jami=true yoki LIMIT n+1
base64 kursorni "maxfiy" deb bilish Mijoz uni o'qiydi va yasaydi Tekshiruv; kerak bo'lsa HMAC
Sahifalashni sukut bo'yicha jim yoqish Mijoz faqat birinchi 20 tasini ko'radi Hujjatda ayting; mijozda limit yoki keyingi sahifa

8. Mashqlar

Yechimlar kurs/mashqlar/25/22-sahifalash/ da.

1-mashq (oson): to'ldiring

  • 3-sahifa, har sahifada 20 ta: ?limit=20&offset=
  • Keyingi sahifa havolasi sarlavhasi —
  • Kursor ichida nima saqlanadi: oxirgi qatorning
  • Bazada keyingi sahifa bormi — LIMIT ga qo'shiladi:
Yechim
  • 40 — 1-sahifa offset 0, 2-si 20, 3-si 40.
  • Link — rel="next", RFC 8288.
  • kalit qiymatlari — saralash kalitlari va id, pozitsiya emas.
  • 1 — LIMIT 21: 21-qator keldi — keyingi sahifa bor.

Mijoz tomoni uchun linkniOqi(sarlavha) yozing: </a?x=1>; rel="next", </a?x=0>; rel="prev" → { next: "/a?x=1", prev: "/a?x=0" }. Sarlavha null bo'lsa — {}. Kanon qiymati bilan sinang: </vazifalar?limit=4&jami=true&offset=4>; rel="next". Yordam: split, regex guruhlari — RegExp darsi.

Yechim
js
function linkniOqi(sarlavha) {
  const natija = {};
  if (sarlavha === null) return natija;
  for (const qism of sarlavha.split(",")) {
    const m = qism.match(/<([^>]*)>\s*;\s*rel="([^"]+)"/);
    if (m) natija[m[2]] = m[1];
  }
  return natija;
}

const kanon = '</vazifalar?limit=4&jami=true&offset=4>; rel="next"';
console.log(linkniOqi(kanon));
console.log(linkniOqi('</a?x=1>; rel="next", </a?x=0>; rel="prev"'));
console.log(linkniOqi(null));

Konsolda:

text
{ next: '/vazifalar?limit=4&jami=true&offset=4' }
{ next: '/a?x=1', prev: '/a?x=0' }
{}

Bu soddalashtirilgan o'quvchi: URL ichida vergul bo'lsa, split(",") uni bo'lib yuboradi. Haqiqiy loyihada RFC 8288 ga to'liq mos kutubxona ishlating.

3-mashq (qiyin): keyset ikki kalit bilan

keysetSahifa(royxat, kursor, limit) yozing: saralash -yaratildi, keyin id o'sish tartibida. Kursor — [yaratildi, id] yoki undefined. Qaytaradi: { sahifa, keyingi } (keyingi — oxirgi qatorning [yaratildi, id] yoki null). Sinov: 7 ta vazifa, ulardan 3 tasi bir xil vaqtda. 3 tadan sahifalab hammasini oling va takror yo'qligini tekshiring. Yordam: «Keyset g'oyasi» bo'limi.

Yechim
js
// kalit: -yaratildi, id; a b dan oldinmi? (<0)
function solishtir(a, b) {
  if (a[0] !== b[0]) return a[0] > b[0] ? -1 : 1; // kamayish
  return a[1] - b[1]; // o'sish
}
const kalit = (v) => [v.yaratildi, v.id];

function keysetSahifa(royxat, kursor, limit) {
  const tartib = [...royxat]
    .sort((a, b) => solishtir(kalit(a), kalit(b)));
  const qolgan = kursor === undefined
    ? tartib
    : tartib.filter((v) => solishtir(kalit(v), kursor) > 0);
  const sahifa = qolgan.slice(0, limit);
  const keyingi = qolgan.length > limit ? kalit(sahifa.at(-1)) : null;
  return { sahifa, keyingi };
}

const vaqtlar = ["10:01", "10:02", "10:02", "10:02", "10:03",
  "10:04", "10:05"];
const royxat = vaqtlar.map((yaratildi, i) =>
  ({ id: i + 1, yaratildi }));
const korildi = [];
let kursor;
do {
  const { sahifa, keyingi } = keysetSahifa(royxat, kursor, 3);
  console.log(sahifa.map((v) => `${v.id}@${v.yaratildi}`).join(" "));
  korildi.push(...sahifa.map((v) => v.id));
  kursor = keyingi ?? undefined;
} while (kursor !== undefined);
console.log("noyob:", new Set(korildi).size, "jami:", korildi.length);

Konsolda:

text
7@10:05 6@10:04 5@10:03
2@10:02 3@10:02 4@10:02
1@10:01
noyob: 7 jami: 7

1-sahifa 5@10:03 da tugadi; 2-sahifa uchta bir vaqtdagi vazifani id bo'yicha oldi. Agar kursorda faqat vaqt bo'lsa va sahifa 2@10:02 da tugasa, "10:02 dan qat'iy keyin" 3 va 4 ni o'tkazib yuborardi. qolgan.length > limit — LIMIT n+1 hiylasining massivdagi ko'rinishi.

4-mashq: API qadami

kursor.ts, sahifala va sorov.ts dagi sahifalash qismini yozing, routerga sahifa va res.links() ni qo'shing. Testlar: offset (Link qiymati, jami: 6, ikkinchi sahifada Link yo'q), kursor (hammasi bir martadan, boshqa sort — 400, buzuq — 400, offset + cursor — 400), sahifala birlik testi. npm run check — 17/17. Holat 22 ni kanon xabari bilan commit qiling.

Yechim

Namuna — «API qadami» bo'limi va kanon test/rest.test.ts. Offset testi:

ts
const r = await ol("/vazifalar?limit=4&offset=0&jami=true");
assert.deepEqual(r.tana.sahifa, { limit: 4, offset: 0, jami: 6 });
assert.equal(r.link,
  "</vazifalar?limit=4&jami=true&offset=4>; rel=\"next\"");
const r2 = await ol("/vazifalar?limit=4&jami=true&offset=4");
assert.equal(r2.tana.vazifalar.length, 2);
assert.equal(r2.link, null); // oxirgi sahifa

Jonli tekshiruv: curl -i '$A/vazifalar?limit=2&fields=matn' — Link da kursor eyJ2IjpbMl0sImkiOiJbW10se31dIn0 (kanon bilan bir xil, chunki u deterministik: [2] va bo'sh saralash/filtr).

9. Real ishda

  • Lentalar (Telegram kanal tarixi, Instagram, bildirishnomalar) — kursor. Admin jadvallari — ko'pincha offset va sahifa raqamlari.
  • Ochiq API'lar: GitHub — Link sarlavhasi; Stripe — starting_after (kursor); Slack — next_cursor.
  • Intervyu: "Offset va kursor farqi?", "Nega katta offset sekin?", "Total count nega qimmat?" — backend intervyusining doimiy savollari.

Xulosa

  • Offset (limit + offset) — oddiy, sakrash mumkin; o'zgaruvchan ro'yxatda takror va yo'qolish (kanon: 100 dan 20 takror).
  • Kursor (keyset) — oxirgi qatorning kalit qiymatlari + id; "qat'iy keyingisi"; izchil, bazada indeks bilan tez.
  • Opaque kursor — base64url(JSON) + imzo; sir emas, faqat tekshiriladi (HMAC — keyinroq).
  • total — faqat so'ralganda; hasNextPage — LIMIT n+1; Link: <…>; rel="next".
  • vazifalar-api: sukut — kursor rejimi, limit 1..100, offset ≤ 10 000; holat 22 — 17 test, commit.

Keyingi dars: Xato javoblari: Problem Details — {"xato": "…"} o'rniga RFC 9457 standart xato formati.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
API pagination: offset, cursor va keyset sahifalash, Link sarlavhasi — IlmHamroh