Command Palette

Search for a command to run...

المدوّنة

بلا اتصال أولًا، لأن الورشة بلا شبكة

نُشر في
مدة القراءة
6 د قراءة
الأشكال
5 أشكال
Tabaga Team

ما علّمنا إياه Batipro وOfficine عن قواعد البيانات المحلية والمزامنة والتراخيص، والأخطاء التي لا توجد إلا على جهاز حقيقي.

السابعة صباحًا، ورشة خارج المدينة. رئيس الورشة يعبر البوابة، وهاتفه يفقد آخر شرطة شبكة. سيسجّل الحضور، ويدوّن تسليمتين من الإسمنت، ويصوّر عارضة متشققة، ويقيّد ثلاث نفقات قبل أن يرى شبكة مجددًا في السادسة والنصف، على Wi-Fi المكتب. خلف طاولة صيدلية في الطرف الآخر من المدينة، حاسوب لم يعرف خادمًا قط يدير سجلات المحل كله. كلاهما يشغّل برنامجنا، وكلاهما علّمنا الدرس نفسه من طرفين متقابلين.

0
نداء شبكة في مسار الكتابة
51
ترحيل مخطط بعد شهرين من الاستعمال اليومي
14 يوم
تجربة تُحسب على الجهاز، بلا خادم
0
صف ضاع في حادثة إعادة التسمية، بفضل اختبار واحد

الورشة بلا شبكة. البرنامج ليس كذلك.

07:00no signal3 pending writes09:30no signal11 pending writes12:00one bar19 pending writes15:00no signal27 pending writes18:30office wifioutbox drainedattendance · materials · expenses · photosevery write lands in SQLite immediately; the network is never on the pathsyncBATIPRO · A CHEF DE CHANTIER’S PHONE, AS THE RELEASE CHECKLIST TESTS IT
الشكل 1.يوم في الورشة: الهاتف يفقد الشبكة عند البوابة، وصندوق الصادر يمتلئ طوال اليوم، وكل شيء يغادر دفعة واحدة من المكتب ليلًا.

قائمة إصدار Batipro تنتهي باختبارين من الواقع: تشغيل رواتب حقيقي، وشخص يزامن من ورشة بلا إشارة. ذلك الاختبار الثاني هو المنتج كله في جملة. لا شيء يفعله العامل في الورشة يجوز أن يعتمد على وجود شبكة، ولا شيء فعله طوال اليوم يجوز أن يضيع حين تعود.

لذلك كل كتابة تحطّ فورًا في SQLite. الحضور والمواد والنفقات والصور حقائق محلية لحظة إدخالها. الشبكة في هذا التصميم ليست بطيئة ولا غير موثوقة؛ هي ببساطة ليست على المسار. حين يصل الهاتف إلى المكتب ليلًا، يُفرَّغ صندوق الصادر بالترتيب، ويغادر اليوم كله دفعة واحدة.

أين تسكن الحقيقة

SQLiteevery screen, report and joinruns here, with no networkLaptop or PCfull local replicaSQLiterows, when a signal is thereAppwriterow shuttle, not a query engineoptional · last resortsource of truthbackup and sync onlyOFFICINE HAS NO SYNC AT ALL IN V1: ONE USER, ONE MACHINE, ONE ENCRYPTED FILE
الشكل 2.أين تسكن الحقيقة: كل جهاز يحمل قاعدة البيانات كاملة؛ والسحابة، إن وُجدت، لا تفعل سوى نقل الصفوف.

ملف README في Batipro يقولها في سطر: كل البيانات تسكن في قاعدة SQLite على الجهاز، عبر Drift، والتطبيق يعمل بالكامل بلا اتصال. السحابة طبقة اختيارية، ملاذ أخير، تُستعمل فقط حين يُراد نسخ احتياطي أو مزامنة. هذا الترتيب مهم. حين تكون القاعدة المحلية هي المخزن الأساسي، فكل شاشة وكل تقرير وكل ربط يجري ضد SQLite على الجهاز، والخادم، إن وُجد، لا يفعل سوى نقل الصفوف.

Officine يذهب أبعد ولا يملك أي مزامنة في نسخته الأولى: مستخدم واحد، جهاز واحد، ملف مشفّر واحد. على Windows لا تعمل حزمة Flutter المعتادة sqflite، فيهيّئ التطبيق sqflite_common_ffi ويبدّل مصنع قاعدة البيانات قبل أن يعمل أي شيء آخر. الترحيلات تسكن في مساعد واحد مرقّم بالإصدارات مع onCreate وonUpgrade، والمخطط يبدأ صغيرًا: عملاء، أطباء، ديون، دفعات الديون، وصفات، طلبات، أدوية، مع فهرس على تاريخ انتهاء الدواء لأن تنبيهات الانتهاء هي الميزة التي طلبها الصيادلة أولًا.

مخطط Batipro اليوم في الإصدار 51. واحد وخمسون ترحيلًا هو شكل منتج حقيقي بعد شهرين من الاستعمال اليومي، ولا يمكن النجاة منه إلا لأن الترحيلات رُقّمت منذ الأول.

