الملحق A: أمثلة تنفيذية مرجعية
أنماط Fastify وNestJS عبر القطاعات الحالية، ومنع التبعيات الدائرية
مُرفَق بـ: سياسة معمارية منصّة RaR-IT لأنظمة SaaS، الأقسام 4–6-1
:::note الغرض يُوضّح هذا الملحق، بأسماء مكوّنات حقيقية مُستمَدّة من الأعمال المتراكمة الحالية لعَمَار وClinivio وEduSuite والنقل والسياحة، كيف يبدو النمطان المعتمدان فعلياً في الشيفرة — نمط Fastify الافتراضي (القسم 5) والسماح المحدود النطاق لـNestJS (القسم 6-1) — بالإضافة إلى قاعدة التبعيات غير الدائرية (القسم 4): كيف يجب إعادة هيكلة علاقة حقيقية ثنائية الاتجاه بين محركين، أو بين قطاعين، بحيث لا تستورد أي حزمة حزمة أخرى تستوردها (مباشرة أو بشكل غير مباشر) في المقابل.
كما يُظهر، عن قصد، أربعة أنماط غير مسموح بها دون اعتماد لجنة مراجعة المعمارية أو إعادة هيكلة: امتلاك Nest لخط أنابيب HTTP الخاص به (A.6)، استيراد عكسي مباشر بين محركين (A.7)، استيراد مباشر بين قطاعين (A.8)، ووصول مستودع لقاعدة البيانات عبر fastify.decorate('db', ...) بدلاً من النمط محدود النطاق حسب المستأجر (A.10) — لكي تكون المخاطر ملموسة لا نظرية.
تشترك جميع الأمثلة في خاصية واحدة غير قابلة للتفاوض: تقرأ آلية اكتشاف Entitlements نفس عقد البيان المستقل عن الإطار بغضّ النظر عن النمط الذي تستخدمه داخلية أي وحدة.
القسم A.11 مختلف في طبيعته عن بقية الملحق: يُقيّم إعداد Row-Level Security لنظام قديم غير مرتبط بـRaR-IT، بحثاً عن سوابق مفيدة، ويفصل الأنماط الجديرة بالتبنّي عن الأنماط التي ستكون تراجعاً لو نُسِخت. :::
A.1 نمط Fastify — وحدة CRUD بسيطة
عَمَار: العقارات
:::info تحديث بتاريخ 2026-08-27
كان هذا المثال يُظهر سابقاً استدعاء fastify.db.query.properties.findMany(...) مباشرة داخل معالج المسار. يتعارض ذلك مع db-access-pattern-decision.md (مُعتمَد بتاريخ 2026-08-26)، وقد نُبِّه له أثناء المراجعة قبل اعتماد السياسة. صُحِّح أدناه إلى النمط الثلاثي الطبقات الذي يحدّده القرار المعماري: مستودع البيانات (Repository) (يستدعي getDb()، بلا استيراد لأي إطار) ← الخدمة (Service) (تفتح withTenantScope()، وتستدعي المستودع وAudit داخلها) ← إضافة Fastify (تملك المسار والبيان وخطّاف التحقق من الاستحقاقات — دون تغيير عمّا سبق).
:::
الحالة الافتراضية والأكثر شيوعاً لوحدة قطاع — لا حاجة لـNestJS لأنه لا يوجد ما يُحقَن، لكن الوصول لقاعدة البيانات لا يزال يمرّ عبر الطبقات الثلاث أدناه، لا مباشرة من معالج المسار.
import { FeatureManifest } from '@rarit-kernel/entitlements';
export const manifest: FeatureManifest = {
key: 'properties',
displayName: { en: 'Properties', ar: 'العقارات' },
requiredPlan: ['starter', 'growth', 'enterprise'],
routes: ['/properties/*'],
};
import { getDb } from '@rarit-kernel/platform-core';
import { eq } from 'drizzle-orm';
import { properties } from '../../../db/schema';
import type { NewProperty } from '../../../db/schema';
// getDb() لا تأخذ أي معامل ولا تستورد أي إطار -- إنما تُحلّ إلى أي معاملة
// (transaction) فتحتها الخدمة المستدعية عبر withTenantScope(). عند استدعائها
// خارج نطاق مفتوح، ترمي استثناءً بدلاً من إرجاع مقبض خاطئ بصمت.
export async function listProperties(tenantId: string) {
// يبقى فلتر tenantId الصريح رغم أن RLS يفرضه أيضاً -- هذا هو دفاع
// "القفلين" الذي يصفه القرار المعماري، لا بقية من مرحلة سابقة على RLS.
return getDb()
.select()
.from(properties)
.where(eq(properties.tenantId, tenantId));
}
export async function insertProperty(input: NewProperty) {
const [row] = await getDb().insert(properties).values(input).returning();
return row;
}
import { withTenantScope } from '@rarit-kernel/platform-core';
import { logAction } from '@rarit-kernel/audit';
import * as propertiesRepo from '../repositories/properties.repository';
export async function listPropertiesForTenant(tenantId: string) {
return withTenantScope(tenantId, () => propertiesRepo.listProperties(tenantId));
}
export async function createProperty(input: CreatePropertyInput) {
return withTenantScope(input.tenantId, async () => {
const property = await propertiesRepo.insertProperty(input);
// تستدعي logAction() الدالة getDb() داخلياً وتنضم لنفس المعاملة.
// لا يعبر أي كائن معاملة حدود الحزمة بين properties وaudit.
// إذا رمت هذه الكتابة استثناءً، يتراجع الإدخال أعلاه أيضاً.
await logAction({
tenantId: input.tenantId,
actorId: input.createdBy,
action: 'property.created',
entityId: property.id,
after: property,
});
return property;
});
}
import fp from 'fastify-plugin';
import { z } from 'zod';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { listPropertiesForTenant, createProperty } from './services/properties.service';
const CreatePropertySchema = z.object({
title: z.string().min(3),
type: z.enum(['apartment', 'villa', 'office', 'land']),
price: z.number().positive(),
city: z.string(),
bedrooms: z.number().int().optional(),
});
export default fp(async (fastify) => {
fastify.addHook('preHandler', requireEntitlement('properties'));
fastify.get('/properties', async (req) => {
return listPropertiesForTenant(req.tenant.tenantId);
});
fastify.post('/properties', { schema: { body: CreatePropertySchema } },
async (req, reply) => {
const property = await createProperty({
...req.body,
tenantId: req.tenant.tenantId,
createdBy: req.tenant.userId,
});
return reply.code(201).send(property);
});
}, { name: 'properties' });
A.2 نمط Fastify — مستهلك رقيق لمحرك مشترك
النقل: العمولات والتسوية
وحدة مهمتها بالكامل القراءة من والكتابة إلى محرك مشترك ذي عقد ثابت. لا يوجد منطق أعمال خاص بالقطاع معقّد بما يكفي لتبرير حاوية حقن تبعيات.
لاحظ ما لا يُظهره هذا المثال عمداً: أين تُضبَط نسبة العمولة نفسها. وفق القسم 4 ("نماذج تحصيل الإيرادات الخاصة بقطاع معيّن هي إعداد فوق Payment وEntitlements، لا محركات جديدة")، تلك النسبة بيانات إعداد خاصة بالمستأجر/الخطة تعيش داخل إعدادات Payment أو Entitlements القائمة على البيانات الوصفية — لا رقماً يحسبه النقل أو يخزّنه بنفسه. تقرأ recordSettlement() أدناه أياً كانت النسبة التي حلّها Payment بالفعل؛ لا يرى النقل منطق النسبة أبداً ولا يُكرّره. إذا احتاج مثال مستقبلي إظهار ضبط ذلك الإعداد (لا استهلاك نتيجته فقط، وهو كل ما تفعله هذه الوحدة)، فمكانه توثيق Payment أو Entitlements نفسه، لا هنا — لا يمرّ أي مسار شيفرة في هذه الوحدة عليه.
export const manifest: FeatureManifest = {
key: 'commission-settlement',
displayName: { en: 'Commission & Settlement', ar: 'العمولات والتسوية' },
requiredPlan: ['growth', 'enterprise'],
routes: ['/commission/*'],
};
import fp from 'fastify-plugin';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { getLedgerForTenant, recordSettlement } from '@rarit-kernel/payment';
export default fp(async (fastify) => {
fastify.addHook('preHandler', requireEntitlement('commission-settlement'));
// Ledger entries (due / pending / approved / rejected) come straight
// from the Payment engine's shared ledger primitive -- Transport does
// not maintain its own copy of settlement state.
fastify.get('/commission/ledger/:packetShopId', async (req) => {
return getLedgerForTenant(req.tenant.tenantId, {
scope: 'packet_shop',
scopeId: req.params.packetShopId,
});
});
fastify.post('/commission/settle/:entryId', async (req, reply) => {
const settled = await recordSettlement(req.tenant.tenantId, req.params.entryId, {
approvedBy: req.tenant.userId,
});
return reply.send(settled);
});
}, { name: 'commission-settlement' });
A.3 نمط Fastify — وحدة CRUD بسيطة (قطاع ثانٍ)
السياحة: إدارة علاقات العملاء
مثال CRUD ثانٍ من قطاع مختلف، لتأكيد أن تقسيم مستودع/خدمة/مسار في A.1 هو المعيار، لا اتفاقية خاصة بعَمَار — وتحديداً أنه يصمد حتى في حالة قراءة-ثم-تحديث كحالة مستوى الولاء أدناه، وهي بالضبط النوع من الشيفرة التي تُغري باستدعاء fastify.db مباشرة.
import { getDb } from '@rarit-kernel/platform-core';
import { eq, and } from 'drizzle-orm';
import { customers } from '../../../db/schema';
export async function getCustomerWithBookings(tenantId: string, customerId: string) {
return getDb().query.customers.findFirst({
where: (c, { eq, and }) => and(eq(c.id, customerId), eq(c.tenantId, tenantId)),
with: { bookings: true },
});
}
export async function getCustomer(tenantId: string, customerId: string) {
return getDb().query.customers.findFirst({
where: (c, { eq, and }) => and(eq(c.id, customerId), eq(c.tenantId, tenantId)),
});
}
export async function setLoyaltyTier(tenantId: string, customerId: string, tier: string) {
await getDb()
.update(customers)
.set({ tier })
.where(and(eq(customers.id, customerId), eq(customers.tenantId, tenantId)));
}
import { withTenantScope } from '@rarit-kernel/platform-core';
import * as customersRepo from '../repositories/customers.repository';
export async function getCustomerProfile(tenantId: string, customerId: string) {
return withTenantScope(tenantId, () => customersRepo.getCustomerWithBookings(tenantId, customerId));
}
export async function updateLoyaltyTier(tenantId: string, customerId: string) {
// تنضم القراءة والكتابة لنفس المعاملة -- لا خطر أن يُحسَب المستوى من
// قيمة تتغيّر بين القراءة والكتابة.
return withTenantScope(tenantId, async () => {
const customer = await customersRepo.getCustomer(tenantId, customerId);
const tier = customer.lifetimeSpend >= 10000 ? 'preferred' : 'standard';
await customersRepo.setLoyaltyTier(tenantId, customerId, tier);
return tier;
});
}
import fp from 'fastify-plugin';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { getCustomerProfile, updateLoyaltyTier } from './services/customers.service';
export default fp(async (fastify) => {
fastify.addHook('preHandler', requireEntitlement('crm'));
fastify.get('/customers/:id', async (req) => {
return getCustomerProfile(req.tenant.tenantId, req.params.id);
});
fastify.patch('/customers/:id/loyalty-tier', async (req, reply) => {
const tier = await updateLoyaltyTier(req.tenant.tenantId, req.params.id);
return reply.send({ tier });
});
}, { name: 'crm' });
A.4 نمط NestJS — مزوّدات مشتركة عبر قوالب متعددة
Clinivio: وحدة القوالب السريرية (السماح المحدود النطاق في القسم 6-1)
هذه هي الحالة التي كُتب لأجلها القسم 6-1: عشرة قوالب سريرية تخصصية، تتشارك عدّة منها لبنات بناء مشتركة — أداة رسم تخطيطي لجسم/تشريح، مُدقِّق قيم مُهيكَلة، بحث في سجل القوالب. يبقى مسار HTTP والبيان وخطّاف التحقق من الاستحقاقات بيد Fastify؛ وNest غير مرئي إطلاقاً من منظور Entitlements.
لا توجد آلية اكتشاف لوحدات Nest، ولا حاجة لها — هذا السؤال ينطبق فقط على طبقة إضافات Fastify، حيث تُصدِّر كل وحدة ملف manifest.ts يفحصه Entitlements عند الإقلاع (القسم 5). أما وحدة Nest مثل ClinicalTemplatesModule أدناه فلا تُفحَص ولا تُسجَّل تلقائياً في أي مكان أبداً؛ إنها صنف TypeScript عادي، يُستورَد صراحةً بالاسم داخل ملف index.ts الخاص بإضافة Fastify الوحيدة التي تستخدمه (ظاهر في كتلة الشيفرة الأخيرة أدناه: import { ClinicalTemplatesModule, ... } from './nest/clinical-templates.module'). لو احتاجتها وحدة ثانية أيضاً، لاستوردتها بنفس الطريقة الصريحة — لا يوجد سجل مركزي لـNest تُسجَّل فيه، بتصميم مقصود، إذ يحصر القسم 6-1 استخدام Nest بسياق حقن تبعيات واحد لكل وحدة، لا تطبيق Nest مشترك يمتد عبر القطاع بأكمله.
:::caution ملاحظة على النطاق هذه الوحدة حالياً ضمن المرحلة 2 من نطاق الإطلاق بالحد الأدنى (مؤجَّلة مع المستشفيات/المراكز/الأسنان). أُدرجت هنا كأوضح مثال حقيقي على هذا النمط، لا كادّعاء بأنها ستُطلَق ضمن الإصدار الأولي. :::
import { Injectable, Module } from '@nestjs/common';
export interface ClinicalTemplateDefinition {
specialtyKey: string;
schema: ZodSchema;
usesBodyDiagram: boolean;
}
@Injectable()
export class TemplateRegistryService {
private templates = new Map<string, ClinicalTemplateDefinition>();
register(template: ClinicalTemplateDefinition) {
this.templates.set(template.specialtyKey, template);
}
get(specialtyKey: string) {
return this.templates.get(specialtyKey);
}
}
@Injectable()
export class BodyDiagramWidgetService {
// Shared by Dermatology (skin map), Orthopedics (pain map),
// Physiotherapy (assessment diagram), and Beauty (treatment-area marking).
renderDiagram(regionMap: BodyRegionMap, markings: Marking[]) {
/* shared rendering + coordinate-validation logic lives here once */
}
}
@Injectable()
export class ClinicalValidationService {
constructor(private readonly registry: TemplateRegistryService) {}
validate(specialtyKey: string, payload: unknown) {
const template = this.registry.get(specialtyKey);
if (!template) throw new UnknownSpecialtyError(specialtyKey);
return template.schema.parse(payload);
}
}
@Module({
providers: [
TemplateRegistryService,
BodyDiagramWidgetService,
ClinicalValidationService,
DermatologyTemplateProvider,
OrthopedicsTemplateProvider,
PhysiotherapyTemplateProvider,
// ... remaining specialty template providers
],
exports: [ClinicalValidationService, BodyDiagramWidgetService],
})
export class ClinicalTemplatesModule {}
import { getDb } from '@rarit-kernel/platform-core';
import { visitNotes } from '../../../db/schema';
import type { NewVisitNote } from '../../../db/schema';
export async function insertVisitNote(input: NewVisitNote) {
const [note] = await getDb().insert(visitNotes).values(input).returning();
return note;
}
import { withTenantScope } from '@rarit-kernel/platform-core';
import * as visitNotesRepo from '../repositories/visit-notes.repository';
import type { ParsedVisitNote } from '../nest/clinical-templates.module';
// تتعامل هذه الخدمة مع الحفظ فقط. التحقق السريري (حقن التبعيات في Nest،
// A.4 أعلاه) والحفظ محدود النطاق حسب المستأجر (platform-core، هنا) اهتمامان
// منفصلان ويبقيان في ملفين منفصلين.
export async function recordVisitNote(tenantId: string, parsed: ParsedVisitNote) {
return withTenantScope(tenantId, () => visitNotesRepo.insertVisitNote({ ...parsed, tenantId }));
}
import fp from 'fastify-plugin';
import { NestFactory } from '@nestjs/core';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { ClinicalTemplatesModule, ClinicalValidationService } from './nest/clinical-templates.module';
import { recordVisitNote } from './services/visit-notes.service';
export const manifest = {
key: 'clinical-templates',
displayName: { en: 'Specialty Clinical Templates', ar: 'القوالب السريرية التخصصية' },
requiredPlan: ['enterprise'],
routes: ['/clinical-templates/*'],
};
export default fp(async (fastify) => {
// Nest used ONLY as a DI application context -- no HTTP adapter,
// no Nest guards/interceptors, no Nest-owned routing. This is the
// required integration pattern under Policy Section 6.1. It is
// unrelated to, and does not replace, the platform-core tenant-scope
// pattern used for persistence below -- Nest handles validation only.
const nestCtx = await NestFactory.createApplicationContext(ClinicalTemplatesModule);
const validation = nestCtx.get(ClinicalValidationService);
fastify.addHook('preHandler', requireEntitlement('clinical-templates'));
fastify.post('/clinical-templates/:specialty/visit-note', async (req, reply) => {
const parsed = validation.validate(req.params.specialty, req.body);
const note = await recordVisitNote(req.tenant.tenantId, parsed);
return reply.code(201).send(note);
});
}, { name: 'clinical-templates' });
A.5 نمط NestJS — كائنات استراتيجية قابلة للتبديل
عَمَار: التوقعات المالية
حالة ثانية حقيقية للسماح المحدود النطاق بـNest: عدّة خوارزميات تنبؤ قابلة للتبديل تُنفّذ نفس الواجهة وتُختار وقت الطلب — نمط الاستراتيجية الكلاسيكي.
import { Inject, Injectable, Module } from '@nestjs/common';
export interface ForecastStrategy {
readonly key: string;
forecast(history: RevenuePoint[], horizonMonths: number): ForecastResult;
}
const FORECAST_STRATEGIES = Symbol('FORECAST_STRATEGIES');
@Injectable()
class LinearTrendStrategy implements ForecastStrategy {
readonly key = 'linear';
forecast(history, horizonMonths) { /* simple linear regression */ }
}
@Injectable()
class SeasonalAdjustedStrategy implements ForecastStrategy {
readonly key = 'seasonal';
forecast(history, horizonMonths) { /* seasonal decomposition */ }
}
@Injectable()
export class ForecastingService {
constructor(@Inject(FORECAST_STRATEGIES) private readonly strategies: ForecastStrategy[]) {}
run(key: string, history: RevenuePoint[], horizonMonths: number) {
const strategy = this.strategies.find((s) => s.key === key) ?? this.strategies[0];
return strategy.forecast(history, horizonMonths);
}
}
@Module({
providers: [
LinearTrendStrategy,
SeasonalAdjustedStrategy,
{
provide: FORECAST_STRATEGIES,
useFactory: (linear: LinearTrendStrategy, seasonal: SeasonalAdjustedStrategy) => [linear, seasonal],
inject: [LinearTrendStrategy, SeasonalAdjustedStrategy],
},
ForecastingService,
],
exports: [ForecastingService],
})
export class FinancialForecastingModule {}
```typescript title="verticals/amaar/modules/financial-forecasting/repositories/revenue-history.repository.ts"
import { getDb } from '@rarit-kernel/platform-core';
import { eq } from 'drizzle-orm';
import { revenueHistory } from '../../../db/schema';
export async function getRevenueHistory(tenantId: string) {
return getDb()
.select()
.from(revenueHistory)
.where(eq(revenueHistory.tenantId, tenantId));
}
import { withTenantScope } from '@rarit-kernel/platform-core';
import * as revenueHistoryRepo from '../repositories/revenue-history.repository';
import type { ForecastingService } from '../nest/financial-forecasting.module';
// هذه خدمة تطبيق خاصة بعَمَار: وصول محدود النطاق حسب المستأجر لجدول
// إيرادات عَمَار نفسه. وهي عمداً ليست جزءاً من حزمة @rarit-kernel/forecasting
// المشتركة في A.8 -- ما يُشارَك هناك هو خوارزمية اختيار الاستراتيجية
// الصرفة فقط (بلا أي وصول لقاعدة البيانات)، لأن جدول إيرادات عَمَار وجدول
// إيرادات السياحة نموذجا بيانات مختلفان. ترقية هذا المستودع أيضاً كانت
// ستُعيد إنشاء نفس نوع اقتران البيانات بين القطاعين الذي يمنعه القسم 4.
export async function forecastRevenue(
tenantId: string,
forecasting: ForecastingService,
strategyKey: string,
horizonMonths: number,
) {
return withTenantScope(tenantId, async () => {
const history = await revenueHistoryRepo.getRevenueHistory(tenantId);
return forecasting.run(strategyKey, history, horizonMonths);
});
}
import fp from 'fastify-plugin';
import { NestFactory } from '@nestjs/core';
import { requireEntitlement } from '@rarit-kernel/entitlements';
import { FinancialForecastingModule, ForecastingService } from './nest/financial-forecasting.module';
import { forecastRevenue } from './services/forecasting.service';
export const manifest = {
key: 'financial-forecasting',
displayName: { en: 'Financial Forecasting', ar: 'التوقعات المالية' },
requiredPlan: ['enterprise'],
routes: ['/forecasting/*'],
};
export default fp(async (fastify) => {
const nestCtx = await NestFactory.createApplicationContext(FinancialForecastingModule);
const forecasting = nestCtx.get(ForecastingService);
fastify.addHook('preHandler', requireEntitlement('financial-forecasting'));
fastify.get('/forecasting/revenue', async (req) => {
const strategyKey = req.query.strategy ?? 'linear';
const horizon = Number(req.query.months ?? 6);
return forecastRevenue(req.tenant.tenantId, forecasting, strategyKey, horizon);
});
}, { name: 'financial-forecasting' });
A.6 نمط مضاد — امتلاك Nest لخط أنابيب HTTP الخاص به
:::danger غير مسموح به دون اعتماد لجنة مراجعة المعمارية يُعرَض هنا فقط لجعل الخطر الموصوف في القسم 6-1 ملموساً — لا تنسخ هذا كقالب انطلاق. :::
المشكلة: بمجرد أن تمتلك Nest نسخة HTTP خاصة بها عبر مُوائم Fastify، لن تمرّ الطلبات الواردة إليها أبداً عبر خطّاف التحقق من الاستحقاقات الخاص بالعملية الأصلية.
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter } from '@nestjs/platform-fastify';
// Nest now owns its own HTTP pipeline and lifecycle.
const nestApp = await NestFactory.create(AiIntelligenceModule, new FastifyAdapter());
await nestApp.init();
// Mounting it under the parent process does NOT route these requests
// through the parent's requireEntitlement() preHandler hook:
fastify.register(async (instance) => {
instance.all('/ai-intelligence/*', (req, reply) =>
nestApp.getHttpAdapter().getInstance().routing(req.raw, reply.raw)
);
});
// -> A tenant without the 'ai-intelligence' entitlement can still
// reach this route. Fixing it requires either a duplicated Nest
// guard re-implementing the same check, or a proxy hop through
// Fastify's own hook first -- both are the added complexity and
// dual-enforcement-path risk Section 6.1 flags before this pattern
// may be used.
إذا اعتمدت لجنة مراجعة المعمارية هذا النمط يوماً لخدمة معيّنة (وفق شرط الاعتماد في القسم 6-1)، فإن تمرير الطلب عبر Fastify (Proxy Hop) هو المعيار المطلوب، لا الحارس المكرَّر. الحارس المكرَّر يعني وجود التحقق من الاستحقاقات مرتين، بلغتين/إطارين مختلفين، يصونهما شخصان مختلفان عبر الزمن — واللحظة التي يُحدَّث فيها أحدهما دون الآخر، يتباين الاثنان بصمت حول من يُسمَح له بالدخول، ولا ينتبه أحد حتى تقع حادثة. يُبقي تمرير الطلب عبر Fastify تطبيقاً واحداً فقط للتحقق (خطّاف preHandler نفسه الذي تستخدمه كل وحدة أخرى في هذا الملحق بالفعل) ويمرّر حركة مرور Nest عبره أولاً. أبطأ لكل طلب، ويستحق القياس، لكن مصدراً واحداً قابلاً للتدقيق للتفويض يستحق أكثر من زمن الاستجابة في استثناء معتمَد، لا أقل.
A.7 تبعية دائرية — المحركات (IAM ↔ Notifications)
يتطلّب القسم 4 من السياسة أن تكون تبعيات المحركات فيما بينها غير دائرية. هذا ليس خطراً افتراضياً: كل تدفّق لإعادة تعيين كلمة المرور قائم على OTP في هذه المنصّة يحتاج IAM لتفعيل إشعار، ويحتاج Notifications لاستيعاب بيانات تواصل المستأجر التي يملكها IAM — علاقة حقيقية ثنائية الاتجاه بين محركين.
خطأ: الاستيراد العكسي المباشر يُنشئ الحلقة
import { sendNotification } from '@rarit-kernel/notifications'; // IAM -> Notifications
export async function requestOtp(userId: string) {
const code = generateOtp();
await storeOtp(userId, code);
const contact = await resolveContact(userId);
await sendNotification(contact, 'otp', { code });
}
import { getTenantContact } from '@rarit-kernel/iam'; // Notifications -> IAM
// @rarit-kernel/iam already imports @rarit-kernel/notifications above --
// the workspace now contains IAM <-> Notifications, a real import cycle.
export async function sendNotification(contactRef, type, payload) {
const contact = await getTenantContact(contactRef); // duplicates IAM's own resolution
await deliver(contact, type, payload);
}
هذا بالضبط الشكل الذي يصفه مرجع التبعيات الدائرية: قد يُثبِّت npm الحزمتين معاً، وقد ينفّذ Node كلا الوحدتين بإرجاع مُصدَّر غير مكتمل التهيئة أثناء الحلقة — لكن لا شيء من ذلك يضمن تسليم رمز OTP فعلياً في كل مسار تنفيذ، وأي مسار سيفشل قد يعتمد على أي محرك يبدأ تهيئته أولاً.
الصواب: يُصدِر IAM حدثاً؛ ولا يقرأ عبر الحد سوى Notifications
import { emitEvent } from '@rarit-kernel/event-bus';
export async function requestOtp(userId: string) {
const code = generateOtp();
await storeOtp(userId, code);
await emitEvent('iam.otp_requested', { userId, code });
// IAM's job ends here. It does not know or care who listens.
}
import { onEvent } from '@rarit-kernel/event-bus';
import { getTenantContact } from '@rarit-kernel/iam'; // one-directional read -- allowed
onEvent('iam.otp_requested', async ({ userId, code }) => {
const contact = await getTenantContact(userId);
await deliver(contact, 'otp', { code });
});
لا يزال يُسمَح لـNotifications باستيراد IAM بشكل قراءة فقط — هذا الاتجاه لم يكن المشكلة إطلاقاً. ما احتاج التغيير هو جانب IAM: لم يعد يستورد Notifications إطلاقاً، لذا تصبح الحلقة مستحيلة بنيوياً، لا مُتجنَّبة بالاتفاقية فقط. ينطبق الحل نفسه بالضبط على زوج Entitlements ↔ Notifications (تنبيهات تجاوز حدود الخطة، قنوات إشعار مقيّدة بالخطة): يُصدِر Entitlements الحدث entitlements.plan_limit_reached؛ ويشترك فيه Notifications ويقرأ بيانات الخطة من Entitlements بشكل قراءة فقط عند حاجته لتحديد القناة المسموحة.
:::caution سؤال مفتوح، لم يُجِب عليه هذا الملحق بعد: هل يحفظ @rarit-kernel/event-bus الأحداث بشكل دائم؟ مُتتبَّع كـالإجراء ACT-003.
تحلّ emitEvent()/onEvent() أعلاه مشكلة اتجاه الاستيراد — ولا تقولان بذاتهما شيئاً عن ضمانات التسليم. إذا كانت @rarit-kernel/event-bus باعثاً داخل العملية (In-process، مثلاً تُغلّف EventEmitter الخاصة بـNode)، يُفقَد الحدث إذا انهارت العملية بين التزام emitEvent('iam.otp_requested', ...) وتشغيل مشترك Notifications فعلياً — أي أن رمز OTP قد لا يُرسَل أبداً بصمت، وهو فشل أسوأ من حلقة الاستيراد العكسي التي صُمِّم هذا النمط لإصلاحها. بالنسبة لتسليم OTP هذه مشكلة تجربة استخدام؛ أما لأي شيء يمسّ Payment أو Audit فهي مشكلة صحة. لا يجزم هذا الملحق بأي تطبيق تستخدمه @rarit-kernel/event-bus فعلياً — هذا سؤال لمالكها، لا شيء يُخمَّن هنا. إذا كانت داخل العملية فقط، فالحل المُوصى به هو صندوق صادر معاملاتي (Transactional Outbox): تُكتب حمولة الحدث إلى جدول outbox ضمن نفس معاملة التغيير المُطلِق (فتلتزم مع التغيير أو لا تلتزم إطلاقاً)، ويستطلع عامل دائم منفصل ذلك الجدول أو يبثّه لإرسال الحدث فعلياً. تحقق من هذا قبل اعتبار أي حدث في هذا النمط آمناً لتدفقات OTP أو الدفع أو الحساسة للتدقيق تحديداً — أما حدث أقل حساسية (كإبطال ذاكرة تخزين مؤقت "شوهد مؤخراً" على مستوى الواجهة فقط) فقد لا يحتاج هذا الضمان إطلاقاً، فالإجابة لا يلزم أن تكون واحدة لكل أنواع الأحداث.
:::
A.8 تبعية دائرية — القطاعات (عَمَار ↔ السياحة)
يتكرّر الخطر نفسه على مستوى أعلى، بين القطاعات بدلاً من المحركات، ويبدأ عادةً ببراءة: يلاحظ أحد المطوّرين أن وحدة التوقعات المالية الخاصة بعَمَار (A.5) قد تصلح تماماً لتوقعات إيرادات السياحة، فيستوردها مباشرة بدلاً من أن يسأل إن كانت تنتمي أصلاً إلى عَمَار.
خطأ: استيراد مباشر بين قطاعين
import { ForecastingModule, ForecastingService } from '@rarit/amaar/modules/financial-forecasting';
// Tourism now depends directly on AMAAR's package.
export default fp(async (fastify) => {
const nestCtx = await NestFactory.createApplicationContext(ForecastingModule);
const forecasting = nestCtx.get(ForecastingService);
// ...
});
يعمل هذا بشكل سليم بمعزل عن غيره. يظهر الخطر لاحقاً: إذا أراد فريق عَمَار بعد ذلك شيئاً تملكه السياحة — منحنيات طلب سفر موسمية، مثلاً، لتحسين استراتيجية توقّع ما — واستورده بنفس الطريقة، فستحتوي مساحة العمل الآن على عَمَار ↔ السياحة، وهو بالضبط شكل A ↔ B الذي يحذّر منه مرجع التبعيات الدائرية، إلا أنه بين قطاعين منتجين بدلاً من حزمتَي npm في درس تعليمي.
الصواب: ترقية المنطق المشترك إلى حزمة قابلة للاستهلاك عبر النواة
لم تكن التوقعات المالية يوماً مفهوماً خاصاً بعَمَار حقاً — إنها قدرة عامة الغرض بناها عَمَار أولاً بمحض الصدفة. وفق السابقة القائمة في القسم 4 للمنطق العابر للقطاعات (المراقبة أداة قياس، لا محرك؛ التسوية إعداد، لا محرك)، فإن مكتبة توقعات مشتركة هي حزمة استهلاك عادية، لا محرك سادس — لا تحتاج تقييداً عبر Entitlements ولا بياناً خاصاً بها.
@Module({
providers: [LinearTrendStrategy, SeasonalAdjustedStrategy, ForecastingService],
exports: [ForecastingService],
})
export class ForecastingModule {}
import { ForecastingModule, ForecastingService } from '@rarit-kernel/forecasting';
// AMAAR now depends downward on a shared package, not sideways on Tourism.
import { ForecastingModule, ForecastingService } from '@rarit-kernel/forecasting';
// Tourism depends on the same shared package. AMAAR and Tourism no longer
// know the other vertical exists.
يعتمد كلا القطاعين الآن اعتماداً نازلاً (Downward) على حزمة مشتركة؛ ولا يعتمد أيّ منهما على الآخر، ولن يُحجَب أيّ منهما مطلقاً بانتظار انتهاء تهيئة حزمة الآخر. هذا هو الحل نفسه المُوصى به عادةً للحلقات على مستوى الحزم ("استخراج الوظائف المشتركة")، مُطبَّقاً على مستوى القطاعات: السؤال ليس أبداً "هل يستطيع npm تثبيت هذا"، بل "هل ينتمي هذا الشيء المشترك فعلياً لأيّ من القطاعين".
A.9 تطبيق منع التبعيات الدائرية عبر CI
يفرض القسم 5 من السياسة قاعدة no-cycle من eslint-plugin-import عبر مساحة العمل بأكملها لكي يفشل هذا النوع من الأخطاء طلب سحب تلقائياً، تماماً كما يفشل انحراف غير مراجَع عن القسم 6. هذا القسم هو الدليل العملي: التثبيت، والإعداد، والربط بـCI، والتطبيق عبر حماية الفروع، والطرح الآمن مقابل قاعدة شيفرة قد تحتوي بالفعل على حلقات لم يلاحظها أحد بعد.
الخطوة 1 — تثبيت الإضافة
عند جذر مساحة العمل (لا داخل حزمة محرك أو قطاع منفردة):
npm install --save-dev eslint eslint-plugin-import eslint-import-resolver-typescript
مُحلِّل TypeScript مطلوب، لا اختياري، في مساحة العمل هذه تحديداً: بدونه، لا يستطيع ESLint تتبّع الاستيرادات عبر حدود الحزم (محرك ← محرك، قطاع ← نواة) التي تُحلّ عبر مسارات tsconfig الخاصة بكل حزمة بدلاً من استيرادات نسبية بسيطة.
الخطوة 2 — ضبط القاعدة عند جذر مساحة العمل
const importPlugin = require('eslint-plugin-import');
module.exports = [
{
plugins: { import: importPlugin },
settings: {
'import/resolver': {
typescript: {
project: [
'./packages/*/tsconfig.json', // @rarit-kernel/* engines
'./verticals/*/tsconfig.json', // AMAAR, Clinivio, EduSuite, Transport, Tourism
],
},
},
},
rules: {
// error, not warn -- a warning does not fail CI and does not
// satisfy Policy Section 5's "cannot merge" requirement.
'import/no-cycle': ['error', { maxDepth: Infinity, ignoreExternal: true }],
},
},
];
إعدادان يستحقان التنويه صراحة:
maxDepth: Infinity— قد يلتقط الفحص الضحل استيراد IAM المباشر لـNotifications، لكنه قد يُفوّت حلقة أطول تمرّ عبر حزمتين أو ثلاث وسيطة. بما أن جوهر هذه المنصّة هو حزم تُشير إلى بعضها بتصميم مقصود، يحتاج الفحص لاجتياز الرسم البياني كاملاً، لا الجيران المباشرين فقط.ignoreExternal: true— يقصر الفحص على حزم مساحة العمل هذه فقط. بدونه، تحاول القاعدة أيضاً تحليل الحلقات داخل تبعياتnode_modulesمن أطراف ثالثة، وهو أمر خارج اهتمام هذه السياسة وقد يُنتج إيجابيات كاذبة أو تشغيل فحص بطيء غير عملي.
الخطوة 3 — إضافة أمر npm مخصَّص
{
"scripts": {
"lint": "eslint . --ext .ts,.tsx"
}
}
يُشغِّل npm run lint بالفعل import/no-cycle (وهي القاعدة المُعرَّفة في eslint.config.js المشترك من الخطوة 2)، فلا يوجد أمر "مقتصر على الحلقات" منفصل يستحق الصيانة. تضمّن إصدار سابق من هذا الدليل أمراً بديلاً lint:cycles باستخدام --no-eslintrc --rulesdir؛ هذان علمان خاصان بالإعداد القديم (Legacy Config) في ESLint 8 فقط، ولا يعملان مع الإعداد المسطَّح (Flat Config) eslint.config.js من الخطوة 2، لذا أُزيل ذلك الأمر بدلاً من إصلاحه، تجنّباً لوجود نظامي إعداد متباينين في مستودع واحد. إذا احتيج لاحقاً لفحص سريع حقيقي بقاعدة واحدة (سيصبح ذا صلة حين يكبر eslint.config.js عن هذه القاعدة الواحدة)، حدِّد نطاقه عبر ملف إعداد مسطَّح ثانٍ يُمرَّر بـ--config، لا بأعلام سطر أوامر قديمة.
الخطوة 4 — ربطه بـCI
name: Lint
on:
pull_request:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint # fails the job -- and the PR -- on any import/no-cycle error
الخطوة 5 — جعل الفحص إلزامياً، لا استشارياً فقط
هذه هي الخطوة التي تُحقّق فعلياً ضمان "لا يمكن الدمج" في القسم 5 من السياسة — بدونها، تصبح مهمة فحص فاشلة مجرد علامة X حمراء يمكن لأي أحد تجاهلها. ضمن إعدادات GitHub الخاصة بالمستودع:
- الإعدادات ← الفروع ← قواعد حماية الفروع ←
main - تفعيل "اشتراط اجتياز فحوصات الحالة قبل الدمج"
- اختيار مهمة
lintمن الخطوة 4 كفحص إلزامي - الجمع مع "اشتراط أن تكون الفروع محدَّثة قبل الدمج" لكي تُكتشَف أيضاً أي حلقة أدخلها طلب سحب آخر مختلف تم دمجه لاحقاً
هذه هي نفس آلية التطبيق — حماية الفروع مع فحص إلزامي — التي يستخدمها بالفعل هذا المستودع لتغييرات السياسة الخاضعة لـCODEOWNERS (راجع ملف README.md الخاص بالمستودع). منع التبعيات الدائرية وتطبيق تغييرات السياسة مثالان لنفس النمط: لا تصبح القاعدة حقيقية إلا عندما يكون GitHub، لا شخصاً، هو من يرفض الدمج.
الخطوة 6 — اختياري: اكتشافه قبل طلب السحب، عبر خطّاف ما-قبل-الالتزام
تطبيق CI (الخطوتان 4–5) هو الحدّ الأدنى الإلزامي. خطّاف ما-قبل-الالتزام حلقة استجابة أسرع فوقه، لا بديلاً عنه — يجب أن يبقى CI يعمل، إذ يمكن دائماً تجاوز الخطّاف محلياً عبر --no-verify.
npm install --save-dev husky lint-staged
npx husky init
npx lint-staged
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --rule '{\"import/no-cycle\": \"error\"}'"]
}
}
الخطوة 7 — طرح هذا مقابل قاعدة شيفرة قائمة بالفعل
تفعيل import/no-cycle عبر مساحة العمل بأكملها للمرة الأولى قد يُظهر حلقات لم يلاحظها أحد بعد — وهذا هو المقصود، لكنه قد يُعطّل أيضاً كل طلب سحب مفتوح دفعة واحدة إذا فُعِّلت القاعدة دون خطة انتقالية. التسلسل المُوصى به:
- شغّل
npm run lintمرة واحدة محلياً قبل إضافة مهمة CI، واقرأ المخرجات — يُخبرك هذا بعدد الحلقات القائمة التي ترثها، لا التي تُدخلها حديثاً. - إذا كان العدد صغيراً (حفنة منها)، أصلحها مباشرة باستخدام الأنماط في A.7–A.8 قبل تفعيل تطبيق CI إطلاقاً.
- إذا كان العدد كبيراً، استخدم
overridesفي ESLint لاستثناء الملفات المتضمِّنة للحلقة القائمة فعلياً حسب المسار مع تعليق مؤرَّخ يوضّح السبب، لكي تُطبَّق القاعدة بالكامل على كل شيفرة جديدة منذ اليوم الأول بينما يُتتبَّع الدين القائم صراحة بدلاً من تجاهله بصمت:
{
files: ['packages/legacy-billing/**/*.ts'],
rules: {
// TODO(2026-09-01, @platform-architecture-owner): pre-existing cycle,
// tracked in JIRA-1234. Do not add new files to this override list.
'import/no-cycle': 'off',
},
},
قائمة استثناءات لا تتقلّص إلا هي أداة انتقالية مشروعة. أما قائمة استثناءات تتّسع فهي القاعدة تفشل بصمت — راجعها بنفس وتيرة المراجعة الربعية للسياسة في القسم 9.
الخطوة 8 — التحقق من أن القاعدة تكتشف فعلياً شيئاً
قبل الوثوق بهذا في بيئة الإنتاج، تأكّد من فشله على مثال حقيقي. أعِد إنتاج النمط الخاطئ من A.7 مؤقتاً (استيراد IAM المباشر لـNotifications) على فرع تجريبي وشغّل مهمة الفحص:
packages/iam/src/otp.ts
1:1 error Dependency cycle via packages/notifications/src/dispatch.ts:1 import/no-cycle
✖ 1 problem (1 error, 0 warnings)
إن لم يفشل هذا، فإن المُحلِّل في الخطوة 2 لا يتتبّع مسارات حزم مساحة العمل بشكل صحيح — راجع مسار import/resolver ← typescript ← project قبل الاعتماد على القاعدة لأي أمر حقيقي.
الخطوة 9 — قاعدة no-cycle تكتشف الحلقات، لا الاستيرادات المحظورة أحادية الاتجاه
:::caution فجوة مستقلة، لا تغطّيها الخطوات 1–8
يحظر القسم 4 أي استيراد مباشر بين قطاعين، في أي اتجاه — لا الاستيرادات الدائرية فقط. لا تستطيع import/no-cycle فرض هذا: إذا استوردت السياحة مباشرة من عَمَار ولم يستورد عَمَار أي شيء بالمقابل، فلا توجد حلقة لتكتشفها القاعدة، رغم أن القسم 4 يحظر الاستيراد بذاته أصلاً. يعمل حل A.8 (الترقية إلى @rarit-kernel/forecasting) سواء وُجد الاتجاه العكسي أم لا — المخالفة هي الاستيراد أحادي الاتجاه نفسه، لا الرحلة ذهاباً وإياباً.
:::
يحتاج فرض هذا لقاعدة حدود/اتجاه، لا قاعدة حلقات — فحص ثانٍ مستقل:
const boundaries = require('eslint-plugin-boundaries');
module.exports = [
// ... existing import/no-cycle block from Step 2 stays as-is ...
{
plugins: { boundaries },
settings: {
'boundaries/elements': [
{ type: 'kernel-engine', pattern: 'packages/*' },
{ type: 'vertical', pattern: 'verticals/*' },
],
},
rules: {
'boundaries/element-types': ['error', {
default: 'disallow',
rules: [
// Verticals may import kernel engines, never each other.
{ from: 'vertical', allow: ['kernel-engine'] },
// Kernel engines may import other kernel engines, never a vertical.
{ from: 'kernel-engine', allow: ['kernel-engine'] },
],
}],
},
},
];
هذا إضافي للخطوة 2، لا بديل عنها — لا تزال import/no-cycle تكتشف حلقات المحركات والقطاعات في A.7 وA.8؛ وتكتشف boundaries/element-types الحالة أحادية الاتجاه التي لا يُظهرها أي من هذين المثالين. ينتميان لنفس مهمة CI من الخطوة 4.
A.10 الوصول لقاعدة البيانات محدود النطاق حسب المستأجر — نمط getDb() / withTenantScope()
يوجد هذا القسم بسبب ملاحظة مراجعة حقيقية: كانت الأمثلة في A.1 وA.3 وA.4 وA.5 تستدعي fastify.db مباشرة داخل معالجات المسارات في نسختها الأصلية، وهو بالضبط النمط الذي يرفضه db-access-pattern-decision.md (مُعتمَد بتاريخ 2026-08-26). يوثّق هذا القسم النمط المعتمَد بذاته، منفصلاً عن أي مثال قطاع واحد، لأنه ينطبق على المحركات الخمسة وكل قطاع دون استثناء.
النمط، بثلاثة أجزاء
-
مكان واحد يفتح الاتصال. يستدعي تطبيق القطاع
initDatabase(connectionString)مرة واحدة عند الإقلاع. لا يقرأ أي محرك أو وحدة قطاعDATABASE_URLبنفسه. -
الخدمة تفتح نطاقاً (Scope). تعمل حالة استخدام واحدة داخل
withTenantScope(tenantId, work). تأخذ هذه عميلاً واحداً من التجمّع (Pool)، وتُشغّلBEGIN، وتضبطstatement_timeoutوapp.tenant_idلتلك المعاملة فقط، وتُشغّلwork، ثمCOMMITأوROLLBACK.set_config('app.tenant_id', tenantId, true)— المعامل الثالثtrue— هو مكافئ استدعاء الدالة لأمرSET LOCALفي PostgreSQL: يُحدَّد نطاق الإعداد بالمعاملة الحالية ويُمسَح تلقائياً عندCOMMITأوROLLBACK. هذا بالضبط ما يجعل النمط آمناً مع تجميع الاتصالات (Connection Pooling): عند إعادة العميل إلى التجمّع في نهايةwithTenantScope()، لا تبقى أي قيمةapp.tenant_idعلى ذلك الاتصال لتُورَث عرضاً في المعاملة التالية غير المرتبطة. استخدامSETالعادي (محدود النطاق بالجلسة، لا بالمعاملة) بدلاً من هذا المعامل الثالث كان سيُسرّب سياق مستأجر إلى أي طلب يُعيد استخدام نفس الاتصال المُجمَّع لاحقاً — خطر حقيقي بين المستأجرين، لا افتراضي. -
المستودع يطلب المقبض. يستدعي
getDb()— بلا معامل قاعدة بيانات وبلا استيراد لأي إطار. عند استدعائها خارج نطاق مفتوح، ترمي استثناءً بدلاً من إرجاع مقبض خاطئ أو غير محدَّد النطاق بصمت.
RLS هو الحد الأساسي غير القابل للتجاوز؛ وفلتر WHERE tenantId = ... على مستوى التطبيق طبقة ثانوية، لا بديل عنه. إذا نسي مطوّر الفلتر الصريح، لا يزال RLS يمنع صفوف المستأجرين الآخرين — هذا هو جوهر تشغيل الطبقتين معاً. لكن العكس غير صحيح ويجب ألا يُعامَل كمكافئ أبداً: لو أُسيء إعداد RLS يوماً، أو نُسي FORCE، أو غاب عن جدول جديد، فإن فلتر التطبيق وحده لا يكفي لاعتبار عزل المستأجرين في ذلك الجدول صحيحاً وفق هذه السياسة. عند الشك في أي الطبقتين "الإجابة"، فـRLS هو الإجابة.
// طبقة الخدمة
export async function doSomething(tenantId: string, input: Input) {
return withTenantScope(tenantId, async () => {
const row = await repo.insertSomething(input);
await logAction({ tenantId, /* ... */ }); // joins the same transaction
return row;
});
}
// طبقة المستودع -- نفس الشكل بالضبط في كل محرك وكل قطاع
export async function insertSomething(input: Input) {
const [row] = await getDb().insert(something).values(input).returning();
return row;
}
خاصيتان تستحقان التكرار هنا رغم ذكرهما في القرار المعماري نفسه:
- نطاق داخل نطاق لنفس المستأجر ينضم لنفس المعاملة؛ ومستأجر مختلف داخل نطاق مفتوح يرمي استثناءً. هذه هي الآلية التي تسمح لمحرك Audit بالكتابة داخل معاملة المستدعي — تستدعي
logAction()الدالةgetDb()داخلياً وتنضم لأي نطاق مفتوح بالفعل. لا يعبر أي كائن معاملة حدود الحزمة أبداً؛ ولا تظهر داخليات Audit نفسها أبداً ضمن استيرادات وحدة العقارات/CRM/التوقعات. - لا تتغيّر تواقيع دوال المستودع أبداً لتبنّي هذا النمط. الدالة التي كانت تأخذ
(tenantId: string)من قبل لا تزال تأخذ(tenantId: string)بعد ذلك — يتغيّر الجسم فقط، من بحث فيMap، أو استدعاء مباشر لـfastify.db، إلىgetDb().
مكان تخزين صفوف التدقيق نتيجة مباشرة للقسم 3-2، لا قراراً منفصلاً. بما أن كل قطاع "بيئة تشغيل وقاعدة بيانات معزولة" والمحركات "إضافات مُدمَجة... داخل عملية الخلفية الخاصة بكل قطاع" (القسم 4)، فإن استدعاء logAction() الخاص بـAudit داخل withTenantScope() لقطاع ما يكتب إلى قاعدة بيانات ذلك القطاع نفسها — لا يوجد مخزن تدقيق مركزي منفصل اليوم. لهذا نتيجة حقيقية تستحق ذكرها صراحة: استعلام عبر سجل تدقيق كل المستأجرين، أو كل القطاعات، في مكان واحد، غير ممكن دون آلية جديدة عابرة لقواعد البيانات لا وجود لها بعد. إذا احتاجت متطلبات مستقبلية ذلك (مثل لوحة تدقيق أمني على مستوى المنصّة بأكملها)، فهذا نطاق جديد، لا شيء يوفّره هذا النمط بالفعل.
النمط المضاد: fastify.decorate('db', ...)
:::danger غير مسموح به
كان هذا النمط موجوداً بالفعل في شيفرة أحد المحركات قبل القرار المعماري (packages/notifications/src/db/drizzle.db.plugin.ts) — ويجري إزالته الآن (متتبَّع كـDBI-12)، لا توسيعه. لا تنسخه في شيفرة جديدة.
:::
import Fastify from 'fastify';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
fastify.decorate('db', drizzle(pool));
// Then, anywhere a route or repository needs data access:
fastify.get('/properties', async (req) => {
return fastify.db.query.properties.findMany(/* ... */); // looks fine. Isn't.
});
ثلاثة أسباب محددة ومستقلة لرفض هذا النمط — أي واحد منها وحده كافٍ:
- المقبض تجمّع (Pool)، لا معاملة. تصبح الكتابة (Mutation) وسطر تدقيقها (Audit) معاملتين ضمنيتين منفصلتين. إذا فشلت كتابة التدقيق بعد أن التزمت الكتابة الأصلية بالفعل، لا يوجد تراجع (Rollback) — تبقى الكتابة قائمة، غير مُسجَّلة في Audit بشكل دائم.
- التجمّع لا يحمل
app.tenant_id. تقارن سياسات Row-Level Security مقابلcurrent_setting('app.tenant_id', true). اتصال تجمّع عادي لا يضبطه أبداً، فلا تجد السياسة ما تُقيّمه مقابله ولا تُصفّي شيئاً. هذا أحدّ صور الخطر: لا شيء ينهار، لا يظهر أي خطأ، وقد تنجح الاختبارات — يتراجع النظام ببساطة وبصمت من طبقتي حماية (فلترWHERE tenantId = ...الخاص بالتطبيق، وسياسة RLS الخاصة بقاعدة البيانات) إلى طبقة واحدة. فقدان قفل قاعدة البيانات غير مرئي حتى يُختبَر تسرّب محدد بين المستأجرين عمداً. - يُسرّب الإطار إلى طبقة المستودع. المستودع الذي يحتاج
fastify.dbيحتاج نسخة Fastify، أي استيرادها أو استلامها — وهذا يتعارض مباشرة مع نفس الفصل الذي تفرضه هذه السياسة بالفعل على خدمات المحركات (القسم 4: الخدمات لا تستورد Fastify أبداً، ولا تستلمrequestأوreplyأبداً).
مرفوض أيضاً: معامل معاملة صريح
جرى تقييم بديل ثانٍ ورُفض في نفس القرار المعماري: تمرير كائن معاملة كمعامل أول صريح لكل دالة مستودع، مثل getPlan(tx, id). هذا أكثر تقليدية وصراحة من AsyncLocalStorage — لكنه رُفض لسببين: (أ) سيُغيّر توقيع كل دالة مستودع عبر المحركات الخمسة وكل قطاع، و(ب) سيتطلب انضمام كتابة محرك Audit لمعاملة المستدعي عبر استلام كائن تلك المعاملة عبر حدود الحزمة، ما يجعل نوع معاملة Drizzle نفسه جزءاً فعلياً من واجهة برمجة كل محرك العامة — فتصبح ترقية Drizzle تغييراً كاسراً في كل مكان دفعة واحدة.
ما يُكلّفه هذا، وكيف يُحتوى
لسياق AsyncLocalStorage الضمني تكلفة حقيقية: أنه ضمني. لا يستطيع المطوّر معرفة ما إذا كان استدعاء دالة مستودع آمناً من توقيعها وحده — يعتمد ذلك على ما إذا كان نطاق ما مفتوحاً أعلى في سلسلة الاستدعاء. المُخفِّف هو أن getDb() تفشل بصوت عالٍ: يظهر نسيان استدعاء withTenantScope() كخطأ يُرمى في أول اختبار يُمارس مسار تلك الشيفرة، لا كاستعلام خاطئ بصمت في الإنتاج.
A.11 أنماط Row-Level Security من مشاريع سابقة — ما يُنقَل وما لا يُنقَل
:::note مختلف في طبيعته عن A.1–A.10
كل ما سبق هذه النقطة يوثّق قراراً اتخذه الفريق بالفعل. هذا القسم تقييم لإعداد RLS في نظام PostgreSQL قديم غير مرتبط (قاعدة بيانات هيئة اعتماد سابقة لـplatform-core، لا مشروع تابع لـRaR-IT)، جرت مراجعته بحثاً عن أنماط تستحق التبنّي. القسمان A.11.1 وA.11.2 مفيدان فعلياً ومكتوبان باتفاقيات تسمية ومخطط RaR-IT نفسها. الأقسام A.11.3–A.11.4 هي أشياء فعلها النظام القديم وستكون تراجعاً لو نُسِخت هنا، وأُبقيت كأمثلة سلبية ملموسة بدلاً من تحذيرات مجرّدة. لا يُلغي هذا القسم db-access-pattern-decision.md — وحيثما يتعارضان، يسود ذلك القرار المعماري.
:::
A.11.1 مفيد: تحديد نطاق جدول فرعي بلا عمود tenant_id خاص به
تُظهر أمثلة النمط الحالية (A.1–A.10) جميعها جداول بعمود tenant_id مباشر. أما جدول فرعي يبعد ربطاً واحداً — وهو شائع بما يكفي ليستحق نمطاً مُسمّى بدلاً من إعادة اختراعه لكل محرك — فيحتاج سياسة محدَّدة النطاق عبر استعلام فرعي بدلاً من ذلك:
ALTER TABLE amaar.property_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE amaar.property_documents FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON amaar.property_documents
USING (
property_id IN (
SELECT id FROM amaar.properties
WHERE tenant_id = current_setting('app.tenant_id', true)
)
);
يبقى هذا سريعاً فقط إذا أصاب الاستعلام الفرعي فهرساً (Index) على الجانبين — amaar.properties.tenant_id (مطلوب أصلاً بموجب السياسة المباشرة على properties نفسها) وamaar.property_documents.property_id. أضِف الثاني صراحةً؛ من السهل نسيانه لأن الجدول "بديهياً" يحتاجه من أجل المفتاح الخارجي، لكن المفتاح الخارجي وحده لا يضمن وجود فهرس.
A.11.2 مُقترَح: دور مرتفع الصلاحية لقراءات مشروعة عبر المستأجرين
:::info الحالة: مُقترَح، مُتتبَّع كـالإجراء ACT-001 — غير معتمَد بعد الشكل أدناه اقتراح ملموس، لا نمط محسوم. يتطلب قراراً من لجنة مراجعة المعمارية قبل الاستخدام، ومُتتبَّع في وثيقة الإجراءات مع الأسئلة الفرعية المفتوحة التي لا يحسمها بذاته. :::
هذا ليس افتراضياً. تحتوي الأعمال المتراكمة الخاصة بالسياحة بالفعل على قصة لا يمكن بناؤها بدونه:
TEN-DASH-01 — بصفتي إدارياً أعلى، أريد لوحة حالة عبر جميع المستأجرين (نشط/معلّق، مستوى الخطة، الاستخدام)... بفرض أنني فتحت لوحة الإداري الأعلى، وعندما تُحمَّل، فإنها تسرد كل مستأجر مع حالته وخطته وتوقيت آخر نشاط له.
لا يمكن تلبية "تسرد كل مستأجر" عبر withTenantScope(tenantId, work) — فهي تفتح بالضبط معاملة واحدة محدَّدة النطاق بـRLS لمستأجر واحد، بتصميم مقصود. تكرارها مرة لكل مستأجر لا يعمل أيضاً: ستحتاج معرفة كل معرّفات المستأجرين مسبقاً للتكرار عليها، وهذا يفترض مسبقاً الإجابة التي وُجدت اللوحة لتوفيرها.
حلّ النظام القديم المشكلة العامة عبر سياسات مرتبطة بكل دور قاعدة بيانات وفق مطالبات JWT الخاصة بـPostgREST، وهذه ليست معمارية هذه المنصّة (يتصل RaR-IT بدور واحد هو APP_ROLE ويقرأ سياق المستأجر من app.tenant_id، لا من مطالبة JWT داخل Postgres). يستعير الاقتراح أدناه الفكرة الجوهرية فقط — سياق مرتفع يتجاوز تحديد نطاق المستأجر بشكل مشروع — لا آلية PostgREST نفسها.
دالة منفصلة ومحدودة — لا معامل أبداً على withTenantScope(). قيمة null سحرية لـtenantId بمعنى "وضع الإدارة" هي بالضبط النوع من الأشياء التي تصبح خطأً لاحقاً (هل كانت null مقصودة، أم نسي أحد تمرير مستأجر؟). دالة بمُسمّى مميّز لا يمكن الوصول إليها عرضاً:
export async function withPlatformAdminScope<T>(
actor: AuthenticatedActor,
reason: string, // required, not optional -- see audit logging below
work: () => Promise<T>,
): Promise<T> {
if (!actor.roles.includes('platform_admin')) {
throw new ForbiddenError('platform_admin role required');
}
return withDbTransaction(async (client) => {
await client.query(`SET LOCAL app.role = 'platform_admin'`);
await client.query(`SET LOCAL TRANSACTION READ ONLY`);
await client.query(`SET LOCAL statement_timeout = ${ADMIN_STATEMENT_TIMEOUT_MS}`);
await logAction({
actorId: actor.id,
action: 'platform_admin.scope_opened',
reason,
timestamp: new Date(),
});
return work();
});
}
ثلاث خصائص إضافية، تُغلق كل منها طريقة محددة يمكن أن يسوء بها الأمر:
-
قراءة فقط افتراضياً.
SET LOCAL TRANSACTION READ ONLYداخل النطاق. تقريباً كل حاجة مشروعة قراءة (لوحة، بحث دعم، تقرير استخدام)؛ أما الكتابة عبر المستأجرين فعملية أخطر فئوياً — قد يمسّ خطأ فيها كل مستأجر دفعة واحدة. أي حاجة كتابة حقيقية عبر المستأجرين تستحق دالة منفصلة أكثر تقييداً، تتطلب اعتماد القسم 8 الخاص بها. -
اختياري لكل جدول، لا تغيير سياسة شامل أبداً. تُضاف عبارة
OR current_setting('app.role', true) = 'platform_admin'فقط للجداول القليلة التي تحتاجها لوحة إدارية فعلياً (بيانات المستأجر الوصفية، الخطة/الحالة)، ولا تُطبَّق أبداً بشكل شامل على بيانات الأعمال (سجلات المرضى، المدفوعات، بيانات العملاء). إضافتها على مستوى المنصّة بأكملها ستُحوّلplatform_adminإلى دور واحد يستطيع قراءة كل شيء، مُبطِلاً معظم الغرض من RLS ومُنشئاً نقطة اختراق كارثية واحدة.مُطبَّقة بضيق -- tenants وtenant_subscriptions، لا بيانات الأعمالCREATE POLICY tenant_isolation ON iam.tenantsUSING (id = current_setting('app.tenant_id', true)OR current_setting('app.role', true) = 'platform_admin');CREATE POLICY tenant_isolation ON entitlements.tenant_subscriptionsUSING (tenant_id = current_setting('app.tenant_id', true)OR current_setting('app.role', true) = 'platform_admin'); -
reasonإلزامي، لا اختياري. يُسجَّل كل استدعاء، ضمن نفس المعاملة، قبل تشغيلwork— يُلتقَط حرفياً، فتجيب مراجعة تدقيق لاحقة عن "لماذا قرأ أحدهم حالة كل مستأجر في هذا التاريخ" بدلاً من مجرّد "قام أحدهم بأمر إداري، بلا سياق".
export async function getTenantStatusDashboard(actor: AuthenticatedActor) {
return withPlatformAdminScope(actor, 'Super Admin dashboard load', async () => {
return getDb().select().from(tenants); // no WHERE clause -- the RLS
// bypass is what makes this legal
});
}
راجع الإجراء ACT-001 للأسئلة الفرعية التي يتركها هذا الاقتراح مفتوحة عمداً (من يُمنَح platform_admin، وهل هو محدود بوقت أم قائم) — قرارات حوكمة/IAM فوق هذه الآلية، لا عوائق أمام الآلية نفسها.
A.11.3 لا يُنسَخ: تفعيل RLS دون فرضه
من مُلقَى النظام القديم نفسه:
ALTER TABLE accred.appraisal ENABLE ROW LEVEL SECURITY;
CREATE POLICY appraisal_policy_webuser ON accred.appraisal TO webuser
USING (regid IN ( SELECT registration.id FROM accred.registration WHERE /* ... */ ));
لا وجود لـFORCE ROW LEVEL SECURITY في أي مكان من المُلقَى. اتصل التطبيق فعلياً بدور غير مالك (webuser، لا sascdb المالك للجدول) — أي أن النصف الأصعب من الخطأ الذي يحذّر منه DB_INTEGRATION.md قد تجُنِّب. لكن بلا FORCE، فإن مالك الجدول تحديداً — سكربت ترحيل، أمر إداري سريع، أي شيء يتصل مصادفةً كـsascdb — يتجاوز كل السياسات أعلاه بصمت. هذا بالضبط الخطر الذي يُسمّيه الجزء 7 من DB_INTEGRATION.md: "مستخدم متميز (Superuser) يتجاهل RLS. ومالك الجدول يتجاهله أيضاً. ولا تظهر أي رسالة خطأ." كلا الأمرين ENABLE وFORCE مطلوبان معاً، في كل مرة؛ هذا النظام القديم مثال حقيقي على شكل الاكتفاء بأحدهما فقط.
A.11.4 لا يُنسَخ: منطق الأعمال داخل المُشغِّلات (Triggers) بدلاً من طبقة الخدمة
CREATE FUNCTION accred.notifyappraisal() RETURNS trigger AS $$
BEGIN
if NEW.status = 2 then
insert into public.notifications (typeid, objid, tobenotified) values (5, NEW.id, NEW.leadappraiser);
PERFORM moct_sasc_auth.addlog('Appraisal Approved', 'succeed, Appraisal ID:'||NEW.id);
end if;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
يُطلِق تغيير الحالة هذا المُشغِّل، الذي يكتب مباشرة إلى جدول إشعارات ويستدعي دالة تسجيل تدقيق — كل ذلك داخل PostgreSQL بالكامل، غير مرئي لطبقة التطبيق. ثلاثة أسباب محددة تجعل نسخ هذا في أي محرك أو قطاع تابع لـRaR-IT تراجعاً:
- يتجاوز Entitlements كلياً. يُطلَق المُشغِّل بلا شرط؛ لا يستطيع استدعاء
requireEntitlement()، فقد يُرسَل إشعار لمستأجر لا تشمله خطته. - يتجاوز نمط ناقل الأحداث الذي تفرضه هذه السياسة بالفعل (القسم 4، الملحق A.7): يمرّ التواصل بين المحركات عبر أحداث مُصدَرة تحديداً لكي يبقى Notifications مفصولاً عمّن يُطلِقه. مُشغِّل قاعدة بيانات يكتب إلى جدول إشعارات مباشرة هو مشكلة الاستيراد العكسي من A.7 نفسها، لكن بلغة SQL بدلاً من TypeScript.
- غير مرئي لحزمة الاختبار العادية. لا شيء في
*.service.tsأو*.repository.tsيُظهر وجود هذا السلوك؛ لن يجد مطوّر يقرأ طبقة الخدمة بلغة TypeScript أي وسيلة لاكتشاف أن تغيير حقل حالة له آثار جانبية.
المكافئ في RaR-IT لدالة notifyappraisal() هو: تستدعي كتلة withTenantScope() في طبقة الخدمة الدالة emitEvent('appraisal.status_changed', {...}) (وفق نمط A.7) بعد التزام تحديث الحالة، ويشترك Notifications في ذلك الحدث. نفس النتيجة، لكن مرئية بلغة TypeScript، ومتحقَّقة عبر الاستحقاقات، ودون اقتران على مستوى قاعدة البيانات.
A.12 جدول ملخّص — اختيار النمط حسب الوحدة
| القطاع / المحركات | الوحدة | النمط المستخدَم | السبب |
|---|---|---|---|
| عَمَار (AMAAR) | العقارات | ✅ Fastify (مباشر) | عملية CRUD عادية؛ لا متعاونين داخليين يستدعون الحقن. |
| النقل (Transport) | العمولات والتسوية | ✅ Fastify (مباشر) | مستهلك رقيق لمحرك Payment ذي العقد الثابت؛ لا تعقيد على جانب القطاع. |
| السياحة (Tourism) | إدارة علاقات العملاء | ✅ Fastify (مباشر) | عملية CRUD عادية عبر قطاع مختلف — تؤكد أن النمط هو المعيار. |
| Clinivio | وحدة القوالب السريرية | 🟡 NestJS (سياق DI) | مزوّدات مشتركة مُعاد استخدامها عبر 10 قوالب تخصصية — حالة حقن تبعيات حقيقية. |
| عَمَار (AMAAR) | التوقعات المالية | 🟡 NestJS (سياق DI) | استراتيجيات تنبؤ متعددة قابلة للتبديل تُختار وقت الطلب — نمط استراتيجية كلاسيكي. |
| السياحة (توضيحي) | الذكاء الاصطناعي | 🔴 غير مسموح — Nest يملك HTTP | يُعرَض في A.6 فقط لتوضيح سبب حاجة هذا النمط لاعتماد لجنة مراجعة المعمارية. |
| IAM ↔ Notifications | تسليم OTP | 🔴 غير مسموح — استيراد عكسي | يُعرَض في A.7 لتوضيح الحل: يُصدِر IAM حدثاً؛ ويشترك فيه Notifications ويقرأ من IAM بشكل قراءة فقط. |
| عَمَار ↔ السياحة | التوقعات المالية / توقّع الإيرادات | 🔴 غير مسموح — استيراد بين قطاعين | يُعرَض في A.8 لتوضيح الحل: الترقية إلى @rarit-kernel/forecasting؛ يعتمد كلا القطاعين اعتماداً نازلاً بدلاً من الاعتماد على بعضهما. |
| جميع المحركات والقطاعات | أي وصول مستودع لقاعدة البيانات | ✅ getDb() + withTenantScope() | إلزامي وفق db-access-pattern-decision.md — النمط الوحيد الذي يُبقي الكتابة وسطر تدقيقها ذرّيين (Atomic) ويسمح لـRLS بالعمل فعلياً. |
| جميع المحركات والقطاعات (توضيحي) | أي وصول مستودع لقاعدة البيانات | 🔴 غير مسموح — fastify.decorate('db', ...) | يُعرَض في A.10 — يُسقِط تطبيق RLS إلى الصفر بصمت ويُسرّب Fastify إلى طبقة المستودع. |
هذا الملحق توضيحي وسيُوسَّع مع بناء وحدات حقيقية جديدة. لا يحمل بذاته قوة السياسة مستقلاً عن الوثيقة الرئيسية — حيثما يبدو أن هذا الملحق ونصّ السياسة يتعارضان، يسود نصّ السياسة (الأقسام 1–10).