Lulucat

Varför vi ännu inte använder en databas

Gaoge ZhangGaoge Zhang

Vi utvärderade Turso/libSQL för vår handskriftsapp på iPad, mätte allt och valde platta snapshot-filer. Arbetsbelastningen behöver ingen databas — och varje steg bör bara betala för problem som redan finns.

Lulucat Notes är en handskriftsapp för iPad. Fram till förra veckan hade den en canvas och inget begrepp om en andra anteckning. Vi skulle lägga till ett anteckningsbibliotek — flera dokument, vart och ett med flera sidor — och den första arkitekturfrågan gällde lagring.

En databas kändes som det självklara svaret. Anteckningsappar lagrar strukturerade data. Strukturerade data hamnar i databaser. Vi utvärderade Turso och dess Swift SDK, fick det att fungera i iOS-simulatorn, mätte riktiga streckdata och valde sedan att inte använda det.

Vi valde platta filer i stället. Här är vad vi hittade och varför vi fattade det beslutet.

Ett öppet anteckningsblock med handskrivna anteckningar och en penna på ett träbord.

Foto av Gabriel CoxUnsplash. Unsplash-licens.

Vad appen faktiskt gör med data

En handskriftsapp har ett smalt och förutsägbart dataåtkomstmönster. Att läsa innebär att öppna en sida och läsa in alla dess element — alla streck, alla bilder — i minnet på en gång. Canvasen innehåller allt; den kör aldrig en partiell fråga. Att skriva innebär att avsluta ett pennstreck och lägga till ett element på sidan. I sällsynta fall suddar användaren en del av ett streck, flyttar en markering eller tar bort något, men det är fortfarande operationer på en enda sida och ett enda element.

Det finns ingen samtidig åtkomst. En person skriver på en sida i ett dokument åt gången. Det finns inte heller någon sökning över dokument — anteckningsbiblioteket behöver bara en titel, en tidsstämpel, ett sidantal och en omslagsminiatyr för varje dokument; inget av detta kräver att sidinnehållet läses.

Databasfrågor, index och samordning av samtidighet är det databaser byggs för. Vår app använder inget av de tre.

Utvärderingen av Turso

Vi utvärderade libsql-swift, det officiella Swift SDK:t för Tursos libSQL-motor.

SDK:t fungerar. Alla 9 testfall går igenom. Vi integrerade det i en kopia av appen, byggde för iOS-simulatorn, startade appen och skapade en lokal databas i appens sandbox. Vi skrev 100 streck med 3400 samplingspunkter vardera — 4,080,000 byte BLOB-data — i en enda transaktion. På vår utvecklings-Mac tog det ungefär 0.019s.

Efter att vi kört PRAGMA wal_checkpoint(TRUNCATE) krympte WAL-filen till noll och vi kunde kopiera bara huvudfilen .db till en annan plats, öppna den och läsa tillbaka all data. Själva motorn är stabil.

SDK:t har kostnader. CLibsql.xcframework väger 161 MB. Efter länkning ökade vår Debug-build för simulatorn från ungefär 1.9 MB till ungefär 8.2 MB. API:t är synkront och blockerande, utan wrappers för Swift Concurrency. Det finns ingen explicit close()-metod. Transaction.commit() kastar inget fel — det underliggande C-API:t returnerar void. Repositoryts README kallar SDK:t en “technical preview”, och den senaste committen gjordes ungefär ett år före vår utvärdering, i juli 2025.

Turso-ekosystemet har en lucka. För nya projekt rekommenderar Turso nu sin nya motor “Turso Database” och protokollet “Turso Sync”. Turso Sync har klient-SDK:er för TypeScript, Python, Go och Rust. Det har inget för Swift. Det äldre Embedded Replica-läget finns i libsql-swift, men dess Swift-initialiserare exponerar inte parametern offline, som behövs för en helt local-first mobilapp. Om vi väljer libsql-swift i dag får vi en lokal SQLite-fork, men inte de synkroniseringsmöjligheter som gör Turso unikt.