المجلد الذي أكل الصيدلية

%APPDATA%\OldName\...the pharmacy's real records%APPDATA%\Officine\...new build, new folderrenamebefore: new folder is empty‘our records look deleted’, no error anywhere_adoptLegacyDatabase: copy, never movedboriginal stays as fallbackpinned by testsbrand_identity_testlegacy_adoption_testnot one row lost
الشكل 3.المجلد الذي أكل الصيدلية: منتج أعيدت تسميته يشير إلى مجلد فارغ بلا أي خطأ. الإصلاح ينسخ القاعدة القديمة إلى الأمام ويترك الأصل كما هو.

هذه كلفتنا فزعة. على Windows، اسم الشركة واسم المنتج في الـ runner يقرران أين تسكن القاعدة، شيء مثل %APPDATA%\Officine\Officine\officine.db. غيّر أيًا من النصين، أو اسم الملف، فيشير بناء جديد إلى مجلد فارغ بلا أي خطأ. في نظر الصيدلي، سجلاته تبدو محذوفة.

أعدنا تسمية المنتج مرة، في البداية. البناء الآن يتبنّى القاعدة القديمة عند أول تشغيل بنسخها إلى الأمام، لا بنقلها أبدًا، فيبقى الأصل في مكانه كاحتياط. اختباران يثبّتان هذا السلوك: واحد يتحقق أن معرّفات العلامة لا تنحرف أبدًا، وواحد يتحقق أن قاعدة أول بناء تُنقل دون فقدان صف. الـ commit الذي أغلق الحادثة يحمل هذا العنوان بالضبط.

عامل موقع التخزين كعقد عام. اكتبه، اختبره، واجعل إعادة التسمية ترحيلًا لا إعادة تسمية.

ملف README في Batipro

المزامنة دمج، لا سلك

ONE DRIFT TRANSACTIONrowupdatedAtoutboxpendinga crash can never lose a pending writeoffline: rows simply wait heredrain, in orderserver(table, rowId, JSON, serverVersion)assigns a monotonic serverVersionknows nothing about the schemapullpullother seatsconflict: last writer wins on updatedAt, tiebreak by serverVersionenough for 1 to 5 seats whose modules barely overlap; written down so nobody upgrades it to CRDTs
الشكل 4.كتابة واحدة، معاملة واحدة: الصف ومدخل صندوق الصادر يحطّان معًا؛ الخادم لا يفعل سوى الترتيب والتمرير؛ والمقاعد الأخرى تسحب.

حين كبر Batipro من جهاز واحد إلى فريق صغير، صمّمنا المزامنة حول جملة من ملاحظاتنا: الجزء الصعب في المزامنة هو الدمج، لا النقل. تفريغ صندوق الصادر، الترتيب، حل التعارضات، إعادة التشغيل بلا تكرار، ترحيل المخطط عبر إصدارات التطبيق. لذا بُني المحرك أولًا ضد نقل عبر الشبكة المحلية، ولم يوجَّه إلى السحابة إلا لاحقًا.

التصميم الذي خرج من ذلك:

  • كل كتابة تضع الصف ومدخل صندوق الصادر في معاملة Drift واحدة. الانهيار لا يستطيع أبدًا أن يضيّع كتابة معلّقة؛ وبلا اتصال، تنتظر الصفوف ببساطة في الصندوق.
  • الخادم يعطي serverVersion متزايدًا لكل صف يقبله. التعارضات تُحل بقاعدة آخر كاتب يفوز على updatedAt، والعملاء لا يقارنون ساعاتهم ببعضهم أبدًا.
  • الخادم ناقل صفوف، لا محرك استعلامات. يخزن (table, rowId, JSON, serverVersion) ولا يعرف شيئًا عن المخطط. التقارير والربط والتجميع تبقى محلية، حيث النسخة الكاملة موجودة أصلًا.
  • الصور والمستندات تسافر خارج المقبس، تُرفع مباشرة إلى تخزين الكائنات وتُفهرس بالصف.
  • قاعدة آخر كاتب يفوز على مستوى الصف تكفي لأن الفرق من مقعد إلى خمسة والوحدات بالكاد تتداخل بين الأدوار. كتبنا هذا التبرير حتى لا «يحسّنه» أحد إلى CRDT لاحقًا.

المرحّل لا يمرر سوى نص مشفّر على العميل، بمفتاح شركة لا يغادر جهاز المالك أبدًا. غلاف النسخ الاحتياطي مبني؛ تشفير الفروقات ليس كذلك، وجدول الحالة في المستودع يقولها بالبرتقالي. جداول الحالة الصادقة هي كيف يُبقي استوديو فردي خارطة طريقه صادقة.

خادم يملكه العميل

