Lulucat

Pourquoi nous n’utilisons pas encore de base de données

Gaoge ZhangGaoge Zhang

Nous avons évalué Turso/libSQL pour notre application d’écriture manuscrite sur iPad, tout mesuré, puis choisi des fichiers plats. Notre charge de travail n’a pas besoin d’une base de données, et chaque étape ne devrait payer que pour les problèmes déjà présents.

Lulucat Notes est une application d’écriture manuscrite pour iPad. Jusqu’à la semaine dernière, elle n’avait qu’un canevas et aucune notion de seconde note. Nous étions sur le point d’ajouter une bibliothèque de notes — plusieurs documents, chacun composé de plusieurs pages — et la première question d’architecture concernait le stockage.

Une base de données semblait être la réponse évidente. Les applications de notes stockent 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é des données de tracés réels, puis avons choisi de ne pas l’utiliser.

Nous avons choisi des fichiers plats. Voici ce que nous avons trouvé et pourquoi nous avons pris cette décision.

Un carnet ouvert contenant des notes manuscrites et un stylo sur un bureau en bois.

Photo de Gabriel Cox sur Unsplash. Licence Unsplash.

Ce que l’application fait réellement avec les données

Une application d’écriture manuscrite a un mode d’accès aux données étroit et prévisible. Lire signifie ouvrir une page et charger en mémoire tous ses éléments à la fois — tous les tracés, toutes les images. Le canevas contient tout ; il n’exécute jamais de requête partielle. Écrire signifie 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 il s’agit toujours d’opérations portant 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 d’un titre, d’un horodatage, d’un nombre de pages et d’une miniature de couverture pour chaque document, et aucun de ces éléments ne nécessite de lire le contenu des pages.

Les bases de données sont conçues pour les requêtes, les index et la coordination de la concurrence. Notre application 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’application, avons compilé pour le simulateur iOS, l’avons lancée et avons créé une base de données locale dans le sandbox de l’application. Nous avons écrit 100 tracés de 3,400 points d’échantillonnage chacun — soit 4,080,000 octets de données BLOB — dans une seule transaction. Cela a pris environ 0.019 seconde sur notre Mac de développement.

Après avoir exécuté PRAGMA wal_checkpoint(TRUNCATE), le fichier WAL a été réduit à zéro. Nous avons pu copier le seul fichier .db principal vers un autre emplacement, l’ouvrir et relire toutes les données. Le moteur lui-même est solide.

Le SDK a un coût. CLibsql.xcframework pèse 161 MB. Après l’avoir lié, 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. Il n’existe pas de méthode close() explicite. Transaction.commit() ne lève pas d’erreur : l’API C sous-jacente renvoie void. Le README du dépôt qualifie le SDK de « technical preview », et le commit le plus récent remontait à environ un an avant notre évaluation, en juillet 2025.

L’écosystème Turso présente un manque. Turso recommande maintenant son nouveau moteur « Turso Database » et son protocole « Turso Sync » pour les nouveaux projets. Turso Sync propose des SDK clients pour TypeScript, Python, Go et Rust. Il n’en propose 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 application mobile entièrement local-first. Adopter libsql-swift aujourd’hui nous donnerait un fork local de SQLite, mais pas les capacités de synchronisation qui distinguent Turso.

Ce qu’une base de données nous coûterait maintenant

Même si le SDK était mature, nous paierions des coûts qui n’apportent rien à notre charge de travail :

La gestion des fichiers auxiliaires WAL. Une base de données en fonctionnement crée les fichiers compagnons -wal et -shm. Copier un document exige soit d’effectuer un checkpoint au préalable, soit de copier les trois fichiers de manière atomique. Exporter un paquet .lnote vers Fichiers ou AirDrop nécessiterait alors une étape d’export préalable que l’utilisateur ne peut pas voir et que le développeur ne peut pas oublier.

Une couche d’adaptation. Il faudrait sérialiser les tracés en BLOB, puis les désérialiser. Les éléments d’une page ont un ordre naturel dans un array que le canevas restitue directement ; une base de données introduirait des colonnes d’ordre des lignes et de z-index. Nous écririons une couche de traduction entre deux représentations des mêmes données et la maintiendrions à chaque changement de schéma.

Une dépendance de 161 MB. Pour une application dont le build Debug fait moins de 2 MB, une dépendance de plus de 80× la taille de l’application est un coût qui mérite d’être remarqué — surtout lorsqu’elle est qualifiée de « technical preview » et qu’elle n’a pas évolué depuis un an.

