---
title: Cursor Pagination في Postgres مع Node.js (2026)
description: 'ابني نظام pagination سريع بالـ cursor على Postgres باستخدام Node.js و TypeScript: استعلامات keyset، وفاصل تعادل (tiebreaker) مركب من (created_at, id)، و cursors بنظام base64url غير شفافة (opaque).'
url: https://nerdleveltech.com/ar/cursor-pagination-postgres-nodejs-tutorial
language: ar
date: '2026-06-10'
category: backend
tags:
  - cursor pagination
  - keyset pagination
  - postgres
  - node.js
  - typescript
  - fastify
  - api design
  - backend
markdown_url: https://nerdleveltech.com/ar/cursor-pagination-postgres-nodejs-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/ar/cursor-pagination-postgres-nodejs-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Cursor Pagination في Postgres مع Node.js (2026)

تعتمد ترقيم الصفحات باستخدام المؤشر (Cursor/Keyset) على جلب الصفحة التالية من خلال البحث بعد آخر صفحة قمت بإرجاعها — `WHERE (created_at, id) < ($1, $2)` — بدلاً من عد الصفحات وتجاهلها باستخدام `OFFSET`. في Postgres، تظل هذه الطريقة سريعة عند أي عمق لأن قاعدة البيانات تقفز مباشرة إلى فهرس B-tree المطابق وتقرأ فقط الصفوف التي ستقوم بإرجاعها.

## ملخص

يبني هذا البرنامج التعليمي العملي ترقيم صفحات باستخدام المؤشر لنقطة نهاية `/products` على **Fastify 5.8.5**[^fastify]، و **node-postgres 8.21.0**[^nodepg]، و **Postgres 18.4**[^pg18]، مع Node 24 LTS و TypeScript 6 في الوضع الصارم (strict mode). ستقوم باستبدال ترقيم الصفحات البطيء باستخدام `OFFSET` باستعلام مفتاح مركب `(created_at, id)`، وتغليف الموضع في مؤشر base64url غير شفاف، وإرجاع `nextCursor` يمكن للعملاء استخدامه للانتقال للصفحة التالية. تم تشغيل كل استعلام مقابل Postgres وفحص أنواع كل ملف في 10 يونيو 2026. الميزانية الزمنية حوالي 30 دقيقة.

## ما ستتعلمه

- لماذا يتباطأ ترقيم الصفحات باستخدام `OFFSET` في الجداول الكبيرة، مع عرض ذلك باستخدام `EXPLAIN` حقيقي
- كيف يبحث ترقيم الصفحات بالمفاتيح (keyset) باستخدام مقارنة قيم الصفوف في Postgres بدلاً من تخطي الصفوف
- كيف يحافظ المؤشر المركب `(created_at, id)` على استقرار ترقيم الصفحات عندما يحتوي عمود الترتيب على قيم مكررة
- كيفية ترميز مؤشر غير شفاف ومفحوص ضد التلاعب باستخدام `base64url`
- كيفية إرجاع `nextCursor` و `hasNextPage` من مسار Fastify باستخدام حيلة "جلب عنصر إضافي واحد"
- كيف يغير الترتيب التنازلي وأعمدة الترتيب التي تقبل NULL عملية المقارنة
- متى يظل استخدام `OFFSET` هو القرار الصحيح

## لماذا يصبح ترقيم الصفحات باستخدام OFFSET بطيئاً

استخدام `LIMIT 20 OFFSET 0` جيد. أما `LIMIT 20 OFFSET 49980` فليس كذلك. لتنفيذ الإزاحة (offset)، لا يزال يتعين على Postgres المرور عبر كل صف يسبقها وتجاهله، لذا تزداد التكلفة كلما تعمقت في الصفحات.[^citus] في مقارنة منشورة لـ `EXPLAIN ANALYZE`، يرتفع وقت تنفيذ استعلام الإزاحة من حوالي 0.13 مللي ثانية في الصفحة الأولى إلى حوالي 5.8 مللي ثانية عند الإزاحة 20,000 — حيث تقرأ خطته 20,100 صف لإرجاع 100 صف فقط — بينما يظل استعلام المفاتيح المكافئ قريباً من 0.1 مللي ثانية ويقرأ فقط الـ 100 صف التي يرجعها.[^bench]

