Lulucat

ہم ابھی تک ڈیٹا بیس کیوں استعمال نہیں کرتے

Gaoge ZhangGaoge Zhang

ہم نے اپنی iPad handwriting app کے لیے Turso/libSQL کا جائزہ لیا، سب کچھ ناپا، اور flat snapshot files منتخب کیں۔ اس workload کو database کی ضرورت نہیں، اور ہر قدم کو صرف پہلے سے موجود مسائل کی قیمت ادا کرنی چاہیے۔

Lulucat Notes iPad کے لیے handwriting app ہے۔ گزشتہ ہفتے تک اس میں صرف ایک canvas تھا اور دوسری note کا کوئی تصور نہیں تھا۔ ہم ایک ایسی note library شامل کرنے والے تھے جس میں کئی documents ہوں اور ہر document میں کئی pages؛ پہلا architectural سوال storage کا تھا۔

Database واضح جواب معلوم ہوتا تھا۔ Notes apps structured data محفوظ کرتی ہیں۔ Structured data databases میں جاتی ہے۔ ہم نے Turso اور اس کے Swift SDK کا جائزہ لیا، اسے iOS simulator پر چلایا، حقیقی stroke data سے benchmark کیا، اور پھر اسے استعمال نہ کرنے کا فیصلہ کیا۔

اس کے بجائے ہم نے flat files منتخب کیں۔ نتائج اور اس فیصلے کی وجوہات یہ ہیں۔

لکڑی کی میز پر کھلی ہوئی نوٹ بک، جس میں handwritten notes ہیں اور ساتھ ایک قلم رکھا ہے۔

تصویر Gabriel Cox نے بنائی، Unsplash پر شائع ہوئی۔ Unsplash License.

App دراصل data کے ساتھ کیا کرتی ہے

Handwriting app کا data access pattern محدود اور قابلِ پیش گوئی ہوتا ہے۔ Reading کا مطلب ہے ایک page کھولنا اور اس کے ہر element — تمام strokes اور تمام images — کو ایک ساتھ memory میں load کرنا۔ Canvas ہر چیز اپنے اندر رکھتا ہے؛ یہ کبھی partial query نہیں چلاتا۔ Writing کا مطلب ہے ایک pen stroke مکمل کرنا اور page میں ایک element append کرنا۔ شاذ و نادر صورتوں میں user کسی stroke کا کچھ حصہ مٹا سکتا ہے، selection منتقل کر سکتا ہے، یا کچھ delete کر سکتا ہے، لیکن یہ سب اب بھی single-page، single-element operations ہیں۔

Concurrent access نہیں ہے۔ ایک وقت میں ایک شخص، ایک document کے ایک page پر لکھتا ہے۔ Cross-document search بھی نہیں ہے؛ note library کو ہر document کے لیے صرف title، timestamp، page count اور cover thumbnail چاہیے، page content پڑھنے کی ضرورت نہیں۔

Databases queries، indexes اور concurrency coordination کے لیے بنائی جاتی ہیں۔ ہماری app ان تینوں میں سے کچھ بھی استعمال نہیں کرتی۔

Turso کا جائزہ

ہم نے libsql-swift کا جائزہ لیا، جو Turso کے libSQL engine کے لیے official Swift SDK ہے۔

SDK کام کرتا ہے۔ اس کے تمام 9 test cases pass ہیں۔ ہم نے اسے app کی ایک copy میں integrate کیا، iOS simulator کے لیے build کیا، launch کیا، اور app sandbox میں local database بنایا۔ ایک ہی transaction میں ہم نے 100 strokes لکھے، ہر stroke میں 3,400 sampling points تھے، یعنی BLOB data کے 4,080,000 bytes۔ ہمارے development Mac پر اس میں تقریباً 0.019s لگے۔

PRAGMA wal_checkpoint(TRUNCATE) چلانے کے بعد WAL file صفر تک سکڑ گئی۔ ہم صرف main .db file کو دوسری جگہ copy کر سکے، اسے کھولا، اور تمام data واپس پڑھ لیا۔ Engine خود ٹھوس ہے۔

