Second Brain documentation

ADR 0007 – Hiérarchie des nœuds : `parent_id` unique, déplacement par sous-ressource, corbeille sans cascade

Accepté (2026-10-14)

← Toutes les décisions

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 que source/target des liens et x-target, via NodeFilter), contrôlée à l’écriture seulement.
  • Écriture :
    • à la création, parentId facultatif dans CreateNodeRequest (inchangé) ; il est ajouté au payload de node.created ("parentId": null pour la racine) ;
    • ensuite, sous-ressource PUT /api/nodes/{id}/parent avec {"parentId": "<uuid>"}, ou parentId absent ou null pour la racine. Idempotente, renvoie le NodeView. Le parent ne passe pas par PATCH /api/nodes/{id}.
  • Contrôles du déplacement, dans cet ordre :
    1. nœud inexistant ou en corbeille : 404 ;
    2. 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) ;
    3. le nœud lui-même : 400, clé parentId ;
    4. parent inexistant ou en corbeille : 400, clé parentId (même règle qu’à la création) ;
    5. 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 de parent_id (déplacement en masse, règles, import) doit passer par la même méthode de service.
  • Base : migration V3 qui ajoute check (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.moved sur le nœud déplacé, payload {"before": {"parentId": …}, "after": {"parentId": …}} (null = racine), et met à jour node.updated_at dans le même UPDATE. 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’indicateur deleted. La chaîne continue au-delà d’un ancêtre en corbeille ; elle s’arrête à un ancêtre purgé (son parent_id est déjà à null). NodeView.parentId est conservé ;
    • enfants : filtre parent=<uuid> de GET /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 dans NodeSummary au 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

  • parentId dans PATCH /api/nodes/{id} : ce PATCH exige title et écrase body (même raison que pour les tags, ADR 0006). En plus, null y serait ambigu (« inchangé » ou « racine ») sans type JsonNullable.
  • POST /api/nodes/{id}/move : verbe dans l’URL, non idempotent en apparence ; le PUT d’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.
  • ltree ou 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 cycle garantit 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 sur event_type (même raisonnement que tags.updated).
  • Événements child.added / child.removed sur les parents : les enfants se déduisent de parent_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 forme NodeSummary de 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 cycle empê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 null remet les enfants à la racine sans événement quand un parent est purgé. La purge (jalon 3) devra écrire un node.moved (after.parentId = null, acteur system) 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 dans before/after de node.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 parentId dans 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).