لا يجب أن تأخذ هذا كأمر مسلم به — فـ `EXPLAIN` يوضح ذلك. مع وجود 50,000 صف وفهرس على `(created_at DESC, id DESC)`، تبدو خطة الإزاحة العميقة كالتالي:

```text
-- EXPLAIN SELECT ... ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 49980;
Limit  (cost=3095.05..3096.29 rows=20 width=35)
  ->  Index Scan using products_created_at_id_idx on products
        (cost=0.29..3096.29 rows=50000 width=35)
```

عقدة `Limit` لديها **تكلفة بدء تشغيل تبلغ 3095.05**: يمر Postgres عبر حوالي 49,980 إدخال فهرس قبل أن يتمكن من إرجاع الصف الأول. استعلام المفاتيح الذي ستبنيه تالياً لديه تكلفة بدء تشغيل تقترب من الصفر. (تعتمد التكاليف الدقيقة على إحصائياتك وإصدار Postgres؛ النقطة المهمة هي *شكل* الخطة.)

ترقيم الصفحات بالإزاحة له مشكلة ثانية هادئة: إذا تم إدراج صفوف أو حذفها أثناء تصفح المستخدم، فستتغير الإزاحات، مما قد يؤدي لرؤية صف مرتين أو تخطي صف بالكامل.[^citus] مؤشرات المفاتيح محصنة ضد ذلك لأنها ترتكز على قيمة، وليس على موضع.

## المتطلبات الأساسية

- **Node.js 24** (Active LTS، مدعوم حتى أبريل 2028)[^node]. يقوم Node 24 بتشغيل ملفات `.ts` عبر `tsx`، لذا لا توجد خطوة بناء (build step) في مرحلة التطوير.
- أي إصدار مدعوم حالياً من **Postgres (14–18)**؛ يستخدم هذا العرض صورة Docker الرسمية **`postgres:18.4`**[^pg18]. مقارنة قيم الصفوف هي معيار SQL وتعمل على جميع هذه الإصدارات.
- معرفة أساسية بـ SQL و `async`/`await`. لا يشترط خبرة سابقة بـ Fastify.

الإصدارات مثبتة في جميع الأنحاء. لصق `latest` في برنامج تعليمي هو الطريقة التي يفسد بها مثال يعمل بعد ثلاثة أسابيع.

## الخطوة 1: إعداد المشروع

```bash
mkdir cursor-pagination-demo && cd cursor-pagination-demo
npm init -y
npm pkg set type=module
npm install --save-exact fastify@5.8.5 pg@8.21.0
npm install --save-exact -D typescript@6.0.3 tsx@4.22.4 @types/node@24.13.1 @types/pg@8.20.0
```

خيار `--save-exact` مهم: `npm install pg@8.21.0` بدونه يكتب `^8.21.0`، لذا فإن عملية `npm install` التالية قد تجلب إصداراً أحدث. قم بتثبيت الإصدار الدقيق عندما يضمن البرنامج التعليمي قابلية إعادة الإنتاج.

ملف `package.json` وملف `tsconfig.json` صارم:

```json
{
  "name": "cursor-pagination-demo",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "node --import tsx --watch src/index.ts",
    "start": "node --import tsx src/index.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": { "fastify": "5.8.5", "pg": "8.21.0" },
  "devDependencies": {
    "@types/node": "24.13.1",
    "@types/pg": "8.20.0",
    "tsx": "4.22.4",
    "typescript": "6.0.3"
  }
}
```

```json
{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["es2023"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src"]
}
```

ابدأ Postgres في Docker:

```bash
docker run --name cursor-pg -e POSTGRES_PASSWORD=postgres \
  -p 5432:5432 -d postgres:18.4
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres"
```

## الخطوة 2: المخطط، الفهرس، وبيانات البداية

العمودان المهمان هما مفتاح الترتيب (`created_at`) ومعرف فريد لفك الارتباط عند التساوي (`id`). قم بإنشاء فهرس متعدد الأعمدة يتطابق اتجاهه مع `ORDER BY` في الاستعلام بحيث يكون البحث عبارة عن مسح لنطاق الفهرس (index range scan)، وليس عملية فرز:[^pg-multicol]