SDK کی قیمت ہے۔ CLibsql.xcframework کا حجم 161 MB ہے۔ Link کرنے کے بعد ہماری Debug simulator build تقریباً 1.9 MB سے بڑھ کر تقریباً 8.2 MB ہو گئی۔ API synchronous اور blocking ہے، Swift Concurrency wrappers نہیں ہیں۔ کوئی واضح close() method نہیں ہے۔ Transaction.commit() throw نہیں کرتا، کیونکہ underlying C API void واپس کرتی ہے۔ Repository کا README SDK کو technical preview کہتا ہے، اور تازہ ترین commit ہماری evaluation سے تقریباً ایک سال پہلے، جولائی 2025 میں ہوا تھا۔

Turso ecosystem میں ایک خلا ہے۔ Turso اب نئے projects کے لیے اپنا نیا Turso Database engine اور Turso Sync protocol تجویز کرتا ہے۔ Turso Sync کے client SDKs TypeScript، Python، Go اور Rust کے لیے ہیں، Swift کے لیے نہیں۔ پرانا Embedded Replica mode libsql-swift میں موجود ہے، لیکن اس کا Swift initializer مکمل local-first mobile app کے لیے درکار offline parameter expose نہیں کرتا۔ آج libsql-swift اپنانے سے ہمیں local SQLite fork ملتا ہے، مگر وہ synchronization capabilities نہیں ملتیں جو Turso کو منفرد بناتی ہیں۔

Database ابھی ہمیں کیا قیمت چکوا سکتا ہے

SDK mature بھی ہو، تب بھی ہمیں ایسی costs ادا کرنی ہوں گی جو ہمارے workload کے لیے کچھ نہیں خریدتیں:

WAL sidecar management۔ چلتا ہوا database -wal اور -shm companion files بناتا ہے۔ Document copy کرنے کے لیے یا تو پہلے checkpoint کرنا ہوگا یا تینوں files کو atomically copy کرنا ہوگا۔ .lnote package کو Files یا AirDrop میں export کرنے کے لیے اب ایک ایسا pre-export step درکار ہوگا جسے user دیکھ نہیں سکتا اور developer بھول نہیں سکتا۔

ایک adapter layer۔ Strokes کو BLOBs میں serialize کر کے واپس deserialize کرنا ہوگا۔ Page elements کی array order فطری ہوتی ہے، جسے canvas براہِ راست render کرتا ہے؛ database row ordering اور z-index columns شامل کرے گا۔ ہمیں ایک ہی data کی دو representations کے درمیان translation layer لکھنی ہوگی اور ہر schema change کے بعد اسے maintain کرنا ہوگا۔

161 MB کی dependency۔ ایسی app کے لیے جس کی Debug build 2 MB سے کم ہو، app سے 80× بڑی dependency قابلِ توجہ cost ہے، خاص طور پر جب اسے technical preview کہا گیا ہو اور ایک سال سے activity نہ ہوئی ہو۔

یہ اخراجات فرضی نہیں ہیں۔ Dependency link ہوتے ہی یہ شروع ہو جاتے ہیں۔ بدلے میں ہمیں querying، indexing اور concurrent writes کی وہ capabilities ملتی ہیں جو ہماری app استعمال نہیں کرتی۔

ہمارا پیش کردہ حل: snapshot file packages

ایک .lnote document ایک directory package ہے:

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 document structure کا source of truth ہے: اس میں document ID، title، timestamps اور pages کی ordered list ہوتی ہے۔ ہر page کا canvas size، timestamps اور element count بھی اسی میں ہوتا ہے۔ Page content files element array کو اسی quantized integer encoding میں محفوظ کرتی ہیں جو app پہلے سے استعمال کرتی ہے — coordinates اور radii 0.1-point precision پر، pressure thousandths میں، اور timestamps relative milliseconds میں۔

