Drift migrations بدون فقد بيانات
القبل، الأثناء، الـ rollback. فحوصات pre-flight أنقذتنا خمس مرات.
جاري تحميل المقال…
القبل، الأثناء، الـ rollback. فحوصات pre-flight أنقذتنا خمس مرات.
جاري تحميل المقال…
ترحيل Drift يعمل مرّة واحدة على كلّ جهاز، أوّل مرّة يفتح فيها المستخدم نسخة تطبيق فيها الـ schema الجديد. لا يوجد DBA يراجعه. لا يوجد rollback. إن انهار، يحصل المستخدم على قاعدة بيانات محلّيّة تالفة، ويمتلئ صندوق دعمك. الترحيل الذي «يعمل على جهازي» يمكن أن يُسقط ألف جهاز.
هذا المقال عن الأنماط التي نجت لنا، والفحص المسبق الذي أنقذنا من شحن ترحيلات سيّئة خمس مرّات.
ترفع 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). جهاز كان غير
متّصل لستّ نسخ لا يزال يحتاج لاجتياز كلّ خطوة. السلسلة هي ترتيب
الترحيل.
كلّ حادث «انهار الترحيل» كان لدينا يقع في إحداها:
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.
٢. أسقط الجدول القديم في إصدار لاحق.
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:
// 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
مفقود تسبّب بانخفاض الترحيل إلى مسح كامل.
ثلاث طبقات دفاع:
مسار الاسترداد يعني أنّ ترحيلاً فاشلاً هو «دقيقة أو دقيقتان من إعادة المزامنة» بدلاً من «بيانات ضائعة وإلغاء تثبيت».
الترحيلات على أجهزة المستخدمين لا تغفر. الطريقة لجعلها مملّة هي:
الجزء التالي من هذه السلسلة عن استعادة الحالة: إعادة التطبيق إلى حيث تركه المستخدم بدون دفع كلفة الـ snapshot في كلّ مرّة. صفحة السلسلة تتابع البقيّة.