Files
mabibli/docs/architecture.md
T
mathieuandClaude Opus 5 b6c4dbaa08 Decoupe la documentation par audience, et corrige trois defauts
Le README faisait 691 lignes, dont 295 de procedure de mise en production —
que personne ne lit avant d'avoir decide d'installer le projet. Or c'est le
premier document lu. Il tombe a 86 lignes et devient un aiguillage.

Un document, un lecteur, une question :

  README.md                    c'est quoi ?              un visiteur
  docs/installer.md            comment je l'heberge ?    qui installe
  docs/publier-une-version.md  comment je sors une v. ?  qui maintient
  docs/architecture.md         pourquoi le code ainsi ?  qui contribue

Trois defauts sortis de la comparaison entre le README et le tour du projet,
dont deux introduits ce matin :

⚠ Le README se contredisait sur example.org. Son « Journal du paquet »
documentait comme une panne reglee (« l'installation s'arretait net ») le
placeholder que le nettoyage vient de retablir. Ce n'est pas une regression —
publier.sh refuse desormais de publier avec, ce qui etait precisement le
garde-fou manquant en aout — mais qui lisait le journal concluait l'inverse.
Le journal est supprime : c'est de l'historique, il vit dans les commits et
dans CLAUDE.md.

⚠ La fusion des migrations n'etait nulle part. La section « Monter de version »
decrivait pas a pas une procedure qui echouerait depuis toute version anterieure
a la 0.5.0, et son « Si la mise a jour echoue » ne mentionnait pas cette cause.
L'avertissement est desormais en tete du README, d'installer.md et de la section
concernee.

⚠ Les trois portees — commune, personnelle, trace — decident de tout dans ce
projet et etaient absentes de son point d'entree. Elles ouvrent architecture.md.

Verifie : aucune ancre morte, aucun lien mort, et les 41 lignes non reprises
sont soit condensees dans le nouveau README, soit du journal supprime a dessein.
Trois d'entre elles sont revenues (build/dist ignore, l'inspiration radarr_ynh,
le renvoi aux commentaires de systemd.service).

626 tests au vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:30:42 +02:00

16 KiB

MaBibli, partie par partie

Ce que fait chaque morceau, la décision qui le tient, et le piège à ne pas rouvrir.

Pour installer, voir installer.md. Pour sortir une version, publier-une-version.md. Le détail complet des décisions, avec leurs mesures et les raisonnements qui se sont révélés faux, est dans CLAUDE.md.

La forme trois projets, un seul processus
Le modèle trois portées, et tout en découle
Le serveur enrichir une fiche depuis un code-barres
Le client consulter d'abord, modifier ensuite
Le hors-ligne consultation seule, neuf instantanés
Le déploiement SSO par en-têtes, et une frontière dure
La méthode comment le projet tranche

La forme : trois projets, un seul processus

Le client Blazor WebAssembly n'est pas un serveur. Compilé, ce ne sont que des fichiers statiques que l'API sert depuis son wwwroot. Un seul processus tourne donc en production.

MaBibli.Client  ─┐                              ┌─ 10 368 lignes
MaBibli.Shared  ─┼──►  dotnet publish  ──►  un  ├─  3 100   (entités, DTO, ISBN, normalisation)
MaBibli.Api     ─┘     (self-contained)  service└─  9 314

                       systemd · 127.0.0.1:PORT · nginx · SSOwat

MaBibli.Shared n'est pas un fourre-tout : il porte ce que les deux côtés doivent calculer à l'identique — la normalisation du texte, le rapprochement d'auteurs, le formatage des ISBN. Un écart entre client et serveur y deviendrait un bug invisible.

⚠️ Le piège du publish, résolu et à ne pas rouvrir

Les empreintes WASM sont désactivées (WasmFingerprintAssets=false), et il ne faut pas les réactiver. Publier l'API réécrivait mal index.html : les placeholders restaient littéraux, la racine répondait 200 et l'application restait blanche.

Vérifier que / renvoie 200 ne prouve rien — c'est ce qui a laissé passer le défaut pendant trois phases. Il faut vérifier les scripts que index.html référence réellement.


Le modèle : trois portées, et tout en découle

C'est la notion structurante du projet. Chaque donnée appartient à l'une des trois, et s'y tenir décide de tout le reste — ce qu'on filtre, ce qu'on affiche, ce qu'on cache.

