हम अभी डेटाबेस क्यों नहीं इस्तेमाल करते
हमने अपनी iPad handwriting app के लिए Turso/libSQL का मूल्यांकन किया, सब कुछ मापा और flat files चुनीं। इस workload को डेटाबेस की ज़रूरत नहीं है — और हर कदम को सिर्फ़ पहले से मौजूद समस्याओं के लिए भुगतान करना चाहिए।
Lulucat Notes iPad के लिए handwriting app है। पिछले हफ़्ते तक इसमें सिर्फ़ एक canvas था और दूसरी note की कोई अवधारणा नहीं थी। हम एक note library जोड़ने वाले थे — कई documents, जिनमें हर document में कई pages हों — और पहला architectural सवाल storage का था।
डेटाबेस सबसे obvious जवाब लगा। Notes apps structured data store करती हैं। Structured data databases में जाता है। हमने Turso और उसके Swift SDK का मूल्यांकन किया, उसे iOS simulator पर चलाया, असली stroke data को benchmark किया और फिर उसे इस्तेमाल न करने का फैसला किया।
हमने इसके बजाय flat files चुनीं। हमें क्या मिला और हमने यह फैसला क्यों लिया, वह यहाँ है।

फोटो Gabriel Cox की, Unsplash पर। Unsplash License।
App असल में data के साथ क्या करती है
Handwriting app का data-access pattern संकरा और predictable होता है। पढ़ने का मतलब है एक page खोलना और उसके हर element को एक साथ memory में load करना — सभी strokes और सभी images। Canvas सब कुछ रखता है; वह कभी partial query नहीं चलाता। लिखने का मतलब है pen stroke पूरा करना और page में एक element append करना। कभी-कभार user stroke का कोई हिस्सा erase करता है, selection move करता है या कुछ delete करता है, लेकिन ये भी single-page, single-element operations ही हैं।
कोई concurrent access नहीं है। एक समय में एक व्यक्ति एक document के एक page पर लिखता है। Documents के बीच search नहीं है — note library को हर document के लिए सिर्फ़ title, timestamp, page count और cover thumbnail चाहिए; इनमें से किसी के लिए page content पढ़ने की ज़रूरत नहीं है।
Databases queries, indexes और concurrency coordination के लिए बनाए जाते हैं। हमारी app इन तीनों में से किसी का इस्तेमाल नहीं करती।
Turso का evaluation
हमने libsql-swift का evaluation किया, जो Turso के libSQL engine के लिए official Swift SDK है।
SDK काम करता है। उसके सभी नौ test cases पास होते हैं। हमने उसे app की एक copy में integrate किया, iOS simulator के लिए build किया, launch किया और app sandbox में local database बनाया। हमने एक ही transaction में 3,400 sampling points वाले 100 strokes लिखे — BLOB data के 4,080,000 bytes। हमारे development Mac पर इसमें लगभग 0.019 seconds लगे।
PRAGMA wal_checkpoint(TRUNCATE) चलाने के बाद WAL file शून्य तक सिमट गई और हम सिर्फ़ मुख्य .db file को दूसरी जगह copy कर सके, उसे खोल सके और सारा data वापस पढ़ सके। Engine खुद sound है।
SDK की costs हैं। CLibsql.xcframework का आकार 161 MB है। Link करने के बाद हमारा Debug simulator build लगभग 1.9 MB से बढ़कर लगभग 8.2 MB हो गया। API synchronous और blocking है, और इसमें Swift Concurrency wrappers नहीं हैं। कोई explicit close() method नहीं है। Transaction.commit() throw नहीं करता — underlying C API void return करती है। Repository का README SDK को «technical preview» कहता है, और सबसे recent commit हमारे evaluation से लगभग एक साल पहले, July 2025 में हुआ था।
Turso ecosystem में एक gap है। Turso अब नए projects के लिए अपने नए «Turso Database» engine और «Turso Sync» protocol की recommendation देता है। 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 को अलग बनाती हैं।
अभी डेटाबेस की कीमत क्या होगी
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 का एक natural 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 न हुई हो।
ये costs hypothetical नहीं हैं। Dependency link होते ही शुरू हो जाती हैं। और बदले में हमें querying, indexing और concurrent writes जैसी capabilities मिलती हैं, जिन्हें हमारी app इस्तेमाल नहीं करती।
जो solution हमने ship किया: 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 है: उसका ID, title, timestamps और pages की ordered list, जिसमें हर page का canvas size, timestamps और element count शामिल है। Page content files उसी quantized integer encoding में element array store करती हैं जिसका 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 है जिसके बारे में users सोचते हैं और एक page से दूसरे page पर 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.5-second debounce के साथ saves user को दिखाई नहीं देते।
Save cost current page पर लिखी जा रही मात्रा के साथ scale होती है, document में pages की total संख्या के साथ नहीं। 200-page notebook उतनी ही तेज़ी से save होती है जितनी 2-page notebook, क्योंकि सिर्फ़ dirty page rewrite होता है।
हर write atomic file operations इस्तेमाल करती है — temporary file में write करना, फिर rename करना — इसलिए save के बीच crash होने पर truncated page नहीं बन सकता। Background में जाते ही सभी dirty pages तुरंत flush हो जाते हैं, ठीक वैसे ही जैसे app 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 के अनुरूप कर देता है। Thumbnails derived होती हैं और किसी भी समय regenerate की जा सकती हैं।
Dangling references से बेहतर orphans। Crash का सबसे खराब परिणाम orphan asset है — assets/ में ऐसी file जिसे कोई page reference नहीं करता। Document बंद होने पर orphans साफ़ कर दिए जाते हैं। उलटी स्थिति — page किसी missing file को reference करे — हो ही नहीं सकती, क्योंकि reference करने वाले page snapshot से पहले assets लिखे जाते हैं।
इन rules को WAL checkpointing और transaction isolation से समझना आसान है, और ये app के single-process, single-page access pattern से बिल्कुल मेल खाते हैं।
Upgrade path लिखा हुआ है
अभी flat files चुनने का मतलब हमेशा के लिए flat files चुनना नहीं है। Package structure इस तरह बनाया गया है कि storage engine को upgrade करने पर package बदले बिना उसके अंदर की चीज़ें बदली जा सकें।
Level 1: snapshot + append journal। अगर write amplification कभी noticeable हो जाए — मान लीजिए, हजारों 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 और zero external dependencies हैं।
क्योंकि page-level snapshots पहले ही cross-page write amplification खत्म कर देते हैं, शायद यह level लंबे समय तक ज़रूरी न हो। हर 0.5 seconds में 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 — विशेष रूप से Swift के लिए Turso Sync — product की वास्तविक ज़रूरत बन जाए।
Migration mechanical है: हर page का element array एक strokes / images table में map होगा, जिसमें हर stroke के लिए एक immutable BLOB होगा (little-endian binary में हर sampling point के लिए 16 bytes)। 100 strokes का 0.019-second benchmark इस approach की viability की पुष्टि करता है। Ship करने से पहले migration verify करेगी कि पुराने package और नए database के element counts और asset counts match करते हैं, और crash recovery, WAL bounds तथा background flush सभी tests पास करते हैं।
कब भुगतान करना चाहिए
यह फैसला databases पर कोई judgment नहीं है। SQLite shared state पर concurrent writers, complex queries और crash recovery संभालता है — हमारी app को अभी इनमें से किसी की ज़रूरत नहीं है। App को जिन समस्याओं का सामना अभी हुआ ही नहीं है, उनकी capabilities के लिए पहले से भुगतान करना net loss है।
Database की costs — dependency, WAL management, adapter layer और binary size — library link होते ही शुरू हो जाती हैं। Benefits तब शुरू होते हैं जब app के पास चलाने के लिए queries, maintain करने के लिए indexes या coordinate करने के लिए concurrent writers हों। इस stage पर उसके पास इन तीनों में से कोई भी नहीं है।
Storage evolution का हर कदम सिर्फ़ उन problems के लिए pay करेगा जो पहले ही सामने आ चुकी हैं। Page-level snapshots आज की समस्या का भुगतान करते हैं: पूरे file को rewrite किए बिना multi-page documents save करना। अगर write amplification measurable हो जाए, तो append journal उस problem के लिए भुगतान करेगा। अगर search या sync product की ज़रूरत बन जाए, तो database उस problem के लिए भुगतान करेगा।
Package structure, manifest और document schema किसी storage engine से बँधे हुए नहीं हैं। Switching cost कम है क्योंकि boundaries सही जगह पर हैं। जिस दिन हमें सचमुच database की ज़रूरत होगी, हम उसे किसी specific, पहले से measured problem के लिए अपनाएँगे — hypothetical problem के लिए नहीं।