Hvorfor vi ennå ikke bruker en database
Vi evaluerte Turso/libSQL for håndskriftappen vår på iPad, målte alt og valgte flate snapshot-filer. Arbeidslasten trenger ikke en database — og hvert trinn bør bare betale for problemer som allerede finnes.
Lulucat Notes er en håndskriftapp for iPad. Fram til forrige uke hadde den ett lerret og ikke noe begrep om et ekstra notat. Vi skulle til å legge til et notatbibliotek — flere dokumenter, hvert med flere sider — og det første arkitekturspørsmålet var lagring.
En database virket som det åpenbare svaret. Notatapper lagrer strukturerte data. Strukturerte data legges i databaser. Vi evaluerte Turso og Swift SDK-et deres, fikk det til å kjøre i iOS-simulatoren, målte ekte strøkdata og valgte så å ikke bruke det.
I stedet valgte vi flate filer. Her er hva vi fant, og hvorfor vi tok den beslutningen.

Foto av Gabriel Cox på Unsplash. Unsplash License.
Hva appen faktisk gjør med data
En håndskriftapp har et smalt og forutsigbart datatilgangsmønster. Å lese betyr å åpne en side og laste inn hvert element på den — alle strøkene, alle bildene — i minnet på én gang. Lerretet holder alt; det kjører aldri et delvis søk. Å skrive betyr å fullføre et pennestrøk og legge til ett element på siden. I sjeldne tilfeller visker brukeren bort en del av et strøk, flytter et utvalg eller sletter noe, men også dette er operasjoner på én side og ett element.
Det finnes ingen samtidig tilgang. Én person skriver på én side i ett dokument om gangen. Det finnes ikke noe søk på tvers av dokumenter — notatbiblioteket trenger bare en tittel, et tidsstempel, et sidetall og et forside-miniatyrbilde for hvert dokument, og ingen av delene krever at sideinnholdet leses.
Spørringer, indekser og koordinering av samtidighet er det databaser er laget for. Appen vår bruker ingen av de tre.
Evalueringen av Turso
Vi evaluerte libsql-swift, det offisielle Swift SDK-et for Turso-motoren libSQL.
SDK-et fungerer. Alle 9 testtilfellene består. Vi integrerte det i en kopi av appen, bygde for iOS-simulatoren, startet det og opprettet en lokal database i appens sandkasse. Vi skrev 100 strøk med 3,400 målepunkter i hvert — 4,080,000 byte med BLOB-data — i én transaksjon. Det tok omtrent 0.019 sekunder på utviklings-Macen vår.
Etter at vi kjørte PRAGMA wal_checkpoint(TRUNCATE), krympet WAL-filen til null, og vi kunne kopiere hovedfilen .db alene til et annet sted, åpne den og lese tilbake alle dataene. Selve motoren er solid.
SDK-et har kostnader. CLibsql.xcframework veier 161 MB. Etter koblingen gikk Debug-bygget vårt for simulatoren fra omtrent 1.9 MB til omtrent 8.2 MB. API-et er synkront og blokkerende, uten Swift Concurrency-innpakninger. Det finnes ingen eksplisitt close()-metode. Transaction.commit() kaster ikke — det underliggende C-API-et returnerer void. README-filen i repositoriet merker SDK-et som en “technical preview”, og den nyeste commiten var omtrent ett år før evalueringen vår, i juli 2025.
Turso-økosystemet har et hull. Turso anbefaler nå den nye motoren “Turso Database” og protokollen “Turso Sync” for nye prosjekter. Turso Sync har klient-SDK-er for TypeScript, Python, Go og Rust. Det finnes ingen for Swift. Den eldre Embedded Replica-modusen finnes i libsql-swift, men Swift-initializeren eksponerer ikke parameteren offline som trengs for en fullstendig local-first-mobilapp. Å ta i bruk libsql-swift i dag gir oss en lokal SQLite-fork, men ikke synkroniseringsfunksjonene som gjør Turso særpreget.
Hva en database ville kostet oss akkurat nå
Selv om SDK-et var modent, ville vi fortsatt betale kostnader som ikke gir noe tilbake for arbeidslasten vår:
Håndtering av WAL-sidefiler. En database som kjører, oppretter følgefilene -wal og -shm. Å kopiere et dokument betyr enten å kjøre checkpoint først eller å kopiere alle tre filene atomisk. Å eksportere en .lnote-pakke til Files eller AirDrop krever nå et trinn før eksport som brukeren ikke kan se, og utvikleren ikke kan glemme.
Et adapterlag. Strøk må serialiseres til BLOB-er og deserialiseres tilbake. Sideelementer har en naturlig array-rekkefølge som lerretet tegner direkte; en database ville innføre radrekkefølge og z-index-kolonner. Vi ville skrevet et oversettelseslag mellom to representasjoner av de samme dataene og vedlikeholdt det gjennom hver eneste endring i skjemaet.
En avhengighet på 161 MB. For en app der Debug-bygget er under 2 MB, er en avhengighet som er større enn 80× selve appen, en kostnad det er verdt å legge merke til — særlig en som er merket “technical preview” og har vært inaktiv i et år.
Disse kostnadene er ikke hypotetiske. De begynner idet avhengigheten kobles inn. Og de kjøper oss funksjoner — spørringer, indeksering, samtidige skrivinger — som appen vår ikke bruker.
Løsningen vi leverte: snapshot-filpakker
Et .lnote-dokument er en katalogpakke:
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 er sannhetskilden for dokumentets struktur: ID-en, tittelen, tidsstemplene og en sortert liste over sider med lerretsstørrelse, tidsstempler og elementantall for hver side. Sideinnholdsfilene lagrer elementarrayet i den samme kvantiserte heltallskodingen som appen allerede bruker — koordinater og radier med 0.1 punkts presisjon, trykk i tusendeler og tidsstempler i relative millisekunder.
Notatbiblioteket leser bare manifest.json og forside-miniatyrbildene. Det tolker aldri document.json eller noe sideinnhold. Når en side åpnes, lastes én .content-fil. Det er den eneste fillesingen som berører strøkdata.
Sider løser write amplification
En håndskriftapp har allerede begrepet side — det er enheten brukerne tenker i, og den de sveiper mellom. Når siden blir enheten for varig lagring, skriver autosave bare om sidene som er endret.
Én håndskrevet side — la oss si 1,000 til 2,000 strøk — tar omtrent 3–5 MB i vårt kvantiserte format. Ett opptak på 21 strøk med 3,400 målepunkter kvantiseres til omtrent 55 KB. Å skrive ett sidesnapshot til flashlagring tar 10–20 millisekunder på moderne maskinvare. Med en debounce på 0.5 sekunder er lagring usynlig for brukeren.
Lagringskostnaden skalerer med hvor mye som er skrevet på siden som er åpen, ikke med det totale antallet sider i dokumentet. En notatbok på 200 sider lagres like raskt som en notatbok på 2 sider, fordi bare den endrede siden skrives om.
Alle skrivinger bruker atomiske filoperasjoner — skriv til en midlertidig fil, og gi den deretter nytt navn — så et krasj under lagring kan ikke produsere en avkortet side. Når appen går i bakgrunnen, tømmes alle endrede sider umiddelbart, i tråd med oppførselen appen allerede hadde da den bare hadde ett lerret.
Konsistens uten transaksjoner
Filpakker har ikke transaksjoner, men de har tydelige regler for eierskap som tjener samme formål:
Ressurser før referanser. Når brukeren setter inn et bilde, skrives ressursfilen umiddelbart til assets/. Sidesnapshotet som refererer til ressursen med ID, skrives senere av autosave med debounce. En side kan aldri referere til en ressurs som ikke finnes på disken.
Sannhetskilden vinner. document.json og pages/-katalogen er sannhetskilden. manifest.json er en cache. Hvis de er uenige, bringer neste lagring cachen i samsvar med kilden. Miniatyrbilder er avledet data og kan genereres på nytt når som helst.
Foreldreløse ressurser er bedre enn hengende referanser. Det verste som kan skje ved et krasj, er en foreldreløs ressurs — en fil i assets/ som ingen sider refererer til. Foreldreløse ressurser ryddes opp når dokumentet lukkes. Det motsatte — at en side refererer til en manglende fil — kan ikke skje, fordi ressursene skrives før sidesnapshotet som refererer til dem.
Disse reglene er enklere å resonnere over enn WAL-checkpointing og transaksjonsisolasjon, og de passer nøyaktig med appens tilgangsmønster med én prosess og én side om gangen.
Oppgraderingsveien er skrevet ned
Å velge flate filer nå betyr ikke at vi velger flate filer for alltid. Pakkestrukturen er utformet slik at en oppgradering av lagringsmotoren endrer innholdet i pakken uten å endre selve pakken.
Nivå 1: snapshot + append-journal. Hvis write amplification noen gang blir merkbar — for eksempel hvis kontinuerlig skriving på en side med tusenvis av strøk fører til en merkbar lagringsforsinkelse — deles hver sidefil i et snapshot og en append-only-journal. Nye elementer legges til som [length][CRC][type][payload]-rammer. Når journalen spilles av, forkastes alle rammer med CRC som ikke stemmer, noe som gir krasjsikkerhet. Når journalen overskrider en terskel eller siden lukkes, slås den sammen med snapshotet igjen. Dette er omtrent 200 kodelinjer uten eksterne avhengigheter.
Siden snapshots på sidenivå allerede fjerner write amplification mellom sider, er det ikke sikkert dette nivået trengs på lenge. Å skrive en side på 5 MB på nytt hvert 0.5 sekund er godt innenfor flashlagringens skrivebudsjett.
Nivå 2: SQLite-database. Hvis appen en dag trenger fulltekstsøk på tvers av notater, synkronisering per element eller indeksering på tvers av dokumenter, blir SQLite det riktige verktøyet. Den sannsynlige motoren da er GRDB, en moden Swift-innpakning som kompileres fra kildekode og har tilnærmet null overhead på binærstørrelsen. libsql-swift vurderes bare på nytt hvis Turso-økosystemet — særlig Turso Sync for Swift — blir et reelt produktbehov.
Migreringsveien er mekanisk: Elementarrayet på hver side mappes til en strokes / images-tabell med én uforanderlig BLOB per strøk (16 byte per målepunkt i little-endian-binærformat). Benchmarken på 0.019 sekunder for 100 strøk bekrefter at tilnærmingen er gjennomførbar. Før lansering vil migreringen kontrollere at antallet elementer og ressurser stemmer mellom den gamle pakken og den nye databasen, og at krasjgjenoppretting, WAL-grenser og bakgrunnstømming består alle tester.
Når skal man betale
Dette valget er ikke en dom over databaser. SQLite håndterer samtidige skrivere, komplekse spørringer og krasjgjenoppretting på tvers av delt tilstand — ingen av delene trengs i appen vår nå. Å betale for funksjoner før appen har problemene de løser, er et nettotap.
Kostnadene ved en database — avhengigheten, WAL-håndteringen, adapterlaget og binærstørrelsen — begynner idet biblioteket kobles inn. Fordelene begynner når appen har spørringer som skal kjøres, indekser som skal vedlikeholdes eller samtidige skrivere som skal koordineres. På dette stadiet har den ingen av de tre.
Hvert trinn i utviklingen av lagringen vår skal bare betale for problemer som allerede har dukket opp. Snapshots på sidenivå betaler for problemet vi har i dag: å lagre dokumenter med flere sider uten å skrive om hele filen. Hvis write amplification blir målbar, betaler en append-journal for det problemet. Hvis søk eller synkronisering blir et produktbehov, betaler en database for det problemet.
Pakkestrukturen, manifestet og dokumentskjemaet er ikke bundet til noen bestemt lagringsmotor. Byttekostnaden er lav fordi grensene ligger på riktig sted. Når dagen kommer da vi faktisk trenger en database, tar vi den i bruk for et konkret, allerede målt problem — ikke et hypotetisk.