انتقل للمحتوى
علي هيثم·TECH·يردّ خلال أقل من ساعة
  • الرئيسيةابدأ من هنا
  • الأعمالدراسات حالة، مشاريع
  • الخدماتما الذي سأبنيه لك
  • المدونةمقالات، سلاسل
  • التدريبدورات، مختبرات، دورات
  • عنّيالمهندس وراء هذا الموقع
تسجيل الدخولابدأ مشروع
علي هيثم · TECH
  • الرئيسية↗
  • الأعمال↗
  • الخدمات↗
  • المدونة↗
  • التدريب↗
  • عنّي↗
تسجيل الدخولابدأ مشروع
متاح
علي هيثم·TECH

استوديو هندسي. دمشق، GMT+3.

aliyosef.online

الاستوديو

  • الأعمال
  • المدونة
  • التدريب
  • عنّي
  • تواصل معي

البوابة

  • تسجيل الدخول
  • فتح تذكرة دعم
  • متابعة مشروع
  • حسابي

مصادر

  • الوثائق
  • حالة الأنظمة
  • سجلّ التغييرات
  • حزمة العلامة
  • الخصوصية
  • الشروط

النشرة

ملاحظات تقنية وأخبار، أسبوعياً. بدون حشو.

مجاني. إلغاء الاشتراك متى ما حبيت.

تابعني على
© 2026 علي هيثم يوسف. جميع الحقوق محفوظة.مبنيّ يدويًا بـ React 19.آخر نشر · 2026-05-08كل الأنظمة تعمل
  1. الرئيسية/
  2. المدوّنة/
  3. Flutter
Flutter

Drift migrations بدون فقد بيانات

القبل، الأثناء، الـ rollback. فحوصات pre-flight أنقذتنا خمس مرات.

٢٦ شباط ٢٠٢٦·9 دقائق·بقلم علي يوسف

جاري تحميل المقال…

شاركXLinkedInRSS
استلم المقال التالي في بريدك.مقال جديد كل أحد. هندسة بلغة واضحة. مجاني، يمكنك إلغاء الاشتراك في أي وقت.اشترك→
AY
علي هيثم يوسف

مهندس وكاتب. أبني منصات متعدّدة المستأجرين، محرّكات مزامنة، وأنظمة موثوقة بهدوء. أكثر من ٦٠٠ مشروع مُسلَّم منذ ٢٠١٥.

اشتركابدأ مشروعاً→

اقرأ بعدها

  • OF
    Series
    ٦ آذار ٢٠٢٦·Flutter

    Flutter offline-first، الجزء الأول: الـ boot

    افتتاح السلسلة. شو بيعمل التطبيق بأول 800ms لما ما في شبكة.

    13 دقيقة
  • SR
    ١٨ شباط ٢٠٢٦·Flutter

    تكلفة استعادة الحالة

    إرجاع التطبيق لمكان ما تركه المستخدم. النسخة الرخيصة مقابل الغالية.

    7 دقائق
  • RB
    ١٠ شباط ٢٠٢٦·Flutter

    Riverpod مقابل Bloc، بعد سنة

    إيصالات من الإنتاج. وين بيناسب كل واحد، وين بيفشل، وشو تمنّينا حدا قاللنا.

    10 دقائق
←العودة إلى كل المقالات

ترحيل Drift يعمل مرّة واحدة على كلّ جهاز، أوّل مرّة يفتح فيها المستخدم نسخة تطبيق فيها الـ schema الجديد. لا يوجد DBA يراجعه. لا يوجد rollback. إن انهار، يحصل المستخدم على قاعدة بيانات محلّيّة تالفة، ويمتلئ صندوق دعمك. الترحيل الذي «يعمل على جهازي» يمكن أن يُسقط ألف جهاز.

هذا المقال عن الأنماط التي نجت لنا، والفحص المسبق الذي أنقذنا من شحن ترحيلات سيّئة خمس مرّات.

ماذا يكون ترحيل Drift فعلاً#