Note library صرف manifest.json اور cover thumbnails پڑھتی ہے۔ یہ document.json یا کسی page content کو parse نہیں کرتی۔ Page کھولنے پر صرف ایک .content file load ہوتی ہے۔ Stroke data تک پہنچنے والی یہی واحد file read ہے۔

Pages write amplification حل کرتی ہیں

Handwriting app میں page کا تصور پہلے سے موجود ہے؛ یہی وہ unit ہے جس کے بارے میں user سوچتا ہے اور جس کے درمیان swipe کرتا ہے۔ Page کو persistence کی unit بنانے سے auto-save صرف بدلے ہوئے pages rewrite کرتا ہے۔

Handwriting کا ایک page — مثلاً 1,000 سے 2,000 strokes — ہمارے quantized format میں تقریباً 3–5 MB کا ہوتا ہے۔ 3,400 sampling points والی 21-stroke recording quantize ہونے کے بعد تقریباً 55 KB کی ہوتی ہے۔ Modern hardware پر ایک page snapshot کو flash storage میں لکھنے میں 10–20 ms لگتے ہیں۔ 0.5s debounce کے ساتھ saves user کو محسوس نہیں ہوتیں۔

Save cost موجودہ page پر ہونے والی writing کی مقدار کے ساتھ بڑھتی ہے، document میں pages کی کل تعداد کے ساتھ نہیں۔ 200-page notebook اتنی ہی تیزی سے save ہوتی ہے جتنی 2-page notebook، کیونکہ صرف dirty page rewrite ہوتا ہے۔

ہر write atomic file operations استعمال کرتی ہے — temporary file میں write، پھر rename — اس لیے save کے دوران crash ہونے سے truncated page پیدا نہیں ہوتا۔ Background میں جاتے ہی app تمام dirty pages فوراً flush کر دیتی ہے، بالکل اسی طرح جیسے single canvas کے زمانے میں کرتی تھی۔

Transactions کے بغیر consistency

File packages میں transactions نہیں ہوتیں، مگر ownership کے واضح rules ہیں جو وہی مقصد پورا کرتے ہیں:

References سے پہلے resources۔ User جب image insert کرتا ہے تو asset file فوراً assets/ میں لکھی جاتی ہے۔ Asset ID کو reference کرنے والا page snapshot debounced auto-save کے ذریعے بعد میں لکھا جاتا ہے۔ اس طرح کوئی page ایسے asset کو reference نہیں کرتا جو disk پر موجود نہ ہو۔

Source of truth جیتتا ہے۔ document.json اور pages/ directory source of truth ہیں۔ manifest.json cache ہے۔ اگر دونوں میں فرق ہو تو اگلا save cache کو source کے مطابق reconcile کر دیتا ہے۔ Thumbnails derived data ہیں اور کسی بھی وقت regenerate کیے جا سکتے ہیں۔

Dangling references کے بجائے orphans۔ Crash کا بدترین نتیجہ ایک orphan asset ہے — assets/ میں ایسی file جسے کوئی page reference نہیں کرتا۔ Document بند ہونے پر orphans صاف کر دیے جاتے ہیں۔ اس کے برعکس page کا کسی missing file کو reference کرنا ممکن نہیں، کیونکہ asset reference کرنے والے page snapshot سے پہلے لکھا جاتا ہے۔

یہ rules WAL checkpointing اور transaction isolation کے مقابلے میں سمجھنے میں آسان ہیں، اور app کے single-process، single-page access pattern سے عین مطابقت رکھتے ہیں۔

Upgrade path واضح ہے

اب flat files کا انتخاب ایک مستقل فیصلہ نہیں ہے۔ Package structure اس طرح بنایا گیا ہے کہ package کو بدلے بغیر storage engine کو upgrade کیا جا سکے؛ صرف package کے اندر کا مواد بدلے گا۔

