ADR 0007 – Hiérarchie des nœuds : `parent_id` unique, déplacement par sous-ressource, corbeille sans cascade
Accepté (2026-10-14)
ADR 0007 – Hiérarchie des nœuds : parent_id unique, déplacement par sous-ressource, corbeille sans cascade
Statut : Accepté (2026-10-14)
Contexte
Le cahier (jalon 1, « Hiérarchie ») demande un arbre de nœuds (espaces, projets, dossiers) en plus des liens. La colonne node.parent_id existe depuis V1 (references node (id) on delete set null, index node_parent_idx) et CreateNodeRequest.parentId est déjà accepté (parent existant, hors corbeille). Il manque le déplacement, le fil d’Ariane et la liste des enfants.
Plusieurs choix sont difficiles à défaire une fois l’arbre construit et les événements écrits : la forme de l’API d’écriture (contrat du client généré), le type et le payload de l’événement (lus par les règles du jalon 3 et par l’écran d’historique), la garantie d’absence de cycle sous écritures concurrentes, et le sort des enfants quand leur parent passe en corbeille.
Décision
- Modèle : un seul parent par nœud (
node.parent_id),null= racine. Pas de table d’arbre, pas de chemin matérialisé, pas de position manuelle : les enfants sont triés par titre à l’affichage. Le multi-emplacement (V2) ajoutera des emplacements secondaires sans toucher àparent_id. - Aucune contrainte de type entre parent et enfant au jalon 1 : tout nœud peut contenir tout nœud. Si une règle devient nécessaire, elle sera déclarée en YAML dans
config/types/(même sémantique quesource/targetdes liens etx-target, viaNodeFilter), contrôlée à l’écriture seulement. - Écriture :
- à la création,
parentIdfacultatif dansCreateNodeRequest(inchangé) ; il est ajouté au payload denode.created("parentId": nullpour la racine) ; - ensuite, sous-ressource
PUT /api/nodes/{id}/parentavec{"parentId": "<uuid>"}, ouparentIdabsent ounullpour la racine. Idempotente, renvoie leNodeView. Le parent ne passe pas parPATCH /api/nodes/{id}.
- à la création,
- Contrôles du déplacement, dans cet ordre :
- nœud inexistant ou en corbeille : 404 ;
- parent inchangé : 200 sans événement ni mise à jour, même si le parent actuel est en corbeille (une valeur inchangée n’est pas recontrôlée, comme pour les références de l’ADR 0005) ;
- le nœud lui-même : 400, clé
parentId; - parent inexistant ou en corbeille : 400, clé
parentId(même règle qu’à la création) ; - parent situé dans le sous-arbre du nœud : 409. Le contrôle remonte les ancêtres du nouveau parent corbeille comprise : un intermédiaire en corbeille fait toujours partie de l’arbre.
- Concurrence : chaque déplacement prend d’abord un verrou consultatif de transaction (
pg_advisory_xact_lock) unique pour toute la hiérarchie, puis verrouille la ligne du nœud, puis lit et contrôle. Deux déplacements croisés (A sous B, B sous A) sont ainsi sérialisés et le second voit le premier (READ COMMITTED). La création n’en a pas besoin : un nœud neuf n’a pas de descendant. Toute écriture future deparent_id(déplacement en masse, règles, import) doit passer par la même méthode de service. - Base : migration
V3qui ajoutecheck (parent_id <> id). Les cycles plus longs ne sont pas exprimables par une contrainte ; ils sont refusés par le service. - Événement : un déplacement effectif écrit un
node.movedsur le nœud déplacé, payload{"before": {"parentId": …}, "after": {"parentId": …}}(null= racine), et met à journode.updated_atdans le mêmeUPDATE. Ni l’ancien ni le nouveau parent ne reçoivent d’événement ni de mise à jour (comme la cible d’un lien). - Lecture :
NodeView.ancestors: chaîne des ancêtres de la racine au parent direct, en une requête récursive (with recursive … cycle id set is_cycle using path, PostgreSQL ≥ 14), corbeille comprise avec l’indicateurdeleted. La chaîne continue au-delà d’un ancêtre en corbeille ; elle s’arrête à un ancêtre purgé (sonparent_idest déjà ànull).NodeView.parentIdest conservé ;- enfants : filtre
parent=<uuid>deGET /api/nodes, combinable avec les autres filtres, enfants directs hors corbeille. Un parent inconnu donne une liste vide. Pas de filtre « racine » ni de nombre d’enfants dansNodeSummaryau jalon 1.
- Corbeille : mettre un nœud en corbeille ne touche pas ses enfants. Ils restent rattachés, visibles et modifiables ; leur fil d’Ariane montre le parent « en corbeille ». La restauration (jalon 3) retrouvera donc l’arbre intact.
Alternatives écartées
parentIddansPATCH /api/nodes/{id}: cePATCHexigetitleet écrasebody(même raison que pour les tags, ADR 0006). En plus,nully serait ambigu (« inchangé » ou « racine ») sans typeJsonNullable.POST /api/nodes/{id}/move: verbe dans l’URL, non idempotent en apparence ; lePUTd’une sous-ressource suit le modèle des tags.- Contrôle des cycles par trigger PostgreSQL : logique métier hors du code testé, et le trigger seul ne protège pas des déplacements croisés concurrents sans le même verrou.
- Verrouiller les ancêtres en
FOR UPDATE: les déplacements croisés finissent en interblocage (40P01, donc une 500) au lieu d’un 409 propre. - Isolation SERIALIZABLE : impose une logique de reprise sur échec de sérialisation, pour un outil mono-utilisateur.
ltreeou chemin matérialisé : un déplacement réécrit tout le sous-arbre (N lignes et, pour rester honnête, N événements). Une CTE récursive sur la clé primaire suffit à la profondeur d’un arbre personnel.- Profondeur maximale imposée : aucun besoin fonctionnel. La clause
cyclegarantit la terminaison des requêtes récursives même sur une donnée corrompue. - Réutiliser
node.updated: les consommateurs devraient inspecter le payload ; un type dédié se filtre surevent_type(même raisonnement quetags.updated). - Événements
child.added/child.removedsur les parents : les enfants se déduisent deparent_id, les doubler crée deux sources de vérité. - Fil d’Ariane par un endpoint séparé : un aller-retour de plus à chaque ouverture de l’éditeur, pour une donnée toujours affichée.
GET /api/nodes/{id}/children: un endpoint de plus, sans les filtres ni la formeNodeSummaryde la recherche, que le langage de requête des vues (jalon 2, filtre « arbre ») remplacera de toute façon.- Cascade de la corbeille sur le sous-arbre : la restauration devrait savoir quels enfants ont été emportés (colonne ou horodatage partagé) et produire N événements. Ce choix relève de l’écran corbeille du jalon 3 et reste possible ensuite.
- Remontée des enfants vers le grand-parent : irréversible ; restaurer le parent ne reconstruit pas l’arbre.
Conséquences
- L’arbre ne peut contenir de cycle que par une écriture SQL directe ; la clause
cycleempêche alors les requêtes récursives de boucler, et le fil d’Ariane s’arrête au premier nœud répété. - Tous les déplacements sont sérialisés par un verrou global. C’est sans effet pour un utilisateur seul, mais à revoir si des règles (jalon 3) déplacent beaucoup de nœuds en masse.
- La clé étrangère
on delete set nullremet les enfants à la racine sans événement quand un parent est purgé. La purge (jalon 3) devra écrire unnode.moved(after.parentId = null, acteursystem) pour chaque enfant avant de supprimer le parent. - Un enfant dont le parent est en corbeille reste trouvable par la recherche, mais pas par la navigation depuis le parent (l’éditeur du parent est en 404). L’action « Déplacer » permet de le rattacher ailleurs.
- Ajouter plus tard des contraintes de type (YAML), une position manuelle (colonne
position, champ dansbefore/afterdenode.moved) ou un filtre « racine » ne change pas ce modèle. - Les sous-tâches (jalon 2) devront choisir entre
parent_id(une tâche ne peut alors pas être à la fois dans un dossier et sous une autre tâche) et un type de lien dédié. Décision à prendre avec ce jalon. - L’export (jalon 1) devra inclure
parentIddans le frontmatter.
Reporté (validé le 2026-10-14)
- Ordre manuel des enfants (colonne
position). - Retour sur le parent après une mise en corbeille.
- Exclusion des descendants dans les suggestions du dialogue « Déplacer » (le serveur répond 409).