ترفع schemaVersion في فئة قاعدة البيانات. Drift يقارن ذلك بالنسخة المُخزَّنة على الجهاز. إن اختلفا، يعمل callback MigrationStrategy.onUpgrade، يأخذ (Migrator m, int from, int to) ويمشي عبر أيّ DDL كتبته.

@override
MigrationStrategy get migration => MigrationStrategy(
  onCreate: (m) => m.createAll(),
  onUpgrade: (m, from, to) async {
    if (from < 2) {
      await m.addColumn(invoices, invoices.dueDate);
    }
    if (from < 3) {
      await m.createTable(payments);
      await _backfillPaymentsFromInvoices(m);
    }
  },
);

لاحظ سلسلة if (from < N)، لا if (from == N - 1). جهاز كان غير متّصل لستّ نسخ لا يزال يحتاج لاجتياز كلّ خطوة. السلسلة هي ترتيب الترحيل.

الأنماط الأربعة التي فشلت لنا#

كلّ حادث «انهار الترحيل» كان لدينا يقع في إحداها:

١. إضافة NOT NULL بدون افتراضيّ#

await m.addColumn(invoices, invoices.status); // status هو NOT NULL

الصفوف الموجودة ليس لديها قيمة لـ status. الـ DDL يفشل، schema_version الجهاز يبقى عند الرقم القديم، ولا يمكن للتطبيق إنهاء الترحيل أبداً. كلّ إعادة تشغيل تُعيد تشغيل نفس الترحيل الفاشل.

الإصلاح: قابل للـ null، ثمّ backfill، ثمّ إضافة القيد في ترحيل لاحق.

// V5: أضف قابل للـ null
await m.addColumn(invoices, invoices.statusOptional);
// V5: backfill
await m.customStatement(
  "UPDATE invoices SET status_optional = 'draft' WHERE status_optional IS NULL"
);
// V6: إعادة التسمية + التشديد في إصدار لاحق بعد أن نعرف أنّ V5 عمل في كلّ مكان

٢. إعادة تسمية جدول#

m.renameTable(...) في Drift غلاف حول ALTER TABLE RENAME. إن كان كودك لا يزال يشير إلى اسم الجدول القديم في أيّ مكان، الترحيل ينجح لكنّ الاستعلام التالي يفشل. أعد التسمية في خطوتَين: ١. أضف جدولاً جديداً، اكتب مزدوجاً من التطبيق، backfill. ٢. أسقط الجدول القديم في إصدار لاحق.

٣. إضافة قيد UNIQUE إلى بيانات بها مكرّرات#

await m.customStatement(
  'CREATE UNIQUE INDEX idx_unique_email ON contacts(email)'
);

إن كان لجهتَي اتّصال على جهاز المستخدم نفس البريد (وهو يحدث، لأنّ المستخدمين)، يفشل إنشاء الـ index. تكتشف فقط حين ينهار الترحيل على جهاز شخص آخر.

الإصلاح: أزل المكرّرات أوّلاً، ثمّ أضف الـ unique index. إزالة المكرّرات نفسها تحتاج عناية لأنّك يجب ألّا تحذف بيانات قد يحتاجها المستخدم:

// علّم المكرّرات بدلاً من حذفها
await m.customStatement('''
  UPDATE contacts SET _dup_of = (
    SELECT MIN(id) FROM contacts c2 WHERE c2.email = contacts.email
  )
  WHERE id NOT IN (
    SELECT MIN(id) FROM contacts GROUP BY email
  )
''');

٤. ترحيلات تستغرق أكثر من ~٣ ثوانٍ#

المستخدم يحدّق في splash مجمّد. يخرج من التطبيق. يُعيد التشغيل. الترحيل يبدأ من جديد. يخرج مجدّداً. التطبيق «مكسور».

كلّ ترحيل يجب أن يكون سريعاً على أبطأ جهاز في الأسطول. بالنسبة لنا هو Android اقتصاديّ عمره أربع سنوات بـ eMMC بطيء. اختبر هناك، لا على M-series Mac.

الفحص المسبق#