Level 1: snapshot + append journal۔ اگر write amplification کبھی محسوس ہونے لگے — مثلاً ہزاروں strokes والے page پر مسلسل لکھنے سے save delay نمایاں ہو جائے — تو ہر page file ایک snapshot اور append-only journal میں تقسیم ہو جائے گی۔ نئے elements [length][CRC][type][payload] frames کے طور پر append ہوں گے۔ Journal replay کرتے وقت جس frame کا CRC match نہ کرے اسے discard کر دیا جائے گا، جس سے crash safety ملے گی۔ Journal threshold سے بڑھ جائے یا page بند ہو جائے تو اسے واپس snapshot میں merge کر دیا جائے گا۔ یہ تقریباً ~200 LOC کا کام ہے، اور کسی external dependency کی ضرورت نہیں۔

Page-level snapshots پہلے ہی cross-page write amplification ختم کر دیتی ہیں، اس لیے یہ level طویل عرصے تک درکار نہیں ہوگا۔ ہر 0.5s بعد 5 MB page rewrite کرنا flash write budgets کے اندر ہے۔

Level 2: SQLite database۔ اگر app کو notes کے درمیان full-text search، per-element synchronization یا cross-document indexing درکار ہو تو SQLite درست tool بن جائے گا۔ اس وقت ممکنہ engine GRDB ہوگا، جو mature، source-compiled Swift wrapper ہے اور binary size overhead تقریباً صفر ہے۔ ہم libsql-swift پر صرف اس وقت دوبارہ غور کریں گے جب Turso ecosystem، خاص طور پر Turso Sync for Swift، حقیقی product need بن جائے۔

Migration path سیدھا ہے: ہر page کا element array strokes / images table میں map ہوتا ہے، ہر stroke کے لیے ایک immutable BLOB ہوتا ہے، اور ہر sampling point little-endian binary میں 16 bytes لیتا ہے۔ 100 strokes کا 0.019s benchmark ثابت کرتا ہے کہ یہ طریقہ قابلِ عمل ہے۔ جاری کرنے سے پہلے migration یہ verify کرے گی کہ پرانے package اور نئے database میں element counts اور asset counts برابر ہیں، اور crash recovery، WAL bounds اور background flush سب pass کرتے ہیں۔

کب ادائیگی کرنی چاہیے

یہ انتخاب ہماری app کی موجودہ ضروریات پر مبنی ہے۔ SQLite shared state میں concurrent writers، complex queries اور crash recovery سنبھالتا ہے، مگر ہماری app کو اس وقت ان میں سے کسی کی ضرورت نہیں۔ ان capabilities کے لیے، ان مسائل کے پیدا ہونے سے پہلے ادائیگی کرنا جنہیں یہ حل کرتی ہیں، اس مرحلے پر صرف اضافی لاگت پیدا کرتا ہے۔

Database کی costs — dependency، WAL management، adapter layer اور binary size — library link ہوتے ہی شروع ہو جاتی ہیں۔ فوائد تب شروع ہوں گے جب app کے پاس چلانے کے لیے queries، برقرار رکھنے کے لیے indexes، یا coordinate کرنے کے لیے concurrent writers ہوں گے۔ اس وقت ان تینوں میں سے کچھ بھی نہیں ہے۔

ہماری storage evolution کا ہر قدم صرف پہلے سے ظاہر ہو چکے مسائل کے لیے ادائیگی کرے گا۔ Page-level snapshots آج کے مسئلے کی قیمت ادا کرتی ہیں: multi-page documents کو پوری file rewrite کیے بغیر save کرنا۔ اگر write amplification قابلِ پیمائش ہو جائے تو append journal اس مسئلے کی قیمت ادا کرے گا۔ اگر search یا sync product need بن جائے تو database اس مسئلے کی قیمت ادا کرے گا۔

Package structure، manifest اور document schema کسی storage engine کے پابند نہیں ہیں۔ Boundaries درست جگہ پر ہیں، اس لیے switching cost کم ہے۔ جب ہمیں واقعی database درکار ہوگا تو ہم اسے کسی مخصوص، پہلے سے ناپے گئے مسئلے کے لیے اپنائیں گے، کسی فرضی مسئلے کے لیے نہیں۔