Portée Ce que c'est Filtre-t-on dessus ?
Commune Catalogue, auteurs, séries, revues, thèmes, prêts Jamais. Un livre absent l'est pour tout le monde
Personnelle Statut de lecture, liste d'envies, bibliographies masquées Toujours, sur l'appelant, sans exception
Trace AjoutePar Jamais — c'est une information, pas une frontière

Le contraste le plus instructif est celui du statut de lecture et du prêt. Deux membres du foyer lisent le même exemplaire à leur rythme : le statut est personnel. Mais un livre sorti l'est pour tous, et n'importe qui doit pouvoir noter son retour : le prêt est commun. Les points d'entrée des prêts ne reçoivent donc aucune identité — pour qu'on ne puisse pas s'en servir par inadvertance.

Cinq tables qui refusent de fusionner

Livre, Revue, Serie, LivreSouhaite, RevueSouhaitee. Le même argument revient quatre fois, et il porte sur un invariant, pas sur le confort :

Logée dans Livres, une revue — ou un souhait — entrerait mécaniquement dans le catalogue, les compteurs, la détection de doublons, les séries et les bibliographies. Il faudrait ajouter « et qui n'est pas une revue » à chaque lecture. Un invariant qu'on réécrit partout finit par être oublié quelque part.

Le prix est réel et se paie là où les listes se rejoignent : affichage, export .txt, export .csv, instantané hors-ligne, renormalisation. C'est là que le sujet se rate s'il est bâclé.

Deux astuces SQLite qui portent des règles métier

  • Index uniques partiels. UNIQUE(LivreId) WHERE DateRetour IS NULL garantit un seul prêt ouvert par livre, tout en laissant l'historique accumuler autant de prêts clos qu'il veut. Même mécanique pour la place d'un tome dans une série, et pour l'ISSN.
  • Colonnes normalisées. SQLite ne sait pas ignorer les accents — NOCASE ne couvre que l'ASCII, et il n'y a pas d'unaccent(). La forme mise à plat est donc stockée, et la recherche y compare un terme passé par exactement la même fonction. ⚠️ Toute écriture doit les recalculer.

⚠️ Deux NULL sont distincts pour SQLite

C'est le piège qui se repose à chaque table personnelle. AuteurNormalise et NumeroNormalise sont NOT NULL avec un défaut vide : sinon « Médor, sans numéro » s'ajouterait autant de fois qu'on cliquerait, l'index unique ne voyant que des valeurs distinctes.

⚠️ Les migrations sont fusionnées

Une seule migration InitialCreate depuis la sortie du projet. Aucune base antérieure à la 0.5.0 n'est migrable — voir installer.md.


Le serveur : enrichir une fiche depuis un code-barres

C'est la fonction qui justifiait un projet sur mesure — les deux solutions existantes échouaient dessus, l'une par dépendance à Google Books, l'autre par un bug d'auteur non récupéré.

EAN-13 lu  ──►  préfixe 977 ?  ──oui──►  périodique : ISSN, titre BnF, fiche revue
                     │non
                     ▼
             BnF SRU · bib.isbn en ISBN-13   ──trouvé──►  notice
                     │rien
                     ▼
             BnF SRU · bib.isbn en ISBN-10   ──trouvé──►  notice
                     │rien
                     ▼
             OpenLibrary /isbn → /works → /authors    ──►  notice
                     │rien
                     ▼
             saisie manuelle, toujours accessible

⚠️ Les deux formes d'ISBN ne sont pas une précaution

La BnF indexe l'ISBN tel qu'imprimé. Un livre d'avant 2007 ne porte qu'un ISBN-10 et est donc introuvable par son ISBN-13 — alors qu'un scanner lit toujours un EAN-13. Mesuré : Germinal en poche donne 0 notice en 13, 3 en 10. Sans la conversion, on perd le fonds ancien, c'est-à-dire l'essentiel d'une bibliothèque constituée.

  • BnF d'abord, OpenLibrary ensuite, et pas l'inverse : la collection est francophone, et le dépôt légal garantit structurellement la meilleure couverture. OpenLibrary est riche sur l'anglophone, lacunaire sur le français.
  • Google Books est écarté — préférence explicite, et clé d'API obligatoire.
  • Les couvertures viennent toujours d'OpenLibrary, quelle que soit la source des métadonnées : le Dublin Core BnF n'en fournit aucune. Le ?default=false est indispensable — sans lui on reçoit un placeholder au lieu d'un 404 franc.
  • ⚠️ Ne jamais valider une URL de couverture par un HEAD avant de l'afficher : OpenLibrary rend des 502 intermittents, on supprimerait au hasard des couvertures existantes.

