Lulucat

Waarom we nog geen database gebruiken

Gaoge ZhangGaoge Zhang

We evalueerden Turso/libSQL voor onze handschriftapp op de iPad, maten alles en kozen voor platte snapshotbestanden. Deze workload heeft geen database nodig — en elke stap zou alleen moeten betalen voor problemen die al bestaan.

Lulucat Notes is een handschriftapp voor de iPad. Tot vorige week had de app één canvas en geen concept van een tweede notitie. We stonden op het punt een notitiebibliotheek toe te voegen — meerdere documenten, elk met meerdere pagina’s — en de eerste architectuurvraag ging over opslag.

Een database leek het voor de hand liggende antwoord. Notitie-apps slaan gestructureerde gegevens op. Gestructureerde gegevens horen in databases. We evalueerden Turso en de Swift SDK ervan, kregen die aan de praat in de iOS-simulator, benchmarkten echte streekdata en besloten hem vervolgens niet te gebruiken.

In plaats daarvan kozen we voor platte bestanden. Dit is wat we ontdekten en waarom we die keuze maakten.

Een geopend notitieboek met handgeschreven notities en een pen op een houten bureau.

Foto door Gabriel Cox op Unsplash. Unsplash License.

Wat de app daadwerkelijk met gegevens doet

Een handschriftapp heeft een smal en voorspelbaar patroon voor gegevenstoegang. Lezen betekent dat je een pagina opent en elk element erop — alle streken, alle afbeeldingen — in één keer in het geheugen laadt. Het canvas bevat alles; het voert nooit een gedeeltelijke query uit. Schrijven betekent dat je een penstreek afrondt en één element aan de pagina toevoegt. In zeldzame gevallen wist de gebruiker een deel van een streek, verplaatst die een selectie of verwijdert die iets, maar ook dat zijn bewerkingen op één pagina en één element.

Er is geen gelijktijdige toegang. Eén persoon schrijft tegelijk op één pagina van één document. Er is geen zoekopdracht over documenten heen — de notitiebibliotheek heeft voor elk document alleen een titel, tijdstempel, aantal pagina’s en thumbnail van de omslag nodig; voor geen daarvan hoeft de pagina-inhoud te worden gelezen.

Queries, indexen en coördinatie van gelijktijdigheid zijn waarvoor databases zijn gebouwd. Onze app gebruikt geen van de drie.

De evaluatie van Turso

We evalueerden libsql-swift, de officiële Swift SDK voor Turso’s libSQL-engine.

De SDK werkt. Alle 9 testgevallen slagen. We integreerden hem in een kopie van de app, bouwden voor de iOS-simulator, startten hem en maakten een lokale database in de sandbox van de app. In één transactie schreven we 100 streken met elk 3,400 samplepunten — 4,080,000 bytes aan BLOB-data. Op onze ontwikkel-Mac duurde dat ongeveer 0.019 seconde.

Na het uitvoeren van PRAGMA wal_checkpoint(TRUNCATE) kromp het WAL-bestand tot nul en konden we alleen het hoofdbestand .db naar een andere locatie kopiëren, het openen en alle gegevens teruglezen. De engine zelf is degelijk.

De SDK heeft kosten. CLibsql.xcframework weegt 161 MB. Na het linken groeide onze Debug-build voor de simulator van ongeveer 1.9 MB naar ongeveer 8.2 MB. De API is synchroon en blokkerend, zonder wrappers voor Swift Concurrency. Er is geen expliciete methode close(). Transaction.commit() gooit niets — de onderliggende C-API retourneert void. De README van de repository noemt de SDK een “technical preview” en de meest recente commit was ongeveer een jaar vóór onze evaluatie, in juli 2025.

Het Turso-ecosysteem heeft een gat. Turso beveelt nu zijn nieuwe engine “Turso Database” en protocol “Turso Sync” aan voor nieuwe projecten. Turso Sync heeft client-SDK’s voor TypeScript, Python, Go en Rust. Een SDK voor Swift is er niet. De oudere modus Embedded Replica bestaat in libsql-swift, maar de Swift-initializer stelt de parameter offline niet beschikbaar die nodig is voor een volledig local-first mobiele app. Als we libsql-swift vandaag overnemen, krijgen we een lokale SQLite-fork, maar niet de synchronisatiemogelijkheden die Turso onderscheidend maken.

Wat een database ons nu zou kosten

Zelfs als de SDK volwassen was, zouden we kosten betalen die voor onze workload niets opleveren:

Beheer van WAL-sidecarbestanden. Een draaiende database maakt de begeleidende bestanden -wal en -shm aan. Een document kopiëren betekent eerst een checkpoint uitvoeren of alle drie de bestanden atomair kopiëren. Een .lnote-pakket exporteren naar Files of AirDrop vereist dan een stap vóór het exporteren die de gebruiker niet kan zien en die de ontwikkelaar niet mag vergeten.

Een adapterlaag. Streken moeten naar BLOB’s worden geserialiseerd en weer gedeserialiseerd. Pagina-elementen hebben een natuurlijke arrayvolgorde die het canvas rechtstreeks rendert; een database zou rijvolgorde en z-index-kolommen introduceren. We zouden een vertaallaag schrijven tussen twee representaties van dezelfde gegevens en die bij elke schemaverandering moeten onderhouden.

Een afhankelijkheid van 161 MB. Voor een app waarvan de Debug-build onder 2 MB blijft, is een afhankelijkheid die groter is dan 80× de app zelf een kost die aandacht verdient — zeker als die als “technical preview” is aangemerkt en al een jaar niet actief is.

Deze kosten zijn niet hypothetisch. Ze beginnen zodra de afhankelijkheid wordt gelinkt. En ze kopen mogelijkheden — query’s, indexering, gelijktijdig schrijven — die onze app niet gebruikt.

De oplossing die we hebben uitgebracht: snapshot-bestandspakketten

Een .lnote-document is een directorypakket:

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 is de bron van waarheid voor de structuur van het document: de ID, titel, tijdstempels en een geordende lijst pagina’s met de canvasgrootte, tijdstempels en het aantal elementen van elke pagina. Bestanden met pagina-inhoud slaan de elementarray op in dezelfde gekwantiseerde integercodering die de app al gebruikt — coördinaten en radii met een precisie van 0.1 punt, druk in duizendsten en tijdstempels in relatieve milliseconden.

De notitiebibliotheek leest alleen manifest.json en thumbnails van de omslag. Hij parseert nooit document.json of pagina-inhoud. Een pagina openen laadt één .content-bestand. Dat is de enige bestandslezing die streekdata raakt.

Pagina’s lossen write amplification op

Een handschriftapp kent het concept pagina al — het is de eenheid waar gebruikers in denken, het ding waartussen ze vegen. Als de pagina de persistentie-eenheid wordt, hoeft autosave alleen de pagina’s die zijn veranderd opnieuw te schrijven.

Eén pagina handschrift — zeg 1,000 tot 2,000 streken — neemt in ons gekwantiseerde formaat ongeveer 3–5 MB in beslag. Eén opname van 21 streken met 3,400 samplepunten wordt na kwantisering ongeveer 55 KB. Een snapshot van één pagina naar flashopslag schrijven duurt op moderne hardware 10–20 milliseconden. Met een debounce van 0.5 seconde zijn de saves onzichtbaar voor de gebruiker.

De opslagkosten schalen met de hoeveelheid schrijfwerk op de huidige pagina, niet met het totale aantal pagina’s in het document. Een notitieboek van 200 pagina’s wordt precies even snel opgeslagen als een notitieboek van 2 pagina’s, omdat alleen de gewijzigde pagina opnieuw wordt geschreven.

Elke schrijfactie gebruikt atomische bestandsbewerkingen — schrijven naar een tijdelijk bestand en het daarna hernoemen — zodat een crash tijdens het opslaan geen afgeknotte pagina kan opleveren. Zodra de app naar de achtergrond gaat, worden alle gewijzigde pagina’s onmiddellijk geflusht, net als bij het gedrag dat de app al had met één canvas.

Consistentie zonder transacties

Bestandspakketten hebben geen transacties, maar wel duidelijke eigendomsregels die hetzelfde doel dienen:

Assets vóór verwijzingen. Wanneer de gebruiker een afbeelding invoegt, wordt het assetbestand meteen naar assets/ geschreven. De paginasnapshot die met een ID naar het asset verwijst, wordt later door autosave met debounce geschreven. Een pagina verwijst nooit naar een asset dat niet op schijf bestaat.

De bron van waarheid wint. document.json en de map pages/ zijn de bron van waarheid. manifest.json is een cache. Als ze niet overeenkomen, brengt de volgende opslag de cache weer in overeenstemming met de bron. Thumbnails zijn afgeleide gegevens en kunnen op elk moment opnieuw worden gegenereerd.

