Ferrum CMS
Un CMS local-first piloté par des fichiers, en Rust : moteur de synchronisation par fusion à trois voies, schémas TOML, contenu JSON et une CLI, sur une feuille de route en trois phases et douze mois.
- Rôle
- Auteur et unique développeurProjet personnel
- Période
- En cours
- Liens
- Pas encore de dépôt public.
Contexte et problème
Ferrum CMS est un système de gestion de contenu que j'écris en Rust. Il est local-first et piloté par des fichiers : le contenu vit sous forme de fichiers JSON sur le disque, les schémas sont des fichiers TOML, un moteur de synchronisation réconcilie les copies divergentes par une fusion à trois voies, et une CLI pilote l'ensemble. La feuille de route s'étend sur douze mois, en trois phases.
Le problème est une vieille tension de l'outillage de contenu. Un CMS classique garde le contenu dans une base de données centrale derrière une API : on a un schéma et une interface d'édition, il faut une connexion pour écrire, et le contenu vit loin du code qui l'affiche. Le contenu versionné dans Git (du Markdown dans le dépôt) donne l'édition hors ligne et les diffs gratuitement, et abandonne le schéma : le contenu structuré dérive dès que deux personnes le modifient, et une fusion ligne par ligne d'un fichier JSON le casse plus souvent qu'elle ne le réconcilie.
Ferrum se place dans cet entre-deux : des fichiers qu'on peut lire, comparer et versionner, un schéma qui garde le contenu structuré valide, et un moteur de fusion qui travaille sur la forme du contenu, champ par champ, plutôt que sur ses lignes.
Contraintes
Le local-first fixe les règles. Deux copies du même contenu vont diverger, puisque c'est tout l'intérêt de l'édition hors ligne, donc les conflits sont un état normal, et le moteur doit les gérer. La fusion ne doit jamais perdre une modification en silence : un humain doit voir où deux personnes ont changé la même chose, et trancher.
Le contenu et les schémas doivent rester lisibles dans un éditeur de texte et faciles à relire dans une pull request, ce qui exclut les formats binaires et impose une sérialisation déterministe : une fusion qui réordonne les clés crée du bruit dans chaque diff. Le schéma doit être quelque chose qu'un développeur écrit à la main.
La portabilité compte pour un outil censé tourner sur un portable comme en CI : un seul binaire, aucun runtime à installer. Et une feuille de route en trois phases sur douze mois n'a de sens qu'avec une coupe franche dans le périmètre, un cœur qui fonctionne avant tout le reste ; le contenu de chaque phase figure plus bas comme point à confirmer.
Mon rôle précis
Je suis l'auteur et le seul développeur de Ferrum : le format, le modèle de synchronisation, l'implémentation Rust, la CLI et la feuille de route sont de moi. J'écris du Rust depuis 2023, et Ferrum est l'endroit où je m'en sers sur un problème qui me tient à cœur.
Décisions clés et compromis
Comment lire cette section
Les choix ci-dessous sont le projet tel qu'il est. Le raisonnement sous chacun expose le pour et le contre général de chaque option, comme dans une revue de conception ; mes propres notes sur les raisons de ces choix restent à ajouter.
Fusion à trois voies, plutôt que « dernier écrit gagnant » ou CRDT
Une fusion à trois voies compare trois versions d'un document : la base commune, la copie locale et la copie distante. Un champ modifié d'un seul côté prend la valeur de ce côté ; modifié différemment des deux côtés, c'est un conflit. Pour du contenu éditorial, le comportement sain est de signaler un conflit avec les trois valeurs, pour qu'un humain tranche.
// Fusion à trois voies champ par champ, exemple illustratif écrit pour cette page
enum Merge<T> {
Unchanged(T),
TakeLocal(T),
TakeRemote(T),
Conflict { base: T, local: T, remote: T },
}
fn merge_field<T: PartialEq + Clone>(base: &T, local: &T, remote: &T) -> Merge<T> {
match (local == base, remote == base) {
(true, true) => Merge::Unchanged(base.clone()),
(false, true) => Merge::TakeLocal(local.clone()),
(true, false) => Merge::TakeRemote(remote.clone()),
(false, false) if local == remote => Merge::Unchanged(local.clone()),
(false, false) => Merge::Conflict {
base: base.clone(),
local: local.clone(),
remote: remote.clone(),
},
}
}La plupart des outils choisissent le dernier écrit gagnant (last-write-wins) : on garde la copie la plus récente. Trivial à implémenter, et ça perd des données par construction, sans bruit, chaque fois que deux personnes touchent au même document. Acceptable pour un cache, inacceptable pour du contenu éditorial.
Les CRDT sont l'autre option sérieuse : chaque réplica converge sans coordination. Le prix est élevé pour un CMS piloté par des fichiers : chaque type de donnée demande sa propre conception de CRDT, les fichiers se remplissent de métadonnées (tombstones, horloges) qu'un humain ne peut plus éditer à la main, et pour de la prose, le résultat convergé est souvent quelque chose qu'aucun rédacteur n'aurait écrit.
Le coût de ce choix : une version de base doit être conservée quelque part, donc il y a un historique à maintenir, et certaines fusions s'arrêtent pour attendre un humain, ce qui impose une procédure de résolution des conflits dans tout ce qui pilote le moteur. Les tableaux sont la partie difficile : fusionner deux listes demande une notion d'identité des éléments, et c'est là qu'une fusion structurelle brille ou se dégrade en diff textuel.
TOML pour les schémas
Un schéma est lu par des humains bien plus souvent que par la machine, donc le format se choisit pour les humains. TOML a des commentaires, des types explicites, et des tables qui correspondent proprement à « une collection a des champs, un champ a des options ». YAML apporte l'indentation significative et le typage implicite (un no sans guillemets devient un booléen avec les parseurs YAML 1.1). JSON n'a pas de commentaires. Des schémas en code Rust donneraient le typage le plus fort et forceraient une recompilation à chaque nouveau type de contenu. Le coût de TOML : l'imbrication profonde devient verbeuse, et le chargeur doit valider le fichier contre un schéma des schémas.
# Schéma illustratif, écrit pour cette page
[collection.post]
label = "Article de blog"
path = "content/posts"
[collection.post.fields.title]
type = "string"
required = true
[collection.post.fields.published_at]
type = "datetime"
[collection.post.fields.tags]
type = "list"
of = "string"JSON pour le contenu
Le contenu est surtout écrit par un outil et lu par n'importe quoi, un site Next.js ou une application mobile. JSON est le format que chaque consommateur sait lire nativement, et sa grammaire stricte est exactement ce qu'une fusion structurelle veut parcourir. Le Markdown avec front matter est meilleur pour la prose et s'effondre sur les relations et les listes d'objets. SQLite serait plus rapide et cesserait d'être un fichier qu'on peut comparer. Ce que JSON coûte : pas de commentaires, des textes longs échappés, et une sérialisation canonique (ordre des clés stable, formatage fixe) pour qu'une synchronisation produise de petits diffs.
La CLI d'abord
Ferrum se pilote depuis une CLI. Une ligne de commande se scripte, tourne en CI, et colle à une façon de travailler où le contenu vit à côté du code. Commencer par là pousse aussi vers une frontière nette : une CLI mince au-dessus d'une bibliothèque, pour que tout éditeur ultérieur repose sur la même bibliothèque. Une interface d'administration d'abord, la voie habituelle, entraîne un serveur et de l'authentification dans le premier jalon. Le coût : réservé aux développeurs, tant qu'un éditeur n'existe pas.
Local-first, en Rust
Local-first signifie que la copie sur le disque est la source de vérité ; le réseau réconcilie les copies, et travailler ne demande aucune connexion. Rust convient : un seul binaire statique, et un système de types où les issues possibles de la fusion décrite plus haut forment un enum exhaustif que le compilateur vérifie. Avec mon bagage TypeScript, Node aurait été plus rapide à prototyper et aurait demandé un runtime sur chaque machine. Le coût de Rust : une itération plus lente et moins de gens capables de contribuer.
Ce que j'ai livré
Les briques sont les quatre que nomme le résumé du projet : les schémas TOML, les fichiers de contenu JSON, le moteur de synchronisation par fusion à trois voies et la CLI. Une session avec une telle CLI ressemble à ceci ; les commandes sont illustratives.
# Commandes illustratives, pas la vraie CLI du projet
cms init
cms validate content/
cms sync --base .cms/base --remote ../shared-content
cms conflicts listRésultats et chiffres
Je n'ai encore aucun chiffre public à montrer pour Ferrum : pas de lien vers un dépôt et pas de benchmark. Tant qu'il n'y a rien de public vers quoi pointer, le résultat honnête est qualitatif : la conception décrite plus haut (fichiers, schéma, fusion, CLI) et une feuille de route de douze mois en trois phases. Les chiffres apparaîtront ici quand ils existeront et pourront être vérifiés.
Ce que je ferais différemment
Ma propre rétrospective sur Ferrum reste à écrire. Voici ce que je mettrais en tête du plan pour un moteur similaire.
Fixer la sérialisation canonique dès le premier jour : les diffs et les tests de fusion dépendent tous deux du fait que deux écritures du même document produisent les mêmes octets. Régler l'identité des tableaux avant d'écrire la fusion : une liste de tags et une liste de sections ne fusionnent pas de la même façon, et le schéma devrait dire quels champs portent une identité stable. Écrire les tests du moteur de fusion sous forme de propriétés (symétrie entre local et distant, idempotence sur des copies identiques, aucune perte silencieuse) avant les exemples : c'est le genre de code où les cas choisis à la main passent et où les cas générés trouvent le bug.