Lulucat

De ce încă nu folosim o bază de date

Gaoge ZhangGaoge Zhang

Am evaluat Turso/libSQL pentru aplicația noastră de scris de mână pe iPad, am măsurat totul și am ales fișiere plate de tip snapshot. Această sarcină de lucru nu are nevoie de o bază de date — iar fiecare pas ar trebui să plătească doar pentru problemele care există deja.

Lulucat Notes este o aplicație de scris de mână pentru iPad. Până săptămâna trecută avea un singur canvas și nu avea noțiunea unei a doua note. Urma să adăugăm o bibliotecă de note — mai multe documente, fiecare cu mai multe pagini — iar prima întrebare de arhitectură era stocarea.

O bază de date părea răspunsul evident. Aplicațiile de notițe stochează date structurate. Datele structurate ajung în baze de date. Am evaluat Turso și Swift SDK-ul său, l-am făcut să ruleze în simulatorul iOS, am măsurat date reale de tușe și apoi am ales să nu-l folosim.

Am ales în schimb fișiere plate. Iată ce am găsit și de ce am luat această decizie.

Un caiet deschis cu notițe scrise de mână și un stilou pe un birou din lemn.

Fotografie de Gabriel Cox pe Unsplash. Licența Unsplash.

Ce face de fapt aplicația cu datele

O aplicație de scris de mână are un tipar îngust și previzibil de acces la date. Citirea înseamnă deschiderea unei pagini și încărcarea simultană în memorie a fiecărui element de pe ea — toate tușele, toate imaginile. Canvas-ul conține totul; nu execută niciodată o interogare parțială. Scrierea înseamnă terminarea unei tușe de pix și adăugarea unui element la pagină. În cazuri rare, utilizatorul șterge o parte dintr-o tușă, mută o selecție sau elimină ceva, dar și acestea sunt operații pe o singură pagină și un singur element.

Nu există acces concurent. O singură persoană scrie la un moment dat pe o singură pagină a unui singur document. Nu există nici căutare între documente — biblioteca de note are nevoie doar de un titlu, un timestamp, un număr de pagini și o miniatură de copertă pentru fiecare document; nimic din acestea nu necesită citirea conținutului paginii.

Interogările, indexurile și coordonarea concurenței sunt lucrurile pentru care sunt construite bazele de date. Aplicația noastră nu folosește niciuna dintre cele trei.

Evaluarea Turso

Am evaluat libsql-swift, Swift SDK-ul oficial pentru motorul libSQL al Turso.

SDK-ul funcționează. Toate cele 9 cazuri de test trec. L-am integrat într-o copie a aplicației, am construit aplicația pentru simulatorul iOS, am lansat-o și am creat o bază de date locală în sandbox-ul aplicației. Am scris 100 de tușe, fiecare cu 3400 de puncte de eșantionare — 4,080,000 de bytes de date BLOB — într-o singură tranzacție. Pe Mac-ul nostru de dezvoltare a durat aproximativ 0.019s.

După rularea PRAGMA wal_checkpoint(TRUNCATE), fișierul WAL s-a micșorat la zero și am putut copia doar fișierul .db principal într-o altă locație, să-l deschidem și să citim înapoi toate datele. Motorul în sine este solid.

SDK-ul are costuri. CLibsql.xcframework are 161 MB. După linkare, buildul Debug pentru simulator a crescut de la aproximativ 1.9 MB la aproximativ 8.2 MB. API-ul este sincron și blocant, fără wrapper-e pentru Swift Concurrency. Nu există o metodă explicită close(). Transaction.commit() nu aruncă excepții — API-ul C subiacent returnează void. README-ul repository-ului numește SDK-ul „technical preview”, iar cel mai recent commit fusese făcut cu aproximativ un an înainte de evaluarea noastră, în iulie 2025.

Ecosistemul Turso are o lacună. Pentru proiectele noi, Turso recomandă acum noul său motor „Turso Database” și protocolul „Turso Sync”. Turso Sync are SDK-uri client pentru TypeScript, Python, Go și Rust. Nu are unul pentru Swift. Modul mai vechi Embedded Replica există în libsql-swift, dar inițializatorul său Swift nu expune parametrul offline necesar unei aplicații mobile complet local-first. Adoptarea libsql-swift astăzi ne-ar oferi un fork local de SQLite, dar nu și capabilitățile de sincronizare care fac Turso distinctiv.