Le relais de couvertures est une surface SSRF

GET /api/couvertures existe parce que le cache hors-ligne lit les octets par fetch(), ce qui exige du CORS — or une URL de couverture collée à la main n'en a pas. Le relais rend l'image de même origine.

Deux verrous, et aucun ne suffit seul. L'URL doit déjà être en base (égalité exacte), et la connexion ne s'ouvre que vers une adresse publiquement routable.

⚠️ Le garde agit dans ConnectCallback, pas avant la requête : résoudre le nom puis laisser HttpClient résoudre à nouveau laisserait passer un DNS rebinding. C'est aussi ce qui permet de suivre les redirections en sécurité — et il le faut, covers.openlibrary.org en fait deux avant d'aboutir sur archive.org.

⚠️ Tout refus répond 404, sans distinguer les cas : autrement le point d'entrée serait un oracle révélant quelles URL l'application connaît, et quelles adresses répondent depuis le serveur.


Le client : consulter d'abord, modifier ensuite

Une règle traverse les 24 écrans : le mode est dans l'URL, jamais dans un booléen interne. /livres/3 consulte, /livres/3/edition modifie. L'écran d'édition devient partageable, et le bouton « retour » du navigateur une sortie naturelle.

⚠️ Le piège Blazor qui va avec

Les deux routes visent le même composant avec le même paramètre : passer de l'une à l'autre ne change aucun paramètre, et le routeur ne redessine rien. L'URL changeait, l'écran restait — alors qu'un chargement direct fonctionnait, ce qui masque le défaut. D'où l'abonnement à LocationChanged, présent sur la fiche livre, la série et la revue.

Ce qui reste manipulable sans entrer en édition

Deux exceptions assumées, parce qu'elles ne touchent pas à la fiche : le statut de lecture (personnel, l'action la plus fréquente de l'application) et prêter / marquer rendu (commun, un geste du quotidien). La suppression, elle, n'existe qu'en édition.

Trois règles d'affichage qui reviennent partout

  • On n'affiche que ce qui a été choisi. Le format n'apparaît que pour les ebooks (un livre est physique par défaut), le type de document que s'il n'est pas NonPrecise, les rôles d'auteurs qu'à partir de deux auteurs. Sans cela, chaque ligne porterait « Non précisé ».
  • On grise, on ne masque jamais — un faux négatif se voit et se corrige, une ligne masquée à tort est invisible. Seule exception, explicite : le masquage manuel d'une œuvre bibliographique, qui est personnel et compté à l'écran.
  • Hors-ligne, on désactive avec un motif, on ne masque pas. Un bouton disparu est indiscernable d'une fonction supprimée : l'utilisateur conclurait à une régression au lieu d'apprendre que c'est le réseau.

⚠️ Deux pièges Blazor payés cher

Une règle CSS scopée n'atteint pas ce que rend un composant enfant. L'attribut de portée b-xxxx n'est posé que sur le balisage du composant courant : .menu-lien ne touchait pas les <a> rendus par NavLink, et le menu restait en liens bruts. Les règles du menu vivent donc en feuille globale — la navigation est ce qui doit le moins pouvoir tomber.

Un composant qui écrit dans un objet prêté par son parent doit le prévenir. Sinon tout ce que le parent calcule à partir de cet objet — bouton désactivé, compteur, message — reste figé sur l'état d'avant. Le symptôme trompe : on cherche une validation fautive là où il n'y a qu'un rendu manquant.


Le hors-ligne : consultation seule, et neuf instantanés

Le besoin réel est de consulter la bibliothèque déjà enregistrée sans réseau. Pas d'écriture, pas de file d'attente, pas de résolution de conflits.

⚠️ « Les données sont déjà en local » est faux

Vrai au sens serveur seulement. Le code tourne dans le navigateur, SQLite vit sur le serveur, et le service worker met en cache les assets, pas les réponses de l'API. Sans travail explicite, l'application se lance hors-ligne et affiche une liste vide.

D'où IndexedDB plutôt que le cache du service worker : un cache de réponses HTTP ne restituerait que les URL déjà visitées, donc une recherche jamais tapée en ligne ne rendrait rien. Ici l'instantané porte le catalogue entier, et la recherche se refait localement.

Un instantané par vue, jamais par requête : catalogue, auteurs, prets-en-cours, utilisateur, souhaits, series, revues, souhaits-revues, version.