Vad en databas skulle kosta oss just nu

Även om SDK:t vore moget skulle vi betala kostnader som inte ger något tillbaka för vår arbetsbelastning:

Hantering av WAL:s sidofiler. En databas som kör skapar följeslagarfilerna -wal och -shm. Att kopiera ett dokument kräver antingen en checkpoint först eller att alla tre filer kopieras atomiskt. Att exportera ett .lnote-paket till Files eller AirDrop skulle nu kräva ett steg före exporten som användaren inte kan se och utvecklaren inte får glömma.

Ett adapterlager. Streck skulle behöva serialiseras till BLOB:er och deserialiseras igen. Sidans element har en naturlig arrayordning som canvasen renderar direkt; en databas skulle införa radordning och z-index-kolumner. Vi skulle skriva ett översättningslager mellan två representationer av samma data och underhålla det vid varje schemaändring.

Ett beroende på 161 MB. För en app vars Debug-build är under 2 MB är ett beroende som är större än 80× själva appen en kostnad värd att lägga märke till — särskilt ett som kallas “technical preview” och har varit inaktivt i ett år.

De här kostnaderna är inte hypotetiska. De börjar så fort beroendet länkas in. Och de köper funktioner — frågor, indexering och samtidiga skrivningar — som appen inte använder.

Lösningen vi levererade: snapshot-filpaket

Ett .lnote-dokument är ett katalogpaket:

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 är källan till sanningen för dokumentets struktur: dess ID, titel, tidsstämplar och en ordnad lista över sidor där varje sida har sin canvasstorlek, sina tidsstämplar och sitt antal element. Sidornas innehållsfiler lagrar elementarrayen i samma kvantiserade heltalskodning som appen redan använder — koordinater och radier med 0.1 punkts precision, tryck i tusendelar och tidsstämplar i relativa millisekunder.

Anteckningsbiblioteket läser bara manifest.json och omslagsminiatyrerna. Det tolkar aldrig document.json eller innehållet i någon sida. När en sida öppnas läses en .content-fil in. Det är den enda filläsningen som rör streckdata.

Sidor löser write amplification

En handskriftsapp har redan begreppet sida — det är den enhet användarna tänker i, det de sveper mellan. Om sidan blir enheten för beständig lagring skriver autosparandet bara om sidorna som ändrats.

En handskriven sida — säg 1,000–2,000 streck — tar ungefär 3–5 MB i vårt kvantiserade format. En inspelning med 21 streck och 3400 samplingspunkter kvantiseras till ungefär 55 KB. Att skriva en sidsnapshot till flashlagring tar 10–20 ms på modern hårdvara. Med en debounce på 0.5s blir sparningarna osynliga för användaren.

Sparandets kostnad skalar med hur mycket som skrivs på den aktuella sidan, inte med det totala antalet sidor i dokumentet. En anteckningsbok på 200 sidor sparas exakt lika snabbt som en på 2 sidor, eftersom bara den ändrade sidan skrivs om.

Varje skrivning använder atomiska filoperationer — skriv till en temporär fil och byt sedan namn — så en krasch mitt i sparandet kan inte skapa en avkortad sida. När appen går till bakgrunden flushas alla ändrade sidor direkt, i linje med beteendet appen redan hade med en enda canvas.

Konsistens utan transaktioner

Filpaket har inga transaktioner, men de har tydliga ägarskapsregler som fyller samma funktion:

Resurser före referenser. När användaren infogar en bild skrivs asset-filen direkt till assets/. Sidsnapshoten, som refererar till asseten med dess ID, skrivs senare av autosparandet med debounce. En sida refererar aldrig till en asset som inte finns på disken.

Källan till sanningen vinner. document.json och katalogen pages/ är källan till sanningen. manifest.json är en cache. Om de inte stämmer överens synkroniserar nästa sparning cachen med källan. Miniatyrer är härledda och kan skapas om när som helst.

