Lulucat

কেন আমরা এখনও ডেটাবেস ব্যবহার করি না

Gaoge ZhangGaoge Zhang

iPad-এর হাতের লেখার অ্যাপের জন্য Turso/libSQL মূল্যায়ন করে, সবকিছু মেপে, আমরা ফ্ল্যাট স্ন্যাপশট ফাইল বেছে নিয়েছি। এই কাজের ধরনে ডেটাবেসের প্রয়োজন নেই—প্রতিটি ধাপে কেবল ইতিমধ্যে দেখা দেওয়া সমস্যার জন্যই খরচ করা উচিত।

Lulucat Notes iPad-এর জন্য একটি হাতের লেখার অ্যাপ। গত সপ্তাহ পর্যন্ত এতে ছিল মাত্র একটি ক্যানভাস, আর দ্বিতীয় নোটের কোনও ধারণাই ছিল না। আমরা নোটের লাইব্রেরি যোগ করতে যাচ্ছিলাম—একাধিক ডকুমেন্ট, প্রতিটিতে একাধিক পৃষ্ঠা—আর প্রথম স্থাপত্যগত প্রশ্ন ছিল স্টোরেজ।

ডেটাবেসই যেন স্পষ্ট উত্তর মনে হচ্ছিল। নোটের অ্যাপগুলো গঠিত ডেটা সংরক্ষণ করে। গঠিত ডেটা ডেটাবেসে যায়। আমরা Turso এবং তার Swift SDK মূল্যায়ন করেছি, iOS simulator-এ চালু করেছি, বাস্তব স্ট্রোকের ডেটা দিয়ে বেঞ্চমার্ক করেছি, তারপর সেটি ব্যবহার না করার সিদ্ধান্ত নিয়েছি।

তার বদলে ফ্ল্যাট ফাইল বেছে নিয়েছি। কী পেয়েছি এবং কেন এই সিদ্ধান্ত নিয়েছি, সেটিই এখানে বলছি।

কাঠের ডেস্কের উপর হাতে লেখা নোট ও কলম-সহ একটি খোলা নোটবুক।

Gabriel Cox-এর তোলা ছবি, Unsplash-এ প্রকাশিত। Unsplash লাইসেন্স

অ্যাপ আসলে ডেটা নিয়ে কী করে

একটি হাতের লেখার অ্যাপের ডেটা অ্যাক্সেসের ধরন সংকীর্ণ ও পূর্বানুমেয়। পড়ার অর্থ হলো একটি পৃষ্ঠা খোলা এবং তার প্রতিটি এলিমেন্ট—সব স্ট্রোক, সব ছবি—একসঙ্গে মেমরিতে লোড করা। ক্যানভাস সবকিছু ধরে রাখে; এটি কখনও আংশিক কুয়েরি চালায় না। লেখার অর্থ হলো একটি পেন স্ট্রোক শেষ করে পৃষ্ঠায় একটি এলিমেন্ট যোগ করা। বিরল ক্ষেত্রে ব্যবহারকারী কোনো স্ট্রোকের অংশ মুছে দেন, সিলেকশন সরান বা কিছু ডিলিট করেন, কিন্তু সেগুলিও এক পৃষ্ঠা ও এক এলিমেন্টের অপারেশন।

কোনও সমসাময়িক অ্যাক্সেস নেই। এক সময়ে একজন মানুষ একটি ডকুমেন্টের একটি পৃষ্ঠায় লেখেন। ডকুমেন্টগুলোর মধ্যে সার্চও নেই—নোটের লাইব্রেরির প্রতিটি ডকুমেন্টের জন্য কেবল শিরোনাম, টাইমস্ট্যাম্প, পৃষ্ঠার সংখ্যা এবং কভার থাম্বনেইল দরকার; এর কোনওটির জন্য পৃষ্ঠার কনটেন্ট পড়তে হয় না।

কুয়েরি, ইনডেক্স এবং সমসাময়িক অ্যাক্সেসের সমন্বয়ের জন্যই ডেটাবেস তৈরি। আমাদের অ্যাপ এই তিনটির একটিও ব্যবহার করে না।

Turso-এর মূল্যায়ন

আমরা libsql-swift মূল্যায়ন করেছি, যা Turso-এর libSQL ইঞ্জিনের অফিসিয়াল Swift SDK।