```sql
CREATE TABLE products (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name        text NOT NULL,
  price       numeric(10,2) NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- Direction matches the ORDER BY in every keyset query below.
CREATE INDEX products_created_at_id_idx ON products (created_at DESC, id DESC);

-- 50,000 rows so the OFFSET problem is measurable. Deterministic timestamps
-- (one minute apart, newest row = highest id) keep the examples below reproducible.
INSERT INTO products (name, price, created_at)
SELECT 'Product ' || g,
       ((g % 100) + 0.99)::numeric(10, 2),
       timestamptz '2026-01-01 00:00:00+00' + (g * interval '1 minute')
FROM generate_series(1, 50000) AS g;
```

احفظ ذلك كملف `schema.sql` وقم بتحميله باستخدام `psql "$DATABASE_URL" -f schema.sql`.

## الخطوة 3: استعلام المفاتيح (Keyset Query)

إليك الفكرة كاملة في عبارة واحدة. للحصول على الصفحة *بعد* صف معروف، اطلب الصفوف التي تكون قيم `(created_at, id)` الخاصة بها بعد قيم ذلك الصف، بنفس الترتيب:

```sql
SELECT id, name, price, created_at
  FROM products
 WHERE (created_at, id) < ($1::timestamptz, $2::bigint)
 ORDER BY created_at DESC, id DESC
 LIMIT $3;
```

تعبير `(created_at, id) < ($1, $2)` هو **مقارنة قيم الصفوف**. يقوم Postgres بمقارنة الحقول من اليسار إلى اليمين: يكون الصف مؤهلاً إذا كان `created_at` الخاص به أقدم، أو إذا تساوى `created_at` وكان `id` الخاص به أصغر.[^pg-rowcompare] إنه المكافئ الموجز والصديق للفهارس للشكل الموسع المعقد:

```sql
-- Equivalent, but harder to read and easier to get wrong:
WHERE created_at < $1 OR (created_at = $1 AND id < $2)
```

يثبت المفتاح المركب جدارته عند تساوي القيم. إذا قمت بترقيم الصفحات بناءً على `created_at` وحده وكان هناك مائة صف تشترك في نفس الطابع الزمني، فقد يستقر مؤشر العمود الواحد في منتصف القيم المتساوية ويتخطى صفوفاً أو يكررها بصمت. إضافة المعرف الفريد `id` يمنح كل صف **ترتيباً كلياً**، لذا تظل الحدود بين الصفحات واضحة دائماً.

يوضح `EXPLAIN` على نفس الجدول المكون من 50,000 صف سبب سرعة ذلك:

```text
-- EXPLAIN SELECT ... WHERE (created_at, id) < ($1, $2) ... LIMIT 20;
Limit  (cost=0.29..1.58 rows=20 width=35)
  ->  Index Scan using products_created_at_id_idx on products
        (cost=0.29..3221.26 rows=49998 width=35)
        Index Cond: (ROW(created_at, id) < ROW('2026-02-04 17:19:00+00', '49999'))
```

تغير شيئان مقارنة بخطة الإزاحة. انخفضت تكلفة بدء تشغيل `Limit` من 3095.05 إلى **0.29**، وأصبحت المقارنة `Index Cond` — حيث يدفع Postgres القيد داخل الفهرس ويبدأ القراءة من المكان الصحيح بدلاً من العد من البداية.[^pg-rowcompare]

## الخطوة 4: واجهة قاعدة بيانات بدون فقدان للبيانات

يقوم node-postgres بإرجاع بعض الأنواع كسلاسل نصية عن قصد، وهناك إعداد افتراضي واحد قد يفسد المؤشر إذا تجاهلته. قم بإنشاء `src/db.ts`:

هناك خياران متعمدان هنا. استدعاء `setTypeParser(1184, …)` يعيد `created_at` كنص Postgres دقيق، لذا فإن القيمة التي تضعها في المؤشر والقيمة التي تقارن بها متطابقتان بايت ببايت — لا يوجد اقتطاع للميكروثانية قد يؤدي لفقدان صف عند الحدود.[^nodepg] وأيضاً `bigint` (`id`) و `numeric` (`price`) يصلان كسلاسل نصية، لأن رقم JS لا يمكنه استيعاب كل قيم أي من النوعين بأمان؛[^nodepg] احتفظ بهما كسلاسل نصية وقم بتحويلهما مرة أخرى باستخدام `$2::bigint` في SQL. واجهة `QueryFn` تجعل دالة الاستعلام قابلة للحقن (injectable)، مما يجعل منطق تقسيم الصفحات قابلاً للاختبار بدون قاعدة بيانات حية.

