Second Brain documentation

ADR 0006 – Tags hiérarchiques : forme canonique, filtre par préfixe, sous-ressource dédiée

Accepté (2026-10-14)

← Toutes les décisions

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) :

    1. normalisation Unicode NFC, puis suppression des espaces aux extrémités ;
    2. retrait d’un # initial (#perso → perso) ;
    3. passage en minuscules (Locale.ROOT) ; les accents sont conservés (été ≠ ete) ;
    4. 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 #123 dans 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/maison est un descendant de perso. Le filtre tag=perso retient les nœuds qui portent perso ou un descendant (tag = 'perso' or tag like 'perso/%', avec _ échappé). personnel n’est pas un descendant de perso. Un parent n’est pas stocké : il n’existe qu’à travers ses descendants. Plusieurs filtres tag se combinent en ET.

  • Index : migration V2 qui remplace node_tag_tag_idx par un index text_pattern_ops (utilisable pour = et LIKE 'x/%' quelle que soit la collation de la base, musl en test, glibc possible en production), plus une contrainte check sur 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 : tags facultatif dans CreateNodeRequest ; ensuite, sous-ressource PUT /api/nodes/{id}/tags qui remplace la liste complète (idempotente). Les tags ne passent pas par PATCH /api/nodes/{id}.

  • Événements : à la création, tags figure dans le payload de node.created. Un remplacement qui change effectivement la liste produit un événement tags.updated avec {before, after} (listes triées) et met à jour node.updated_at. Un remplacement sans effet ne produit ni événement ni mise à jour.

  • Lecture : NodeView.tags et NodeSummary.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 (SNCB affiché 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’un LIKE indexé suffit pour des milliers de lignes.
  • Table tag avec parent_id : à maintenir (création implicite des parents, nettoyage des orphelins), sans besoin au jalon 1.
  • Tags dans PATCH /api/nodes/{id} : ce PATCH exige title et écrase body. 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.removed par 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.updated avec tags dans before/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 sur event_type.

Conséquences

  • La normalisation est irréversible : un tag saisi SNCB est stocké sncb. Changer ces règles plus tard impose une migration de données et des événements tags.updated sur 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_tag par préfixe et un tags.updated par 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).