Cât ne-ar costa o bază de date acum

Chiar dacă SDK-ul ar fi matur, tot am plăti costuri care nu aduc nimic pentru sarcina noastră de lucru:

Gestionarea fișierelor auxiliare WAL. O bază de date care rulează creează fișierele companion -wal și -shm. Copierea unui document necesită fie un checkpoint înainte, fie copierea atomică a tuturor celor trei fișiere. Exportul unui pachet .lnote în Files sau AirDrop ar necesita acum un pas înainte de export pe care utilizatorul nu-l poate vedea, iar dezvoltatorul nu-l poate uita.

Un strat de adaptare. Tușele ar trebui serializate în BLOB-uri și deserializate la loc. Elementele paginii au o ordine naturală într-un array pe care canvas-ul îl redă direct; o bază de date ar introduce ordonarea rândurilor și coloane de z-index. Am scrie un strat de traducere între două reprezentări ale acelorași date și l-am întreține la fiecare schimbare de schemă.

O dependență de 161 MB. Pentru o aplicație al cărei build Debug are sub 2 MB, o dependență de peste 80× dimensiunea aplicației în sine este un cost care merită observat — mai ales una etichetată „technical preview”, cu un an de inactivitate.

Aceste costuri nu sunt ipotetice. Încep în momentul în care dependența este linkată. Și ne cumpără capabilități — interogări, indexare și scrieri concurente — pe care aplicația noastră nu le folosește.

Soluția livrată: pachete de fișiere snapshot

Un document .lnote este un pachet de director:

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 este sursa de adevăr pentru structura documentului: ID-ul, titlul, timestamp-urile și o listă ordonată de pagini, fiecare cu dimensiunea canvas-ului, timestamp-urile și numărul de elemente. Fișierele cu conținutul paginilor stochează array-ul de elemente în aceeași codificare întreagă cuantizată pe care aplicația o folosește deja — coordonate și raze cu precizie de 0.1 puncte, presiune în miimi și timestamp-uri în milisecunde relative.

Biblioteca de note citește doar manifest.json și miniaturile de copertă. Nu interpretează niciodată document.json sau conținutul vreunei pagini. Deschiderea unei pagini încarcă un singur fișier .content. Aceasta este singura citire de fișier care atinge datele despre tușe.

Paginile rezolvă write amplification

O aplicație de scris de mână are deja conceptul de pagină — este unitatea la care se gândesc utilizatorii, lucrul între care glisează. Transformarea paginii în unitatea de persistență înseamnă că auto-save rescrie doar paginile care s-au schimbat.

O singură pagină scrisă de mână — să spunem, 1,000–2,000 de tușe — ocupă aproximativ 3–5 MB în formatul nostru cuantizat. O înregistrare cu 21 de tușe și 3400 de puncte de eșantionare se cuantizează la aproximativ 55 KB. Scrierea unui snapshot de pagină în memoria flash durează 10–20 ms pe hardware modern. Cu un debounce de 0.5s, salvările sunt invizibile pentru utilizator.

Costul salvării crește odată cu volumul de scris de pe pagina curentă, nu cu numărul total de pagini din document. Un caiet de 200 de pagini se salvează exact la fel de repede ca unul de 2 pagini, deoarece este rescrisă doar pagina dirty.

Fiecare write folosește operații atomice pe fișiere — scriere într-un fișier temporar, apoi redenumire —, astfel încât un crash în timpul salvării nu poate produce o pagină trunchiată. Când aplicația intră în background, descarcă imediat toate paginile dirty, păstrând comportamentul pe care îl avea deja cu un singur canvas.

Consistență fără tranzacții

Pachetele de fișiere nu au tranzacții, dar au reguli clare de ownership care servesc aceluiași scop:

Resursele înaintea referințelor. Când utilizatorul inserează o imagine, fișierul asset este scris imediat în assets/. Snapshot-ul paginii, care referențiază asset-ul prin ID, este scris mai târziu de auto-save-ul cu debounce. Nicio pagină nu referențiază vreodată un asset care nu există pe disc.

Sursa de adevăr câștigă. document.json și directorul pages/ sunt sursa de adevăr. manifest.json este un cache. Dacă nu sunt de acord, următoarea salvare reconciliază cache-ul cu sursa. Miniaturile sunt derivate și pot fi regenerate oricând.

