قرار: كيف يصل المستودع إلى قاعدة البيانات
:::info سجلّ مُنعكَس — الإصدار 1.0 · المصدر الأصلي: rarit-saas-kernel
هذه الصفحة إعادة إنتاج أمينة لملف docs/Database-Guide/db-access-pattern-decision.md من مستودع rarit-saas-kernel، مُنعكَسة في هذه المجموعة وفق الإجراء ACT-004. هذه لقطة بتاريخ الإصدار المُشار إليه أدناه، لا مزامنة حية — إذا عُدِّلت نسخة rarit-saas-kernel، تحتاج هذه الصفحة تحديثاً يدوياً ورقم إصدار جديداً، تماماً كما يُعامَل أي مستند معتمَد آخر في signed-records/. تستشهد السياسة، القسم 5 والقسم 6 والملحق A.10 بهذا القرار مباشرة.
يُشير الأصل أيضاً إلى وثيقتين شقيقتين من نفس مجموعة docs/Database-Guide/ (architecture-database-ownership-and-migrations.md وWORK_PLAN.md) غير مُنعكَستين هنا — تُركت تلك الإشارات كنصّ عادي أدناه، تماماً كما كُتبت في المصدر.
:::
الحالة: مُعتمَد بتاريخ 2026-08-26. مُدمَج في feat/db-integration. القصة: DBI-00 (SASK-178)
ينطبق على: المحركات الخمسة كافة، وكل قطاع يستهلكها.
لماذا نحتاج هذا
كل *.repository.ts يحفظ البيانات في Map اليوم، باستثناء iam/src/repositories/lockout.repository.ts، الذي يُشغّل SQL خاماً مع احتياطي في الذاكرة. نحن على وشك إعادة كتابتها كلها.
سيقوم خمسة أشخاص بهذا العمل، أربعة محركات في آنٍ واحد. دون نمط متّفق عليه واحد، نحصل على أربع إجابات مختلفة لنفس السؤال، ونكتشف ذلك وقت الدمج. حدث هذا فعلاً مرة: احتاج PR #61 لاختراع مقبض قاعدة بيانات خاص به لأنه لم يكن هناك ما يُنسَخ.
هذه الوثيقة هي الشيء الذي يُنسَخ.
القرار
يحصل المستودع على مقبضه من getDb()، المُصدَّرة من platform-core. يعيش المقبض داخل نطاق تنفيذ تفتحه الخدمة، باستخدام AsyncLocalStorage الخاصة بـNode.
ثلاثة أجزاء، لا شيء غيرها.
1. مكان واحد يفتح الاتصال. يستدعي تطبيق القطاع initDatabase(connectionString) مرة واحدة عند الإقلاع. لا يقرأ أي محرك DATABASE_URL.
2. الخدمة تفتح نطاقاً. تدخل حالة استخدام واحدة داخل withTenantScope(tenantId, work). تأخذ تلك عميلاً واحداً من التجمّع، وتُشغّل BEGIN، وتضبط statement_timeout وapp.tenant_id لتلك المعاملة فقط، وتُشغّل العمل، ثم COMMIT أو ROLLBACK. تعيش الخدمات في src/v1/services/.
3. المستودع يطلب المقبض. يستدعي getDb(). لا تأخذ معامل قاعدة بيانات ولا تستورد أي إطار. تعيش المستودعات في src/repositories/.
// service
export async function grantOverride(input: GrantOverrideInput) {
const saved = await withTenantScope(input.tenantId, async () => {
const row = await repo.insertOverride(input);
await logAction({
/* ... */
}); // joins the same transaction
return row;
});
invalidateEntitlementCache(input.tenantId, input.entitlementKey); // after commit
return saved;
}
// repository: same signature, only the body changes
export async function getActiveSubscription(tenantId: string) {
const [row] = await getDb()
.select({ id: tenantSubscriptions.id, planId: tenantSubscriptions.planId })
.from(tenantSubscriptions)
.where(
and(
eq(tenantSubscriptions.tenantId, tenantId),
eq(tenantSubscriptions.status, "active"),
),
)
.limit(1);
return row ?? null;
}
خاصيتان تهمّان:
- نطاق داخل نطاق ينضم. نفس المستأجر، نفس المعاملة. مستأجر مختلف داخل نطاق مفتوح يرمي استثناءً. هكذا يكتب محرك Audit داخل معاملة المستدعي دون أن يعبر أي كائن معاملة حدود الحزمة.
getDb()ترمي استثناءً عند عدم فتح نطاق. لا احتياطي، لا نطاق كسول. نطاق منسي يفشل في اختبار، لا في الإنتاج.
مصنع الوحدة يأخذ آلية وصول، لا مقبضاً
const entitlements = createEntitlementsModule({ getDb, logger, config });
طبقتان، لا خياران. يُجيب المصنع عن سؤال وقت الإقلاع: أي قاعدة بيانات تستخدمها هذه الوحدة. يُجيب سياق التنفيذ عن سؤال وقت الطلب: أي معاملة مفتوحة الآن. getDb() هي الآلية التي يستخدمها المستودع للوصول للثاني. لا تحلّ محل حقن المصنع.
نُمرّر getDb بدلاً من db لكي يكون هناك مصدر واحد للمقبض. تجمّع يُمرَّر عند الإقلاع لا يعرف المستأجر وليس معاملة، فلا يستطيع حمل app.tenant_id ولن يعمل RLS أبداً.
تبقى تواقيع طرق المستودع نفسها بأي حال، فانتقال لاحق إلى createRepository(db) سيُغيّر المُنشئ فقط.
راجع القسم 6 من architecture-database-ownership-and-migrations.md للتصفيح الكامل.
الخيارات التي رفضناها
fastify.decorate("db", drizzle(pool)). هذا نمط Fastify الشائع ويطبّقه محرك واحد بالفعل. ثلاث مشاكل: سيحتاج المستودع نسخة Fastify للوصول للمقبض، وهذا يمنعه CLAUDE.md ("<engine>.service.ts لا تستورد Fastify أبداً")؛ ومقبض مُزوَّد تجمّع لا معاملة، فلا يمكن كتابة كتابة وسطر تدقيقها معاً؛ ولا يحمل app.tenant_id، فلا يفعل RLS شيئاً.
تمرير معاملة كمعامل أول صريح، getPlan(tx, id). أوضح، وهو ما تُظهره تعليمات العمل اليوم. رُفض لسببين. يُغيّر توقيع كل دالة مستودع وكل موضع استدعاء في خمس حزم. ويجب أن تنضم كتابة محرك Audit لمعاملة المستدعي، ما يعني تمرير نوع معاملة Drizzle عبر حدود الحزم. سيصبح نوع Drizzle واجهة برمجة منشورة للمحركات الخمسة كافة، وستصبح ترقية Drizzle تغييراً كاسراً في كل مكان دفعة واحدة.
إبقاء withTenant() كما هي. تفتح معاملتها الخاصة في كل استدعاء، فتحطّ كتابة وسطر تدقيقها في معاملتين. هذا هو الشيء الذي نحاول إصلاحه.
ما نقبله
AsyncLocalStorage حالة ضمنية. هذه هي التكلفة الحقيقية لهذا الاختيار ونتحملها عن معرفة. المُخفِّف أنها تفشل بصوت عالٍ: getDb() ترمي استثناءً عند عدم فتح نطاق، فيظهر نطاق منسي في أول اختبار بدلاً من إرجاع قيمة خاطئة في الإنتاج.
تُرجع getDb() أيضاً مقبضاً لقاعدة البيانات بأكملها، لا لجداول محرك واحد فقط. لا شيء تقنياً يمنع المحرك أ من قراءة جداول المحرك ب. هذا ليس جديداً: pool الخام المُصدَّر من platform-core اليوم يفعل الشيء نفسه ويتجاوز RLS أيضاً. تبقى الحدود قاعدة مراجعة حالياً. قاعدة lint تُقيّد كل مستودع بملفات *.tables.ts الخاصة به متابعة معقولة.
إضافة الإشعارات
packages/notifications/src/db/drizzle.db.plugin.ts:16 تُزوِّد نسخة Drizzle كـdb.
أُزيلت. لا يمرّ أي استعلام عبرها اليوم، فلا شيء يعتمد عليها والتغيير بلا تكلفة. مُغطّاة بـDBI-12.
يوجد سبب ثانٍ. ينصّ CLAUDE.md على أن تعريفات الجداول تعيش في src/<engine>.tables.ts، مباشرة تحت src/، بلا مجلد db/ مُغلِّف، ويُسمّي مجلد db/ الخاص بهذا المحرك كالمُراد تسطيحه. إزالة الإضافة وتسطيح المجلد نفس العمل.
اعتماد المالك: Ziad Ashraf. مُعطى.
قواعد تأتي مع هذا القرار
- لا تتغيّر تواقيع دوال المستودع. نفس الاسم، نفس المعاملات. الجسم فقط يتغيّر. إذا اعتقدت أن التوقيع يجب أن يتغيّر، توقف وأثِر الأمر.
- أبقِ التطبيق في الذاكرة. صدِّره كـ
createInMemory<Engine>Repository(). اختبارات الوحدة والتطبيقات المثالية تعمل بدون Postgres. - لا حزمة تقرأ
DATABASE_URL. تطبيق القطاع فقط يفعل. - الخدمة تفتح النطاق. المستودع لا يفعل أبداً.
platform-coreتُجمَّد بمجرد دمجdb-scope.tsفيfeat/db-integration. أي تغيير بعد ذلك يُعلَن ويُعيد الجميع القاعدة في نفس اليوم.
الملكية
يملك كل فريق محرك إعادة كتابته الخاصة. لا أحد يفتح ملفاً داخل حزمة محرك آخر.
قصة واحدة، مالك واحد، فرع واحد. لا يتشارك شخصان فرعاً أبداً.
| العمل | الحزمة | المالك |
|---|---|---|
| DBI-01 نطاق التنفيذ | platform-core | Muhammad Al-Shurman |
| DBI-03 fastify والطلب | platform-core | Ziad Ashraf |
| DBI-05 اتفاقية RLS | platform-core | Fatima |
| DBI-06 التطبيق المرجعي | audit | Fatima |
| DBI-07، DBI-08، DBI-09 | entitlements | Muhammad Al-Shurman |
| DBI-10، DBI-11 | iam | Amal Mousa |
| DBI-12 | notifications | Ziad Ashraf |
| DBI-13 | payment | Mahmoud Magdy |
| DBI-15 سجل الترحيل | platform-core | Muhammad Al-Shurman |
المالكون أعلاه مُقترَحون، لا مؤكَّدون. كل شخص يؤكّد ملكيته الخاصة.
ثلاث قصص في المرحلة 1 تمسّ platform-core، ويملكها ثلاثة أشخاص مختلفون. index.ts وحده مشترك، فتُدمَج DBI-01 أولاً ويُعيد الآخران القاعدة عليها. التفصيل الكامل: docs/Database-Guide/WORK_PLAN.md.
الترتيب
platform-core أولاً، لأن كل محرك يستورد منها. ثم audit، لأن كتابة زائد سطر تدقيقها في معاملة واحدة هي الخاصية التي وُجد هذا النمط لأجلها، وaudit أصغر مكان لإثباتها. ثم الأربعة المتبقية بالتوازي.
بمجرد دمج audit، تصبح هي المرجع. افتح ذلك الملف قبل السؤال على المجموعة.
العملية الكاملة، والملكية، وتعريف الإنجاز: docs/Database-Guide/DB_INTEGRATION.md.