# نصب، ارتقا و جداسازی دیتابیس عملیاتی

تاریخ: ۲۰۲۶-۱۱-۰۱. این سند خروجی مرحله سوم و مکمل [مرز داده‌ها](DATA_BOUNDARIES.fa.md) است. جدول محصولی CRM، فروش یا حسابداری در این مرحله ساخته نمی‌شود؛ جدول‌های آزمایش در `tests/Fixtures/OperationalMigrations` فقط برای اثبات جداسازی‌اند.

## مرز مرکزی و عملیاتی

- مهاجرت‌های فعلی هسته و ماژول‌ها همچنان مرکزی‌اند و با `php artisan migrate` اجرا می‌شوند.
- مهاجرت عملیاتی هسته در `database/operational/migrations` و مهاجرت عملیاتی آینده هر ماژول در `Modules/<Module>/database/operational/migrations` قرار می‌گیرد؛ این مسیرها در migrator مرکزی ثبت نمی‌شوند.
- هر دیتابیس فیزیکی یک دفتر مستقل `migrations` دارد؛ کلید اجرای مهاجرت، UID محل داده در `data_stores` است. دو کسب‌وکار مشترک دو بار مهاجرت نمی‌گیرند. اتصال با alias به دیتابیس دارای marker متفاوت، رد می‌شود.
- مشخصات دیتابیس در `tenant_store` و شناسه کسب‌وکارهای پذیرفته‌شده در `tenant_businesses` ذخیره می‌شود. این جدول‌ها کلید خارجی به مرکزی ندارند. UID محل و نوع SHARED/DEDICATED باید با نگاشت مرکزی برابر باشد.

## نقش‌ها و آماده‌سازی توسط مدیر استقرار

۱. مدیر استقرار یک دیتابیس PostgreSQL تازه ایجاد می‌کند. مالک دیتابیس حساب مدیریتی است؛ مالک schema و جدول‌های برنامه حساب مستقل مهاجرت است. حساب اجرای برنامه مالک جدول یا عضو نقش دیگری نیست و SUPERUSER، BYPASSRLS، CREATEDB، CREATEROLE، REPLICATION یا اختیار CREATE در دیتابیس/schema ندارد.

۲. الگوی `database/operational/provisioning.sql` با متغیرهای psql به نام `database`، `runtime_role` و `migrator_role` روی همان هدف مشخص اجرا می‌شود. فایل رمز ندارد. ورود رمز فقط با روش امن محلی نظیر دستور تعاملی `\password` انجام می‌شود؛ رمز در چت، فایل نسخه‌بندی‌شده یا آرگومان فرمان قرار نمی‌گیرد. روی دیتابیس دوم همان کلاستر، بخش ایجاد نقش موجود تکرار نمی‌شود و ACL هر دیتابیس جدا اعمال می‌شود. این الگو روی دیتابیس موجودِ نامشخص قابل اجرا نیست.

۳. تنظیم runtime در `config/tenant_databases.php` و بخش `migration` شامل حساب جداگانه مهاجرت تکمیل می‌شود. کلید `primary` از متغیرهای `TENANT_DB_*` و `TENANT_MIGRATION_USERNAME` / `TENANT_MIGRATION_PASSWORD` خوانده می‌شود. برای محل دوم کلید تنظیم مستقل تعریف می‌شود. دو حساب به همان host/port/database متصل می‌شوند و فقط نام کاربری و رمز متفاوت دارند. در محیط اجرا که مهاجرت انجام نمی‌دهد، تنظیم محرمانه حساب مهاجرت لازم نیست و بهتر است تزریق نشود.

۴. بعد از نصب مجاز جدول‌های مرکزی، مدیر استقرار محل داده را ثبت و فعال می‌کند. نصب و ارتقای محل فعال مستقل از وضعیت کسب‌وکار و حتی بدون کسب‌وکار فعال انجام می‌شود. پذیرش کسب‌وکار در `tenant_businesses` مرحله‌ای جدا در همان فرمان است و فقط نگاشت‌های ACTIVE را اضافه می‌کند؛ تعلیق کسب‌وکار مانع ارتقای ساختار نیست و مجوز ورود عملیاتی نیز ایجاد نمی‌کند. دیتابیس اختصاصی فقط کسب‌وکار مالک را می‌پذیرد. برای کسب‌وکار جدید در محل مشترک، پس از ثبت نگاشت همین فرمان مجدداً اجرا می‌شود تا پذیرش محلی ایجاد شود؛ مهاجرت قبلی تکرار نمی‌شود.

