> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# خدمة طلبات حقيقية بـ Node.js: إطار Fastify مع PostgreSQL
- URL: https://arabroot.io/articles/خدمة-طلبات-حقيقية-node-js-fastify-postgresql/
- Published: 2026-10-31T08:13:28.000Z
- Updated: 2026-10-31T09:33:43.000Z
- Description: نبني خدمة طلبات متجر بـ Fastify وPostgreSQL 17 بمعاملة تقفل المخزون وتحسب الضريبة بالهللات، ونختبرها بعشرين طلباً متوازياً على مخزون عشرة، ثم نقيس الـ Docker Image والبدء والذاكرة والإنتاجية بنفس طريقة كل لغات المسار.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

الشركة الناشئة التي تبيع القهوة والتمور والهيل في مقالات هذه المرحلة تحتاج الآن خدمتها الحقيقية: واجهة API صغيرة يطلب منها الموقع قائمة المنتجات، ويرسل إليها الطلب، ويسأل عنه بعد ذلك. والمواصفات قصيرة، ولكن كل سطر فيها يخفي مشكلة رأيناها في المقالات السابقة: المبالغ يجب أن تكون بالهللات لأن `9.90 * 3` أعطى `29.700000000000003`، والمخزون يجب ألا يباع مرتين لأن عشرين طلباً نجحت من مخزون عشرة عندما كان الفحص في الذاكرة، والمدخلات يجب أن تفحص عند الحدود لأن `Number('')` يساوي صفراً، والأخطاء يجب أن تتحول إلى رموز HTTP صحيحة بدلاً من أن تسقط العملية. وفي هذا المقال سوف نبني هذه الخدمة بالطريقة المعتادة في Node.js اليوم: إطار Fastify مع مكتبة `pg` وPostgreSQL 17، بـ TypeScript، مع اختبار وحدة واختبار تكامل على قاعدة بيانات حقيقية، وDockerfile متعدد المراحل، ثم نقيسها.

