Lulucat

Perché non usiamo ancora un database

Gaoge ZhangGaoge Zhang

Abbiamo valutato Turso/libSQL per la nostra app di scrittura a mano su iPad, misurato tutto e scelto file snapshot piatti. Il carico di lavoro non ha bisogno di un database — e ogni passaggio dovrebbe pagare solo i problemi che esistono già.

Lulucat Notes è un’app per la scrittura a mano su iPad. Fino alla settimana scorsa aveva una sola tela e non conosceva il concetto di una seconda nota. Stavamo per aggiungere una libreria delle note — più documenti, ciascuno con più pagine — e la prima domanda architetturale riguardava l’archiviazione.

Un database sembrava la risposta ovvia. Le app per le note memorizzano dati strutturati. I dati strutturati vanno nei database. Abbiamo valutato Turso e il suo Swift SDK, lo abbiamo fatto funzionare nel simulatore iOS, abbiamo misurato dati reali di tratti e poi abbiamo scelto di non usarlo.

Abbiamo scelto invece file piatti. Ecco che cosa abbiamo scoperto e perché abbiamo preso questa decisione.

Un quaderno aperto con appunti scritti a mano e una penna su una scrivania di legno.

Foto di Gabriel Cox su Unsplash. Licenza Unsplash.

Che cosa fa davvero l’app con i dati

Un’app per la scrittura a mano ha uno schema di accesso ai dati ristretto e prevedibile. Leggere significa aprire una pagina e caricare in memoria, tutta insieme, ogni elemento che contiene — tutti i tratti, tutte le immagini. La tela contiene tutto; non esegue mai una query parziale. Scrivere significa terminare un tratto di penna e aggiungere un elemento alla pagina. In rari casi l’utente cancella una parte di un tratto, sposta una selezione o elimina qualcosa, ma anche queste restano operazioni su una singola pagina e un singolo elemento.

Non c’è accesso concorrente. Una persona scrive su una pagina di un documento alla volta. Non c’è ricerca tra documenti — la libreria delle note ha bisogno solo di un titolo, un timestamp, un conteggio delle pagine e una miniatura di copertina per ogni documento; nulla di tutto ciò richiede di leggere il contenuto delle pagine.

Query, indici e coordinamento della concorrenza sono ciò per cui vengono costruiti i database. La nostra app non usa nessuna delle tre cose.

La valutazione di Turso

Abbiamo valutato libsql-swift, lo Swift SDK ufficiale per il motore libSQL di Turso.

Lo SDK funziona. Tutti e 9 i suoi casi di test passano. Lo abbiamo integrato in una copia dell’app, abbiamo eseguito la build per il simulatore iOS, l’abbiamo avviata e abbiamo creato un database locale nella sandbox dell’app. Abbiamo scritto 100 tratti da 3,400 punti di campionamento ciascuno — 4,080,000 byte di dati BLOB — in un’unica transazione. Sul nostro Mac di sviluppo ci sono voluti circa 0.019 secondi.

Dopo aver eseguito PRAGMA wal_checkpoint(TRUNCATE), il file WAL si è ridotto a zero e abbiamo potuto copiare il solo file .db principale in un’altra posizione, aprirlo e rileggere tutti i dati. Il motore in sé è solido.

Lo SDK ha dei costi. CLibsql.xcframework pesa 161 MB. Dopo il linking, la build Debug del simulatore è passata da circa 1.9 MB a circa 8.2 MB. L’API è sincrona e bloccante, senza wrapper per Swift Concurrency. Non esiste un metodo close() esplicito. Transaction.commit() non genera errori — l’API C sottostante restituisce void. Il README del repository definisce lo SDK una “technical preview” e il commit più recente risaliva a circa un anno prima della nostra valutazione, nel luglio 2025.

Nell’ecosistema Turso c’è una lacuna. Oggi Turso raccomanda il nuovo motore “Turso Database” e il protocollo “Turso Sync” per i progetti nuovi. Turso Sync dispone di SDK client per TypeScript, Python, Go e Rust. Non ne esiste uno per Swift. La modalità Embedded Replica, più vecchia, è presente in libsql-swift, ma l’initializer Swift non espone il parametro offline necessario per un’app mobile completamente local-first. Adottare oggi libsql-swift ci dà un fork locale di SQLite, ma non le capacità di sincronizzazione che rendono Turso distintivo.