## الخطوة 5: مؤشرات غامضة باستخدام base64url

يجب ألا يرى العملاء أبداً قيم الأعمدة الخام الخاصة بك — فهذا يربط البودكاست الذكي الخاص بك بمخطط قاعدة البيانات (schema). قم بتشفير الموضع في رمز مميز واحد غامض. أنشئ `src/cursor.ts`:

```ts
export interface Cursor {
  createdAt: string;
  id: string;
}

export class InvalidCursorError extends Error {
  constructor() {
    super('invalid cursor');
    this.name = 'InvalidCursorError';
  }
}

// base64url is URL-safe: no +, /, or = to percent-encode in a query string.
export function encodeCursor(cursor: Cursor): string {
  return Buffer.from(JSON.stringify(cursor)).toString('base64url');
}

// Encoding is not encryption: never put secrets in a cursor. Always validate the
// decoded shape so a tampered or truncated value becomes a 400, not a 500.
export function decodeCursor(raw: string): Cursor {
  let parsed: unknown;
  try {
    parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
  } catch {
    throw new InvalidCursorError();
  }
  if (
    typeof parsed !== 'object' ||
    parsed === null ||
    typeof (parsed as Record<string, unknown>).createdAt !== 'string' ||
    typeof (parsed as Record<string, unknown>).id !== 'string'
  ) {
    throw new InvalidCursorError();
  }
  const { createdAt, id } = parsed as Cursor;
  return { createdAt, id };
}
```

`base64url` هو الترميز الصحيح لقيمة سلسلة الاستعلام (query-string): فهو يستخدم `-` و `_` بدلاً من `+` و `/` ويحذف الحشو (padding)، لذا فإنه يبقى في عنوان URL دون تغيير.[^base64url] التحقق من الصحة لا يقل أهمية عن الترميز — فالمؤشر يأتي من الإنترنت المفتوح، لذا يجب أن تفشل أي قيمة غير صالحة وتتحول إلى خطأ `400`، وليس استثناءً غير معالج.

## الخطوة 6: منطق تقسيم الصفحات

الآن قم بتجميع الاستعلام والمؤشر. أنشئ `src/pagination.ts`:

```ts
import type { QueryFn } from './db.ts';
import { decodeCursor, encodeCursor } from './cursor.ts';

// bigint (int8) and numeric come back from node-postgres as strings, because a
// JS number cannot safely hold every value of either type. Keep them as strings.
export interface Product {
  id: string;
  name: string;
  price: string;
  created_at: string;
}

export interface Page<T> {
  data: T[];
  pageInfo: { nextCursor: string | null; hasNextPage: boolean };
}

const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;

export function clampLimit(raw: unknown): number {
  const n =
    typeof raw === 'string' ? Number.parseInt(raw, 10)
    : typeof raw === 'number' ? raw
    : Number.NaN;
  if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
  return Math.min(Math.trunc(n), MAX_LIMIT);
}

export async function listProducts(
  query: QueryFn,
  opts: { limit: number; cursor?: string | undefined },
): Promise<Page<Product>> {
  const { limit } = opts;
  const fetchCount = limit + 1; // one extra row tells us whether a next page exists

  let rows: Product[];
  if (opts.cursor) {
    const { createdAt, id } = decodeCursor(opts.cursor);
    const result = await query<Product>(
      `SELECT id, name, price, created_at
         FROM products
        WHERE (created_at, id) < ($1::timestamptz, $2::bigint)
        ORDER BY created_at DESC, id DESC
        LIMIT $3`,
      [createdAt, id, fetchCount],
    );
    rows = result.rows;
  } else {
    const result = await query<Product>(
      `SELECT id, name, price, created_at
         FROM products
        ORDER BY created_at DESC, id DESC
        LIMIT $1`,
      [fetchCount],
    );
    rows = result.rows;
  }

  const hasNextPage = rows.length > limit;
  const data = hasNextPage ? rows.slice(0, limit) : rows;
  const last = data[data.length - 1];
  const nextCursor =
    hasNextPage && last ? encodeCursor({ createdAt: last.created_at, id: last.id }) : null;

  return { data, pageInfo: { nextCursor, hasNextPage } };
}
```