## فرمان نصب و ارتقا

مشاهده طرح، بدون بازکردن دیتابیس عملیاتی:

```powershell
php artisan tenant-databases:migrate
php artisan tenant-databases:migrate --store=<store-uuid>
```

اجرای طرح روی هدف آماده و مجاز:

```powershell
php artisan tenant-databases:migrate --store=<store-uuid> --execute
# فقط در production، علاوه بر execute:
php artisan tenant-databases:migrate --store=<store-uuid> --execute --force
```

بدون گزینه store، همه محل‌های فعال پردازش می‌شوند، از جمله محل اختصاصی کسب‌وکار تعلیق‌شده یا محل بدون کسب‌وکار فعال. اتصال مهاجرت مستقیماً از شناسه محل پیدا می‌شود. انتخاب صریح محل ناشناخته یا غیرفعال خطاست و به طرح خالی موفق تبدیل نمی‌شود. طرح بر اساس محل گروه‌بندی می‌شود. برای هر محل، قفل advisory تراکنشی گرفته می‌شود؛ marker بررسی، مهاجرت‌های pending اجرا و کسب‌وکارهای فعال نگاشت‌شده پذیرفته می‌شوند. تمام تغییرات SQL یک محل در تراکنش محلی همان محل قرار دارند. شکست یک محل آن تراکنش را برمی‌گرداند؛ محل‌های قبلیِ موفق برنمی‌گردند و اجرای مجدد از دفتر مهاجرت ادامه می‌دهد.

پیش از ورود عملیاتی، نام مهاجرت‌های لازم همین نسخه برنامه با دفتر همان محل مقایسه می‌شود؛ وجود مهاجرت اجرا‌نشده باعث رد ورود با علت داخلی `OPERATIONAL_SCHEMA_OUTDATED` می‌شود. runtime فقط اجازه SELECT روی دفتر دارد. روی محل نصب‌شده با نسخه قبلی این زیرساخت، فرمان نصب/ارتقا باید با مجوز لازم دوباره اجرا شود تا این مجوز خواندن نیز اعمال شود. این کنترل، کامل‌بودن نام مهاجرت‌ها را بررسی می‌کند و جایگزین بررسی تغییر محتوای مهاجرت اجراشده نیست.

هدف نصب اولیه باید بدون جدول کاربری باشد؛ هدف دارای marker ناسازگار رد می‌شود. خطای خام حاوی تنظیمات اتصال چاپ نمی‌شود. وضعیت فرمان ناموفق می‌ماند تا پیش‌نیازها توسط مدیر استقرار بررسی شوند. فرمان‌های پیش‌فرض `tenants:migrate` و دیگر فرمان‌های چرخه عمر پکیج همچنان مسدودند؛ دستور `migrate` مرکزی جایگزین فرمان بالا نیست. fresh، rollback مخرب، انتقال داده و جابه‌جایی کسب‌وکار بین محل‌ها در این مرحله وجود ندارند.

مهاجرت‌های عملیاتی فقط عملیات SQL قابل تراکنش دارند. ساخت شاخص concurrent، I/O، commit دستی و تراکنش توزیع‌شده در این مسیر مجاز نیست. نام فایل‌های مهاجرت هسته و ماژول‌ها باید در کل مجموعه یکتا باشد. ماژول عملیاتی نصب‌شده schema خود را در همه محل‌ها دارد؛ حق استفاده کسب‌وکار از ماژول، کنترل مجزای مرکزی است.

## قرارداد جدول و مدل عملیاتی