⚠️ Toute vue nouvelle doit en recevoir un, faute de quoi son écran est mort hors-ligne — c'est exactement ce qui était arrivé à la liste d'envies, et le symptôme (un 404 en plein écran) ne désignait pas la cause.

⚠️ navigator.onLine ne vaut que par sa négation

« Faux » est fiable, « vrai » ne prouve rien — portail captif, serveur arrêté, Wi-Fi sans Internet. D'où deux notions : EnLigne (ce que l'interface affiche) et TenterLeReseau (faut-il tenter un appel).

Quand la panne vient du serveur, aucun événement online ne viendra jamais : sans cette distinction, l'application restait bloquée sur le cache jusqu'au rechargement.

Et deux événements distincts, sinon boucle infinie : un rechargement écrirait un instantané → nouvel événement → nouveau rechargement.

Le filtre existe en deux exemplaires, et c'est assumé

FiltreLivres (serveur, sur IQueryable, colonnes normalisées déjà calculées) et FiltreLivresLocal (navigateur, sur des DTO qui n'en portent pas) ne peuvent pas être le même code. Le garde-fou est un test qui confronte les deux sur le même jeu de données et 17 jeux de critères.

⚠️ Toute nouvelle dimension de filtre s'ajoute aux deux, et au test. Une divergence silencieuse serait pire qu'un cache absent : l'utilisateur conclurait que le livre n'est pas dans sa bibliothèque.


Le déploiement : SSO par en-têtes, et une frontière dure

MaBibli n'a aucune connexion propre. nginx authentifie via le portail YunoHost, SSOwat injecte YNH_USER, l'application se contente de lire. OIDC a été écarté : YunoHost n'en documente aucun pour les apps.

⚠️ L'écoute sur 127.0.0.1 est une frontière de sécurité, pas un réglage

L'application fait confiance à YNH_USER parce que SSOwat l'écrase à chaque requête traversant nginx. Un service joignable directement permettrait à quiconque de forger YNH_USER et de contourner tout le portail. Ne jamais élargir ASPNETCORE_URLS.

Contrainte Pourquoi elle ne se négocie pas
Domaine entier, pas de sous-chemin <base href="/"> et les empreintes du service worker sont figés à la compilation, et rien n'est compilé sur le serveur
HTTPS Le scan caméra n'existe qu'en contexte sécurisé. Tester depuis une IP locale échouera toujours
x86_64 Le publish visé est linux-x64 ; déclarer d'autres architectures promettrait des archives qui n'existent pas
Dépôt public ynh_setup_source télécharge sans jeton, et un dépôt privé répond 404 — pas 403, symptôme identique à « la release n'existe pas »

Deux pièges systemd invisibles hors d'un vrai serveur

  • Environment= découpe sur les espaces : sans guillemets, la ligne définit deux variables au lieu d'une, et le service meurt sur un ArgumentException.
  • ProtectHome=yes masque /home, où vit le data_dir de YunoHost — ReadWritePaths ne perce pas cette protection. Il faut tmpfs + BindPaths.

Ni l'un ni l'autre ne peut sortir d'un dotnet run : ils tiennent au gestionnaire de services. C'est ce qui justifie de tester l'installation réelle, pas seulement le binaire.


La méthode, qui est peut-être le vrai acquis

Le projet a une manière de trancher, et elle a été payée plusieurs fois :

  • Mesurer avant de tailler. Le menu de choix de version paraissait le meilleur candidat à la coupe ; les neuf montées de version ont montré trois sauts mineurs. Il sert une fois sur trois.
  • La vitesse ne dit rien du taux de réussite. Un décodeur de code-barres a été blanchi sur des mesures de vitesse alors qu'il ratait des codes. Pour juger un décodeur, il faut mesurer ce qu'il lit, pas ce qu'il coûte.
  • Une source intermittente produit exactement les symptômes d'une source lacunaire. Un auteur cru absent de la BnF y avait 85 notices ; c'était un délai dépassé, vu deux fois et pris pour deux problèmes.
  • Le rendu ne se vérifie pas à l'œil. Un débordement de 8 px n'était visible que par scrollWidth, et un 404 intermittent que par un chronométrage.
  • Toujours comparer deux publish. Le coût d'une bibliothèque n'est pas le poids de son assembly — c'est la traîne qu'elle impose au trimmer.

Le corollaire, tenu jusqu'ici : quand un raisonnement s'est révélé faux, il est resté écrit, avec sa correction. C'est ce qui empêche de le reconduire — et trois cas montrent qu'il aurait été reconduit sans cette trace.