الحيلة التي تستحق الذكر هي `fetchCount = limit + 1`. طلب صف إضافي واحد يخبرك ما إذا كانت هناك صفحة أخرى موجودة دون الحاجة لاستعلام `COUNT(*)` منفصل. إذا حصلت على صفوف عددها `limit + 1`، فهذا يعني وجود صفحة تالية — احذف الصف الإضافي وأنشئ `nextCursor` من آخر صف احتفظت به فعلياً؛ وإلا فإن `nextCursor` سيكون `null`. تقوم دالة `clampLimit` بتحديد سقف لحجم الصفحة حتى لا يتمكن العميل من طلب مليون صف.

## الخطوة 7: نقطة نهاية Fastify

قم بربطها بمسار (route). أنشئ `src/server.ts`:

```ts
import Fastify, { type FastifyInstance } from 'fastify';
import type { QueryFn } from './db.ts';
import { InvalidCursorError } from './cursor.ts';
import { clampLimit, listProducts } from './pagination.ts';

interface ListQuery {
  limit?: string;
  cursor?: string;
}

export function buildServer(query: QueryFn): FastifyInstance {
  const app = Fastify({ logger: false });

  app.get<{ Querystring: ListQuery }>('/products', async (request, reply) => {
    const limit = clampLimit(request.query.limit);
    try {
      return await listProducts(query, { limit, cursor: request.query.cursor });
    } catch (err) {
      if (err instanceof InvalidCursorError) {
        return reply.code(400).send({ error: 'invalid cursor' });
      }
      throw err;
    }
  });

  return app;
}
```

ونقطة الإدخال، `src/index.ts`:

```ts
import { createPool, makeQuery } from './db.ts';
import { buildServer } from './server.ts';

const pool = createPool(process.env.DATABASE_URL ?? 'postgres://localhost:5432/postgres');
const app = buildServer(makeQuery(pool));

const port = Number(process.env.PORT ?? 3000);
const address = await app.listen({ port, host: '0.0.0.0' });
console.log(`listening on ${address}`);
```

قم بتشغيله باستخدام `npm run dev`. يمكنك التحقق من الأنواع (type-check) للمشروع بالكامل في أي وقت باستخدام `npm run typecheck`.

## الخطوة 8: التعادلات، الترتيب التنازلي، والقيم الفارغة (NULLs)

هناك ثلاثة تفاصيل تتعلق بالدقة تفرق بين استعلام keyset يعمل في عرض تجريبي واستعلام يعمل في بيئة الإنتاج.

**يجب أن يكون الاتجاه موحداً.** تطبق مقارنة قيم الصفوف *نفس* المعامل على كل عمود،[^pg-rowcompare] لذا فإن `(created_at, id) < ($1, $2)` تعبر فقط عن "كلا العمودين تنازلياً". وهي تقترن مع `ORDER BY created_at DESC, id DESC` ولا شيء غير ذلك. إذا كنت بحاجة إلى اتجاهات مختلطة — مثلاً `created_at DESC, name ASC` — فلا يمكن لمقارنة الصفوف الواحدة تمثيل ذلك، ويجب عليك العودة إلى صيغة `OR`/`AND` الموسعة من الخطوة 3.

**يجب أن تكون أعمدة الفرز NOT NULL.** إذا كان الحقل المقارن NULL، فإن مقارنة الصفوف تنتج NULL بدلاً من true، لذا فإن `WHERE` تسقط الصف ولن يظهر أبداً في أي صفحة.[^pg-rowcompare] لهذا السبب تم التصريح بـ `created_at` كـ `NOT NULL` والمفتاح الأساسي هو فاصل التعادل. إذا كان يجب عليك تقسيم الصفحات بناءً على عمود يقبل القيم الفارغة، فاستخدم الفرز بناءً على `COALESCE(col, sentinel)` أو أضف بديلاً مضمون الوجود.

**للأمام فقط، حسب التصميم.** هذا المؤشر يقسم الصفحات في اتجاه واحد. "الصفحة السابقة" تعني تشغيل الاستعلام المعاكس (`> ($1, $2)` مع عكس الترتيب) وعكس النتيجة في الكود — وهو أمر مباشر، لكنه مسار كود منفصل يستحق التخطيط له بدلاً من إضافته لاحقاً بشكل عشوائي.

## التحقق

تصفح صفحات البودكاست الذكي وراقب كيف ينقلك المؤشر للأمام:

```bash
# First page
curl "http://localhost:3000/products?limit=2"
```