- `OperationalSchema::keys($table)` ستون‌های `id`، `uid` و `business_id`، یکتایی ترکیبی و FK محلی به `tenant_businesses` را می‌سازد. همه ستون‌ها و جدول‌های محصول در مهاجرت خود کامنت فارسی می‌گیرند.
- یکتایی شماره و کلیدهای محصول شامل `business_id` است؛ مثلاً `unique(['business_id', 'number'])`.
- `OperationalSchema::parent($table, 'parent_id', 'parents')` رابطه ترکیبی کسب‌وکار/والد را می‌سازد؛ شناسه عددی والد به‌تنهایی کافی نیست.
- پس از ساخت جدول، `OperationalSchema::secure('table_name')` الزامی است: ENABLE و FORCE RLS، policy محدودکننده، منع تغییر مالکیت ردیف و فقط مجوز SELECT/INSERT/UPDATE/DELETE برای runtime. مجوز TRUNCATE یا DDL داده نمی‌شود. جدول‌های فنی marker و migration از جدول محصول مستثنا هستند و runtime حق تغییرشان را ندارد.
- مدل محصول از `TenantModel` ارث می‌برد. scope خواندن، انتساب business_id هنگام ایجاد، منع تغییر مالکیت و منع استفاده از مدل بازیابی‌شده یک کسب‌وکار در زمینه کسب‌وکار دیگر اعمال می‌شود. حذف scope جایگزین مجوز نیست و RLS برقرار می‌ماند.
- کوئری مستقیم باید از `TenantRuntime::connection()` استفاده کند. استفاده از اتصال پیش‌فرض central برای جدول عملیاتی ممنوع است. شناسه‌های مرجع مرکزی منطقی‌اند؛ FK بین دو دیتابیس ساخته نمی‌شود.

## زمینه درخواست، صف و حافظه نهان

`TenantRuntime::run` پس از انتخاب محل و کنترل نقش، یک تراکنش عملیاتی باز می‌کند و `app.business_id` را با `set_config(..., true)` فقط برای همان تراکنش تنظیم می‌کند. ثبت موفق commit می‌شود؛ استثنا یا نتیجه ناموفق rollback می‌شود و اتصال purge و زمینه پاک می‌شود. تشخیص نتیجه در مصرف‌کننده است: میان‌افزار HTTP پاسخ با وضعیت ۴۰۰ به بالا یا پاسخ حامل exception را ناموفق می‌داند و همان پاسخ را پس از rollback برمی‌گرداند. میان‌افزار صف، کار fail یا release یا حذف‌شده پیش از پایان اجرا را ناموفق می‌داند؛ حذف عادی کار توسط worker پس از خروج از میان‌افزار انجام می‌شود. انتقال transaction-level GUC به درخواست بعدی مجاز نیست. commit/rollback دستی یا تحویل اتصال باز به callback پخش فایل/کار مؤخر خارج از این محدوده ممنوع است؛ داده مورد نیاز باید داخل همان محدوده خوانده شود.

مسیر عملیاتی HTTP پس از احراز هویت، عضویت، اعتبار توکن و مجوز شعبه وارد این محدوده می‌شود. مسیرهای مرکزی ورود و صورتحساب به آن وابسته نیستند. کار صف از `UseTenantDatabase(businessId)` استفاده می‌کند و فقط شناسه‌ها را سریال می‌کند؛ مقصد جاری در هر تلاش دوباره خوانده می‌شود. مجوز انجام کار صف باید در منطق همان کار بررسی شود.

در صف، `UseTenantDatabase` باید آخرین میان‌افزار باشد و نزدیک‌ترین لایه به اجرای handler باقی بماند؛ میان‌افزارهایی مانند `ThrottlesExceptions` بیرون آن قرار می‌گیرند تا ابتدا rollback و پاک‌سازی انجام شود و سپس release/fail یا ثبت شکست اجرا شود. علاوه بر این، وضعیت fail/release/delete کار پس از بازگشت عادی handler نیز بررسی می‌شود.

برای ارسال کار از داخل تراکنش عملیاتی، ثبت انتشار از طریق `TenantRuntime::afterCommit($callback)` الزامی است. این متد callback را به رکورد تراکنش اتصال `tenant` در سطح جاری وصل می‌کند؛ rollback همان سطح آن را حذف می‌کند و اجرای callback پس از commit نهایی است. فقط شناسه‌ها در callback نگه داشته شوند. داخل callback، کار با روش معمول Laravel به صف غیرهم‌زمان ارسال می‌شود. `after_commit` در اتصال‌های database، redis، sqs و beanstalkd نیز فعال است. تولید payload یا انتشار/اجرای مستقیم کار هنگام بازبودن تراکنش عملیاتی با `OPERATIONAL_QUEUE_REQUIRES_TENANT_COMMIT` رد می‌شود؛ این کنترل شامل مسیر عادی ارسال تکی و bulk است. API خام صف مانند `pushRaw` قرارداد مجاز عملیات نیست. این الزام از وابسته‌شدن انتشار به commit یک تراکنش مرکزی تو‌در‌تو نیز جلوگیری می‌کند.