Verweesde assets zijn beter dan bungelende verwijzingen. Het slechtste gevolg van een crash is een verweesd asset — een bestand in assets/ waarnaar geen enkele pagina verwijst. Verweesde assets worden opgeruimd wanneer het document wordt gesloten. Het omgekeerde — een pagina die naar een ontbrekend bestand verwijst — kan niet gebeuren, omdat assets worden geschreven vóór de paginasnapshot die ernaar verwijst.

Deze regels zijn eenvoudiger te doorgronden dan WAL-checkpointing en transactie-isolatie, en ze passen precies bij het patroon van één proces en één pagina tegelijk in de app.

Het upgradepad staat op papier

Nu voor platte bestanden kiezen betekent niet dat we voor altijd platte bestanden kiezen. De pakketstructuur is zo ontworpen dat een upgrade van de opslagengine verandert wat er in het pakket zit, zonder het pakket zelf te veranderen.

Niveau 1: snapshot + append-journal. Als write amplification ooit merkbaar wordt — bijvoorbeeld wanneer continu schrijven op een pagina met duizenden streken een merkbare opslagvertraging veroorzaakt — wordt elk paginabestand opgesplitst in een snapshot en een append-only-journal. Nieuwe elementen worden toegevoegd als frames [length][CRC][type][payload]. Bij het afspelen van het journal wordt elk frame waarvan de CRC niet overeenkomt weggegooid, wat crashveiligheid biedt. Wanneer het journal een drempel overschrijdt of de pagina wordt gesloten, wordt het weer samengevoegd met de snapshot. Dit zijn ongeveer 200 regels code zonder externe afhankelijkheden.

Omdat snapshots op paginaniveau write amplification tussen pagina’s al elimineren, is dit niveau misschien nog lange tijd niet nodig. Een pagina van 5 MB elke 0.5 seconde opnieuw schrijven blijft ruim binnen de schrijfbudgetten van flashopslag.

Niveau 2: SQLite-database. Als de app ooit full-text search over notities, synchronisatie per element of indexering over documenten heen nodig heeft, wordt SQLite het juiste gereedschap. De waarschijnlijke engine op dat moment is GRDB, een volwassen, uit broncode gecompileerde Swift-wrapper met vrijwel geen overhead voor de binaire grootte. libsql-swift wordt alleen opnieuw overwogen als het Turso-ecosysteem — specifiek Turso Sync voor Swift — een echte productbehoefte wordt.

Het migratiepad is mechanisch: de elementarray van elke pagina wordt gemapt naar een tabel strokes / images met één onveranderlijke BLOB per streek (16 bytes per samplepunt in little-endian binaire vorm). De benchmark van 0.019 seconde voor 100 streken bevestigt dat deze aanpak haalbaar is. Voor het uitbrengen controleert de migratie of het aantal elementen en assets overeenkomt tussen het oude pakket en de nieuwe database, en of crashherstel, WAL-grenzen en flushen op de achtergrond allemaal slagen.

Wanneer betaal je

Deze beslissing is geen oordeel over databases. SQLite handelt gelijktijdige schrijvers, complexe queries en crashherstel over gedeelde toestand af — niets daarvan heeft onze app nu nodig. Betalen voor die mogelijkheden voordat de app de problemen heeft die ze oplossen, is netto verlies.

De kosten van een database — de afhankelijkheid, het WAL-beheer, de adapterlaag, de binaire grootte — beginnen zodra de library wordt gelinkt. De voordelen beginnen wanneer de app queries heeft om uit te voeren, indexen om te onderhouden of gelijktijdige schrijvers om te coördineren. In dit stadium heeft de app geen van de drie.

Elke stap in onze evolutie van opslag betaalt alleen voor problemen die al zijn verschenen. Snapshots op paginaniveau betalen voor het probleem dat we vandaag hebben: documenten met meerdere pagina’s opslaan zonder het hele bestand opnieuw te schrijven. Als write amplification meetbaar wordt, betaalt een append-journal voor dat probleem. Als zoeken of synchronisatie een productbehoefte wordt, betaalt een database voor dat probleem.

De pakketstructuur, het manifest en het documentschema zijn niet gebonden aan een opslagengine. De overstapkosten zijn laag omdat de grenzen op de juiste plek liggen. Wanneer de dag komt dat we echt een database nodig hebben, nemen we die in gebruik voor een specifiek, al gemeten probleem — niet voor een hypothetisch probleem.