```json
{
  "data": [
    { "id": "50000", "name": "Product 50000", "price": "0.99",
      "created_at": "2026-02-04 17:20:00+00" },
    { "id": "49999", "name": "Product 49999", "price": "99.99",
      "created_at": "2026-02-04 17:19:00+00" }
  ],
  "pageInfo": {
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTAyLTA0IDE3OjE5OjAwKzAwIiwiaWQiOiI0OTk5OSJ9",
    "hasNextPage": true
  }
}
```

أرسل `nextCursor` مباشرة للحصول على الصفحة التالية — لا يوجد إزاحة (offset)، ولا عدد صفوف. هذه المرة ستعود `Product 49998` تالياً:

```bash
curl "http://localhost:3000/products?limit=2&cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTAyLTA0IDE3OjE5OjAwKzAwIiwiaWQiOiI0OTk5OSJ9"
```

المؤشر الذي تم التلاعب به أو اقتطاعه يعيد خطأ `400` نظيفاً بدلاً من تتبع المكدس (stack trace):

```bash
curl -i "http://localhost:3000/products?cursor=not-a-real-cursor"
# HTTP/1.1 400 Bad Request
# {"error":"invalid cursor"}
```

## الأخطاء الشائعة

**الصفحات تتخطى أو تكرر الصفوف.** من المؤكد تقريباً أنك تقوم بتقسيم الصفحات بناءً على عمود غير فريد بدون فاصل تعادل. تأكد من أن المؤشر و `ORDER BY` كلاهما يتضمنان الـ `id` الفريد، وأن مقارنة `<` تغطي الصف بالكامل.

**صف بختم زمني مكرر يظهر مرتين.** نفس السبب الجذري: مؤشر العمود الواحد لا يمكنه كسر التعادل. مقارنة الصفوف المركبة `(created_at, id)` تحل المشكلة لأن `id` فريد.

**الاستعلام يضيف خطوة `Sort` بدلاً من البحث في الفهرس.** قم بتشغيل `EXPLAIN` — أنت تريد `Index Scan` (أو `Index Scan Backward`) بدون عقدة `Sort`. الحل هو فهرس متعدد الأعمدة يغطي *كلا* عمودي الفرز، `(created_at, id)`. فهرس العمود الواحد `(created_at)` ليس كافياً: لا يزال يتعين على Postgres ترتيب الصفوف داخل كل ختم زمني. لا يلزم أن يتطابق الاتجاه تماماً — يقرأ Postgres شجرة B-tree في أي من الاتجاهين، لذا فإن فهرس `(created_at DESC, id DESC)` يخدم أيضاً استعلام المرآة التصاعدي عبر مسح خلفي. تحتاج فقط إلى فهرس محدد الاتجاه عندما يعمل عمودا الفرز في اتجاهين *متعاكسين*، مثل `created_at DESC, name ASC`.[^pg-multicol]

**صف عند حدود الصفحة يختفي أو يتكرر أحياناً.** هذا هو فخ دقة الختم الزمني — عندما يقع صفان في نفس الميلي ثانية، لا يمكن لـ JS `Date` (دقة الميلي ثانية) التمييز بين قيم `timestamptz` بالميكروثانية التي يخزنها Postgres، لذا تصبح قيمة المؤشر غامضة. استدعاء `setTypeParser(1184, …)` في `src/db.ts` يحافظ على `created_at` كنص دقيق بحيث تكون الرحلة ذهاباً وإياباً بدون فقدان للبيانات.[^nodepg]

**خطأ `invalid cursor` في مؤشر أصدرته للتو.** قيمة base64url لا تحتاج إلى ترميز URL إضافي — أبجديتها آمنة بالفعل لـ URL — لذا فإن الجاني المعتاد هو تغيير القيمة أثناء النقل: ترميزها مرتين، أو اقتطاعها، أو نسخها مع علامات اقتباس محيطة. قم بفك تشفيرها يدوياً باستخدام `Buffer.from(value, 'base64url').toString()` لترى بالضبط ما استلمه المعالج الخاص بك.

## متى يظل OFFSET هو الأفضل