Föräldralösa filer hellre än hängande referenser. Det värsta resultatet av en krasch är en föräldralös asset — en fil i assets/ som ingen sida refererar till. Föräldralösa filer rensas när dokumentet stängs. Det omvända — en sida som refererar till en fil som saknas — kan inte hända, eftersom asseter skrivs innan sidsnapshoten som refererar till dem.

De här reglerna är enklare att resonera om än WAL-checkpointing och transaktionsisolering, och de passar exakt med appens åtkomstmönster: en process, en sida.

Uppgraderingsvägen är nedskriven

Att välja platta filer nu betyder inte att vi väljer platta filer för alltid. Paketstrukturen är utformad så att en uppgradering av lagringsmotorn ändrar innehållet i paketet utan att ändra själva paketet.

Nivå 1: snapshot + append journal. Om write amplification någon gång blir märkbar — säg att kontinuerligt skrivande på en sida med tusentals streck orsakar en tydlig fördröjning vid sparning — delas varje sidfil upp i en snapshot och en journal som bara kan fyllas på. Nya element läggs till som frames [length][CRC][type][payload]. När journalen spelas upp slängs varje frame vars CRC inte stämmer, vilket ger kraschsäkerhet. När journalen överskrider en gräns eller sidan stängs slås den ihop med snapshoten igen. Det är ungefär ~200 LOC utan externa beroenden.

Eftersom snapshots på sidnivå redan tar bort write amplification mellan sidor kanske den här nivån inte behövs på länge. Att skriva om en sida på 5 MB var 0.5s ligger gott och väl inom flashlagringens skrivbudget.

Nivå 2: SQLite-databas. Om appen någon gång behöver fulltextsökning bland anteckningar, synkronisering per element eller indexering över dokument blir SQLite rätt verktyg. Den troliga motorn då är GRDB, ett moget Swift-wrapperbibliotek som kompileras från källkod och nästan inte ökar binärstorleken alls. libsql-swift skulle bara omprövas om Turso-ekosystemet — särskilt Turso Sync för Swift — blir ett verkligt produktbehov.

Migreringen är mekanisk: varje sidas elementarray mappas till en strokes / images-tabell med en oföränderlig BLOB per streck (16 byte per samplingspunkt i little-endian-binärformat). Benchmarken på 0.019s för 100 streck bekräftar att metoden fungerar. Före leverans kontrollerar migreringen att antalet element och assets stämmer mellan det gamla paketet och den nya databasen, och att återställning efter krasch, WAL-gränser och flush i bakgrunden klarar alla tester.

När man ska betala

Det här beslutet är inget omdöme över databaser. SQLite hanterar samtidiga skrivoperationer, komplexa frågor och kraschåterställning över delat tillstånd — inget av det behövs i vår app just nu. Att betala för funktioner innan appen har problemen de löser är en nettoförlust.

Kostnaderna för en databas — beroendet, WAL-hanteringen, adapterlagret och binärstorleken — börjar i samma ögonblick som biblioteket länkas in. Fördelarna börjar när appen har databasfrågor att köra, index att underhålla eller samtidiga skrivoperationer att samordna. I det här skedet har den inget av de tre.

Varje steg i vår lagringsutveckling kommer bara att betala för problem som redan har dykt upp. Snapshots på sidnivå betalar för dagens problem: att spara dokument med flera sidor utan att skriva om hela filen. Om write amplification blir mätbar betalar en append journal för det problemet. Om sökning eller synkronisering blir ett produktbehov betalar en databas för det problemet.

Paketstrukturen, manifestet och dokumentschemat är inte bundna till någon lagringsmotor. Byteskostnaderna är låga eftersom gränserna ligger på rätt ställen. När dagen kommer då vi verkligen behöver en databas inför vi den för ett specifikt, redan uppmätt problem — inte för ett hypotetiskt.