SDK কাজ করে। তার নয়টি টেস্ট কেসই পাস করে। আমরা সেটিকে অ্যাপের একটি কপিতে ইন্টিগ্রেট করেছি, iOS সিমুলেটরের জন্য বিল্ড করেছি, লঞ্চ করেছি এবং অ্যাপ স্যান্ডবক্সে একটি স্থানীয় ডেটাবেস তৈরি করেছি। একটিমাত্র ট্রানজ্যাকশনে 100টি স্ট্রোক লিখেছি, প্রতিটির 3,400টি স্যাম্পলিং পয়েন্ট—মোট 4,080,000 বাইট BLOB ডেটা। আমাদের ডেভেলপমেন্ট Mac-এ এতে প্রায় 0.019 সেকেন্ড লেগেছে।

PRAGMA wal_checkpoint(TRUNCATE) চালানোর পর WAL ফাইলটি শূন্যে নেমে এল, এবং আমরা কেবল মূল .db ফাইলটি অন্য জায়গায় কপি করে খুলতে পেরেছি, সব ডেটাও পড়তে পেরেছি। ইঞ্জিনটি নিজে নির্ভরযোগ্য।

SDK-এর খরচ আছে। CLibsql.xcframework-এর আকার 161 MB। লিঙ্ক করার পর আমাদের Debug সিমুলেটর বিল্ড প্রায় 1.9 MB থেকে প্রায় 8.2 MB হয়ে গেল। API synchronous ও blocking, এবং Swift Concurrency-এর wrapper নেই। কোনও explicit close() method নেই। Transaction.commit() throw করে না—অন্তর্নিহিত C API void return করে। রিপোজিটরির README SDK-টিকে “technical preview” বলে, আর মূল্যায়নের প্রায় এক বছর আগে, জুলাই 2025-এ সর্বশেষ commit হয়েছিল।

Turso-এর ইকোসিস্টেমে একটি ফাঁক আছে। নতুন প্রকল্পের জন্য Turso এখন তার নতুন “Turso Database” ইঞ্জিন এবং “Turso Sync” প্রোটোকল সুপারিশ করে। Turso Sync-এর client SDK আছে TypeScript, Python, Go এবং Rust-এর জন্য। Swift-এর জন্য নেই। পুরনো Embedded Replica mode libsql-swift-এ আছে, কিন্তু তার Swift initializer-এ fully local-first mobile app-এর জন্য দরকারি offline parameter প্রকাশ করা হয়নি। আজ libsql-swift নিলে আমরা একটি local SQLite fork পাব, কিন্তু Turso-কে আলাদা করে তোলে যে synchronization capability, তা পাব না।

এই মুহূর্তে ডেটাবেস আমাদের কতটা খরচ করাবে

SDK পরিণত হলেও আমাদের কাজের ধরনে কোনো সুবিধা দেয় না এমন খরচ দিতে হত:

WAL sidecar ব্যবস্থাপনা। চলমান ডেটাবেস -wal এবং -shm companion file তৈরি করে। একটি ডকুমেন্ট কপি করার অর্থ হয় আগে checkpoint করা, নয়তো তিনটি ফাইল atomically কপি করা। Files বা AirDrop-এ .lnote package export করতে এখন এমন একটি pre-export step দরকার, যা ব্যবহারকারী দেখতে পান না এবং developer ভুলে যেতে পারেন না।

একটি adapter layer। স্ট্রোকগুলোকে BLOB-এ serialize করে আবার deserialize করতে হত। পৃষ্ঠার এলিমেন্টগুলোর natural array order আছে, যা ক্যানভাস সরাসরি render করে; ডেটাবেস row ordering এবং z-index column যোগ করত। একই ডেটার দুটি representation-এর মধ্যে একটি translation layer লিখতে হত, এবং প্রতিটি schema change-এর সঙ্গে সেটি maintain করতে হত।

161 MB-এর একটি নির্ভরতা। যে অ্যাপের Debug build 2 MB-এর নিচে, তার নিজের চেয়ে 80×-এর বেশি বড় একটি নির্ভরতা এমন খরচ, যেটি লক্ষ করার মতো—বিশেষ করে সেটি “technical preview” নামে চিহ্নিত এবং এক বছর ধরে নিষ্ক্রিয় হলে।

এই খরচগুলি কাল্পনিক নয়। নির্ভরতা লিঙ্ক হওয়ার মুহূর্ত থেকেই এগুলি শুরু হয়। আর এর বিনিময়ে আমরা পাই কোয়েরি চালানো, ইনডেক্স করা এবং সমসাময়িক লেখার ক্ষমতা—যার কোনওটিই আমাদের অ্যাপ ব্যবহার করে না।

যে সমাধান আমরা চালু করেছি: স্ন্যাপশট ফাইল প্যাকেজ