این روش تضمین انتشار اتمیک بین دو دیتابیس ندارد: خرابی فرایند یا خطای ارسال پس از commit می‌تواند انتشار را ناموفق کند و دیگر امکان rollback داده ثبت‌شده نیست. برای تضمین تحویل در آینده، طراحی outbox لازم است. عملیات محصول و کارهای صف باید در برابر تلاش مجدد نیز رفتار مشخص داشته باشند.

برای کش، remember و قفل عملیاتی از `TenantCache` استفاده می‌شود. کلید از شناسه کسب‌وکار و هش کلید مصرف‌کننده ساخته می‌شود؛ تغییر محل دیتابیس کلید کسب‌وکار را تغییر نمی‌دهد. `Cache::flush` یا استفاده مستقیم از Cache عمومی، قرارداد کش عملیاتی نیست. کش مرکزی ورود و مجوزها مستقل می‌ماند؛ تصمیم امنیتی با داده کش نامعتبر جایگزین نمی‌شود.

## حدود تضمین امنیت

RLS خواندن و نوشتن بدون فیلتر برنامه را به کسب‌وکار جاری محدود می‌کند؛ بدون زمینه، داده‌ای برنمی‌گردد و درج رد می‌شود. runtime علاوه بر marker، نقش، مالکیت، FORCE RLS، وجود ستون business_id و متن policy محدودکننده را از کاتالوگ بررسی می‌کند و ناسازگاری را رد می‌کند. حساب مهاجرت مالک و مجاز به مدیریت schema و داده همه کسب‌وکارهای همان محل است؛ رمز آن نباید در اختیار فرایند عادی API قرار گیرد.

خطاهای شروع زمینه و فرمان مهاجرت با رویداد داخلی `operational_database_failure` و فقط کد علتِ مجاز، شناسه پیگیری، شناسه کسب‌وکار و شناسه محل در صورت در دسترس بودن ثبت می‌شوند. exception خام، پیام آزاد، SQL، DSN و رمز به logger داده نمی‌شوند. علت‌هایی مانند `OPERATIONAL_POLICY_DRIFT`، `UNSAFE_OPERATIONAL_RUNTIME_ROLE` و `OPERATIONAL_STORE_MISMATCH` قابل تشخیص‌اند؛ خطای ناشناخته فقط دسته ثابت دارد. پاسخ API همچنان عمومی است. فرمان CLI کد علت و شناسه پیگیری و محل شکست را اعلام می‌کند.

این مدل GUC را از کد قابل اعتماد برنامه دریافت می‌کند. دارنده رمز runtime که بتواند SQL دلخواه اجرا و مقدار GUC را جعل کند، از مرز اعتماد برنامه عبور کرده است؛ این طراحی ادعای جداسازی رمزنگاری‌شده در برابر چنین مهاجمی ندارد. جلوگیری از تزریق SQL، حفاظت رمز و ممنوعیت اجرای SQL ورودی کاربر همچنان الزامی است. نقش مدیریتی و مالک migration نیز مرز اعتمادند. انتقال زنده بین محل‌ها و پارتیشن‌بندی جدول‌ها نیاز به طراحی جدا دارند.