Quanto ci costerebbe un database, oggi

Anche se lo SDK fosse maturo, continueremmo a pagare costi che non portano alcun vantaggio per il nostro carico di lavoro:

Gestione dei file collaterali WAL. Un database in esecuzione crea i file compagni -wal e -shm. Copiare un documento significa eseguire prima un checkpoint oppure copiare tutti e tre i file in modo atomico. Esportare un pacchetto .lnote in Files o via AirDrop richiederebbe ora un passaggio di pre-esportazione che l’utente non può vedere e che lo sviluppatore non può dimenticare.

Un livello adattatore. I tratti dovrebbero essere serializzati in BLOB e poi deserializzati. Gli elementi di una pagina hanno un ordine naturale dell’array che la tela può disegnare direttamente; un database introdurrebbe l’ordine delle righe e colonne z-index. Scriveremmo un livello di traduzione tra due rappresentazioni degli stessi dati e dovremmo mantenerlo a ogni modifica dello schema.

Una dipendenza da 161 MB. Per un’app la cui build Debug è sotto i 2 MB, una dipendenza più grande di 80× l’app stessa è un costo degno di nota — soprattutto se è una “technical preview” inattiva da un anno.

Questi costi non sono ipotetici. Cominciano nel momento in cui la dipendenza viene collegata. E ci comprano capacità — query, indicizzazione, scritture concorrenti — che la nostra app non usa.

La soluzione che abbiamo distribuito: pacchetti di file snapshot

Un documento .lnote è un pacchetto di directory:

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 è la fonte di verità per la struttura del documento: il suo ID, il titolo, i timestamp e un elenco ordinato di pagine con le dimensioni della tela, i timestamp e il conteggio degli elementi di ciascuna pagina. I file del contenuto delle pagine memorizzano l’array degli elementi nella stessa codifica intera quantizzata che l’app usa già — coordinate e raggi con precisione di 0.1 punti, pressione in millesimi, timestamp in millisecondi relativi.

La libreria delle note legge solo manifest.json e le miniature di copertina. Non analizza mai document.json né il contenuto di alcuna pagina. Aprire una pagina carica un solo file .content. È l’unica lettura di file che tocca i dati dei tratti.

Le pagine risolvono la write amplification

Un’app per la scrittura a mano ha già il concetto di pagina — è l’unità a cui pensano gli utenti, quella tra cui passano scorrendo. Rendere la pagina l’unità di persistenza significa che il salvataggio automatico riscrive solo le pagine cambiate.

Una singola pagina di scrittura a mano — diciamo da 1,000 a 2,000 tratti — occupa circa 3–5 MB nel nostro formato quantizzato. Una registrazione di 21 tratti con 3,400 punti di campionamento viene quantizzata in circa 55 KB. Scrivere uno snapshot di pagina nella memoria flash richiede da 10 a 20 millisecondi sull’hardware moderno. Con un debounce di 0.5 secondi, i salvataggi sono invisibili all’utente.

Il costo del salvataggio cresce con la quantità di scrittura sulla pagina corrente, non con il numero totale di pagine del documento. Un quaderno di 200 pagine salva esattamente alla stessa velocità di uno di 2 pagine, perché viene riscritta solo la pagina modificata.

Ogni scrittura usa operazioni atomiche sui file — scrive in un file temporaneo e poi lo rinomina — quindi un crash durante il salvataggio non può produrre una pagina troncata. Quando l’app passa in background, esegue immediatamente il flush di tutte le pagine modificate, replicando il comportamento che aveva già con una sola tela.

Consistenza senza transazioni

I pacchetti di file non hanno transazioni, ma hanno regole chiare di proprietà che svolgono la stessa funzione:

Risorse prima dei riferimenti. Quando l’utente inserisce un’immagine, il file dell’asset viene scritto subito in assets/. Lo snapshot della pagina, che fa riferimento all’asset tramite ID, viene scritto più tardi dal salvataggio automatico con debounce. Non esiste mai un momento in cui una pagina faccia riferimento a un asset che non esiste sul disco.

Vince la fonte di verità. document.json e la directory pages/ sono la fonte di verità. manifest.json è una cache. Se non concordano, il salvataggio successivo riconcilia la cache per farla corrispondere alla fonte. Le miniature sono derivate e possono essere rigenerate in qualsiasi momento.

