lithair tient en une phrase : l’état vit en mémoire, un journal d’événements sur disque permet de le reconstruire, et tout sort d’un seul binaire. C’est écrit dans son slogan — In Memory We Trust.
Cette semaine, il a reçu un stockage SQL.
Dit comme ça, ça ressemble à un reniement. Ça n’en est pas un, et la raison tient moins à ce que le nouvel adaptateur sait faire qu’à la liste de ce qu’il refuse de promettre.
Ce qui a été livré
Un nouveau crate, lithair-turso, versionné à part et marqué expérimental. On l’ajoute à l’application, on pose une annotation sur le modèle, et on enregistre ce modèle comme n’importe quel autre :
#[derive(Clone, Serialize, Deserialize, DeclarativeModel)]
#[storage(turso)]
struct Archive {
#[db(primary_key)]
id: String,
#[http(validate = "non_empty")]
title: String,
}
LithairServer::new()
.with_model::<LiveTask>("./data/tasks", "/api/tasks") // natif, par défaut
.with_model::<Archive>("./data/archives", "/api/archives") // SQL
.serve().await?;
Au démarrage, lithair ouvre ./data/archives/model.db — un fichier Turso embarqué, compatible SQLite — et génère les routes CRUD : validation, PATCH atomique, authentification par session, permissions du modèle. Pas de repository à écrire, pas de handler HTTP, pas d’ouverture de base.
Et le point qui compte pour tous ceux qui n’en veulent pas : lithair-core ne dépend pas du pilote Turso. Une application native ne le compile pas, ne le télécharge pas, ne sait pas qu’il existe.
Le choix : une seule autorité par modèle
La RFC qui cadre le travail commence par séparer trois choses qu’on confond facilement :
1. modèles natifs l'existant : événements, rétention, réplication
2. modèles SQL SQL possède l'état courant ; les lectures interrogent SQL
3. projections SQL un état dérivé, reconstruit depuis le journal natif
← plus tard, et c'est un autre produit
Cette version livre le (2) à côté du (1). Un modèle a une autorité : soit le chemin mémoire-d’abord, soit SQL. Jamais les deux, jamais un cache de l’un devant l’autre. Une application peut mélanger des modèles des deux sortes ; un modèle ne peut pas.
La conséquence est écrite noir sur blanc : il n’y a pas de transaction distribuée entre un modèle natif et un modèle SQL. Un flux qui touche les deux a besoin d’une saga ou d’une compensation, conçue à part. Le framework ne prétend pas le faire pour vous.
Ce qu’il refuse — et où il le refuse
Un modèle natif lithair sait faire beaucoup de choses : historique, rétention mémoire/disque, flux SSE, réplication Raft, audit du cycle de vie. Un modèle SQL ne sait rien de tout ça. La tentation, dans ce genre d’ajout, c’est d’accepter les annotations et de les ignorer en silence.
Ici, c’est l’inverse :
non fourni sur un modèle SQL
─────────────────────────────────────────────────────────────
unicité secondaire, clés étrangères, migration native,
historique, rétention, immuabilité, SSE, Raft, jointures,
cohérence de cache transparente
→ la macro REFUSE la déclaration qui les demande (à la compilation)
→ le démarrage REFUSE le clustering natif et (au lancement)
l'admin de données sur un serveur qui a des
modèles SQL
Le second refus est le plus instructif. L’admin de données de lithair sait exporter et sauvegarder les modèles natifs. Sur un serveur mixte, cet export réussirait — et oublierait tout ce qui vit en SQL. Une sauvegarde qui réussit en omettant la moitié des données, c’est exactement le genre de succès dont on a passé l’été à se débarrasser. Alors le serveur refuse de démarrer dans cette configuration. C’est moins pratique. C’est vrai.
Même logique pour les répertoires : choisir Turso dans un dossier qui contient des événements natifs, ou l’inverse, ne démarre pas. Changer de moteur est une migration explicite, pas un effet de bord d’une annotation.
Cinq releases en deux jours
La v1.11.0 est sortie, et quatre autres ont suivi dans les deux jours. Aucune n’ajoute de paillettes ; chacune corrige ce qu’un premier usage réel a montré.
- v1.12.0 — les migrations. La première mouture disait : pour changer de schéma, migrez à la main ou changez de collection. Ça n’a pas tenu une journée. On déclare maintenant
version = 2, migrations(note_v2): des transformations Rust ordonnées, appliquées au démarrage, validées avant de servir, et qui s’annulent en bloc si l’une échoue ou panique. Les rétrogradations sont refusées. - v1.12.1 — la pagination. Les listes SQL renvoyaient 50 résultats et rien qui dise qu’il y en avait d’autres. Elles portent maintenant
has_moreetnext_offset. Avec une précision honnête dans la note : le décompte se fait avant le filtre de permissions, donc une page autorisée peut être vide et avoir quand même une suite. - v1.12.2 — la parité. Un DELETE SQL répondait
200 {"deleted": true}; un DELETE natif répond204sans corps. Deux moteurs, deux contrats pour la même route : corrigé, et la note dit aux clients d’arrêter de parser un corps.
Et puis il y a la v1.11.1, qui ne concerne pas Turso du tout.
Le bug qui n’avait rien à voir — et qui compte le plus
Le même jour, une application construite sur lithair a remonté ceci : créer un enregistrement, le supprimer (204), redémarrer le serveur — et le GET sur l’identifiant supprimé répond 200.
Le journal sur disque était correct : l’événement Deleted y était, au bon endroit, après la création. C’est le rejeu qui était faux. Il désérialisait chaque enveloppe comme un modèle et l’insérait, sans regarder le type d’événement. Or une enveloppe de suppression contient l’enregistrement supprimé. Donc le rejeu le réinsérait. Un enregistrement effacé ressuscitait à chaque redémarrage, et le même défaut touchait les suppressions répliquées d’enregistrements évincés de la mémoire.
C’était là depuis la 1.10 au moins, et les modèles Turso n’étaient pas concernés. C’est le bug le plus sérieux de la semaine, il vit dans le moteur historique, et ce n’est aucun test qui l’a trouvé : c’est quelqu’un qui s’en servait. Corrigé en v1.11.1 le jour même — le rejeu interprète maintenant les opérations dans l’ordre d’écriture, y compris supprimer-puis-recréer le même identifiant — sans réécrire le journal ni toucher à la chaîne de hachage.
Il y a une ironie que je ne vais pas maquiller : la semaine où le framework gagne un second moteur en promettant de ne rien faire semblant, c’est le premier qui se fait prendre à faire semblant. Un 204 suivi d’une résurrection, c’est exactement un succès annoncé pour un travail qui ne tient pas.
Ce qu’on a appris
Qu’on peut ajouter un stockage SQL à un framework mémoire-d’abord sans le trahir, à une condition : ne pas lui faire porter les promesses de l’autre. Le natif reste le défaut, le SQL est un choix par modèle, le crate a sa propre série de versions en 0.x, et tout ce qu’il ne sait pas faire est une erreur de compilation ou un refus de démarrer — pas une ligne dans une FAQ.
Et la limite, nommée : c’est expérimental. Pas de PostgreSQL, pas de projections, pas de SSE sur les modèles SQL, égalité de chaînes pour seul filtre, et une pagination plafonnée à 100 candidats. La RFC se termine par une section « prochaine décision », pas par une feuille de route.
Le slogan ne change pas. In Memory We Trust — et quand ce n’est pas en mémoire, on vous le dit au moment où vous compilez.