قبل كلّ إصدار نشغّل خطوة CI:

  1. تتحقّق من snapshot للـ schema السابق.
  2. تولّد مجموعة بيانات اصطناعيّة تمارس كلّ عمود، متضمّنة حالات حدّيّة واقعيّة (سلاسل طويلة، unicode، NULLs، مكرّرات موجودة مسبقاً).
  3. تُقلِع النسخة الجديدة مقابل تلك القاعدة وتشغّل الترحيل حتّى اكتماله.
  4. تؤكّد أنّ عدد الصفوف لم ينخفض.
  5. تؤكّد أنّ schema بعد الترحيل يطابق المتوقّع.
  6. تقيس الوقت كلّه وتفشل إن استغرق أكثر من ٣ ثوانٍ.
// drift_migration_test.dart
test('ترحيل v8 → v9 على مجموعة بيانات اصطناعيّة', () async {
  // ابنِ قاعدة بيانات v8
  final tempPath = await _createV8Database();

  // افتح كـ v9؛ هذا يطلق الترحيل
  final stopwatch = Stopwatch()..start();
  final db = AppDatabase(NativeDatabase(File(tempPath)));
  await db.userPrefs.currentTenant(); // اجبر استخدام schema
  stopwatch.stop();

  expect(stopwatch.elapsed.inSeconds, lessThan(3));
  expect(await db.contacts.select().get(), hasLength(_v8ContactCount));
  expect(await db.payments.select().get(), isNotEmpty);
});

هذا الاختبار الواحد التقط خمسة bugs حقيقيّة كنّا سنشحنها لولاه: NOT NULL بدون backfill، إعادة تسمية كسرت استعلاماً، إزالة مكرّرات فقدت ملاحظات، ALTER استغرق ١٤ ثانية على المجموعة الاصطناعيّة، و index مفقود تسبّب بانخفاض الترحيل إلى مسح كامل.

البيانات الاصطناعيّة ليست بيانات حقيقيّة

المجموعة الاصطناعيّة فحص عقلانيّ، لا ضمان. للمستخدمين الحقيقيّين حالات حدّيّة حقيقيّة. نُكمل بـ telemetry اختياريّ: أيّ ترحيل يستغرق > ٥ ثوانٍ أو يرمي يُبلَّغ عنه مع schema-version والتوقيت (بدون محتويات صفوف). الشهر الأوّل من schema جديد هو حين تجد الحالات التي فاتتها بياناتك الاصطناعيّة.

ماذا نفعل حين ينهار ترحيل في الميدان#

ثلاث طبقات دفاع:

  1. الكشف. التطبيق يلتقط استثناء الترحيل ويعرض splash «خطأ قاعدة بيانات» مع إجراء استرداد.
  2. الاسترداد. زرّ «استعد من السحابة» يسحب من الخادم إلى قاعدة بيانات محلّيّة جديدة. يعمل لأنّ كلّ صفّ مجال مُكرَّر على الخادم.
  3. التشخيص. الـ telemetry يتضمّن خطوة الترحيل التي فشلت ونسخة الـ schema. ندفع hotfix يستهدف تلك الخطوة المحدّدة.

مسار الاسترداد يعني أنّ ترحيلاً فاشلاً هو «دقيقة أو دقيقتان من إعادة المزامنة» بدلاً من «بيانات ضائعة وإلغاء تثبيت».

الخلاصة المملّة#

الترحيلات على أجهزة المستخدمين لا تغفر. الطريقة لجعلها مملّة هي:

  • دائماً قابل للـ null + backfill + قيد عبر إصدارات متعدّدة.
  • لا تُعِد التسمية في لقطة واحدة.
  • أزل المكرّرات قبل قيود unique.
  • اختبر على أجهزة بطيئة حقيقيّة.
  • شغّل الفحص المسبق على كلّ PR.

الجزء التالي من هذه السلسلة عن استعادة الحالة: إعادة التطبيق إلى حيث تركه المستخدم بدون دفع كلفة الـ snapshot في كلّ مرّة. صفحة السلسلة تتابع البقيّة.