وهذا المقال من مرحلة [JavaScript وTypeScript](https://arabroot.io/start/#stage-lang-js) في مسار لغات البرمجة عملياً، والخدمة نفسها بنفس الجداول ونفس الاختبارات ونفس طريقة القياس تبنى في كل لغات المسار، حتى تقارن بين اللغات على شيء حقيقي وليس على مثال «Hello World»، وهي نفس فكرة خدمة الأسعار التي ناقشناها في مقال [كيف تختار لغة البرمجة لمشروعك](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AE%D8%AA%D8%A7%D8%B1-%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%84%D9%85%D8%B4%D8%B1%D9%88%D8%B9%D9%83/). والكود كاملاً في مجلد `lab` مع المقال، ومعه سكربتا القياس `measure.sh` و`coldstart.sh` وكل المخرجات الأصلية، وسوف نعرض هنا الأجزاء المهمة ونشرح لماذا كتبت بهذا الشكل، والأخطاء التي واجهناها أثناء البناء.

وسوف نناقش في هذا المقال ما يلي:

- المواصفات المشتركة: الجداول، والنقاط الثلاث، وقواعد الضريبة والأخطاء.
- لماذا Fastify، وكيف يبدو المشروع: الملفات، و`package.json`، وملفا `tsconfig` للفحص والبناء.
- دالة المبالغ بالهللات واختبار الوحدة لها، بما في ذلك 9.90 في 3.
- معاملة واحدة لكل طلب: قفل الصفوف بـ `FOR UPDATE` بترتيب ثابت، وفحص المخزون، والطرح، والإدخال.
- التحقق من المدخلات بـ JSON Schema، والإعدادات الافتراضية في Fastify التي يجب أن تعرفها، ومشكلة واجهناها مع معاملات المسار.
- اختبار التكامل على PostgreSQL حقيقي، واختبار عشرين طلباً متوازياً، وماذا يحدث بدون القفل.
- Dockerfile متعدد المراحل، ثم القياس: حجم الـ Image، والبدء، والذاكرة، وwrk مرتين.

## المواصفات المشتركة Specification: نفس الخدمة في كل لغة

الخدمة لها ثلاث نقاط فقط، وثلاثة جداول في PostgreSQL 17، والمخطط التالي ننسخه كما هو في كل لغة، ومعه ثلاثة منتجات بأسماء عربية:

```sql
CREATE TABLE IF NOT EXISTS products (
  id serial PRIMARY KEY,
  name_ar text NOT NULL,
  price_halalas int NOT NULL CHECK (price_halalas >= 0),
  stock int NOT NULL CHECK (stock >= 0)
);
CREATE TABLE IF NOT EXISTS orders (
  id serial PRIMARY KEY,
  created_at timestamptz DEFAULT now(),
  total_halalas int NOT NULL
);
CREATE TABLE IF NOT EXISTS order_items (
  order_id int NOT NULL REFERENCES orders(id),
  product_id int NOT NULL REFERENCES products(id),
  qty int NOT NULL CHECK (qty > 0),
  price_halalas int NOT NULL
);
INSERT INTO products (name_ar, price_halalas, stock) VALUES
  ('قهوة عربية مختصة 250 غرام', 4500, 100),
  ('تمر سكري 1 كيلو', 3800, 100),
  ('هيل حب 100 غرام', 990, 10);
```

ولاحظ أن قاعدة البيانات نفسها تحمي بعض القواعد بقيود CHECK: لا يوجد سعر سالب، ولا مخزون سالب، ولا كمية صفر. فحتى لو أخطأ كود التطبيق فلن يستطيع أن يكتب مخزوناً بقيمة 1-، وهذا خط الدفاع الأخير وليس الأول. وفي البند `order_items` نخزن سعر المنتج وقت الطلب، لأن سعر المنتج قد يتغير غداً، والفاتورة يجب أن تبقى كما كانت. والنقاط الثلاث كما يلي:

- `GET /products`: قائمة المنتجات بصيغة JSON.
- `POST /orders` بجسم `{"items": [{"product_id": 3, "qty": 2}]}`: في معاملة واحدة تقفل صفوف المنتجات، وتفحص المخزون، وتطرحه، وتدخل الطلب وبنوده، وتحسب المجموع مع ضريبة القيمة المضافة 15% بالهللات كأعداد صحيحة مع تقريب النصف إلى الأعلى. والرد 409 عند نقص المخزون، و400 عند المدخلات الخاطئة.
- `GET /orders/{id}`: الطلب مع بنوده، و404 إذا لم يوجد.

![مسار الطلب من العميل أو wrk إلى Fastify 5.12.7 حيث app.ts بالمسارات وJSON Schema ومعالج الأخطاء، ثم orders.ts حيث createOrder في معاملة واحدة وmoney.ts للضريبة مع pg.Pool بحد 10، ثم PostgreSQL 17.11، وتحتها النقاط الثلاث GET products وPOST orders وGET orders id ورموز الرد](https://arabroot.io/content/images/2026/10/js-real-service-01-architecture.webp)

ثلاثة ملفات للمنطق وملف للتشغيل، وكل ملف له مسؤولية واحدة

## لماذا Fastify؟ الإطار Framework والبدائل

أشهر إطار للسيرفرات في Node.js تاريخياً هو Express، وما زال موجوداً في أغلب المشاريع القديمة والشروحات، وتوجد أطر أكبر مثل NestJS التي تبني فوق Express أو Fastify طبقة كاملة من الوحدات وحقن الاعتماديات تشبه Spring في Java، وأطر أحدث مثل Hono التي تعمل على Node.js وعلى بيئات أخرى. واخترنا Fastify لهذه الخدمة لثلاثة أسباب عملية: التحقق من المدخلات بـ JSON Schema مدمج في الإطار نفسه بدلاً من مكتبة إضافية، وهذا بالضبط ما تحتاجه خدمة تستقبل طلبات مالية، ومعالجة الأخطاء في الدوال غير المتزامنة تعمل بدون أي غلاف، فالخطأ المرمي داخل `async` يصل إلى معالج الأخطاء تلقائياً، وأداؤه من الأعلى بين أطر Node.js. وإذا كنت قادماً من Spring أو ASP.NET فسوف تجد Fastify أخف بكثير: لا توجد Annotations ولا حاوية حقن، وإنما دالة تنشئ التطبيق، ودوال تسجل المسارات.

وإذا كنت ستعمل على مشروع قائم بـ Express فانتبه إلى فرق واحد مهم: في Express 4 لا يصل الخطأ المرمي داخل دالة `async` إلى معالج الأخطاء، وإنما يصبح وعداً مرفوضاً بلا معالج، فتسقط العملية كلها كما رأينا في مقال الأخطاء، ولذلك كانت المشاريع القديمة تلف كل دالة بغلاف يمسك الخطأ ويمرره بـ `next(err)`. وExpress 5 أصلح ذلك، ولكن كثيراً من المشاريع ما زالت على الإصدار 4\. أما في Fastify فالدالة `async` هي الطريقة الأساسية من البداية، وما ترجعه يرسل كرد JSON، وما ترميه يصل إلى `setErrorHandler`. والفرق الثاني أن Fastify يبني كل شيء على الإضافات Plugins المغلفة Encapsulated، فكل إضافة لها نطاقها الخاص من المسارات والإعدادات، وهذا مفيد عندما تكبر الخدمة إلى عشرات المسارات، ولم نحتجه في ثلاث نقاط.

وهيكل المشروع كما يلي، وهو نفس الهيكل الذي شرحناه في مقال [JavaScript لمن يعرف لغة أخرى](https://arabroot.io/articles/javascript-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D9%84%D8%BA%D8%A9-%D8%A7%D8%AE%D8%B1%D9%89-node-js-%D9%85%D8%B9-typescript/) مع ملف بناء إضافي:

```text
orders-api/
├── package.json  package-lock.json
├── tsconfig.json          # type check only (noEmit), used by tests and CI
├── tsconfig.build.json    # emits dist/ for the production image
├── Dockerfile  .dockerignore
├── schema.sql
├── src/
│   ├── money.ts           # totals and VAT in halalas
│   ├── orders.ts          # createOrder / getOrder, one transaction
│   ├── app.ts             # Fastify routes, schemas, error handler
│   └── server.ts          # pg.Pool, listen, graceful shutdown
└── test/
    ├── money.test.ts      # unit
    └── api.test.ts        # integration against PostgreSQL
```

وثبتنا الحزم داخل Container من `node:26.13.0-slim`، فكانت النتيجة:

```text
orders-api@1.0.0 /work
+-- @types/node@26.6.6
+-- @types/pg@8.23.1
+-- fastify@5.12.7
+-- pg@8.23.1
`-- typescript@7.0.2

packages: 61 production, 66 total
```

أي أن حزمتين مباشرتين للإنتاج، Fastify و`pg`، جلبتا معهما 59 حزمة أخرى، وهذا رقم معتدل في عالم npm، ولكنه يذكرك بأن كل اعتمادية مباشرة تأتي بشجرة كاملة، وأن ملف القفل هو ما يثبت هذه الشجرة. والملف `package.json` بعد التثبيت فيه السكربتات التي يستخدمها الفريق وCI:

```json
"scripts": {
  "start": "node dist/server.js",
  "dev": "node --watch src/server.ts",
  "typecheck": "tsc -p tsconfig.json",
  "build": "tsc -p tsconfig.build.json",
  "test:unit": "node --test test/money.test.ts",
  "test": "node --test --test-concurrency=1 test/"
}
```

ولاحظ أن التطوير يشغل ملفات TypeScript مباشرة مع `--watch` الذي يعيد التشغيل عند كل حفظ، أما الإنتاج فيشغل `dist/server.js` المبني، والسبب هو تكلفة حذف الأنواع عند البدء التي قسناها في المقال الأول من المرحلة. والخيار `--test-concurrency=1` يشغل ملفات الاختبار واحداً بعد الآخر، لأن اختبار التكامل يعيد إنشاء الجداول قبل كل اختبار، ولو عمل ملفان على نفس قاعدة البيانات معاً لحذف أحدهما جداول الآخر.

## المبالغ بالهللات Money: دالة صغيرة واختبار وحدة Unit Test

أول ملف نكتبه هو أهم ملف، لأنه المكان الوحيد الذي تحسب فيه المبالغ، وكل ما عداه ينقلها فقط:

```typescript
// Money is integer halalas everywhere (1 SAR = 100 halalas). No floats, no rounding until display.
export const VAT_PERCENT = 15;

export type PricedLine = { priceHalalas: number; qty: number };
export type Totals = { subtotal: number; vat: number; total: number };

// VAT on the order subtotal, rounded half up, integer arithmetic only:
// 2970 * 15 = 44550 -> (44550 + 50) / 100 = 446.0 -> 446
export function vatHalalas(subtotal: number): number {
  return Math.floor((subtotal * VAT_PERCENT + 50) / 100);
}

export function totals(lines: PricedLine[]): Totals {
  const subtotal = lines.reduce((sum, l) => sum + l.priceHalalas * l.qty, 0);
  if (!Number.isSafeInteger(subtotal)) throw new RangeError(`subtotal out of range: ${subtotal}`);
  const vat = vatHalalas(subtotal);
  return { subtotal, vat, total: subtotal + vat };
}
```

في الكود أعلاه لاحظ التالي:

- الضريبة تحسب على مجموع الطلب وليس على كل بند، وهذا قرار في المواصفات وليس تفصيلاً، لأن التقريب على كل بند ثم الجمع يعطي رقماً مختلفاً، كما يبين الاختبار الثالث بعد قليل.
- التقريب بعملية صحيحة: نضيف 50 قبل القسمة على 100، ثم نأخذ الجزء الصحيح، فلا يظهر أي كسر عشري في أي خطوة، وهذا يعمل لأن المجموع موجب دائماً.
- الفحص `Number.isSafeInteger` يحمي من الحالة التي شرحناها في مقال [الأنواع والذاكرة والأخطاء](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%88%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D9%81%D9%8A-javascript/): رقم أكبر من 2 أس 53 يفقد دقته بصمت، فنرمي `RangeError` بدلاً من أن نحفظ مبلغاً خاطئاً.

واختبار الوحدة يغطي المثال الذي نستخدمه في كل المسار، وحالات التقريب الحدية:

```typescript
test('9.90 SAR x 3 = 29.70, VAT 4.455 -> 4.46, total 34.16', () => {
  assert.deepEqual(totals([{ priceHalalas: 990, qty: 3 }]), { subtotal: 2970, vat: 446, total: 3416 });
});

test('VAT rounds half up only at exactly half a halala', () => {
  assert.equal(vatHalalas(10), 2);    // 1.5 -> 2
  assert.equal(vatHalalas(3), 0);     // 0.45 -> 0
  assert.equal(vatHalalas(4), 1);     // 0.6 -> 1
  assert.equal(vatHalalas(0), 0);
});

test('VAT is on the order subtotal, not per line', () => {
  // per line: vat(990)=149 twice = 298; on the subtotal: vat(1980) = 297
  assert.equal(totals([{ priceHalalas: 990, qty: 1 }, { priceHalalas: 990, qty: 1 }]).vat, 297);
});
```

والمخرج سوف يكون كما يلي:

```text
✔ 9.90 SAR x 3 = 29.70, VAT 4.455 -> 4.46, total 34.16 (11.257473ms)
✔ VAT rounds half up only at exactly half a halala (0.560038ms)
✔ VAT is on the order subtotal, not per line (0.347947ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0
```

والاختبار الثالث هو الذي يحمي قرار المواصفات: علبتا هيل في بندين منفصلين، فإذا حسب أحدهم الضريبة لكل بند فسوف تكون 149 مرتين، أي 298 هللة، أما على المجموع 1980 فهي 297.5 تقرب إلى 297\. وفرق هللة واحدة يبدو تافهاً، ولكنه يظهر في مطابقة الحسابات آخر الشهر، والأسوأ أن يحسبه النظام بطريقة ونظام الفوترة بطريقة أخرى. لذلك نكتب القرار كاختبار له اسم واضح، فمن يغيره يعرف أنه يغير قراراً وليس سطراً. وقواعد الفوترة الرسمية في السعودية تصدرها هيئة الزكاة والضريبة والجمارك في صفحة [الفوترة الإلكترونية](https://zatca.gov.sa/en/E-Invoicing/Pages/default.aspx?ref=arabroot.io)، وخدمة حقيقية تصدر فواتير يجب أن تطابق متطلباتها، وهذا خارج نطاق مقال عن اللغة.

## معاملة واحدة Transaction لكل طلب: القفل ثم الفحص ثم الكتابة

الآن قلب الخدمة: الدالة `createOrder`. وقبل الكود تذكر السباق الذي صنعناه في مقال [التزامن في JavaScript](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%81%D9%8A-javascript-%D8%A7%D9%84%D9%88%D8%B9%D9%88%D8%AF-promises-%D9%88%D8%A7%D9%84%D8%B9%D9%85%D8%A7%D9%84-workers/): قراءة المخزون، ثم `await`، ثم كتابة القيمة الجديدة، فنجح عشرون طلباً من مخزون عشرة. وقلنا هناك إن القفل في الذاكرة لا يكفي لأن الخدمة تعمل بعدة نسخ، والإصلاح الحقيقي في قاعدة البيانات. والطرق الممكنة ثلاث:

- **التحديث المشروط**: `UPDATE products SET stock = stock - 2 WHERE id = 3 AND stock >= 2`، ثم تفحص عدد الصفوف المتأثرة. وهذا ممتاز لمنتج واحد، ولكنه يصبح معقداً مع عدة منتجات في الطلب، لأنك تحتاج أن تتراجع عن الأول إذا فشل الثاني.
- **مستوى العزل Serializable**: تترك PostgreSQL تكتشف التعارض وتلغي إحدى المعاملتين بخطأ، وعليك أن تعيد المحاولة. وهذا صحيح، ولكنه ينقل التعقيد إلى منطق إعادة المحاولة، كما يشرح توثيق [مستويات العزل في PostgreSQL](https://www.postgresql.org/docs/17/transaction-iso.html?ref=arabroot.io).
- **قفل الصفوف `SELECT ... FOR UPDATE`**: تقفل صفوف المنتجات في بداية المعاملة، فكل طلب آخر يريد نفس المنتج ينتظر حتى تنتهي المعاملة، ثم يقرأ المخزون الجديد. وهذا أبسط طريقة لعدة منتجات، وهي التي اخترناها.

والكود كما يلي، مختصراً من الملف `src/orders.ts`:

```typescript
export async function createOrder(pool: pg.Pool, items: OrderItemInput[]) {
  // The same product twice in one request becomes one line with the summed quantity.
  const wanted = new Map<number, number>();
  for (const { product_id, qty } of items) wanted.set(product_id, (wanted.get(product_id) ?? 0) + qty);
  const ids = [...wanted.keys()].sort((a, b) => a - b);   // fixed lock order: no deadlock between two orders

  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { rows } = await client.query<{ id: number; price_halalas: number; stock: number }>(
      `SELECT id, price_halalas, stock FROM products WHERE id = ANY($1::int[]) ORDER BY id${NO_LOCK ? '' : ' FOR UPDATE'}`,
      [ids],
    );
    if (rows.length !== ids.length) {
      const found = new Set(rows.map((r) => r.id));
      throw new UnknownProductError(ids.filter((id) => !found.has(id)));
    }
    for (const p of rows) {
      const qty = wanted.get(p.id)!;
      if (p.stock < qty) throw new OutOfStockError(p.id, qty, p.stock);
    }
    for (const p of rows) {
      const qty = wanted.get(p.id)!;
      await client.query('UPDATE products SET stock = stock - $2 WHERE id = $1', [p.id, qty]);
    }
    const lines = rows.map((p) => ({ productId: p.id, priceHalalas: p.price_halalas, qty: wanted.get(p.id)! }));
    const t = totals(lines);
    const { rows: [order] } = await client.query<{ id: number; created_at: Date }>(
      'INSERT INTO orders (total_halalas) VALUES ($1) RETURNING id, created_at', [t.total]);
    await client.query(
      `INSERT INTO order_items (order_id, product_id, qty, price_halalas)
       SELECT $1, * FROM unnest($2::int[], $3::int[], $4::int[])`,
      [order.id, lines.map((l) => l.productId), lines.map((l) => l.qty), lines.map((l) => l.priceHalalas)],
    );
    await client.query('COMMIT');
    return { id: order.id, created_at: order.created_at, /* items and totals */ };
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}
```

والصورة التالية تبين خطوات المعاملة بالترتيب، ومكان كل رد خطأ:

![ثماني خطوات: BEGIN، ثم SELECT FOR UPDATE مرتباً بالمعرف يقفل الصفوف، ثم منتج غير موجود يعطي ROLLBACK و400، ثم نقص المخزون يعطي ROLLBACK و409، ثم UPDATE يطرح داخل قاعدة البيانات، ثم totals تحسب 2970 وضريبة 446 ومجموع 3416، ثم INSERT للطلب وللبنود من unnest، ثم COMMIT يحرر الأقفال](https://arabroot.io/content/images/2026/10/js-real-service-02-transaction.webp)

كل الطلب ينجح أو كله يلغى، والأقفال تتحرر عند COMMIT أو ROLLBACK

وكل سطر هنا له سبب، فاقرأه كما يلي:

- **اتصال واحد للمعاملة كلها**: `pool.connect()` يأخذ اتصالاً ويحتفظ به حتى `release()`، والسبب كما يشرح [توثيق node-postgres للمعاملات](https://node-postgres.com/features/transactions?ref=arabroot.io) أن `pool.query` العادية قد ترسل كل استعلام على اتصال مختلف، فيصبح `BEGIN` على اتصال و`UPDATE` على اتصال آخر خارج المعاملة. وهذا أشهر خطأ في المعاملات مع `pg`.
- **`finally` يعيد الاتصال دائماً**: إذا نسيت `release()` في مسار خطأ واحد، فكل خطأ يأخذ اتصالاً من المجموعة إلى الأبد، وبعد عشرة أخطاء تتوقف الخدمة كلها تنتظر اتصالاً، دون أي رسالة.
- **ترتيب المعرفات قبل القفل**: طلب فيه المنتجان 1 و3، وطلب آخر فيه 3 و1، لو قفل كل منهما بترتيب ورود المنتجات لأمكن أن يملك الأول المنتج 1 وينتظر 3، ويملك الثاني 3 وينتظر 1، وهذا الجمود الذي صنعناه بالأقفال في مقال التزامن. و`ORDER BY id` مع `FOR UPDATE` يجعل الجميع يقفلون بنفس الترتيب، كما يوصي [توثيق الأقفال في PostgreSQL](https://www.postgresql.org/docs/17/explicit-locking.html?ref=arabroot.io).
- **دمج المنتج المكرر**: إذا أرسل العميل نفس المنتج في بندين، نجمع الكميتين قبل الفحص، وإلا سوف يمر كل بند على الفحص وحده بنفس المخزون.
- **الطرح في قاعدة البيانات**: `stock = stock - $2` وليس `stock = $newValue` محسوبة في التطبيق، فحتى لو حدث خطأ في القفل يوماً ما يبقى الطرح صحيحاً، والقيد `CHECK (stock >= 0)` يمنع السالب.
- **البنود باستعلام واحد**: `unnest` يحول ثلاث مصفوفات إلى صفوف، فطلب بعشرين بنداً يبقى استعلاماً واحداً إلى قاعدة البيانات بدلاً من عشرين.
- **الأخطاء كلاسات لها أسماء**: `OutOfStockError` و`UnknownProductError` يرثان من `Error` كما شرحنا في مقال الأنواع والأخطاء، فيعرف معالج الأخطاء كيف يحول كل واحد منهما إلى رمز HTTP.

وقد يتساءل البعض: لماذا لا نستخدم ORM مثل Prisma أو Drizzle أو TypeORM؟ والإجابة أن هذه الأدوات مفيدة في مشروع كبير بعشرات الجداول، ولكن هذه المعاملة بالذات، بقفل الصفوف وترتيبها و`unnest`، تحتاج أن ترى SQL الذي يرسل فعلاً، وأغلب أدوات ORM تجعل `FOR UPDATE` خياراً مخفياً أو تحتاج إلى SQL خام على أي حال. والقاعدة: اكتب SQL مباشرة للأجزاء التي يعتمد عليها صحة المال والمخزون، واستخدم ما يريحك في الباقي.

### الاستعلامات المعاملية Parameterized Queries وأنواع الصفوف

لاحظ أن كل قيمة تصل من العميل تذهب إلى PostgreSQL عبر `$1` و`$2` وليس بدمجها في نص الاستعلام، فحتى لو تجاوز أحدهم التحقق وأرسل نصاً فيه علامة تنصيص أو أمر SQL فلن يكون إلا قيمة. والسبب أن `pg` يرسل النص والقيم منفصلين في بروتوكول PostgreSQL، فالخادم لا يحلل القيمة كجزء من الأمر أصلاً، وهذا هو العلاج الحقيقي لحقن SQL Injection، وليس تنظيف النصوص يدوياً. والاستثناء الوحيد في الكود هو `NO_LOCK` الذي يغير نص الاستعلام، وقيمته تأتي من متغير بيئة يتحكم فيه من يشغل الخدمة، وليس من الطلب. والقاعدة: لا يوجد قالب نصي `` `...${value}...` `` يحمل قيمة من المستخدم داخل SQL أبداً، وأي أداة تدقيق في الفريق يجب أن تبحث عن هذا الشكل.

ولاحظ أيضاً النوع العام في `client.query<{ id: number; price_halalas: number; stock: number }>`، فهو يجعل المحرر يعرف شكل الصفوف، ولكنه وعد منك للمترجم وليس فحصاً، فإذا غيرت اسم عمود في قاعدة البيانات أو رجع العمود نصاً لأنه `numeric` فلن يعترض أحد قبل التشغيل. وهذا هو نفس الحد الذي رأيناه في مقال TypeScript بين الأنواع داخل الكود والبيانات القادمة من الخارج، وقاعدة البيانات «خارج» بالنسبة للمترجم. لذلك اخترنا أعمدة `int` التي يرجعها `pg` كأرقام فعلاً، واختبار التكامل يقارن الصفوف بقيم محددة، فيكشف أي تغيير في الشكل.

## التحقق Validation عند الحدود: JSON Schema في Fastify

في مقال الأنواع رأينا أن TypeScript لا ترى البيانات القادمة وقت التشغيل، وأن JSON يغير الأنواع في الطريق، وأن JavaScript تحول النصوص إلى أرقام دون أن تسأل. لذلك فإن أول ما يمر عليه جسم الطلب هو مخطط JSON Schema يعرفه المسار، وFastify يفحصه بمكتبة Ajv قبل أن يصل الطلب إلى الدالة. والملف `src/app.ts` كاملاً تقريباً:

```typescript
const orderBody = {
  type: 'object',
  required: ['items'],
  additionalProperties: false,
  properties: {
    items: {
      type: 'array', minItems: 1, maxItems: 50,
      items: {
        type: 'object', required: ['product_id', 'qty'], additionalProperties: false,
        properties: { product_id: { type: 'integer', minimum: 1 }, qty: { type: 'integer', minimum: 1, maximum: 1000 } },
      },
    },
  },
} as const;

export function buildApp(pool: pg.Pool, opts: { logger?: boolean } = {}): FastifyInstance {
  // Strict validation: no type coercion ("2" is not 2), extra fields rejected instead of silently removed.
  const app = Fastify({ logger: opts.logger ?? false, ajv: { customOptions: { coerceTypes: false, removeAdditional: false } } });

  app.get('/products', async () => {
    const { rows } = await pool.query('SELECT id, name_ar, price_halalas, stock FROM products ORDER BY id');
    return rows;
  });

  app.post<{ Body: { items: OrderItemInput[] } }>('/orders', { schema: { body: orderBody } }, async (req, reply) => {
    const order = await createOrder(pool, req.body.items);
    return reply.code(201).send(order);
  });

  // Path params always arrive as strings; with coercion off we validate the digits and convert ourselves.
  app.get<{ Params: { id: string } }>('/orders/:id', {
    schema: { params: { type: 'object', properties: { id: { type: 'string', pattern: '^[1-9][0-9]{0,9}$' } } } },
  }, async (req, reply) => {
    const id = Number(req.params.id);
    const order = id <= 2147483647 ? await getOrder(pool, id) : null;   // beyond int4 cannot exist
    return order ?? reply.code(404).send({ error: 'order not found' });
  });

  app.setErrorHandler((err, req, reply) => {
    if (err instanceof OutOfStockError) return reply.code(409).send({ error: err.message, product_id: err.productId, left: err.left });
    if (err instanceof UnknownProductError) return reply.code(400).send({ error: err.message });
    if ((err as { validation?: unknown }).validation) return reply.code(400).send({ error: (err as Error).message });
    req.log.error(err);
    return reply.code(500).send({ error: 'internal error' });
  });
  return app;
}
```

وأهم سطر في الملف هو سطر `ajv`، لأن Fastify له [إعدادات افتراضية لـ Ajv](https://fastify.dev/docs/latest/Reference/Validation-and-Serialization/?ref=arabroot.io) يجب أن تعرفها قبل أن تثق بالتحقق. فالإعداد الأساسي في الإطار فيه `coerceTypes: 'array'`، أي أن Ajv يحول القيم لتطابق المخطط، فالكمية `"2"` كنص تصبح الرقم 2 وتمر، وفيه `removeAdditional: true`، أي أن الحقول الزائدة تحذف بصمت بدلاً من أن ترفض حتى مع `additionalProperties: false`. وهذا قد يكون مناسباً لنموذج HTML، ولكنه في خدمة مالية يعني أن عميلاً أرسل `"qty": "2"` أو أخطأ في اسم حقل لن يعرف أبداً أن طلبه غير صحيح. لذلك أطفأنا الخيارين، فصار المخطط يقول ما يقبله بالضبط. ومعالج الأخطاء الواحد في النهاية هو المكان الوحيد الذي تتحول فيه الأخطاء إلى رموز HTTP: 409 لنقص المخزون مع الكمية المتبقية، و400 للتحقق وللمنتج غير الموجود، و500 مع تسجيل الخطأ لكل ما عدا ذلك، دون أن نرسل تفاصيله الداخلية للعميل.

![يمين: حالات جسم POST orders التي ترجع 400: جسم فارغ، وitems فارغة، وكمية صفر، وكمية نصية 2، وكمية 1.5، ومنتج 99 غير موجود. يسار: مشكلة coerceTypes false مع معاملات المسار التي جعلت orders 7 يرجع 400، مع رسالة الاختبار الفاشل والحل بمخطط نصي ونمط أرقام](https://arabroot.io/content/images/2026/10/js-real-service-06-validation.webp)

المخطط يقرر ما يدخل، ومعالج الأخطاء يقرر ما يخرج

### مشكلة واجهناها: التحويل Coercion ومعاملات المسار

في النسخة الأولى كان مخطط `GET /orders/:id` يقول إن `id` من نوع `integer`، وهو ما يبدو طبيعياً. وعندما شغلنا اختبارات التكامل فشل اختباران:

```text
✖ POST /orders: 3 x 9.90 SAR -> 3416 halalas, stock 10 -> 7, GET /orders/:id (336.666728ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  + actual - expected
  + undefined
  - 3416
✖ 400 on bad input (317.150188ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  400 !== 404
```

والطلب نفسه أنشئ بنجاح، ولكن قراءته أرجعت جسماً بلا `total_halalas`، والطلب غير الموجود أرجع 400 بدلاً من 404\. والسبب أن معاملات المسار في HTTP نصوص دائماً، فالرقم 7 في `/orders/7` يصل كالنص `"7"`، وكان Fastify يحوله إلى رقم بالإعداد الافتراضي، فلما أطفأنا التحويل لنحمي جسم الطلب، صار كل معرف في المسار يفشل في التحقق من النوع `integer` ويرجع 400\. والحل الذي اخترناه أن نصف المعامل كما هو فعلاً: نص من أرقام فقط بنمط `^[1-9][0-9]{0,9}$`، ثم نحوله بـ `Number()` في الدالة، ونرجع 404 لأي رقم أكبر من حد العمود `int` بدلاً من أن نرسله إلى قاعدة البيانات فترمي خطأ. والحل الآخر الذي يذكره نفس توثيق Fastify هو أن تحدد مدققاً مختلفاً لكل جزء من الطلب، فتطفئ التحويل للجسم وحده وتبقيه للمسار والاستعلام. والدرس العام: كل إعداد «يحمي» يجب أن يرافقه اختبار يثبت أنه لم يكسر الحالة الصحيحة، ولولا اختبار التكامل لوصلت هذه المشكلة إلى الإنتاج.

## التشغيل Server والإغلاق الآمن Graceful Shutdown

الملف الأخير `src/server.ts` هو الوحيد الذي يقرأ متغيرات البيئة وينشئ الموارد، وهذا يجعل `buildApp` قابلة للاختبار بأي `Pool` نعطيها إياه:

```typescript
import pg from 'pg';
import { buildApp } from './app.ts';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: Number(process.env.PG_POOL_MAX ?? 10),
});
const app = buildApp(pool, { logger: process.env.LOG !== '0' });

const close = async (signal: string) => {
  app.log.info({ signal }, 'shutting down');
  await app.close();      // stop accepting, finish in-flight requests
  await pool.end();       // then close database connections
  process.exit(0);
};
process.once('SIGTERM', close);
process.once('SIGINT', close);

await app.listen({ host: '0.0.0.0', port: Number(process.env.PORT ?? 3000) });
```

والإشارة `SIGTERM` هي ما يرسله Docker وKubernetes عند إيقاف الـ Container أو استبداله بنسخة جديدة، وبعدها بمهلة يرسل `SIGKILL` الذي لا يمكن التقاطه. فإذا لم تلتقط `SIGTERM` فكل طلب كان في منتصف معاملة سوف يقطع، وهذا يعني طلبات فشلت عند العميل مع كل نشر جديد. والترتيب مهم: أوقف استقبال الطلبات الجديدة وانتظر الطلبات الحالية بـ `app.close()`، ثم أغلق اتصالات قاعدة البيانات. والعنوان `0.0.0.0` ضروري داخل الـ Container، لأن الاستماع على `127.0.0.1` وحده يجعل الخدمة غير مرئية من خارج الـ Container. وحجم المجموعة `max` عشرة افتراضياً كما في [توثيق التجميع في node-postgres](https://node-postgres.com/features/pooling?ref=arabroot.io)، وهو حد لكل نسخة من الخدمة، فأربع نسخ تعني أربعين اتصالاً، ويجب أن يبقى المجموع تحت `max_connections` في PostgreSQL.

## اختبار التكامل Integration Test على PostgreSQL حقيقي

اختبار الوحدة يثبت أن الحساب صحيح، ولكنه لا يثبت شيئاً عن المعاملة والأقفال والمخطط، ولذلك نحتاج اختبار تكامل يعمل على PostgreSQL حقيقي، وليس على قاعدة بيانات في الذاكرة أو على محاكاة Mock، لأن ما نريد اختباره هو سلوك PostgreSQL نفسه مع `FOR UPDATE`. والاختبار يشغل الخدمة على منفذ عشوائي، ويرسل إليها طلبات HTTP حقيقية بـ `fetch`، ويعيد إنشاء الجداول من `schema.sql` قبل كل اختبار:

```typescript
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
const app = buildApp(pool);
let base = '';
const schema = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');

before(async () => { base = await app.listen({ host: '127.0.0.1', port: 0 }); });
after(async () => { await app.close(); await pool.end(); });
beforeEach(async () => {
  await pool.query('DROP TABLE IF EXISTS order_items, orders, products');
  await pool.query(schema);
});

test('20 parallel orders for a product with stock 10 -> exactly 10 succeed', async () => {
  const results = await Promise.all(Array.from({ length: 20 }, () => post({ items: [{ product_id: 3, qty: 1 }] })));
  const codes = results.map((r) => r.status);
  const ok = codes.filter((c) => c === 201).length;
  const conflict = codes.filter((c) => c === 409).length;
  const { rows: [p] } = await pool.query('SELECT stock FROM products WHERE id = 3');
  const { rows: [o] } = await pool.query('SELECT count(*)::int AS n FROM orders');
  console.log(`  parallel: 201=${ok} 409=${conflict} other=${20 - ok - conflict} stock=${p.stock} orders=${o.n}`);
  assert.equal(ok, 10);
  assert.equal(conflict, 10);
  assert.equal(p.stock, 0);
  assert.equal(o.n, 10);
});
```

وبقية الاختبارات في الملف تفحص قائمة المنتجات، وطلباً عادياً يحسب 3416 هللة وينزل المخزون من 10 إلى 7 ثم يقرأ الطلب، والرد 409 مع التأكد أن شيئاً لم يكتب، وست حالات 400 ومعرف غير رقمي ومعرف غير موجود. وشغلنا PostgreSQL في Container على شبكة Docker خاصة، والاختبارات في Container آخر على نفس الشبكة:

```bash
docker network create slj-js-net
docker run -d --name slj-js-db --network slj-js-net -e POSTGRES_PASSWORD=shop -e POSTGRES_USER=shop -e POSTGRES_DB=shop postgres:17-alpine
docker run --rm --network slj-js-net -e DATABASE_URL=postgres://shop:shop@slj-js-db:5432/shop -v "$PWD:/src:ro" node:26.13.0-slim sh /src/test.sh
```

والمخرج بعد إصلاح مشكلة المسار سوف يكون كما يلي:

```text
tsc: no errors
✔ GET /products lists the three seeded products (1413.838024ms)
✔ POST /orders: 3 x 9.90 SAR -> 3416 halalas, stock 10 -> 7, GET /orders/:id (213.196592ms)
✔ 409 on insufficient stock, and nothing is written (144.914896ms)
✔ 400 on bad input (185.29155ms)
  parallel: 201=10 409=10 other=0 stock=0 orders=10
✔ 20 parallel orders for a product with stock 10 -> exactly 10 succeed (1726.294461ms)
✔ 9.90 SAR x 3 = 29.70, VAT 4.455 -> 4.46, total 34.16 (1.88346ms)
✔ VAT rounds half up only at exactly half a halala (0.43476ms)
✔ VAT is on the order subtotal, not per line (0.28714ms)
ℹ tests 8
ℹ pass 8
ℹ fail 0
ℹ duration_ms 4967.899308
```

في المخرج أعلاه لاحظ التالي:

- السطر `parallel` هو ما يهمنا: عشرون طلباً أرسلت معاً عبر HTTP، فنجح عشرة بالرمز 201، ورفض عشرة بالرمز 409، والمخزون صفر، وعدد الطلبات في الجدول عشرة بالضبط. فالقفل في PostgreSQL فعل ما فشل فيه الفحص في الذاكرة.
- أول اختبار أخذ 1.4 ثانية، وأغلب الوقت فتح أول اتصال بقاعدة البيانات وتحميل الكود، والاختبارات بعده بين 145 و220 ميلي ثانية مع إعادة إنشاء الجداول كل مرة.
- اختبار التوازي أخذ 1.7 ثانية، لأن الطلبات العشرين تنتظر بعضها عند قفل الصف، وهذا ثمن الصحة: الطلبات على نفس المنتج تتسلسل، أما الطلبات على منتجات مختلفة فتعمل معاً.
- `tsc: no errors` في البداية، فالاختبارات تشغل الكود بحذف الأنواع، والفحص خطوة مستقلة قبلها.

![مقارنة: مع SELECT FOR UPDATE والطرح في قاعدة البيانات نجح 10 بالرمز 201 ورفض 10 بالرمز 409 والمخزون 0، ومع NO_LOCK حيث القراءة بلا قفل وكتابة قيمة محسوبة نجح 20 ولم يرفض أي طلب والمخزون 8](https://arabroot.io/content/images/2026/10/js-real-service-03-parallel.webp)

نفس الاختبار مع القفل وبدونه

### كيف يرى PostgreSQL الطلبات العشرين؟ داخل القفل Row Lock

من المفيد أن تتخيل ما حدث داخل PostgreSQL في اختبار التوازي، لأنه يفسر الرقمين: عشرة نجحت، وعشرة رفضت. فالطلبات العشرون وصلت إلى Fastify معاً، ولكن المجموعة فيها عشرة اتصالات فقط، فبدأت عشر معاملات وانتظرت عشر أخرى في طابور المكتبة. والمعاملات العشر وصلت كلها إلى `SELECT ... FOR UPDATE` على صف المنتج 3، فأخذت الأولى قفل الصف، ووقفت التسع الأخرى تنتظر هذا القفل داخل PostgreSQL. وعندما نفذت الأولى `UPDATE` و`COMMIT` صار المخزون 9 وتحرر القفل، فأخذته الثانية. وهنا التفصيل المهم: الثانية بدأت انتظارها عندما كان المخزون 10، ولكنها لا ترجع القيمة القديمة، لأن PostgreSQL في مستوى Read Committed، كما يشرح [توثيق مستويات العزل](https://www.postgresql.org/docs/17/transaction-iso.html?ref=arabroot.io)، يعيد قراءة الصف بنسخته الأحدث بعد أن يحصل على القفل، فترى 9\. وهكذا حتى العاشرة التي رأت 1 فطرحته، ثم الحادية عشرة رأت 0 فرمت `OutOfStockError` وعملت `ROLLBACK`، وكذلك كل ما بعدها.

وهذا يعني أن الطلبات على نفس المنتج تتسلسل فعلاً، واحداً بعد الآخر، وزمن كل طلب يشمل انتظاره للطلبات التي قبله، ولهذا أخذ اختبار التوازي 1.7 ثانية. أما طلبان على منتجين مختلفين فلا يقفل أحدهما صف الآخر، فيعملان معاً. فإذا كان لديك منتج واحد عليه عرض يطلبه الجميع في نفس الدقيقة، فهذا الصف هو عنق الزجاجة Bottleneck في الخدمة كلها، والحل عندها ليس في اللغة ولا في الإطار، وإنما في التصميم، مثل حجز كميات مسبقاً أو تقسيم المخزون، وهذا موضوع تصميم أنظمة وليس موضوع هذا المقال.

### ماذا يحدث إذا حذفنا FOR UPDATE؟ تجربة NO\_LOCK

اختبار يمر لا يثبت أن القفل هو سبب النجاح، فربما كانت الطلبات تصل متتابعة بالصدفة. لذلك أضفنا إلى `createOrder` مفتاحاً للتجربة فقط: مع متغير البيئة `NO_LOCK=1` يقرأ المخزون بدون `FOR UPDATE`، وينتظر 5 ميلي ثوان كما لو كانت الشبكة أبطأ، ثم يكتب القيمة المحسوبة في التطبيق `stock = $newValue`، أي نفس الكود الذي يكتبه كثيرون في أول نسخة. والمخرج:

```text
  parallel: 201=20 409=0 other=0 stock=8 orders=20
✖ 20 parallel orders for a product with stock 10 -> exactly 10 succeed (2144.424119ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  20 !== 10
```

عشرون طلباً نجحت، وعشرون طلباً في الجدول، والمخزون يقول إنه بقي 8\. أي أن المتجر باع عشرين علبة هيل من عشر، وقاعدة البيانات تظن أن لديه ثماني علب أخرى للبيع، وهذا أسوأ من الخطأين معاً، لأن القيد `CHECK (stock >= 0)` لم يتدخل أصلاً، فلم تكتب أي قيمة سالبة. والسبب أن كل معاملة قرأت المخزون قبل أن تكتب المعاملات الأخرى، ثم كتبت قيمتها فوق قيمهم، وهذا التحديث المفقود Lost Update بنفس الشكل الذي رأيناه في الذاكرة. ولاحظ أن مستوى العزل الافتراضي في PostgreSQL، [Read Committed](https://www.postgresql.org/docs/17/transaction-iso.html?ref=arabroot.io)، لا يمنع هذا، فكل معاملة رأت بيانات «مؤكدة» صحيحة، ولكنها قديمة لحظة الكتابة. فالمعاملة وحدها لا تكفي، وما يحمي المخزون هو القفل أو الطرح داخل قاعدة البيانات أو مستوى عزل أعلى.

## Dockerfile متعدد المراحل Multi-stage

الـ Image الذي يعمل في الإنتاج يجب أن يحتوي ما يلزم للتشغيل فقط: Node.js، وحزم الإنتاج، والكود المبني. أما TypeScript وتعريفات الأنواع وذاكرة npm المؤقتة فمكانها مرحلة البناء، وهذا ما تفعله [المراحل المتعددة](https://docs.docker.com/build/building/multi-stage/?ref=arabroot.io):

```dockerfile
# syntax=docker/dockerfile:1
# Stage 1: all dependencies, type check and build to plain JavaScript
FROM node:26.13.0-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npx tsc -p tsconfig.json && npx tsc -p tsconfig.build.json

# Stage 2: production dependencies only
FROM node:26.13.0-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Stage 3: runtime, no npm cache, no TypeScript, non-root user
FROM node:26.13.0-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
```

في الإعداد أعلاه لاحظ التالي:

- ننسخ `package.json` وملف القفل قبل الكود، فإذا تغير الكود فقط استخدم Docker طبقة `npm ci` من الذاكرة المؤقتة ولم يعد التحميل، وهذا يحول بناء بعد تعديل سطر واحد من دقيقة إلى ثوان.
- مرحلة `build` تفحص الأنواع قبل البناء، فـ Image لا يبنى إذا كان في الكود خطأ أنواع.
- مرحلة `deps` منفصلة عن `build`، حتى لا تحمل `node_modules` في الإنتاج أي حزمة تطوير، و`--omit=dev` يترك 61 حزمة من 66.
- `USER node`: الـ Image الرسمي فيه مستخدم اسمه `node`، و[أفضل الممارسات في مستودع Docker Image الرسمي](https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md?ref=arabroot.io) توصي بتشغيل التطبيق به بدلاً من root.
- `CMD ["node", "dist/server.js"]` بصيغة المصفوفة وليس `npm start`، لأن `npm` لا يمرر إشارة `SIGTERM` إلى العملية بشكل موثوق، فيضيع الإغلاق الآمن الذي كتبناه.

ومع الـ Dockerfile ملف صغير لا يقل أهمية هو `.dockerignore`، وفيه `node_modules` و`dist` و`test`. والسبب أن الأمر `docker build` يرسل مجلد المشروع كله إلى محرك البناء قبل أن يبدأ، فإذا كان في المجلد `node_modules` من جهاز المطور بحجم مئات الميجابايتات فسوف يرسل في كل بناء، والأسوأ أن `COPY . .` في Dockerfile آخر قد ينسخ حزماً مبنية لنظام Windows أو macOS فوق حزم Linux، فتعمل الخدمة على جهازك وتفشل في الـ Container برسالة غامضة عن ملف أصلي لا يمكن تحميله. ونحن ننسخ الملفات بأسمائها، ولكن الملف يحمي من أي تعديل لاحق على الـ Dockerfile.

وبنينا الـ Image ونظرنا في طبقاته:

```bash
docker build -t slj-js-orders:1 .
docker images slj-js-orders:1
docker history slj-js-orders:1 --format '{{.Size}}\t{{.CreatedBy}}'
```

```text
build took 72 s
IMAGE             ID             DISK USAGE   CONTENT SIZE   EXTRA
slj-js-orders:1   184ca8ca4131        371MB         87.5MB
0B	CMD ["node" "dist/server.js"]
0B	EXPOSE [3000/tcp]
0B	USER node
12.3kB	COPY package.json ./ # buildkit
28.7kB	COPY /app/dist ./dist # buildkit
16.4MB	COPY /app/node_modules ./node_modules # buil…
8.19kB	WORKDIR /app
0B	ENV NODE_ENV=production
```

![ثلاث مراحل: build فيها npm ci وtsc للفحص والبناء، وdeps فيها npm ci بدون حزم التطوير، والتشغيل فيها node_modules بـ 16.4 MB وdist بـ 28.7 kB ومستخدم node، وتحتها حجم الـ Image 371 MB منها 355 MB للـ Image الأساسي و16.5 MB أضفناها](https://arabroot.io/content/images/2026/10/js-real-service-04-dockerfile.webp)

كل ما أضفناه فوق Node.js أقل من 17 ميجابايت

والأرقام تقول إن الـ Image 371 ميجابايت على القرص، منها 355 للـ Image الأساسي `node:26.13.0-slim`، وما أضفناه 16.4 ميجابايت لحزم الإنتاج و28.7 كيلوبايت فقط للكود المبني. فإذا أردت Image أصغر فالمكان الذي يستحق الجهد هو الأساس وليس الكود، مثل Images بلا Shell من نوع Distroless، وهذا موضوع تقليل حجم الـ Images في مسار Docker، وليس موضوع اللغة.

## القياس Benchmark: نفس الطريقة في كل لغة

الآن القياس الذي نكرره في كل لغات المسار: الـ Container محدود بنواتين و512 ميجابايت، ونقيس حجم الـ Image، والبدء حتى أول رد 200، والذاكرة وهو خامل بعد خمس ثوان، ثم [wrk](https://github.com/wg/wrk?ref=arabroot.io) بخيطين وخمسين اتصالاً لمدة 15 ثانية على `GET /products` مرتين، ثم الذاكرة بعد الضغط. وأطفأنا سجل الطلبات بـ `LOG=0` أثناء القياس، لأن كتابة سطر JSON لكل طلب تقيس سرعة السجلات وليس الخدمة. والمخرج:

```text
== image size
371.2 MB
== idle memory after 5 s
34.23MiB / 512MiB
== wrk run 1
Running 15s test @ http://slj-js-svc:3000/products
  2 threads and 50 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    42.24ms   64.88ms 897.62ms   96.54%
    Req/Sec   759.67    295.59     1.73k    72.01%
  22559 requests in 15.10s, 9.23MB read
Requests/sec:   1493.66
Transfer/sec:    625.76KB
== wrk run 2
Running 15s test @ http://slj-js-svc:3000/products
  2 threads and 50 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    34.56ms   20.89ms 254.24ms   78.38%
    Req/Sec   756.68    281.34     1.68k    73.20%
  22644 requests in 15.09s, 9.26MB read
Requests/sec:   1501.08
Transfer/sec:    628.87KB
== memory after load
46.52MiB / 512MiB
```

وسكربت القياس المشترك يقيس زمن البدء أيضاً من الجهاز نفسه، بحلقة `curl` إلى المنفذ المنشور، وأعطى في التشغيل الأول رقماً غير معقول هو 61,895 ميلي ثانية. وعندما فحصنا السبب وجدنا أن الأمر `docker run` وحده يأخذ حوالي 5 ثوان على Docker Desktop في Windows قبل أن يرجع، وأن كل محاولة `curl` فاشلة من Git Bash إلى منفذ لم يفتح بعد تعلق مدة طويلة لأن السكربت لا يحدد مهلة لكل محاولة، فالرقم يقيس Docker Desktop والشبكة على Windows وليس الخدمة. لذلك قسنا البدء بطريقة أدق من داخل شبكة Docker، والمشكلة نفسها درس في القياس: قبل أن تثق برقم، تأكد أنه يقيس ما تظنه يقيسه.

ولذلك قسنا زمن البدء من داخل شبكة Docker: لحظة بدء الـ Container كما يسجلها Docker، حتى أول رد 200 يراه Container آخر يسأل كل بضع ميلي ثوان، ثلاث مرات:

```text
run 1: start -> first 200: 2961 ms
run 2: start -> first 200: 2989 ms
run 3: start -> first 200: 4529 ms
```

ولأن الأمر `docker run` نفسه كان بطيئاً جداً على هذا الجهاز المشغول، أخذنا أيضاً الرقم الذي نقارن به بين لغات المسار: زمن العملية نفسها حتى تصبح جاهزة، من لحظة بدء الـ Container كما يسجلها Docker إلى الحقل `time` في سطر السجل `Server listening` الذي يطبعه Fastify، ثلاث مرات:

```text
run 1: container start -> "Server listening" log: 6010 ms
run 2: container start -> "Server listening" log: 3189 ms
run 3: container start -> "Server listening" log: 2935 ms
```

والقراءتان الأخيرتان قريبتان من قياس الشبكة، أما الأولى فتبين كم يتأثر البدء بانشغال الجهاز في تلك اللحظة.

![أربعة أرقام: حجم الـ Image 371 MB، والبدء حتى أول 200 بين 2.96 و4.53 ثانية، والذاكرة خاملاً 34.2 MiB، وبعد wrk 46.5 MiB، وجدول wrk بتشغيلين: 1493.66 و1501.08 طلباً في الثانية بمتوسط 42.24 و34.56 ms وأعلى زمن 897.62 و254.24 ms](https://arabroot.io/content/images/2026/10/js-real-service-05-measure.webp)

الأرقام تقارن بين اللغات بنفس الجهاز ونفس الطريقة، وليست أرقاماً مطلقة

وهذه الأرقام تحتاج قراءة حذرة، وفيما يلي ما تقوله وما لا تقوله:

- **الإنتاجية حوالي 1500 طلب في الثانية في التشغيلين**، وكل طلب يذهب إلى PostgreSQL ويرجع ثلاثة صفوف ويحولها إلى JSON. والرقمان متقاربان جداً، 1493.66 و1501.08، وهذا يعني أن الإنتاجية ثابتة.
- **الزمن الأعلى مختلف كثيراً**: 898 ميلي ثانية في التشغيل الأول و254 في الثاني، والمتوسط 42 مقابل 35\. والتشغيل الأول هو أول ضغط على الخدمة بعد البدء، فالمحرك V8 ما زال يترجم الدوال الساخنة، ومجموعة الاتصالات تفتح اتصالاتها العشرة، وهذا ما يسمى الإحماء Warm-up، ولهذا نشغل wrk مرتين.
- **الذاكرة صغيرة**: 34 ميجابايت خاملة، و46.5 بعد 45 ألف طلب، وهي بعيدة جداً عن حد 512، وعن حد الـ Heap الذي يحسبه Node.js من ذاكرة الـ Container كما قسنا في مقال الأنواع والذاكرة، وهو 268 ميجابايت.
- **البدء ثلاث ثوان تقريباً، وهذا رقم مرتفع**، والسبب الأكبر هو الجهاز وليس الخدمة: في نفس الساعة احتاج Node.js الفارغ `node -e 0` داخل نفس الـ Image بين 304 و583 ميلي ثانية، واستيراد Fastify و`pg` وحده بين 836 و1415 ميلي ثانية، مع حصة نواتين وجهاز مشغول. فخذ الرقم للمقارنة بين اللغات على نفس الجهاز، وليس كرقم تتوقعه على سيرفرك.
- **من أين تأتي 34 ميجابايت؟** Node.js الفارغ نفسه يحجز ذاكرة للمحرك V8 وللكود المترجم وللمكتبات القياسية، ثم Fastify و`pg` وكودنا، ومجموعة الاتصالات بقاعدة البيانات، وكلها تقريباً ثابتة لا تتغير مع عدد الطلبات، أما الزيادة إلى 46.5 بعد الضغط فهي كائنات مؤقتة للطلبات لم يجمعها الجامع بعد، وكود ترجمه V8 أثناء الضغط. وهذا الرقم هو ما سوف تقارنه مع اللغات الأخرى، حيث تختلف الصورة كثيراً بين لغة تترجم إلى ملف تنفيذي واحد ولغة تحمل آلة افتراضية كاملة.
- **ما لا تقوله الأرقام**: wrk يعمل على نفس الجهاز بلا حد، فهو ينافس الخدمة وقاعدة البيانات على نفس الأنوية، والطلب نفسه بسيط جداً. فهذا ليس اختبار ضغط لخدمة حقيقية، والطريقة الصحيحة لذلك شرحناها في مقال [اختبار الضغط Load Testing](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%AD%D9%85%D8%A7%D9%84-%D9%88%D8%A7%D9%84%D8%B6%D8%BA%D8%B7-load-%D9%88-stress-testing-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/).

### ماذا يحدث إذا غيرنا حجم مجموعة الاتصالات Pool Size؟

السؤال الذي يتكرر عند كل خدمة جديدة: كم اتصالاً بقاعدة البيانات نحتاج؟ والحدس يقول «أكثر أفضل»، فجربنا نفس القياس بثلاثة أحجام للمجموعة عبر متغير البيئة `PG_POOL_MAX`، مع إحماء wrk لعشر ثوان قبل كل قياس، ثم عددنا الاتصالات المفتوحة من جهة PostgreSQL بالاستعلام `pg_stat_activity`:

```text
== PG_POOL_MAX=2
    Latency    67.70ms   28.13ms 212.14ms   70.80%
Requests/sec:    739.86
3
== PG_POOL_MAX=10
    Latency    57.79ms   45.48ms 646.86ms   87.71%
Requests/sec:    935.23
11
== PG_POOL_MAX=25
    Latency    67.80ms   96.76ms   1.33s    96.30%
Requests/sec:    905.81
26
```

والرقم الأخير في كل قسم هو عدد الاتصالات، وهو حجم المجموعة زائد اتصال `psql` نفسه الذي سأل. ولاحظ أولاً أن الإنتاجية هنا حوالي 900 طلب في الثانية وليست 1500 كما في القياس الرئيسي، لأن الجهاز كان أكثر انشغالاً في هذه الساعة، وهذا تذكير آخر بأن نقارن الأرقام داخل نفس التشغيل فقط. وداخل هذا التشغيل: اتصالان فقط أعطيا 740 طلباً في الثانية، فالطلبات تنتظر دورها في طابور المكتبة، وعشرة أعطت 935، وخمسة وعشرون لم تعط شيئاً إضافياً، 906، بل صار أعلى زمن 1.33 ثانية بدلاً من 647 ميلي ثانية. والسبب أن الخدمة محدودة بنواتين، وعندما تصبح الخدمة نفسها هي الحد فإن مزيداً من الاتصالات يعني مزيداً من الطلبات المفتوحة في نفس الوقت تتنافس على نفس المعالج، ومزيداً من العمل على PostgreSQL، دون أي فائدة. وهذا يطابق ما تقوله قاعدة شائعة في ضبط PostgreSQL: المجموعة الصغيرة المشغولة أفضل من المجموعة الكبيرة المنتظرة، والرقم الصحيح يأتي من القياس على جهازك، وليس من ضرب عدد المستخدمين في شيء.

## ما الذي ينقص هذه الخدمة قبل الإنتاج؟ Production Readiness

الخدمة في هذا المقال كاملة من حيث المواصفات المشتركة، ولكنها ليست جاهزة للإنتاج كما هي، ومن المفيد أن تعرف ما ينقصها حتى لا تنسخها وتنشرها. فالسجلات Logs في Fastify تأتي من مكتبة pino بصيغة JSON سطراً لكل حدث، مثل السطر الذي طبعته الخدمة عند البدء:

```text
{"level":30,"time":1793436614858,"pid":1,"hostname":"6b1864d758bb","msg":"Server listening at http://127.0.0.1:3000"}
```

وهذا الشكل مناسب لأدوات جمع السجلات لأن كل حقل قابل للبحث، والحقل `time` بالميلي ثانية منذ 1970 وليس نصاً مقروءاً، والرقم `30` يعني مستوى info. وFastify يضيف لكل طلب معرفاً `reqId` يظهر في كل سطر يخصه، فتتبع طلباً واحداً بين مئات الأسطر. وما ينقص بعد ذلك قائمة معروفة:

- فحص صحة Health Check منفصل، مثل `GET /health` يسأل قاعدة البيانات `SELECT 1`، يستخدمه الموزع وKubernetes لمعرفة متى تكون النسخة جاهزة.
- المصادقة Authentication والصلاحيات، فالآن أي أحد يستطيع أن يطلب وأن يقرأ أي طلب برقمه.
- حد الطلبات Rate Limit لكل عميل، حتى لا يستطيع عميل واحد أن يستهلك كل الاتصالات العشرة.
- مفتاح عدم التكرار Idempotency Key على `POST /orders`، لأن العميل الذي انقطع اتصاله سوف يعيد الطلب، ولا تريد أن تبيعه مرتين.
- أداة ترحيل Migrations لمخطط قاعدة البيانات بدلاً من `schema.sql` واحد، فالمخطط سوف يتغير، ويجب أن يتغير بنفس الطريقة في كل البيئات.
- مهلة على الاستعلامات `statement_timeout` وعلى انتظار اتصال من المجموعة `connectionTimeoutMillis`، فلا يبقى طلب معلقاً إلى الأبد كما رأينا مع الجمود في مقال التزامن.

وكل بند من هذه له مقال أو أكثر في مسارات أخرى على الموقع، والمهم هنا أن تعرف أن هيكل الخدمة لا يتغير عند إضافتها: إضافات Plugins في Fastify تسجل قبل المسارات، والمنطق في `orders.ts` يبقى كما هو.

## أخطاء شائعة Common Mistakes في خدمات Node.js

- **`pool.query('BEGIN')` ثم `pool.query` للباقي**: كل استعلام قد يذهب إلى اتصال مختلف، فالمعاملة ليست معاملة. خذ اتصالاً واحداً بـ `pool.connect()`.
- **نسيان `release()` في مسار خطأ**: المجموعة تفرغ بعد عشرة أخطاء وتتوقف الخدمة بصمت. ضعه في `finally`.
- **فحص المخزون ثم الكتابة بدون قفل**: عشرون طلباً نجحت من عشرة في تجربة `NO_LOCK`.
- **قفل الصفوف بترتيب ورودها في الطلب**: جمود بين طلبين بنفس المنتجات بترتيب مختلف، فرتب المعرفات.
- **الثقة بالإعدادات الافتراضية للتحقق**: Fastify يحول الأنواع ويحذف الحقول الزائدة بصمت، فقرر ما تريده صراحة واختبره.
- **المبالغ كأرقام عشرية أو أعمدة `numeric` بلا انتباه**: `pg` يرجع `numeric` و`bigint` كنصوص كما في [توثيق الأنواع في node-postgres](https://node-postgres.com/features/types?ref=arabroot.io)، فإذا جمعتها كما هي حصلت على دمج نصوص.
- **`CMD ["npm", "start"]`**: الإشارة لا تصل إلى Node.js بشكل موثوق، والطلبات تقطع عند كل نشر.
- **الاستماع على `127.0.0.1` داخل الـ Container**: الخدمة تعمل ولا يصل إليها أحد.
- **اختبار المعاملات على محاكاة**: الـ Mock لا يعرف شيئاً عن `FOR UPDATE`، واختبار التوازي يحتاج PostgreSQL حقيقياً.

## قائمة تحقق Checklist قبل نشر الخدمة

| البند                                                           | في هذه الخدمة                               |
| --------------------------------------------------------------- | ------------------------------------------- |
| المبالغ أعداد صحيحة بالهللات، والضريبة في دالة واحدة لها اختبار | money.ts مع 3 اختبارات                      |
| كل كتابة متعلقة بالمال أو المخزون داخل معاملة باتصال واحد       | pool.connect() مع BEGIN وCOMMIT وROLLBACK   |
| قفل الصفوف بترتيب ثابت، والطرح داخل قاعدة البيانات              | ORDER BY id FOR UPDATE وstock = stock - $2  |
| قيود في قاعدة البيانات كخط دفاع أخير                            | CHECK على السعر والمخزون والكمية            |
| مخطط تحقق صارم بلا تحويل وبلا حذف صامت                          | coerceTypes: false وremoveAdditional: false |
| معالج أخطاء واحد يحول الأخطاء إلى رموز HTTP                     | 409 و400 و404 و500                          |
| اختبار تكامل على PostgreSQL حقيقي مع اختبار توازي               | عشرون طلباً، عشرة تنجح                      |
| إغلاق آمن عند SIGTERM                                           | app.close() ثم pool.end()                   |
| Image متعدد المراحل بمستخدم غير root                            | 371 MB، منها 16.5 أضفناها                   |

## تدريب عملي

- غير المعاملة لتستخدم التحديث المشروط `UPDATE ... WHERE stock >= $2 RETURNING stock` بدلاً من `FOR UPDATE`، وشغل اختبار التوازي، وفكر كيف تتعامل مع طلب فيه منتجان نجح الأول وفشل الثاني.
- أضف إلى الاختبارات طلبين متوازيين، الأول بالمنتجين 1 ثم 3، والثاني 3 ثم 1، مئة مرة، واحذف `sort` من الكود، وانظر هل يظهر خطأ الجمود `40P01` من PostgreSQL.
- أضف رأس `Idempotency-Key` إلى `POST /orders` وجدولاً يحفظه، حتى لا يتكرر الطلب إذا أعاد العميل الإرسال بعد انقطاع، كما شرحنا في مقال [Idempotency](https://arabroot.io/articles/idempotency-%D9%83%D9%8A%D9%81-%D8%AA%D8%AC%D8%B9%D9%84-%D8%A7%D9%84%D8%B9%D9%85%D9%84%D9%8A%D8%A9-%D8%A7%D9%85%D9%86%D8%A9-%D8%B9%D9%86%D8%AF-%D8%AA%D9%83%D8%B1%D8%A7%D8%B1%D9%87%D8%A7/).
- شغل wrk على `POST /orders` بمنتج مخزونه مليون، وقارن الإنتاجية بـ `GET /products`، ولاحظ أثر قفل الصف الواحد على طلبات نفس المنتج.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- خدمة الطلبات بثلاث نقاط مبنية بـ Fastify 5.12.7 و`pg` 8.23.1 وTypeScript 7 على Node.js 26، بحزمتين مباشرتين للإنتاج و61 حزمة في الشجرة.
- المبالغ بالهللات، والضريبة على مجموع الطلب بتقريب النصف للأعلى بعملية صحيحة: 2970 ثم 446 ثم 3416، والفرق بين الضريبة على البند وعلى المجموع هللة كاملة في مثال بسيط.
- معاملة واحدة باتصال واحد، وقفل الصفوف بترتيب ثابت، والطرح داخل قاعدة البيانات: عشرون طلباً متوازياً على مخزون عشرة أعطت عشرة 201 وعشرة 409 ومخزوناً صفراً، وبدون القفل أعطت عشرين ناجحاً ومخزوناً يقول 8.
- الإعدادات الافتراضية في Fastify تحول الأنواع وتحذف الحقول الزائدة، وإطفاء التحويل كسر معاملات المسار حتى أصلحناه بمخطط نصي، واختبار التكامل هو الذي كشف ذلك.
- مجموعة الاتصالات بحجم 2 أعطت 740 طلباً في الثانية، و10 أعطت 935، و25 لم تزد شيئاً وضاعفت أعلى زمن إلى 1.33 ثانية، لأن الخدمة المحدودة بنواتين هي الحد وليس عدد الاتصالات، فالرقم الصحيح للمجموعة يأتي من القياس على جهازك وليس من التخمين.
- طريقة القياس نفسها تحتاج فحصاً: زمن البدء من خارج Docker Desktop على Windows أعطى 62 ثانية لا علاقة لها بالخدمة، ومن داخل شبكة Docker أعطى ثلاث ثوان تقريباً.
- الـ Image 371 ميجابايت منها 16.5 للخدمة، والذاكرة 34 ميجابايت خاملة و46.5 بعد الضغط، وwrk حوالي 1500 طلب في الثانية في التشغيلين، والبدء ثلاث ثوان تقريباً على جهاز مشغول.

ونفس الخدمة بنفس الاختبارات والقياس تجدها في بقية لغات المسار، والمقال التالي في مرحلة [JavaScript وTypeScript](https://arabroot.io/start/#stage-lang-js) يجمع الأخطاء التي يقع فيها القادمون إلى اللغة وخطة القراءة من المصادر الرسمية:

[![](https://arabroot.io/content/images/2026/10/js-concurrency-cover-cover.webp)دليلالتزامن في JavaScript: الوعود Promises والعمال Workersمئة طلب HTTP نزلت من 5.6 ثانية إلى 284 ميلي ثانية بالوعود وحدها، وحساب ثقيل لم يتحرك إلا بالعمال: نموذج التزامن في Node.js 26 بقياسات حقيقية، مع سباق وجمود حقيقيين وإصلاحهما.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%81%D9%8A-javascript-%D8%A7%D9%84%D9%88%D8%B9%D9%88%D8%AF-promises-%D9%88%D8%A7%D9%84%D8%B9%D9%85%D8%A7%D9%84-workers/)[![](https://arabroot.io/content/images/2026/10/js-types-memory-cover-cover.webp)دليلالأنواع Types والذاكرة والأخطاء في JavaScriptلماذا ظهر في الفاتورة 29.700000000000003، ولماذا انهارت الخدمة برسالة heap out of memory، وأين يهرب الخطأ من try/catch: قيم JavaScript وذاكرتها وأخطاؤها بقياسات وتشغيلات حقيقية على Node.js 26.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%88%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%88%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D9%81%D9%8A-javascript/)[![](https://arabroot.io/content/images/2026/10/js-for-devs-cover-cover.webp)دليلJavaScript لمن يعرف لغة أخرى: Node.js مع TypeScript والأدواتإذا كنت تبرمج بلغة أخرى وانتقلت إلى فريق يعمل بـ Node.js وTypeScript، فهذا يومك الأول: الإصدار الصحيح، والصيغة مقارنة بما تعرفه، وأدوات العمل وهيكل المشروع بأوامر ومخرجات حقيقية.](https://arabroot.io/articles/javascript-%D9%84%D9%85%D9%86-%D9%8A%D8%B9%D8%B1%D9%81-%D9%84%D8%BA%D8%A9-%D8%A7%D8%AE%D8%B1%D9%89-node-js-%D9%85%D8%B9-typescript/)[![](https://arabroot.io/content/images/2026/10/postgres-for-everything-cover-cover.webp)دليلPostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.](https://arabroot.io/articles/postgresql-%D9%84%D9%83%D9%84-%D8%B4%D9%8A%D8%A1-%D9%85%D8%AA%D9%89-%D8%AA%D9%83%D9%81%D9%8A%D9%83-%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%88%D8%A7%D8%AD%D8%AF%D8%A9/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وبناء الخدمة واختبارها وقياسها على Node.js v26.13.0 وFastify 5.12.7 وpg 8.23.1 وTypeScript 7.0.2 وPostgreSQL 17.11.