لماذا Appwrite لطبقة المزامنة؟ لأنه قابل للاستضافة الذاتية. Firebase وSupabase سحابيان فقط، وهذا يفرض بطاقة بنكية دولية على كل عميل، والبطاقة الدولية هي بالضبط العائق الذي شكّل كل بنية الدفع عندنا. لذا يحصل كل عميل لـ Batipro على مشروع Appwrite خاص به باسمه، مع ثلاثة خيارات استضافة: Appwrite Cloud، أو VPS في الجزائر يُفوتر بالدينار وبفاتورة محلية، أو جهاز في مكتبه. كود التطبيق واحد في الثلاثة. فقط عنوان الخادم يختلف.

رخصة تعمل عبر الهاتف

installday 0Pro trial startsday 14trial endsfalls to Gratuit, never locked outactivationonline or by phonerequest code → activation codechannelper licencestable, or beta for one keyno in-app beta toggle, on purposea toggle means one support call to turn it on and no way to know which build was running when it brokeINSTALLERS: INNO SETUP IN CI · SHA-256 · ED25519 SIGNATURE · TAGS ONLY FROM MAIN
الشكل 5.رخصة تنجو من شبكة ميتة: التجربة تُحسب على الجهاز، والتفعيل يمكن إملاؤه عبر الهاتف، والقنوات تُضبط لكل رخصة لا لكل بناء.

التثبيت الجديد يبدأ تجربة أربعة عشر يومًا تُحسب محليًا، بلا نداء خادم وبلا رمز موقّع. حين تنقضي، يتراجع التطبيق إلى مستوى مجاني بدل أن يقفل على المستخدم. التفعيل يتحدث إلى واجهة تراخيص صغيرة على Cloudflare Workers، لكن هناك أيضًا تفعيل بلا اتصال: المستخدم يقرأ رمز طلب عبر الهاتف، وكونسول البائع يصدر رمز تفعيل بالمقابل. هذا المسار موجود لأن أول التجارب فُعّلت من أماكن كان الهاتف فيها الشيء الوحيد الذي يملك إشارة.

قنوات الإصدار لكل رخصة، لا لكل بناء. كل عميل يطلب stable؛ والرخصة تستطيع تجاوزها إلى beta لمفتاح واحد. لم نشحن عمدًا زر «إصدارات تجريبية» في التطبيق، لأن الزر يعني مكالمة دعم لتشغيله ولا وسيلة لمعرفة أي بناء كان يعمل حين انكسر شيء.

المثبّتات تبنيها CI بـ Inno Setup، مع مجموع تحقق، وتوقيع بمفتاح Ed25519. المثبّت غير الموقّع يجب ألا يكون قابلًا للنشر، والوسم الذي ليس على main يرفضه سير العمل.

أخطاء لا توجد إلا على أجهزة حقيقية

  • ملف المخطط عندنا كان يستعمل CREATE TABLE IF NOT EXISTS، فإضافة عمود لم تفعل شيئًا على قاعدة موجودة. أداة النشر أبلغت عن نجاح، والعمود لم يكن هناك، ونقطة إدارة واحدة أجابت بـ 500 ضد القاعدة الحقيقية بينما تجتاز كل اختبار وحدة ضد المستودع في الذاكرة. الإصلاح كان سكربت ترحيل حقيقي مع اختبار يُسقط البناء عند انحراف المخطط.
  • التطبيق يحتاج تعريفين وقت البناء: عنوان الخادم والمفتاح العام. بالأول وحده يصل إلى الخادم ثم يرفض الرخصة التي تُسلَّم له. بالثاني وحده لا يغادر الجهاز أبدًا. لفترة، كان الاثنان ينتجان «فشل التفعيل» نفسه الذي ينتجه مفتاح خاطئ، فيرسلك لفحص الكونسول والرخصة، المكانين اللذين ليست فيهما المشكلة. رسائل الخطأ الآن تسمّي التعريف الناقص.
  • تصدير CSV مفتوح في Excel أظهر حرفًا غريبًا في أول خلية. UTF-8 BOM. سطر واحد لإزالته، وظهيرة كاملة للعثور عليه.
  • ثلاثة وعشرون حقلًا قابلًا للتحرير كانت تدمّر قيمتها حين تكون الواجهة بالإنجليزية أو العربية، لأن منسّق الأرقام والمحلّل اختلفا على الفاصل العشري. أخطاء اللغة تختبئ في مسار الكتابة، لا في مسار القراءة.

إن كنت تبني للأماكن نفسها

  1. قرّر من يحمل الحقيقة، الجهاز أم الخادم، ولا تجادل فيها مجددًا.
  2. ضع الكتابة ومدخل صندوق الصادر في معاملة واحدة. كل ما عداه في المزامنة أسهل من ضياع كتابة.
  3. ابنِ محرك المزامنة ضد نقل وهمي أولًا.
  4. ثبّت مسار التخزين والمعرّفات والترحيلات بالاختبارات. هي الأجزاء التي تكسرها إعادة التسمية بصمت.
  5. أبقِ جدول حالة بثلاثة ألوان وحدّثه حين يكون شيء غير مبني.
  6. تحقق من الإصدار كما يفعل العميل: تشغيل رواتب حقيقي، وشخص يزامن من ورشة بلا إشارة.
Command Palette

Search for a command to run...