Mundarija (26)
- Bu darsda
- 1. Nega bu kerak?
- 2. Offset sahifalash
- 2.1 Qanday ishlaydi
- 2.2 Muammo: o'zgaruvchan ro'yxat
- 2.3 Kanon o'lchovi
- 3. Kursor va keyset
- 3.1 Keyset g'oyasi
- 3.2 Bazada nega tezroq
- 3.3 Shaffof bo'lmagan kursor
- 3.4 Halol eslatma: kursor sir emas
- 4. total, hasNextPage va Link
- 4.1 total — qimmat
- 4.2 hasNextPage — keyingisi bormi
- 4.3 Link sarlavhasi
- 5. Qaysi birini tanlash
- 6. API qadami: sahifalash va commit (holat 22)
- 7. Ko'p uchraydigan xatolar
- 8. Mashqlar
- 1-mashq (oson): to'ldiring
- 2-mashq (o'rta): Link sarlavhasini o'qish
- 3-mashq (qiyin): keyset ikki kalit bilan
- 4-mashq: API qadami
- 9. Real ishda
- Xulosa
- Manbalar
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 havolasiLink: <…>; 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.
totalsonining narxini vahasNextPageni qanday aniqlashni bilasiz.Linksarlavhasini 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):
$ 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:
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:
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 0Offset 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:
- 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:
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:
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:
eyJ2IjpbMl0sImkiOiJbW10se31dIn0
[ 2 ]
null
nullBirinchi qator kanon curl dagi kursor bilan aynan bir xil: eyJ2IjpbMl0sImkiOiJbW10se31dIn0. Kanon chiqishi:
$ 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):
{"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. total, hasNextPage va Link
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.
4.3 Link sarlavhasi
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:
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: nullDiagrammada 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.
- Yangi fayl
src/umumiy/kursor.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;
}src/vazifalar/ombor.tsga sahifalash tiplari (Filtrlash darsidagiRoyxatSorovishu tipni ishlatadi):
/** 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;
};src/vazifalar/royxat-mantiq.ts—filtrlavakalitlarFiltrlash darsida yozilgan; faylning qolgan qismi:
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.
src/vazifalar/sorov.ts— sahifalash qismi (faylning boshi Filtrlash darsida):
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" };
}- Router —
GET /vazifalarhandler'i to'liq (marshrut.ts;keyingiHavola— «Link sarlavhasi» bo'limida):
.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.
- Holat 22 dagi
src/vazifalar/xotira-ombor.tsto'liq — oldingi to'rt darsdagiteglar,soni,royxatshu yerda birlashgan:
// 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):
// 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>;GET /vazifalarendi{ vazifalar, sahifa }qaytaradi.vazifalarfrontend'i (22-qism) obyekt kutadi —{ versiya, vazifalar }, shuning uchun shaklning o'zi muammo emas: ortiqchasahifamaydonini uning Zod sxemasi jim tashlab yuboradi. Lekin holat 22 javobida majburiyversiyahali yo'q — sxema uni rad etardi. To'plamversiyasi 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=100bilan so'raladi.
Kanon testlari — kursor bilan hammasi bir martadan va o'chirilgan qator (test/rest.test.ts, qisqartirilgan):
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):
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 —
LIMITga 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.
2-mashq (o'rta): Link sarlavhasini o'qish
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
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:
{ 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
// 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:
7@10:05 6@10:04 5@10:03
2@10:02 3@10:02 4@10:02
1@10:01
noyob: 7 jami: 71-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:
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 sahifaJonli 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 —
Linksarlavhasi; 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,limit1..100,offset≤ 10 000; holat 22 — 17 test, commit.
Keyingi dars: Xato javoblari: Problem Details — {"xato": "…"} o'rniga RFC 9457 standart xato formati.
Manbalar
- RFC 8288 — Web Linking (
Linksarlavhasi): https://www.rfc-editor.org/rfc/rfc8288 - RFC 4648 — base64url alifbosi: https://www.rfc-editor.org/rfc/rfc4648
- GitHub REST API — Using pagination: https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api (2026-10-24)
- Use The Index, Luke — "Paging Through Results" (keyset va OFFSET): https://use-the-index-luke.com/no-offset
- Kanon: holat 22 (
kursor.ts,sahifala, testlar 17),t22(vaqt, izchillik, kursor uzunligi),tekshiruv-22(curl, yuk).
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!