একটি .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 ডকুমেন্টের কাঠামোর মূল উৎস: তার ID, title, timestamp এবং ক্রমানুসারে সাজানো পৃষ্ঠার তালিকা; প্রতিটি পৃষ্ঠার canvas size, timestamp এবং element count-সহ। পৃষ্ঠার content file-গুলো অ্যাপের ব্যবহৃত একই quantized integer encoding-এ element array সংরক্ষণ করে—0.1-point precision-এ coordinate ও radius, thousandth-এ pressure, relative millisecond-এ timestamp।

নোটের লাইব্রেরি কেবল manifest.json এবং cover thumbnail পড়ে। এটি কখনও document.json বা কোনও পৃষ্ঠার content parse করে না। একটি পৃষ্ঠা খুললে একটি .content file load হয়। স্ট্রোকের ডেটা ছোঁয়া একমাত্র file read এটিই।

পৃষ্ঠা write amplification-এর সমাধান করে

একটি হাতের লেখার অ্যাপে পৃষ্ঠার ধারণা আগে থেকেই আছে—ব্যবহারকারী যে একক নিয়ে ভাবেন, যে পৃষ্ঠাগুলোর মধ্যে সোয়াইপ করেন। পৃষ্ঠাকে সংরক্ষণের একক বানালে স্বয়ংক্রিয় সংরক্ষণ কেবল বদলে যাওয়া পৃষ্ঠাগুলিই নতুন করে লেখে।

একটি হাতে লেখা পৃষ্ঠা—ধরা যাক 1,000 থেকে 2,000টি স্ট্রোক—আমাদের quantized format-এ প্রায় 3–5 MB জায়গা নেয়। 3,400টি sampling point-সহ 21টি স্ট্রোকের একটি recording quantize হয়ে প্রায় 55 KB হয়। আধুনিক hardware-এ একটি page snapshot flash storage-এ লিখতে 10–20 ms লাগে। 0.5-second debounce থাকলে save ব্যবহারকারীর কাছে অদৃশ্য থাকে।

সংরক্ষণের খরচ বর্তমান পৃষ্ঠায় লেখার পরিমাণের সঙ্গে বাড়ে, ডকুমেন্টের মোট পৃষ্ঠার সংখ্যার সঙ্গে নয়। 200-পৃষ্ঠার নোটবুক এবং 2-পৃষ্ঠার নোটবুক একই গতিতে সংরক্ষিত হয়, কারণ কেবল পরিবর্তিত পৃষ্ঠাটিই নতুন করে লেখা হয়।

প্রতিটি লেখা atomic file operation ব্যবহার করে—একটি অস্থায়ী ফাইলে লেখা, তারপর rename—তাই সংরক্ষণের সময় crash হলেও truncated page তৈরি হতে পারে না। Background-এ যাওয়া মাত্র সব পরিবর্তিত পৃষ্ঠা flush হয়; একক ক্যানভাসের সময় অ্যাপের যে আচরণ ছিল, এটিও সেটিই বজায় রাখে।

ট্রানজ্যাকশন ছাড়াই সঙ্গতি

ফাইল প্যাকেজে ট্রানজ্যাকশন নেই, কিন্তু স্পষ্ট মালিকানার নিয়ম আছে, যা একই কাজ করে:

রেফারেন্সের আগে রিসোর্স। ব্যবহারকারী ছবি ঢোকালে অ্যাসেট ফাইলটি সঙ্গে সঙ্গে assets/-এ লেখা হয়। অ্যাসেটের ID উল্লেখ করা পৃষ্ঠার snapshot পরে debounced auto-save-এর মাধ্যমে লেখা হয়। কোনও সময়েই পৃষ্ঠা এমন অ্যাসেটকে reference করে না, যা disk-এ নেই।

মূল উৎসই চূড়ান্ত। document.json এবং pages/ ডিরেক্টরিই মূল উৎস। manifest.json হল ক্যাশ। দুটির মধ্যে অমিল হলে পরের সংরক্ষণ ক্যাশকে মূল উৎসের সঙ্গে মিলিয়ে দেয়। থাম্বনেইল থেকে তৈরি হয়, তাই যেকোনও সময় আবার তৈরি করা যায়।

ঝুলন্ত রেফারেন্সের চেয়ে অনাথ ভালো। Crash-এর সবচেয়ে খারাপ ফল অনাথ অ্যাসেট—assets/-এ এমন একটি ফাইল, যাকে কোনও পৃষ্ঠা reference করে না। ডকুমেন্ট বন্ধ হলে অনাথ অ্যাসেট পরিষ্কার করা হয়। উল্টোটি—পৃষ্ঠা এমন ফাইল reference করছে, যা missing—ঘটতে পারে না, কারণ reference করা page snapshot-এর আগে asset লেখা হয়।

এই নিয়মগুলি WAL checkpointing এবং transaction isolation-এর চেয়ে বোঝা সহজ, এবং অ্যাপের single-process, single-page access pattern-এর সঙ্গে হুবহু মেলে।

