Pourquoi nous n’utilisons toujours pas de base de données
Nous avons évalué Turso/libSQL pour notre appli d’écriture manuscrite sur iPad, tout mesuré et choisi des fichiers plats. Cette charge de travail n’a pas besoin d’une base de données, et chaque étape devrait seulement payer pour les problèmes déjà là.
Lulucat Notes est une appli d’écriture manuscrite pour iPad. Jusqu’à la semaine dernière, elle n’avait qu’un canevas et aucune notion de deuxième note. Nous étions sur le point d’ajouter une bibliothèque de notes — plusieurs documents, chacun avec plusieurs pages — et la première question d’architecture portait sur le stockage.
Une base de données semblait être la réponse évidente. Les applis de notes enregistrent des données structurées. Les données structurées vont dans des bases de données. Nous avons évalué Turso et son SDK Swift, l’avons fait fonctionner dans le simulateur iOS, avons mesuré de vrais tracés et avons finalement choisi de ne pas l’utiliser.
Nous avons plutôt choisi des fichiers plats. Voici ce que nous avons constaté et pourquoi nous avons pris cette décision.

Photo : Gabriel Cox, sur Unsplash. Licence Unsplash.
Ce que l’appli fait réellement avec les données
Une appli d’écriture manuscrite a un mode d’accès aux données étroit et prévisible. Lire, c’est ouvrir une page et charger en mémoire tous ses éléments d’un coup — tous les tracés et toutes les images. Le canevas contient tout ; il n’exécute jamais de requête partielle. Écrire, c’est terminer un tracé et ajouter un élément à la page. Dans de rares cas, l’utilisateur efface une partie d’un tracé, déplace une sélection ou supprime quelque chose, mais ce sont encore des opérations sur une seule page et un seul élément.
Il n’y a pas d’accès concurrent. Une personne écrit à la fois sur une page d’un document. Il n’y a pas de recherche entre les documents : la bibliothèque de notes n’a besoin que du titre, d’un horodatage, du nombre de pages et d’une vignette de couverture pour chaque document ; rien de tout cela ne demande de lire le contenu des pages.
Les bases de données sont faites pour les requêtes, les index et la coordination de la concurrence. Notre appli n’utilise aucun de ces trois éléments.
L’évaluation de Turso
Nous avons évalué libsql-swift, le SDK Swift officiel du moteur libSQL de Turso.
Le SDK fonctionne. Ses neuf cas de test passent tous. Nous l’avons intégré à une copie de l’appli, l’avons compilée pour le simulateur iOS, lancée et avons créé une base de données locale dans le sandbox de l’appli. Nous avons écrit 100 tracés de 3,400 points d’échantillonnage chacun — 4,080,000 octets de données BLOB — dans une seule transaction. Sur notre Mac de développement, cela a pris environ 0.019 seconde.
Après l’exécution de PRAGMA wal_checkpoint(TRUNCATE), le fichier WAL a rétréci jusqu’à zéro. Nous avons pu copier uniquement le fichier .db principal ailleurs, l’ouvrir et relire toutes les données. Le moteur lui-même est solide.
Le SDK a ses coûts. CLibsql.xcframework pèse 161 MB. Après la liaison, notre build Debug du simulateur est passé d’environ 1.9 MB à environ 8.2 MB. L’API est synchrone et bloquante, sans wrappers pour Swift Concurrency. Elle n’a pas de méthode close() explicite. Transaction.commit() ne lance pas d’erreur : l’API C sous-jacente retourne void. Le README du dépôt qualifie le SDK de « technical preview », et le plus récent commit datait d’environ un an avant notre évaluation, en juillet 2025.
L’écosystème Turso a un trou. Turso recommande maintenant son nouveau moteur « Turso Database » et son protocole « Turso Sync » pour les nouveaux projets. Turso Sync offre des SDK clients pour TypeScript, Python, Go et Rust. Il n’en offre pas pour Swift. L’ancien mode Embedded Replica existe dans libsql-swift, mais son initialiseur Swift n’expose pas le paramètre offline nécessaire à une appli mobile complètement local-first. Adopter libsql-swift aujourd’hui nous donnerait un fork local de SQLite, mais pas les capacités de synchronisation qui font la particularité de Turso.
Ce qu’une base de données nous coûterait aujourd’hui
Même avec un SDK mature, nous paierions des coûts qui ne servent pas notre charge de travail :
La gestion des fichiers auxiliaires WAL. Une base de données active crée les fichiers compagnons -wal et -shm. Copier un document demande soit de faire un checkpoint avant, soit de copier les trois fichiers atomiquement. Exporter un paquet .lnote vers Fichiers ou AirDrop exigerait donc une étape préalable que l’utilisateur ne voit pas et que le développeur ne peut pas oublier.
Une couche d’adaptation. Il faudrait sérialiser les tracés en BLOB et les désérialiser ensuite. Les éléments d’une page ont un ordre naturel dans un array que le canevas rend directement ; une base de données ajouterait des colonnes pour l’ordre des lignes et le z-index. Nous écririons une couche de traduction entre deux représentations des mêmes données et devrions la tenir à jour à chaque changement de schéma.
Une dépendance de 161 MB. Pour une appli dont le build Debug fait moins de 2 MB, une dépendance de plus de 80× la taille de l’appli est un coût digne d’attention — surtout quand elle est marquée « technical preview » et inactive depuis un an.
Ces coûts ne sont pas théoriques. Ils commencent dès que la dépendance est liée. En échange, nous obtenons des capacités — requêtes, indexation et écritures concurrentes — que notre appli n’utilise pas.
La solution livrée : des paquets de fichiers d’instantanés
Un document .lnote est un paquet de répertoire :
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 est la source de vérité de la structure du document : son identifiant, son titre, ses horodatages et une liste ordonnée de pages indiquant, pour chaque page, la taille du canevas, les horodatages et le nombre d’éléments. Les fichiers de contenu des pages stockent l’array d’éléments dans le même encodage entier quantifié que celui qu’utilise déjà l’appli — coordonnées et rayons à une précision de 0.1 point, pression en millièmes et horodatages en millisecondes relatives.
La bibliothèque de notes ne lit que manifest.json et les vignettes de couverture. Elle n’analyse jamais document.json ni le contenu d’une page. Ouvrir une page charge un seul fichier .content. C’est la seule lecture de fichier qui touche aux données des tracés.
Les pages règlent l’amplification des écritures
Une appli d’écriture manuscrite connaît déjà la notion de page — c’est l’unité à laquelle pensent les utilisateurs, celle qu’ils parcourent en balayant. Faire de la page l’unité de persistance signifie que la sauvegarde automatique ne réécrit que les pages qui ont changé.
Une seule page manuscrite — disons 1,000 à 2,000 tracés — occupe environ 3–5 MB dans notre format quantifié. Un enregistrement de 21 tracés comprenant 3,400 points d’échantillonnage se quantifie à environ 55 KB. Écrire un instantané de page sur un stockage flash prend 10–20 ms sur du matériel récent. Avec un debounce de 0.5 seconde, les sauvegardes sont invisibles pour l’utilisateur.
Le coût de sauvegarde suit la quantité d’écriture sur la page actuelle, et non le nombre total de pages du document. Un carnet de 200 pages se sauvegarde exactement aussi vite qu’un carnet de 2 pages, puisque seule la page modifiée est réécrite.
Chaque écriture utilise des opérations atomiques sur les fichiers — écrire dans un fichier temporaire, puis le renommer —, de sorte qu’un plantage au milieu de la sauvegarde ne puisse pas produire une page tronquée. Le passage en arrière-plan vide immédiatement toutes les pages modifiées, comme le faisait déjà l’appli avec un seul canevas.
La cohérence sans transactions
Les paquets de fichiers n’ont pas de transactions, mais ils ont des règles de propriété claires qui remplissent le même rôle :
Les ressources avant les références. Quand l’utilisateur insère une image, le fichier de ressource est écrit immédiatement dans assets/. L’instantané de la page, qui référence la ressource par son identifiant, est écrit plus tard par la sauvegarde automatique avec debounce. Une page ne référence donc jamais une ressource qui n’existe pas sur le disque.
La source de vérité l’emporte. document.json et le répertoire pages/ sont la source de vérité. manifest.json est un cache. S’ils ne correspondent pas, la sauvegarde suivante réconcilie le cache avec la source. Les vignettes sont dérivées et peuvent être recréées en tout temps.
Des orphelins plutôt que des références pendantes. Le pire résultat d’un plantage est une ressource orpheline — un fichier dans assets/ auquel aucune page ne fait référence. Les orphelins sont nettoyés quand le document est fermé. L’inverse — une page qui référence un fichier manquant — est impossible, puisque les ressources sont écrites avant l’instantané de page qui les référence.
Ces règles sont plus simples à raisonner que le checkpointing de WAL et l’isolation des transactions, et elles correspondent exactement au mode d’accès à un seul processus et une seule page de l’appli.
Le chemin de mise à niveau est écrit
Choisir des fichiers plats maintenant ne veut pas dire les choisir pour toujours. La structure du paquet est conçue pour que la mise à niveau du moteur de stockage change son contenu sans changer le paquet lui-même.
Niveau 1 : instantané + journal append-only. Si l’amplification des écritures devenait perceptible — par exemple, si l’écriture continue sur une page de milliers de tracés causait un délai de sauvegarde notable —, chaque fichier de page serait séparé en un instantané et un journal append-only. Les nouveaux éléments seraient ajoutés sous forme de trames [length][CRC][type][payload]. La relecture du journal rejette toute trame dont le CRC ne correspond pas, ce qui assure la sécurité en cas de plantage. Quand le journal dépasse un seuil ou que la page est fermée, il est fusionné de nouveau dans l’instantané. Cela représente ~200 LOC et zéro dépendance externe.
Comme les instantanés par page éliminent déjà l’amplification des écritures entre les pages, ce niveau ne sera peut-être pas nécessaire avant longtemps. Réécrire une page de 5 MB toutes les 0.5 secondes reste bien en deçà des budgets d’écriture flash.
Niveau 2 : base de données SQLite. Si l’appli avait un jour besoin d’une recherche plein texte dans les notes, d’une synchronisation par élément ou d’une indexation entre les documents, SQLite deviendrait le bon outil. Le moteur probable serait alors GRDB, un wrapper Swift mature compilé à partir des sources, avec une surcharge binaire presque nulle. Nous ne reconsidérerions libsql-swift que si l’écosystème Turso — plus précisément Turso Sync pour Swift — devenait un véritable besoin du produit.
La migration est mécanique : l’array d’éléments de chaque page correspond à une table strokes / images contenant un BLOB immuable par tracé (16 octets par point d’échantillonnage en binaire little-endian). Le benchmark de 0.019 seconde pour 100 tracés confirme que l’approche est viable. Avant la livraison, la migration vérifierait que les nombres d’éléments et de ressources concordent entre l’ancien paquet et la nouvelle base de données, et que la récupération après plantage, les limites du WAL et la vidange en arrière-plan passent tous les tests.
Quand payer
Cette décision ne porte pas de jugement sur les bases de données. SQLite prend en charge les écritures concurrentes, les requêtes complexes et la récupération après plantage sur un état partagé — rien de tout cela n’est nécessaire à notre appli pour le moment. Payer ces capacités avant que l’appli n’ait les problèmes qu’elles règlent est une perte nette.
Les coûts d’une base de données — la dépendance, la gestion du WAL, la couche d’adaptation et la taille du binaire — commencent dès que la bibliothèque est liée. Les avantages commencent lorsque l’appli a des requêtes à exécuter, des index à entretenir ou des écritures concurrentes à coordonner. À cette étape, elle n’a aucun de ces trois besoins.
Chaque étape de l’évolution de notre stockage ne paiera que pour les problèmes déjà apparus. Les instantanés par page paient pour le problème actuel : enregistrer des documents de plusieurs pages sans réécrire tout le fichier. Si l’amplification des écritures devient mesurable, un journal append-only paiera pour ce problème. Si la recherche ou la synchronisation devient un besoin du produit, une base de données paiera pour ce problème.
La structure du paquet, le manifeste et le schéma du document ne sont liés à aucun moteur de stockage. Les coûts de changement sont faibles parce que les frontières sont au bon endroit. Le jour où nous aurons besoin d’une base de données, nous l’adopterons pour un problème précis et déjà mesuré — pas pour un problème hypothétique.