Ces coûts ne sont pas hypothétiques. Ils commencent dès que la dépendance est liée. Et ils nous achètent des capacités — requêtes, indexation, écritures concurrentes — que notre application 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 contenant, 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 déjà utilisé par l’application — coordonnées et rayons avec une précision de 0.1 point, pression en millièmes, horodatages en millisecondes relatives.

La bibliothèque de notes ne lit que manifest.json et les miniatures 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ésolvent l’amplification des écritures

Une application d’écriture manuscrite possède 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 modifiées.

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 comportant 3,400 points d’échantillonnage se quantifie en environ 55 KB. Écrire un instantané de page sur un stockage flash prend 10–20 ms sur du matériel moderne. Avec un debounce de 0.5 seconde, les sauvegardes sont invisibles pour l’utilisateur.

Le coût de la sauvegarde évolue avec la quantité d’écriture sur la page actuelle, et non avec le nombre total de pages du document. Un carnet de 200 pages s’enregistre exactement aussi vite qu’un carnet de 2 pages, car 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 crash en plein enregistrement ne peut 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’application 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. Lorsque l’utilisateur insère une image, le fichier de ressource est immédiatement écrit dans assets/. L’instantané de la page, qui référence la ressource par son identifiant, est écrit plus tard par la sauvegarde automatique temporisée. À aucun moment une page ne référence une ressource absente du 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 divergent, la sauvegarde suivante réconcilie le cache avec la source. Les miniatures sont dérivées et peuvent être régénérées à tout moment.

Des orphelins plutôt que des références pendantes. Le pire résultat d’un crash est une ressource orpheline — un fichier dans assets/ auquel aucune page ne fait référence. Les orphelins sont nettoyés à la fermeture du document. L’inverse — une page qui référence un fichier manquant — ne peut pas se produire, 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 correspondent exactement au mode d’accès de l’application, à un processus et une page à la fois.

La voie de mise à niveau est documentée

Choisir des fichiers plats aujourd’hui ne signifie pas les choisir pour toujours. La structure du paquet est conçue de sorte que la mise à niveau du moteur de stockage modifie le contenu du paquet sans modifier le paquet lui-même.

Niveau 1 : instantané + journal append-only. Si l’amplification des écritures devenait un jour perceptible — par exemple, si l’écriture continue sur une page contenant des milliers de tracés provoquait un délai d’enregistrement visible —, chaque fichier de page serait divisé 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 écarte toute trame dont le CRC ne correspond pas, ce qui assure la sécurité en cas de crash. Lorsque le journal dépasse un seuil ou que la page est fermée, il est fusionné à nouveau dans l’instantané. Cela représente ~200 LOC et zéro dépendance externe.

Comme les instantanés au niveau des pages éliminent déjà l’amplification des écritures entre les pages, ce niveau pourrait ne pas être nécessaire avant longtemps. Réécrire une page de 5 MB toutes les 0.5 secondes reste largement dans les budgets d’écriture du stockage flash.

Niveau 2 : base de données SQLite. Si l’application avait un jour besoin d’une recherche plein texte dans les notes, d’une synchronisation par élément ou d’un index entre les documents, SQLite deviendrait le bon outil. Le moteur probable serait alors GRDB, un wrapper Swift mature, compilé depuis les sources, avec une surcharge binaire presque nulle. libsql-swift ne serait réévalué que si l’écosystème Turso — et plus précisément Turso Sync pour Swift — devenait un besoin réel 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 mise en production, la migration vérifierait que les nombres d’éléments et de ressources correspondent entre l’ancien paquet et la nouvelle base de données, et que la récupération après crash, les limites du WAL et la vidange en arrière-plan passent tous les tests.

Quand payer

Cette décision ne constitue pas un jugement sur les bases de données. SQLite gère les écritures concurrentes, les requêtes complexes et la récupération après crash sur un état partagé — rien de tout cela n’est nécessaire à notre application aujourd’hui. Payer ces capacités avant que l’application ne rencontre les problèmes qu’elles résolvent 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 bénéfices commencent lorsque l’application a des requêtes à exécuter, des index à maintenir ou des écritures concurrentes à coordonner. À ce stade, elle n’a aucun de ces trois besoins.

Chaque étape de l’évolution de notre stockage ne paiera que pour les problèmes qui sont déjà apparus. Les instantanés au niveau des pages paient pour le problème que nous avons aujourd’hui : enregistrer des documents de plusieurs pages sans réécrire le fichier entier. 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 dépendent d’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 réellement 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.