আপগ্রেডের পথ লিখে রাখা আছে

এখন ফ্ল্যাট ফাইল বেছে নেওয়া মানে চিরকাল ফ্ল্যাট ফাইল বেছে নেওয়া নয়। প্যাকেজের কাঠামো এমনভাবে তৈরি যে স্টোরেজ ইঞ্জিন আপগ্রেড করলে প্যাকেজের ভিতরের জিনিস বদলাবে, প্যাকেজ নিজে বদলাবে না।

Level 1: snapshot + append journal। কখনও write amplification চোখে পড়ার মতো হলে—যেমন হাজার হাজার stroke-সহ page-এ continuous writing করলে save delay লক্ষণীয় হয়ে ওঠে—প্রতিটি page file একটি snapshot এবং একটি append-only journal-এ ভাগ হবে। নতুন element [length][CRC][type][payload] frame হিসেবে append হবে। Journal replay করার সময় CRC না-মেলা frame বাদ পড়বে, ফলে crash safety পাওয়া যাবে। Journal threshold ছাড়িয়ে গেলে বা page close হলে সেটি আবার snapshot-এ merge হবে। এটি zero external dependency-সহ মোটামুটি 200 LOC।

Page-level snapshot ইতিমধ্যেই cross-page write amplification দূর করে, তাই এই level অনেক দিন প্রয়োজন নাও হতে পারে। প্রতি 0.5 সেকেন্ডে 5 MB page rewrite flash write budget-এর মধ্যেই থাকে।

Level 2: SQLite database। অ্যাপের কখনও নোটগুলোর মধ্যে full-text search, per-element synchronization অথবা cross-document indexing দরকার হলে SQLite সঠিক tool হয়ে উঠবে। তখন সম্ভাব্য engine হবে GRDB, একটি mature, source-compiled Swift wrapper, যার binary size overhead প্রায় শূন্য। Turso ecosystem—বিশেষ করে Swift-এর জন্য Turso Sync—বাস্তব product need হলে তবেই libsql-swift আবার বিবেচনা করা হবে।

মাইগ্রেশনের পথ যান্ত্রিক: প্রতিটি পৃষ্ঠার element array strokes / images table-এ map হবে, প্রতিটি স্ট্রোকের জন্য একটি immutable BLOB (little-endian binary-তে প্রতি sampling point 16 byte)। 100টি স্ট্রোকের 0.019-second benchmark পদ্ধতিটি viable বলে নিশ্চিত করে। Ship করার আগে migration পুরনো package এবং নতুন database-এর element count ও asset count মেলে কি না, এবং crash recovery, WAL bound ও background flush সব pass করে কি না, তা যাচাই করবে।

কখন খরচ করা উচিত

এই সিদ্ধান্ত ডেটাবেসের বিরুদ্ধে রায় নয়। SQLite concurrent writer, complex query এবং shared state-এর crash recovery সামলায়—আমাদের অ্যাপের এখন এর কোনওটাই দরকার নেই। যে সমস্যার সমাধান করার জন্য অ্যাপটিতে ডেটাবেস লাগবে, সেই সমস্যা দেখা দেওয়ার আগে তার জন্য খরচ করা নিট লোকসান।

ডেটাবেসের খরচ—নির্ভরতা, WAL management, adapter layer, binary size—লাইব্রেরি লিঙ্ক হওয়ার মুহূর্তেই শুরু হয়। সুবিধা শুরু হয় যখন অ্যাপের চালানোর মতো query, রক্ষণাবেক্ষণের মতো index অথবা সমন্বয় করার মতো concurrent writer থাকে। এই পর্যায়ে তিনটির একটিও নেই।

স্টোরেজের বিবর্তনের প্রতিটি ধাপে আমরা কেবল ইতিমধ্যে দেখা দেওয়া সমস্যার জন্যই খরচ করব। পেজ-স্তরের snapshot আজকের সমস্যার সমাধান করে: পুরো file rewrite না করে multi-page document save করা। Write amplification measurable হলে append journal সেই সমস্যার সমাধান করবে। Search বা sync পণ্যের প্রয়োজন হয়ে উঠলে database সেই সমস্যার সমাধান করবে।

প্যাকেজের কাঠামো, manifest এবং document schema কোনও storage engine-এর সঙ্গে বাঁধা নয়। সীমানা সঠিক জায়গায় থাকায় পরিবর্তনের খরচ কম। যেদিন সত্যিই database দরকার হবে, সেদিন কাল্পনিক সমস্যার জন্য নয়, নির্দিষ্ট ও ইতিমধ্যে মাপা সমস্যার জন্য সেটি নেব।