Orfani, nu referințe suspendate. Cel mai rău rezultat al unui crash este un asset orfan — un fișier din assets/ la care nu referențiază nicio pagină. Orfanii sunt curățați când documentul este închis. Inversul — o pagină care referențiază un fișier lipsă — nu se poate întâmpla, deoarece asset-urile sunt scrise înaintea snapshot-ului paginii care le referențiază.

Aceste reguli sunt mai ușor de urmărit decât WAL checkpointing și izolarea tranzacțiilor și se potrivesc exact cu tiparul de acces single-process, single-page al aplicației.

Calea de upgrade este scrisă

Alegerea fișierelor plate acum nu înseamnă alegerea lor pentru totdeauna. Structura pachetului este proiectată astfel încât upgrade-ul motorului de stocare să schimbe conținutul pachetului fără să schimbe pachetul în sine.

Nivelul 1: snapshot + append journal. Dacă write amplification devine vreodată perceptibilă — să spunem că scrierea continuă pe o pagină cu mii de tușe provoacă o întârziere vizibilă la salvare — fiecare fișier de pagină se împarte într-un snapshot și un journal append-only. Elementele noi sunt adăugate ca frame-uri [length][CRC][type][payload]. La redarea journal-ului, orice frame al cărui CRC nu corespunde este eliminat, oferind siguranță la crash. Când journal-ul depășește un prag sau pagina este închisă, acesta este îmbinat din nou în snapshot. Sunt aproximativ 200 LOC, cu zero dependențe externe.

Deoarece snapshot-urile la nivel de pagină elimină deja write amplification între pagini, este posibil ca acest nivel să nu fie necesar mult timp. Rescrierea unei pagini de 5 MB la fiecare 0.5s se încadrează cu mult în bugetul de scrieri al memoriei flash.

Nivelul 2: baza de date SQLite. Dacă aplicația va avea vreodată nevoie de căutare full-text între note, sincronizare la nivel de element sau indexare între documente, SQLite va fi instrumentul potrivit. Motorul probabil atunci va fi GRDB, un wrapper Swift matur, compilat din surse, cu overhead aproape zero pentru dimensiunea binară. libsql-swift ar fi reconsiderat doar dacă ecosistemul Turso — în special Turso Sync pentru Swift — devine o nevoie reală a produsului.

Migrarea este mecanică: array-ul de elemente al fiecărei pagini corespunde unui tabel strokes / images, cu câte un BLOB imuabil pentru fiecare tușă (16 bytes pentru fiecare punct de eșantionare, în format binar little-endian). Benchmark-ul de 0.019s pentru 100 de tușe confirmă că abordarea este viabilă. Înainte de lansare, migrarea va verifica dacă numărul de elemente și numărul de asset-uri coincid între pachetul vechi și baza de date nouă și dacă trec toate testele pentru recuperarea după crash, limitele WAL și flush-ul în background.

Când să plătim

Această decizie nu este o judecată asupra bazelor de date. SQLite gestionează writeri concurenți, interogări complexe și recuperarea după crash pentru stare partajată — aplicația noastră nu are nevoie acum de niciuna dintre acestea. A plăti pentru capabilități înainte ca aplicația să aibă problemele pe care le rezolvă reprezintă o pierdere netă.

Costurile unei baze de date — dependența, gestionarea WAL, stratul de adaptare și dimensiunea binarului — încep în momentul în care biblioteca este linkată. Beneficiile încep când aplicația are interogări de rulat, indexuri de întreținut sau writeri concurenți de coordonat. În această etapă nu are niciuna dintre cele trei.

Fiecare pas al evoluției stocării noastre va plăti doar pentru probleme care au apărut deja. Snapshot-urile la nivel de pagină plătesc pentru problema de azi: salvarea documentelor cu mai multe pagini fără rescrierea întregului fișier. Dacă write amplification devine măsurabilă, un append journal va plăti pentru acea problemă. Dacă search-ul sau sync-ul devine o nevoie a produsului, o bază de date va plăti pentru acea problemă.

Structura pachetului, manifest-ul și schema documentului nu sunt legate de niciun motor de stocare. Costurile de schimbare sunt mici deoarece granițele sunt în locurile potrivite. Când va veni ziua în care chiar vom avea nevoie de o bază de date, o vom adopta pentru o problemă specifică, deja măsurată — nu pentru una ipotetică.