تقسيم الصفحات باستخدام keyset لا يخلو من المقايضات، والصدق بشأنها هو الفرق بين النمط الراسخ والتقليد الأعمى. لا يمكن للمؤشرات القفز إلى "الصفحة 47" — لا يوجد وصول عشوائي للصفحات — وهي لا تعطيك إجمالي عدد الصفوف بتكلفة منخفضة.[^citus] إذا كانت واجهة المستخدم الخاصة بك تعرض صفحات مرقمة أو تسمية "عرض 1-20 من 4,312" فوق جدول صغير ونادراً ما يتغير، فإن استخدام `OFFSET` العادي أبسط ومناسب تماماً. الجأ إلى keyset عندما تكون مجموعة البيانات كبيرة، أو عندما يكون نمط الوصول هو "الصفحة التالية" / التمرير اللانهائي، أو عندما تتغير الصفوف أثناء تصفح المستخدمين. لتقسيم الصفحات المتسلسل والعميق على نطاق واسع، فإن keyset هو الخيار الذي يظل سريعاً.[^bench]

يتماشى هذا بشكل طبيعي مع بقية أجزاء الـ API المخصصة للإنتاج. إذا كنت تقوم بتأمين نقاط نهاية الكتابة أيضًا، فراجع [مفاتيح التكرار (idempotency keys) لـ API باستخدام Node.js و Postgres](https://nerdleveltech.com/idempotency-keys-nodejs-postgres-API-tutorial)، ولتطوير شكل الاستجابة بمرور الوقت، راجع [استراتيجيات إصدارات API والمفاضلات وأفضل الممارسات](https://nerdleveltech.com/mastering-API-versioning-strategies-tradeoffs-and-best-practices). للحصول على الصورة الأوسع لمكان ملاءمة تقسيم الصفحات (pagination)، يغطي [دليل تطوير API](https://nerdleveltech.com/learn-about-API-development-types-tools-and-best-practices) خيارات التصميم المحيطة.

## الخطوات التالية

- أضف مسار "الصفحة السابقة" باستخدام استعلام مرآتي (`>` وترتيب عكسي).
- قم بإرجاع `ORDER BY` مستقر للقوائم المفلترة عن طريق إلحاق نفس ذيل الـ `(created_at, id)` بعد أعمدة الفلترة الخاصة بك.
- انقل المؤشر (cursor) إلى مخطط طلب (request schema) بحيث يقوم Fastify بالتحقق من صحة `limit` و `cursor` قبل تشغيل المعالج (handler) الخاص بك.

[^fastify]: Fastify releases, npm `fastify` (5.8.5, dist-tag `latest`, verified 2026-06-10). <https://www.npmjs.com/package/fastify>

[^nodepg]: node-postgres — Data Types: types with no registered parser come back as strings (so `bigint` and `numeric` are strings), `timestamptz` parses to a millisecond-precision JS `Date`, and per the docs "your microseconds will be truncated when converting to a JavaScript date object … If you need to preserve them, I recommend using a custom type parser." <https://node-postgres.com/features/types>

[^pg18]: PostgreSQL 18.4 release announcement (current minor as of 2026-06-10). <https://www.postgresql.org/about/news/postgresql-184-1710-1614-1518-and-1423-released-3297/>

[^citus]: Citus Data, "Five ways to paginate in Postgres, from the basic to the exotic." <https://www.citusdata.com/blog/2016/03/30/five-ways-to-paginate/>

[^bench]: Nishant, "Optimizing Pagination in PostgreSQL: OFFSET/LIMIT vs. Keyset," DEV Community (Oct 2024) — `EXPLAIN ANALYZE` shows the offset query at \~0.125 ms on page 1 rising to \~5.81 ms at `OFFSET 20000` (its index scan reads 20,100 rows to return 100), versus \~0.1 ms for the keyset query. <https://dev.to/scion01/optimizing-pagination-in-postgresql-offsetlimit-vs-keyset-21dp>

[^node]: Node.js Releases — Node 24 is Active LTS, maintenance through April 2028. <https://nodejs.org/en/about/previous-releases>

[^pg-multicol]: PostgreSQL Documentation, "Multicolumn Indexes." <https://www.postgresql.org/docs/current/indexes-multicolumn.html>

[^pg-rowcompare]: PostgreSQL Documentation, §9.25.5 "Row Constructor Comparison" — operators applied per field, left to right; NULL pair yields NULL. <https://www.postgresql.org/docs/current/functions-comparisons.html>

[^base64url]: Node.js Buffer documentation — `base64url` encoding (URL- and filename-safe alphabet). <https://nodejs.org/API/buffer.html#buffers-and-character-encodings>
