السلسلة: هذا الدرس جزء من سلسلة NestJS 11. اقرأ المقال الرئيسي.
Prisma 7 غيّر المعادلة لمشاريع Node.js التي تحتاج ORM typé ومُنتجاً. إعادة كتابة المحرّك بـ TypeScript صرفاً، المنشورة في 7.0 نوفمبر 2025 والمستقرّة عبر patches متعاقبة حتى 7.7.0 أبريل 2026، تلغي تبعية Rust الثنائية التي كانت تطرح مشاكل على المضيفين المصغّرين وتُضخّم node_modules. هذا الدرس يبني طبقة persistance كاملة لـ API NestJS 11: schema إعلاني، migrations مُصدَّرة، service قابل للحقن، transactions، soft-delete، وseed قابل للتكرار.
المتطلبات
- تطبيق NestJS 11 شغّال
- PostgreSQL 16 أو 17 (محلي عبر Docker أو مُدار)
- Node.js 22 LTS وpnpm 9
- معرفة أساسية بـ SQL وmigrations
- 75 دقيقة
الخطوة 1 — إطلاق PostgreSQL محلياً مع Docker Compose
# docker-compose.dev.yml
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: acme
POSTGRES_PASSWORD: acme_dev
POSTGRES_DB: acme
ports: ["5432:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:
أطلق القاعدة بـ docker compose -f docker-compose.dev.yml up -d. اختبار اتصال: psql postgresql://acme:acme_dev@localhost:5432/acme -c '\l'.
الخطوة 2 — تثبيت Prisma وتهيئة المشروع
cd apps/api
pnpm add @prisma/client@7.7
pnpm add -D prisma@7.7
npx prisma init --datasource-provider postgresql
الأمر prisma init ينشئ prisma/schema.prisma هيكلاً أدنى وملف .env. اضبط فوراً URL: postgresql://acme:acme_dev@localhost:5432/acme?schema=public.
الخطوة 3 — تعريف schema إعلاني
generator client { provider = "prisma-client-js" }
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
role Role @default(MEMBER)
orders Order[]
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Order {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
amount Decimal @db.Decimal(12, 2)
currency String @db.Char(3)
createdAt DateTime @default(now())
@@index([userId])
}
enum Role { OWNER ADMIN MEMBER }
ثلاث اختيارات: cuid() يُولّد معرّفات قصيرة قابلة للفرز؛ لمشروع جديد 2026 يُوصى بـ cuid(2) (الأولى مدَّمَة لأسباب أمنية). الحقل deletedAt يُفعّل soft-delete. التوجيه @db.Decimal(12, 2) يفرض numeric(12,2) دقيقاً للمبالغ — أبداً float.
الخطوة 4 — إنشاء أول migration
npx prisma migrate dev --name init
الأمر يُولّد prisma/migrations/<timestamp>_init/migration.sql ويُعيد توليد client TypeScript. في الإنتاج، الـ pipeline ينشر migrations بـ npx prisma migrate deploy قبل إقلاع التطبيق — ترتيب حرج.
الخطوة 5 — كشف PrismaService قابل للحقن
// apps/api/src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}
الخدمة تُسجَّل في PrismaModule موسوماً @Global() لتجنّب استيرادها في كل module أعمال. هذه الممارسة تُبرَّر لأن persistance عرضية.
الخطوة 6 — تطبيق soft-delete عبر extension
Prisma 7 يكشف extensions للمنطق العرضي، التي تستبدل middleware القديم ($use) المُهجَر في 4.16، المُزال في 6.14، والغائب تماماً في Prisma 7.
// apps/api/src/prisma/soft-delete.extension.ts
import { Prisma } from '@prisma/client';
export const softDelete = Prisma.defineExtension({
query: {
user: {
async delete({ args, query }) {
return query({ ...args, data: { deletedAt: new Date() } } as any);
},
async findMany({ args, query }) {
args.where = { ...args.where, deletedAt: null };
return query(args);
},
},
},
});
الـ extension تنطبق على client عند الإنشاء: new PrismaClient().$extends(softDelete). من هذه اللحظة، userService.delete(id) لا يحذف السطر بل يُعيّن deletedAt.
الخطوة 7 — Transactions وsavepoints
await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: { userId, amount, currency: 'XOF' } });
await tx.user.update({ where: { id: userId }, data: { lastOrderAt: new Date() } });
await this.queue.add('order-confirmation', { orderId: order.id });
});
هذه transaction تنشئ الطلب، تُحدّث المستخدم، وتدفع job تأكيد. إن رفع إنفاذ queue استثناءً، التعديلان على القاعدة يُلغيان بـ rollback آلي. ملاحظة: العملية على queue ليست في transaction PostgreSQL — Redis وPostgres نظامان متمايزان. لتماسك حقيقي، نمط outbox يكتب الرسالة في جدول outbox في نفس transaction، وworker BullMQ يقرأ هذا الجدول لنشر الرسائل.
الخطوة 8 — Seed قابل للتكرار واختبارات تكامل
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import argon2 from 'argon2';
const prisma = new PrismaClient();
async function main() {
await prisma.user.upsert({
where: { email: 'owner@acme.test' },
update: {},
create: {
email: 'owner@acme.test',
password: await argon2.hash('TempPassword123!'),
role: 'OWNER',
},
});
}
main().finally(() => prisma.$disconnect());
النمط الموصى به يستخدم upsert للبقاء idempotent: تنفيذ seed مرتين لا يخلق نسخاً مكرَّرة. tahxnig كلمة السرّ بـ argon2 منذ seed يتجنّب وجود كلمات سرّ صريحة محلياً.
الخطوة 9 — Indices، EXPLAIN وأداء
EXPLAIN ANALYZE
SELECT * FROM "Order" WHERE "userId" = 'cl12345' AND "createdAt" > NOW() - INTERVAL '30 days';
المخرَج يدلّ إن استخدم PostgreSQL Index Scan (إشارة جيدة) أو Seq Scan (للتصحيح على جدول كبير). للطلبات التي تجمع عدة أعمدة، index مركّب @@index([userId, createdAt]) يُسرّع التصفية بشكل جذري. على جدول 10 ملايين سطر، الانتقال من Seq Scan إلى Index Scan يحوّل طلباً من 1.2 ثانية إلى 8 مللي ثانية.
أخطاء شائعة
| الخطأ | السبب | الحل |
|---|---|---|
| P1001 Can’t reach database | URL خاطئ أو Postgres غير مُطلَق | تحقّق docker compose ps وDATABASE_URL |
| P3009 migrations failed | Migration جزئي محجوب | prisma migrate resolve --rolled-back |
| نوع Prisma غير محدَّث | generate غير مُعاد بعد تعديل schema |
npx prisma generate |
| اتصالات مشبَعة في prod | Pool client لكل طلب | Singleton PrismaService، pool size في URL |
| Soft-delete متجاوَز | findFirst بلا extension |
مدّد كل الـ models، ليس user فقط |
أسئلة شائعة
هل نُفضّل Drizzle على Prisma؟ الاختيار يعتمد على التحكم في SQL المُولَّد. Drizzle يكشف query builder typé قريباً من SQL أصلي. Prisma يبقى أكثر إنتاجية لـ models كلاسيكية.
كيف نُدير عدة schemas PostgreSQL؟ المعامل ?schema=public,billing في DATABASE_URL يُفعّل multi-schemas. كل model يستطيع تحديد @@schema("billing").
أي حجم pool؟ القاعدة عدد cores × 2 + 1 لحمل كلاسيكي. على VPS 2 vCPU مع نسخة NestJS، استهدف connection_limit=5.
ماذا نفعل بـ migrations طويلة جداً؟ أي migration قد يحجب جدولاً حرجاً أكثر من 100 مللي ثانية يجب المرور بنمط expand and contract: أضف العمود nullable، انشر الكود الذي يكتب في الاثنين، backfill في الخلفية، انشر الكود الذي يقرأ من الجديد فقط، احذف القديم في migration منفصل.