Meglio gli orfani dei riferimenti pendenti. Il risultato peggiore di un crash è un asset orfano — un file in assets/ a cui non fa riferimento nessuna pagina. Gli orfani vengono ripuliti quando il documento viene chiuso. Il contrario — una pagina che fa riferimento a un file mancante — non può accadere, perché gli asset vengono scritti prima dello snapshot della pagina che li referenzia.

Queste regole sono più semplici da comprendere del checkpointing WAL e dell’isolamento delle transazioni, e corrispondono esattamente allo schema di accesso a processo singolo e pagina singola dell’app.

Il percorso di aggiornamento è già scritto

Scegliere i file piatti oggi non significa sceglierli per sempre. La struttura del pacchetto è progettata in modo che l’aggiornamento del motore di archiviazione cambi ciò che sta dentro il pacchetto senza cambiare il pacchetto stesso.

Livello 1: snapshot + journal append. Se un giorno la write amplification diventasse percepibile — per esempio, se la scrittura continua su una pagina con migliaia di tratti causasse un ritardo evidente nel salvataggio — ogni file di pagina verrebbe diviso in uno snapshot e un journal append-only. I nuovi elementi verrebbero aggiunti come frame [length][CRC][type][payload]. Durante la riproduzione del journal, ogni frame il cui CRC non corrisponde viene scartato, garantendo la sicurezza in caso di crash. Quando il journal supera una soglia o la pagina viene chiusa, viene nuovamente unito allo snapshot. Si tratta di circa 200 righe di codice con zero dipendenze esterne.

Poiché gli snapshot a livello di pagina eliminano già la write amplification tra pagine, questo livello potrebbe non servire ancora per molto tempo. Una riscrittura di una pagina da 5 MB ogni 0.5 secondi rientra ampiamente nel budget di scrittura della memoria flash.

Livello 2: database SQLite. Se un giorno l’app avesse bisogno della ricerca full-text tra le note, della sincronizzazione per elemento o dell’indicizzazione tra documenti, SQLite diventerebbe lo strumento giusto. Il motore probabile a quel punto sarebbe GRDB, un wrapper Swift maturo, compilato dal sorgente, con un overhead sulla dimensione binaria quasi nullo. libsql-swift verrebbe rivalutato solo se l’ecosistema Turso — in particolare Turso Sync per Swift — diventasse una reale esigenza di prodotto.

Il percorso di migrazione è meccanico: l’array degli elementi di ogni pagina viene mappato in una tabella strokes / images con un BLOB immutabile per tratto (16 byte per punto di campionamento in formato binario little-endian). Il benchmark di 0.019 secondi per 100 tratti conferma che l’approccio è praticabile. Prima della distribuzione, la migrazione verificherebbe che il numero di elementi e quello degli asset coincidano tra il vecchio pacchetto e il nuovo database, e che il recupero dopo crash, i limiti WAL e il flush in background superino tutti i controlli.

Quando vale la pena pagare

Questa decisione non è un giudizio sui database. SQLite gestisce scritture concorrenti, query complesse e il recupero da crash su stato condiviso — nessuna di queste cose serve attualmente alla nostra app. Pagare per tali capacità prima che l’app abbia i problemi che risolvono è una perdita netta.

I costi di un database — la dipendenza, la gestione del WAL, il livello adattatore, la dimensione binaria — iniziano nel momento in cui la libreria viene collegata. I vantaggi iniziano quando l’app ha query da eseguire, indici da mantenere o scritture concorrenti da coordinare. In questa fase non ne ha nessuna.

Ogni passaggio dell’evoluzione del nostro sistema di archiviazione pagherà solo i problemi già comparsi. Gli snapshot a livello di pagina pagano il problema che abbiamo oggi: salvare documenti multipagina senza riscrivere l’intero file. Se la write amplification diventasse misurabile, un journal append pagherebbe quel problema. Se la ricerca o la sincronizzazione diventassero una necessità di prodotto, un database pagherebbe quel problema.

La struttura del pacchetto, il manifest e lo schema del documento non sono legati ad alcun motore di archiviazione. Il costo del passaggio è basso perché i confini sono nel posto giusto. Quando arriverà il giorno in cui avremo davvero bisogno di un database, lo adotteremo per un problema specifico e già misurato — non per uno ipotetico.