Lulucat

لماذا لا نستخدم قاعدة بيانات حتى الآن

Gaoge ZhangGaoge Zhang

قيّمنا Turso/libSQL لتطبيقنا للكتابة اليدوية على iPad، وقسنا كل شيء، واخترنا ملفات اللقطات المسطّحة. لا يحتاج عبء العمل إلى قاعدة بيانات، وكل خطوة لا ينبغي أن تدفع إلا تكلفة المشكلات الموجودة فعلاً.

Lulucat Notes تطبيق للكتابة اليدوية على iPad. حتى الأسبوع الماضي، لم يكن يملك سوى لوحة واحدة، ولم يكن يعرف مفهوم الملاحظة الثانية. كنا على وشك إضافة مكتبة للملاحظات — مستندات متعددة، لكل منها صفحات متعددة — وكان أول سؤال معماري هو التخزين.

بدت قاعدة البيانات الإجابة الواضحة. تطبيقات الملاحظات تخزّن بيانات منظّمة، والبيانات المنظّمة توضع في قواعد البيانات. قيّمنا Turso وSwift SDK الخاص به، وشغّلناه على محاكي iOS، وقسنا بيانات ضربات القلم الحقيقية، ثم اخترنا ألا نستخدمه.

اخترنا الملفات المسطّحة بدلًا من ذلك. إليكم ما وجدناه ولماذا اتخذنا هذا القرار.

دفتر مفتوح عليه ملاحظات مكتوبة بخط اليد وقلم على مكتب خشبي.

صورة من Gabriel Cox على Unsplash. ترخيص Unsplash.

ما الذي يفعله التطبيق فعليًا بالبيانات

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

لا يوجد وصول متزامن. يكتب شخص واحد على صفحة واحدة من مستند واحد في كل مرة. ولا يوجد بحث عبر المستندات — فمكتبة الملاحظات لا تحتاج لكل مستند إلا إلى عنوان، وطابع زمني، وعدد الصفحات، وصورة مصغرة للغلاف، ولا يتطلب أي منها قراءة محتوى الصفحة.

الاستعلامات والفهارس وتنسيق الوصول المتزامن هي الأشياء التي صُممت لها قواعد البيانات. تطبيقنا لا يستخدم أيًا من الثلاثة.

تقييم Turso

قيّمنا libsql-swift، وهو Swift SDK الرسمي لمحرك libSQL الخاص بـ Turso.

يعمل SDK. تنجح حالات الاختبار التسع كلها. دمجناه في نسخة من التطبيق، وبنينا التطبيق لمحاكي iOS، وشغّلناه، وأنشأنا قاعدة بيانات محلية في sandbox الخاص بالتطبيق. كتبنا 100 ضربة، لكل منها 3,400 نقطة أخذ عينات — أي 4,080,000 بايت من بيانات BLOB — ضمن معاملة واحدة. استغرق ذلك نحو 0.019 ثانية على جهاز Mac التطويري لدينا.

بعد تشغيل PRAGMA wal_checkpoint(TRUNCATE)، تقلّص ملف WAL إلى الصفر، وتمكّنا من نسخ ملف .db الرئيسي وحده إلى موقع آخر، وفتحه، وقراءة كل البيانات منه. المحرك نفسه سليم.

لدى SDK تكاليف. يزن CLibsql.xcframework مقدار 161 MB. وبعد الربط، انتقل إصدار Debug لمحاكي التطبيق لدينا من نحو 1.9 MB إلى نحو 8.2 MB. واجهة API متزامنة وحاجبة، ولا توجد أغلفة لـ Swift Concurrency. ولا توجد طريقة close() صريحة. ولا ترمي Transaction.commit() استثناءً — إذ تعيد واجهة C الأساسية void. ويصف README الخاص بالمستودع SDK بأنه «معاينة تقنية»، وكان أحدث commit قبل تقييمنا بنحو عام، في يوليو 2025.

هناك فجوة في منظومة Turso. توصي Turso الآن بمحركها الجديد «Turso Database» وبروتوكول «Turso Sync» للمشاريع الجديدة. لدى Turso Sync حزم SDK للغات TypeScript وPython وGo وRust. لكنه لا يملك واحدًا لـ Swift. يوجد نمط Embedded Replica الأقدم في libsql-swift، لكن مُهيّئ Swift فيه لا يعرِض المعامل offline المطلوب لتطبيق جوّال يعتمد على العمل المحلي أولًا بالكامل. إن اعتماد libsql-swift اليوم يمنحنا نسخة SQLite محلية، لكنه لا يمنحنا قدرات المزامنة التي تميّز Turso.

ما الذي ستكلّفنا قاعدة البيانات الآن

حتى لو كان SDK ناضجًا، لكنا سندفع تكاليف لا تشتري شيئًا لِعبء عملنا:

إدارة الملفات الجانبية لـ WAL. تنشئ قاعدة البيانات قيد التشغيل ملفيًّا مرافقين هما -wal و-shm. ونسخ مستند يعني إما تنفيذ checkpoint أولًا أو نسخ الملفات الثلاثة ذريًا. ويتطلب تصدير حزمة .lnote إلى Files أو AirDrop الآن خطوة تصدير مسبقة لا يراها المستخدم ولا يستطيع المطوّر نسيانها.

طبقة تكييف. يجب تسلسل الضربات في BLOBs وإلغاء تسلسلها من جديد. تملك عناصر الصفحة ترتيب مصفوفة طبيعيًا ترسمه اللوحة مباشرة؛ وستضيف قاعدة البيانات ترتيبًا للصفوف وأعمدة z-index. سنكتب طبقة ترجمة بين تمثيلين للبيانات نفسها، ثم نحافظ عليها عبر كل تغيير في المخطط.

تبعية بحجم 161 MB. بالنسبة إلى تطبيق يقل حجم إصدار Debug فيه عن 2 MB، فإن تبعيةً يزيد حجمها على 80× حجم التطبيق نفسه تمثل تكلفةً تستحق الانتباه — لا سيما إذا كان موصوفًا بأنه «معاينة تقنية» وظل عامًا بلا نشاط.

هذه التكاليف ليست افتراضية. تبدأ لحظة ربط التبعية. وهي تمنحنا قدرات — الاستعلام، والفهرسة، والكتابات المتزامنة — لا يستخدمها تطبيقنا.

الحل الذي أطلقناه: حزم ملفات اللقطات

مستند .lnote هو حزمة دليل:

Documents/Notes/<UUID>.lnote/
  manifest.json              # library cache: title, time, page count, cover
  document.json              # source of truth: document metadata + page order
  pages/
    <page-uuid>.content      # one snapshot per page
  assets/                    # document-level shared resources
    <asset-uuid>.jpg
  thumbnails/
    <page-uuid>.jpg          # per-page thumbnail; first page doubles as cover

يمثل document.json مصدر الحقيقة لبنية المستند: معرّفه، وعنوانه، وطوابعه الزمنية، وقائمة الصفحات المرتبة، مع حجم لوحة كل صفحة وطوابعها الزمنية وعدد عناصرها. وتخزّن ملفات محتوى الصفحات مصفوفة العناصر بالترميز الصحيح المُكمَّم نفسه الذي يستخدمه التطبيق بالفعل — الإحداثيات وأنصاف الأقطار بدقة 0.1 نقطة، والضغط بالألف، والطوابع الزمنية بالمللي ثانية النسبية.

تقرأ مكتبة الملاحظات manifest.json والصور المصغرة للغلاف فقط. ولا تحلّل document.json أو محتوى أي صفحة. وعند فتح صفحة، تحمّل ملف .content واحدًا. وهذه هي قراءة الملف الوحيدة التي تلامس بيانات ضربات القلم.

الصفحات تحل مشكلة تضخيم الكتابة

يمتلك تطبيق الكتابة اليدوية مفهوم الصفحة أصلًا — فهي الوحدة التي يفكر فيها المستخدم، والشيء الذي ينتقل المستخدم بين صفحاته بالسحب. وجعل الصفحة وحدة الاستمرارية يعني أن الحفظ التلقائي لا يعيد كتابة إلا الصفحات التي تغيّرت.

تشغل صفحة واحدة من الكتابة اليدوية — لنقل 1,000 إلى 2,000 ضربة — نحو 3–5 MB في تنسيقنا المُكمَّم. وتُكمِّم جلسة تسجيل واحدة من 21 ضربة، مع 3,400 نقطة أخذ عينات، إلى نحو 55 KB. وتستغرق كتابة لقطة صفحة واحدة إلى ذاكرة التخزين الفلاشية 10–20 ms على العتاد الحديث. ومع تأخير 0.5s، تصبح عمليات الحفظ غير مرئية للمستخدم.

تتوسع تكلفة الحفظ مع مقدار الكتابة في الصفحة الحالية، لا مع إجمالي عدد صفحات المستند. ويحفظ دفتر من 200 صفحة بالسرعة نفسها تمامًا التي يحفظ بها دفتر من صفحتين، لأن الصفحة المتسخة وحدها هي التي يعاد كتابة لقطة لها.

تستخدم كل كتابة عمليات ملف ذرّية — الكتابة إلى ملف مؤقت ثم إعادة تسميته — ولذلك لا يمكن لتعطل أثناء الحفظ أن ينتج صفحة مبتورة. ويؤدي الانتقال إلى الخلفية إلى تفريغ كل الصفحات المتسخة فورًا، بما يطابق السلوك الذي كان التطبيق يملكه مع لوحة واحدة.

الاتساق من دون معاملات

لا تملك حزم الملفات معاملات، لكنها تملك قواعد ملكية واضحة تؤدي الغرض نفسه:

الموارد قبل المراجع. عندما يُدرج المستخدم صورة، يُكتب ملف الأصل في assets/ فورًا. وتُكتب لقطة الصفحة، التي تشير إلى الأصل بمعرّفه، لاحقًا بواسطة الحفظ التلقائي المؤجّل. لا تشير الصفحة في أي وقت إلى أصل غير موجود على القرص.

مصدر الحقيقة ينتصر. إن document.json ودليل pages/ هما مصدر الحقيقة. وmanifest.json ذاكرة تخزين مؤقت. إذا اختلفا، توفّق عملية الحفظ التالية الذاكرة المؤقتة لتطابق المصدر. أما الصور المصغرة فمشتقة ويمكن إعادة توليدها في أي وقت.

الأصول اليتيمة أفضل من المراجع المعلّقة. أسوأ نتيجة لتعطل هي أصل يتيم — ملف في assets/ لا تشير إليه أي صفحة. تُنظَّف الأصول اليتيمة عند إغلاق المستند. أما العكس — أن تشير صفحة إلى ملف مفقود — فلا يمكن أن يحدث، لأن الأصول تُكتب قبل لقطة الصفحة التي تشير إليها.

هذه القواعد أسهل في التفكير من checkpointing لـ WAL وعزل المعاملات، كما أنها تطابق تمامًا نمط الوصول ذي العملية الواحدة والصفحة الواحدة في التطبيق.

مسار الترقية مكتوب

اختيار الملفات المسطّحة الآن لا يعني اختيارها إلى الأبد. صُممت بنية الحزمة بحيث يغيّر ترقية محرك التخزين ما بداخل الحزمة من دون تغيير الحزمة نفسها.

المستوى 1: لقطة + سجل إلحاق. إذا أصبح تضخيم الكتابة ملحوظًا يومًا ما — لنقل أن الكتابة المستمرة على صفحة فيها آلاف الضربات تسبب تأخيرًا ملحوظًا في الحفظ — ينقسم ملف كل صفحة إلى لقطة وسجل للإلحاق فقط. تُلحق العناصر الجديدة كإطارات [length][CRC][type][payload]. ويؤدي تشغيل السجل مجددًا إلى إسقاط أي إطار لا يطابق CRC الخاص به، مما يوفر أمانًا عند التعطل. وعندما يتجاوز السجل حدًا معينًا أو تُغلق الصفحة، يُدمج مجددًا في اللقطة. وهذا نحو 200 LOC من دون أي تبعيات خارجية.

وبما أن لقطات مستوى الصفحة تلغي تضخيم الكتابة بين الصفحات أصلًا، فقد لا نحتاج إلى هذا المستوى وقتًا طويلًا. وإعادة كتابة صفحة بحجم 5 MB كل 0.5s تقع ضمن ميزانيات كتابة الذاكرة الفلاشية بسهولة.

المستوى 2: قاعدة بيانات SQLite. إذا احتاج التطبيق يومًا إلى بحث كامل النص عبر الملاحظات، أو مزامنة لكل عنصر، أو فهرسة عبر المستندات، فستصبح SQLite الأداة المناسبة. ومن المرجح حينها أن يكون المحرك هو GRDB، وهو غلاف Swift ناضج يُجمَّع من المصدر مع عبء زائد يقارب الصفر على الحجم الثنائي. ولن يُعاد النظر في libsql-swift إلا إذا أصبحت منظومة Turso — وبالتحديد Turso Sync لـ Swift — حاجة حقيقية للمنتج.

مسار الترحيل آلي: تُطابَق مصفوفة عناصر كل صفحة مع جدولَي strokes / images، مع BLOB غير قابل للتغيير لكل ضربة (16 بايتًا لكل نقطة أخذ عينات في ملف ثنائي little-endian). ويؤكد اختبار 0.019 ثانية لـ 100 ضربة أن النهج قابل للتطبيق. وقبل الإطلاق، سيتحقق الترحيل من تطابق أعداد العناصر والأصول بين الحزمة القديمة وقاعدة البيانات الجديدة، ومن اجتياز استعادة التعطل، وحدود WAL، وتفريغ الخلفية.

متى ندفع التكلفة

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

تبدأ تكاليف قاعدة البيانات — التبعية، وإدارة WAL، وطبقة التكييف، والحجم الثنائي — لحظة ربط المكتبة. وتبدأ الفوائد عندما يملك التطبيق استعلامات لتشغيلها، أو فهارس للحفاظ عليها، أو كتّابًا متزامنين لتنسيقهم. وفي هذه المرحلة لا يملك أيًا من الثلاثة.

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

لا ترتبط بنية الحزمة، وملف manifest، ومخطط المستند بأي محرك تخزين. وتكاليف التبديل منخفضة لأن الحدود موضوعة في مكانها الصحيح. وعندما يأتي اليوم الذي نحتاج فيه فعلًا إلى قاعدة بيانات، فسنعتمدها لمشكلة محددة قِيسَت بالفعل — لا لمشكلة افتراضية.