ADR 0006 – Tags hiérarchiques : forme canonique, filtre par préfixe, sous-ressource dédiée
Accepté (2026-10-14)
ADR 0006 – Tags hiérarchiques : forme canonique, filtre par préfixe, sous-ressource dédiée
Statut : Accepté (2026-10-14)
Contexte
Le cahier (jalon 1, « Tags ») demande des tags libres hiérarchiques (#perso/maison), distincts des aspects. La table node_tag(node_id, tag) existe depuis V1 mais aucune API ne l’alimente. Trois choix sont difficiles à défaire une fois des tags saisis : la forme stockée (une normalisation perd de l’information), le sens d’un filtre sur un tag parent (repris plus tard par le langage de requête des vues), et la façon dont l’API écrit les tags (contrat du client généré, forme des événements node_event lus par les règles du jalon 3).
Décision
-
Aucune définition YAML : un tag est une chaîne libre, sans déclaration préalable dans
config/. -
Forme canonique, calculée côté serveur à chaque écriture et à chaque filtre (classe pure
Tags) :- normalisation Unicode NFC, puis suppression des espaces aux extrémités ;
- retrait d’un
#initial (#perso→perso) ; - passage en minuscules (
Locale.ROOT) ; les accents sont conservés (été≠ete) ; - découpage sur
/, espaces retirés aux extrémités de chaque segment, toute suite d’espaces internes remplacée par-(liste de courses→liste-de-courses).
Le résultat est refusé (400, clé
tags) si un segment est vide (/perso,perso/,a//b), si un segment contient autre chose que des lettres, marques, chiffres,-ou_([\p{L}\p{M}\p{N}_-]+), si le tag n’est fait que de chiffres (2024, comme Obsidian, pour qu’un futur#123dans un texte ne soit pas un tag), ou s’il dépasse 100 caractères. Un nœud porte au plus 50 tags ; les doublons après normalisation sont fusionnés sans erreur ; les tags sont rendus triés. -
Hiérarchie par préfixe, sans table d’arbre :
perso/maisonest un descendant deperso. Le filtretag=persoretient les nœuds qui portentpersoou un descendant (tag = 'perso' or tag like 'perso/%', avec_échappé).personneln’est pas un descendant deperso. Un parent n’est pas stocké : il n’existe qu’à travers ses descendants. Plusieurs filtrestagse combinent en ET. -
Index : migration
V2qui remplacenode_tag_tag_idxpar un indextext_pattern_ops(utilisable pour=etLIKE 'x/%'quelle que soit la collation de la base, musl en test, glibc possible en production), plus une contraintechecksur la forme structurelle (tag <> '', pas de/initial, final ou doublé). La normalisation complète reste en Java (minuscules :lower()de PostgreSQL dépend de la collation). -
Écriture :
tagsfacultatif dansCreateNodeRequest; ensuite, sous-ressourcePUT /api/nodes/{id}/tagsqui remplace la liste complète (idempotente). Les tags ne passent pas parPATCH /api/nodes/{id}. -
Événements : à la création,
tagsfigure dans le payload denode.created. Un remplacement qui change effectivement la liste produit un événementtags.updatedavec{before, after}(listes triées) et met à journode.updated_at. Un remplacement sans effet ne produit ni événement ni mise à jour. -
Lecture :
NodeView.tagsetNodeSummary.tags(listes triées, jamais nulles).GET /api/tags?q=&limit=rend une liste plate{tag, count}des tags stockés :count= nombre de nœuds hors corbeille qui portent exactement ce tag ; un tag porté uniquement par des nœuds en corbeille n’apparaît pas.
Alternatives écartées
- Conserver la casse saisie (unicité sur
lower(tag), affichage de la première casse comme Obsidian) : il faut choisir quelle casse afficher quand deux nœuds divergent, une colonne ou un index d’expression de plus, et la casse varie selon l’ordre de saisie. Le gain (SNCBaffiché en capitales) ne vaut pas cette complexité pour un outil mono-utilisateur. - Retirer les accents (
unaccent) : perte d’information en français, extension supplémentaire. L’insensibilité aux accents relève de la recherche (facettes, V2), pas du stockage. - Refuser les espaces au lieu de les convertir : plus strict mais pénible à la saisie au clavier mobile ; la conversion est déterministe et idempotente, et garde des tags compatibles avec la syntaxe inline
#tag(qui s’arrête au premier espace). - Extension
ltree: labels restreints (pas de-avant PostgreSQL 16, alphanumérique dépendant de la locale), une extension de plus, alors qu’unLIKEindexé suffit pour des milliers de lignes. - Table
tagavecparent_id: à maintenir (création implicite des parents, nettoyage des orphelins), sans besoin au jalon 1. - Tags dans
PATCH /api/nodes/{id}: cePATCHexigetitleet écrasebody. Toute écriture de tags hors de l’éditeur (palette, action rapide, règle) devrait renvoyer titre et contenu, au risque d’écraser une modification concurrente. - Endpoints unitaires
POST/DELETE /api/nodes/{id}/tags/{tag}: le/du tag impose un encodage dans le chemin. Ils sont inutiles tant que l’éditeur envoie la liste complète ; on pourra les ajouter sans rien casser. - Un événement
tag.added/tag.removedpar tag : un enregistrement produirait N lignes dans l’historique. La règle « avant/après » s’applique comme pour un champ multivalué, et l’ajout ou le retrait se déduit des deux listes. node.updatedavectagsdansbefore/after: les consommateurs (historique, règles) devraient inspecter le payload pour savoir ce qui a changé. Un type d’événement dédié se filtre surevent_type.
Conséquences
- La normalisation est irréversible : un tag saisi
SNCBest stockésncb. Changer ces règles plus tard impose une migration de données et des événementstags.updatedsur les nœuds touchés. - Le langage de requête des vues (jalon 2) reprendra la même sémantique (
tags: [perso]inclut les descendants) et la même condition SQL. - Le front reproduit les transformations (pas la validation) pour afficher la forme finale dès la saisie : deux implémentations à garder alignées, couvertes par la même table de cas dans les tests Java et Vitest.
- Renommer ou fusionner un tag (y compris un sous-arbre) reste possible plus tard sans changer de modèle :
update node_tagpar préfixe et untags.updatedpar nœud touché. - Les
#tagsécrits dans le corps Markdown ne sont pas indexés par cette décision. S’ils le sont un jour, ils suivront la même forme canonique. Décision : à traiter avec la tranche éditeur Markdown / liens wiki, en distinguant ces tags de ceux saisis en chips (un remplacement des tags ne doit pas les effacer). - Un tag parent sans nœud propre n’apparaît pas dans
GET /api/tags: l’arbre des tags (parents synthétisés, comptes cumulés) relève des facettes (V2).