مبنای فنی: [امنیت سطح ردیف PostgreSQL](https://www.postgresql.org/docs/18/ddl-rowsecurity.html) و [زمینه تنظیمات تراکنشی](https://www.postgresql.org/docs/18/functions-admin.html). به‌خصوص مالک جدول و BYPASSRLS می‌توانند محدودیت‌ها را دور بزنند و TRUNCATE تحت RLS نیست؛ به همین علت نقش و مجوزهای آن بخشی از کنترل‌اند.

## معیار بررسی پایان مرحله

آزمون `OperationalIsolationTest` دو کسب‌وکار را روی یک دیتابیس و کسب‌وکار سوم را روی دیتابیس اختصاصی قرار می‌دهد. جدول‌های آزمون محصول نیستند. موارد بررسی: نصب دو محل و اجرای مجدد بدون مهاجرت تکراری، ماندگاری commit، rollback خطا، خواندن خام و بدون scope، درج و تغییر مالکیت غیرمجاز، FK بین دو کسب‌وکار، یکتایی محلی، منع DDL/TRUNCATE/SET ROLE، زمینه غایب، تفکیک کش و قفل، صف و پاک‌سازی، نقش نامناسب و marker اشتباه.

اجرای کلاستر کاملاً موقت با احراز هویت SCRAM و رمز تصادفی فقط در حافظه فرایند و فایل موقت initdb که حذف می‌شود:

```powershell
powershell -ExecutionPolicy Bypass -File tests/Invoke-OperationalPostgres.ps1
```

مسیر پیش‌فرض باینری `C:\Program Files\PostgreSQL\18\bin` است و با گزینه `-PostgresBin` تغییر می‌کند. اسکریپت پورت آزاد loopback می‌گیرد، کلاستر موقت می‌سازد، مجموعه تست را اجرا و پس از توقف فقط شاخه تصادفی خودش را حذف می‌کند. هیچ اتصال به دیتابیس تنظیم‌شده پروژه ندارد. بدون اجرای این اسکریپت، آزمون واقعی PostgreSQL با پیام روشن skipped می‌شود؛ موفقیت SQLite به معنی اثبات RLS نیست.

## نتیجه اجرای مرحله سوم

این نتایج مربوط به نسخه پیش از اصلاحات بازبینی آسترا هستند. در نوبت اصلاحات، طبق محدوده درخواست تست‌ها اجرا یا تغییر داده نشده‌اند؛ نتیجه تاریخی زیر تأیید رفتار اصلاحات جدید نیست.

- اسکریپت فوق روی PostgreSQL 18 محلی و کاملاً موقت اجرا شد: **۴۲ تست، ۱۵۰۵ assertion، همگی موفق**. هیچ تستی در این اجرا skipped نبود.
- دو دیتابیس فیزیکی ساخته شد؛ کسب‌وکارهای ۱ و ۲ مشترک و کسب‌وکار ۳ اختصاصی بودند. نقش اجرا با رمز مستقل و بدون عضویت نقش دیگر احراز هویت شد؛ آزمایش با اتصال superuser و سپس SET ROLE جایگزین اتصال واقعی runtime نشد.
- نصب و اجرای مجدد دفتر مهاجرت، commit پایدار و rollback، RLS در SQL خام و حذف scope، FK و یکتایی، تفکیک کش/قفل و صف، منع DDL/TRUNCATE/SET ROLE، marker اشتباه و نقش مالک بررسی شدند. تغییر متن policy با همان نام، افزودن عضویت نقش و حذف FORCE RLS نیز در کنترل runtime رد شدند.
- PHP lint و Pint روی تغییرات اجرا شدند. به علت نبود Git، پس از شکست گزینه dirty، مسیرهای مشخص به Pint داده شدند. فایل php.ini تغییر نکرد؛ SQLite فقط در فرایند آزمون برای fixture مرکزی بارگذاری شد.
- راه‌اندازی `pg_ctl start` در sandbox ویندوز با خطای restricted token مواجه شد. harness، postgres را مستقیماً با همان توکن محدود موجود و پنجره مخفی اجرا می‌کند؛ توقف با pg_ctl و کنترل مسیر پاک‌سازی انجام می‌شود. هیچ سطح دسترسی بالاتری درخواست نشد.
- داده مرکزی آزمون در SQLite حافظه‌ای بود و خود عملیات، policyها، نقش‌ها و دفتر مهاجرت روی PostgreSQL واقعی آزموده شدند. این اجرا اثبات استقرار شبکه‌ای روی سرور دوم، انتقال داده نسخه قبلی یا آمادگی انتشار محصول نیست.
- دیتابیس پروژه/تولید، فایل واقعی محیط و snapshot تاریخی کامنت‌ها تغییر نکردند. اسکریپت provisioning روی سرور پروژه اجرا نشده است؛ مدیر استقرار همچنان باید هدف‌های واقعی و نقش‌ها را طبق همین راهنما آماده کند.
