backend

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

١٠ يونيو ٢٠٢٦

Cursor Pagination in Postgres with Node.js (2026)

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

ملخص

يبني هذا البرنامج التعليمي العملي ترقيم صفحات باستخدام المؤشر لنقطة نهاية /products على Fastify 5.8.51، و node-postgres 8.21.02، و Postgres 18.43، مع 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 المرور عبر كل صف يسبقها وتجاهله، لذا تزداد التكلفة كلما تعمقت في الصفحات.4 في مقارنة منشورة لـ EXPLAIN ANALYZE، يرتفع وقت تنفيذ استعلام الإزاحة من حوالي 0.13 مللي ثانية في الصفحة الأولى إلى حوالي 5.8 مللي ثانية عند الإزاحة 20,000 — حيث تقرأ خطته 20,100 صف لإرجاع 100 صف فقط — بينما يظل استعلام المفاتيح المكافئ قريباً من 0.1 مللي ثانية ويقرأ فقط الـ 100 صف التي يرجعها.5

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

-- 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؛ النقطة المهمة هي شكل الخطة.)

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

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

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

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

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

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 صارم:

{
  "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"
  }
}
{
  "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:

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)، وليس عملية فرز:7

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) الخاصة بها بعد قيم ذلك الصف، بنفس الترتيب:

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 الخاص به أصغر.8 إنه المكافئ الموجز والصديق للفهارس للشكل الموسع المعقد:

-- 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 صف سبب سرعة ذلك:

-- 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 القيد داخل الفهرس ويبدأ القراءة من المكان الصحيح بدلاً من العد من البداية.8

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

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

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

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

يجب ألا يرى العملاء أبداً قيم الأعمدة الخام الخاصة بك — فهذا يربط البودكاست الذكي الخاص بك بمخطط قاعدة البيانات (schema). قم بتشفير الموضع في رمز مميز واحد غامض. أنشئ src/cursor.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 دون تغيير.9 التحقق من الصحة لا يقل أهمية عن الترميز — فالمؤشر يأتي من الإنترنت المفتوح، لذا يجب أن تفشل أي قيمة غير صالحة وتتحول إلى خطأ 400، وليس استثناءً غير معالج.

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

الآن قم بتجميع الاستعلام والمؤشر. أنشئ src/pagination.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:

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:

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 يعمل في عرض تجريبي واستعلام يعمل في بيئة الإنتاج.

يجب أن يكون الاتجاه موحداً. تطبق مقارنة قيم الصفوف نفس المعامل على كل عمود،8 لذا فإن (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 تسقط الصف ولن يظهر أبداً في أي صفحة.8 لهذا السبب تم التصريح بـ created_at كـ NOT NULL والمفتاح الأساسي هو فاصل التعادل. إذا كان يجب عليك تقسيم الصفحات بناءً على عمود يقبل القيم الفارغة، فاستخدم الفرز بناءً على COALESCE(col, sentinel) أو أضف بديلاً مضمون الوجود.

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

التحقق

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

# First page
curl "http://localhost:3000/products?limit=2"
{
  "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 تالياً:

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

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

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.7

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

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

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

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

يتماشى هذا بشكل طبيعي مع بقية أجزاء الـ API المخصصة للإنتاج. إذا كنت تقوم بتأمين نقاط نهاية الكتابة أيضًا، فراجع مفاتيح التكرار (idempotency keys) لـ API باستخدام Node.js و Postgres، ولتطوير شكل الاستجابة بمرور الوقت، راجع استراتيجيات إصدارات API والمفاضلات وأفضل الممارسات. للحصول على الصورة الأوسع لمكان ملاءمة تقسيم الصفحات (pagination)، يغطي دليل تطوير API خيارات التصميم المحيطة.

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

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

Footnotes

  1. Fastify releases, npm fastify (5.8.5, dist-tag latest, verified 2026-06-10). https://www.npmjs.com/package/fastify

  2. 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 2 3 4

  3. 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/ 2

  4. 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/ 2 3

  5. 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 2

  6. Node.js Releases — Node 24 is Active LTS, maintenance through April 2028. https://nodejs.org/en/about/previous-releases

  7. PostgreSQL Documentation, "Multicolumn Indexes." https://www.postgresql.org/docs/current/indexes-multicolumn.html 2

  8. 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 2 3 4

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