Second Brain documentation

ADR 0009 – Liens wiki : le corps fait foi, index `node_link` de type `wiki`, résolution par clé de titre

Accepté (2026-10-15)

← Toutes les décisions

ADR 0009 – Liens wiki : le corps fait foi, index node_link de type wiki, résolution par clé de titre

Statut : Accepté (2026-10-15)

Décisions retenues (Aurélien, mode autonome du 2026-10-15 : options recommandées) : grammaire [[T]], |alias, #section (section sans effet de navigation), ![[…]] non lien ; clé insensible à la casse, accents conservés ; doublons : le plus ancien gagne, avertissement dans l’autocomplétion ; non résolus dans node_wiki_unresolved, rattrapés à la création ; renommage : réécriture des corps des sources ; un link.added/link.removed par lien ; wiki mécanisme du moteur (nom réservé, libellés en Java) ; retrait manuel refusé (409) ; création immédiate depuis l’autocomplétion, sans type, à la racine ; pas d’autocomplétion en mode source ; dépendances commonmark-java 0.30.1, @tiptap/suggestion 3.32.0 (exacte) et @floating-ui/dom ~1.8.0 (MIT, pair requis de la suggestion) ; plafond de 500 cibles ; #tags du corps reportés à une tranche dédiée ; pas de réindexation des corps existants (données de dev).

Contexte

Le cahier (jalon 1, « Liens wiki » et « Liens libres ») demande que les [[Titre]] écrits dans le corps soient proposés à la frappe, créent le nœud absent, et soient indexés comme liens non typés pour alimenter les backlinks. L’ADR 0001 réserve déjà le type wiki dans node_link. L’ADR 0008 conserve [[…]] et ![[…]] tels quels grâce à l’extension PreservedInline, et prévoit que l’indexation côté serveur reconnaisse [[…]] exactement comme le tokenizer du front, avec une table de cas commune. L’ADR 0006 renvoie à cette tranche la question des #tags du corps.

Plusieurs choix sont difficiles à défaire une fois des notes écrites et des événements journalisés :

  • la grammaire reconnue, puisque le texte stocké, l’export et l’import Obsidian (V2) en dépendent ;
  • la règle de résolution d’un titre vers un nœud (casse, doublons, corbeille), qui détermine les liens présents dans l’historique ;
  • le devenir des liens quand la cible est renommée, créée après coup ou mise à la corbeille ;
  • la forme des événements node_event, lus par l’écran d’historique et les règles du jalon 3 ;
  • le statut du type wiki face à la règle « le domaine vit dans config/ ».

Ce qu’on a vérifié (2026-10-15) :

  • marked 17.0.6 avec une extension inline [[…]] : un \[[x]] n’est pas un lien, alors que \\[[x]] en est un (nombre impair d’antislashs = échappement). Le contenu des blocs et spans de code, des blocs HTML, des balises HTML en ligne et des liens automatiques <…> n’est pas analysé. [[x]] est reconnu dans les listes, les citations, les titres, l’emphase et le texte d’un lien Markdown. Dans un tableau GFM, marked découpe d’abord les cellules et transforme \| en |, si bien que [[A\|alias]] arrive sous la forme [[A|alias]]. [[a [[b]] c]] donne [[b]], [[[x]]] donne [[x]], et [[a⏎b]] n’est pas un lien.
  • Le sérialiseur de tableaux de Tiptap échappe les | du texte des cellules (escapePipesInCellText) : un [[A|alias]] placé dans une cellule est réécrit [[A\|alias]], ce qui reste conforme à la convention Obsidian.
  • org.commonmark:commonmark 0.30.1 (BSD-2-Clause, 220 Ko, aucune dépendance d’exécution) fournit des positions source par nœud, y compris en ligne (IncludeSourceSpans.BLOCKS_AND_INLINES, SourceSpan.getInputIndex()). Le prototype « masquer Code, FencedCodeBlock, IndentedCodeBlock, HtmlBlock, HtmlInline, LinkReferenceDefinition et les liens automatiques, puis balayer le texte brut » rend le même résultat que marked sur les 21 cas sondés (listes, citations avec code clôturé, tableaux, HTML, échappements, imbrications).
  • @tiptap/suggestion 3.32.0 (MIT, publié le 2026-10-13) déclare en pair @tiptap/core et @tiptap/pm en 3.32.0 exacte, ainsi que @floating-ui/dom ^1.0.0 (dernière version : 1.8.0), importé de façon statique. Il fournit items({ query, signal }) asynchrone avec debounce et annulation, findSuggestionMatch surchargeable et un montage géré props.mount(element).

Décision

Grammaire (identique côté Java et TypeScript)

  • Une occurrence est [[ + intérieur + ]]. L’intérieur fait au moins un caractère et ne contient ni [, ni ], ni saut de ligne (\n, \r). Le balayage prend l’occurrence la plus à gauche, puis reprend après ]].
  • Échappement : un [[ précédé d’un nombre impair de \ n’est pas une occurrence.
  • Exclusions : rien n’est reconnu dans les spans de code, les blocs de code (clôturés ou indentés), les blocs HTML, les balises HTML en ligne, les liens automatiques <…> ni les définitions de liens par référence.
  • Découpage de l’intérieur : \| est d’abord lu comme |. Le premier | sépare la cible de l’alias (espaces retirés ; un alias vide équivaut à aucun). Dans la cible, le premier # sépare le titre de la section (espaces retirés).
  • [[Titre]], [[Titre|alias]] et [[Titre#section]] sont des liens. La section est conservée dans le texte et affichée (Titre > section), mais la navigation ouvre le nœud sans défiler jusqu’au titre (jalon 1).
  • Un [[ précédé d’un ! non échappé est une transclusion (![[…]]). Ce n’est pas un lien au jalon 1 : elle n’est ni indexée ni cliquable, et reste du texte préservé (V2). \![[x]] est un lien ordinaire.
  • Un titre vide après découpage ([[ ]], [[|x]], [[#section]]) n’est pas un lien et reste du texte.
  • Clé d’un titre (fonction WikiLinks.key / wikiKey) : normalisation NFC, retrait aux extrémités des espaces du jeu \s de JavaScript (U+00A0, U+202F et U+FEFF compris), compactage des suites d’espaces internes en un seul espace, puis passage en minuscules (Locale.ROOT, sigma final compris). Les accents sont conservés, comme pour les tags (ADR 0006).
  • Titre utilisable comme cible : un titre dont la clé n’est pas vide et qui ne contient ni [, ni ], ni |, ni #, ni accent grave (` : réécrit dans un [[…]], il ouvrirait un span de code qui masquerait les liens suivants), ni saut de ligne. Les autres titres restent valides, mais l’autocomplétion ne les propose pas (option désactivée, avec une explication).
  • Une seule table de cas, frontend/src/app/core/markdown/wiki-link-cases.json, couvre les occurrences, les clés et les titres utilisables. Elle est lue par Vitest (import JSON) et par le test Java (Path.of("..", "frontend", …), même procédé que ExampleDefinitionsTest pour ../config). Ce n’est pas une table dupliquée.

Analyse côté serveur

  • Classe pure node/WikiLinks : org.commonmark:commonmark 0.30.1 calcule les zones exclues à partir des positions source, puis une expression régulière balaie le texte brut hors de ces zones, en appliquant la règle d’échappement. Pas d’extension GFM : le découpage des cellules ne change pas les occurrences, puisque \| est traité dans l’intérieur.
  • Le front ne calcule rien pour l’index : il utilise la même grammaire pour le rendu (extension marked) et pour l’éditeur (nœud Tiptap wikiLink).

Résolution

  • Une clé désigne le nœud hors corbeille dont la clé de titre est égale. S’il y en a plusieurs, le plus ancien l’emporte (created_at, puis id) : créer un doublon ne détourne jamais un lien existant.
  • Stabilité : à la réindexation d’une source, un lien wiki existant est conservé tant que la clé de titre de sa cible figure encore dans le corps. Une cible passée à la corbeille ne reste liée que si aucun nœud vivant n’a cette clé : sinon le lien bascule vers le nœud vivant le plus ancien (link.removed puis link.added), à la prochaine réindexation de la source. Seules les clés qui ne sont pas encore couvertes sont résolues. Au plus un lien par clé et par source ; en cas de collision (renommage vers un titre déjà lié), le nœud le plus ancien l’emporte.
  • Lien vers soi-même : il n’est pas indexé (pas de rétrolien vers soi). Le front le résout localement.
  • La clé de titre est stockée dans la colonne node.title_key, calculée en Java dans NodeRepository à chaque écriture du titre. On n’utilise pas lower() de PostgreSQL, qui dépend de la collation (même raison que dans l’ADR 0006).

Index et événements

  • Le corps fait foi. Les lignes node_link de type wiki en sont l’index : une ligne par cible résolue distincte, recalculée dans la transaction de toute écriture du corps ou du titre (NodeService.create, NodeService.update) par différence avec l’existant.
  • Chaque ligne ajoutée ou retirée produit link.added / link.removed sur la source, avec la même forme de payload que les liens typés (linkId, type: "wiki", targetId, targetTitle), en plus du node.updated qui porte le corps avant/après. Un enregistrement sans changement du corps ni du titre ne produit rien.
  • Les clés non résolues sont stockées dans node_wiki_unresolved(source_id, target_key, target_text) (target_text, le titre tel qu’écrit, n’est pas lu au jalon 1 : il est réservé à un affichage futur des liens non résolus, par exemple « Créer X » depuis un écran de liens), sans événement (ce sont du texte du corps, déjà journalisé). À la création d’un nœud, les lignes non résolues de sa clé sont résolues vers lui (rattrapage) : link.added sur chaque source, sans changer leur updated_at, puisque leur contenu ne change pas.
  • Toutes les écritures de l’index wiki prennent d’abord un verrou consultatif de transaction (pg_advisory_xact_lock(hashtext('node.wiki'))), puis le verrou de la ligne source. Une création et un enregistrement concurrents ne peuvent donc pas laisser une ligne non résolue alors que la cible existe.
  • Au plus 500 cibles distinctes par corps. Au-delà : 400 avec la clé body.
  • Contraintes des liens typés : elles ne s’appliquent pas (source, target, cardinality). Le type wiki coexiste avec un lien typé entre les mêmes nœuds (l’unicité porte sur source, cible, type).

wiki est un mécanisme du moteur, pas une définition

  • wiki n’a pas de fichier dans config/links/. C’est le lien produit par la syntaxe du corps, au même titre que parent_id pour la hiérarchie : aucun nom du domaine n’est codé. Le chargeur refuse un fichier config/links/wiki.yaml (« nom réservé au moteur »).
  • Constantes Java WikiLinks.TYPE = "wiki". Les libellés sont fixés par le moteur : « Mentionne » pour un lien sortant, « Mentionné dans » pour un rétrolien.
  • API : BacklinkGroup.kind prend la valeur wiki (en plus de link et field) et LinkView gagne kind (link | wiki). POST /api/nodes/{id}/links avec type: wiki répond 400. DELETE d’un lien wiki répond 409 (« ce lien vient du contenu : retirez [[…]] du texte »), car il reviendrait au prochain enregistrement. Le panneau « Liens » affiche les liens wiki en lecture seule.

Renommage

  • Quand la clé du titre d’un nœud X change, chaque source liée à X par un lien wiki (corbeille comprise) voit ses occurrences de l’ancienne clé réécrites avec le nouveau titre : [[Ancien|alias]] devient [[Nouveau|alias]] et [[ancien#s]] devient [[Nouveau#s]]. Les transclusions et le code ne sont pas modifiés. Chaque source reçoit un node.updated (corps avant/après) avec "reason": "wiki.rename" et "renamedNodeId". Les lignes node_link restent inchangées. Les lignes non résolues de la nouvelle clé sont rattrapées.
  • Le corps soumis avec le renommage est traité comme celui des autres sources : si le nœud était le destinataire de ses propres [[Ancien]] (aucun lien existant vers un autre nœud de cette clé, et le nœud est le plus ancien vivant de la clé), ces occurrences sont réécrites avant l’enregistrement et le node.updated du nœud porte aussi "reason": "wiki.rename".
  • Garde défensive : après réécriture, l’ensemble des clés du corps doit être celui d’avant, l’ancienne clé étant remplacée par celle du nouveau titre ; sinon (titre qui masquerait d’autres liens, par exemple une balise HTML ouverte) rien n’est réécrit, comme pour un titre inutilisable.
  • Verrous de lignes : FOR NO KEY UPDATE (et non FOR UPDATE), pour ne pas entrer en conflit avec le FOR KEY SHARE que prennent les clés étrangères d’un lien ajouté vers la cible (interblocage renommage / ajout de lien typé).
  • Si le nouveau titre n’est pas utilisable comme cible, rien n’est réécrit et les sources sont réindexées : les liens vers X disparaissent (link.removed) et la clé passe en non résolue.
  • Un changement de casse seul ne change pas la clé : rien n’est réécrit.

Création du nœud absent

  • Depuis l’autocomplétion, l’entrée « Créer « X » » crée le nœud aussitôt (POST /api/nodes {title}, sans type ni parent, node.created habituel), puis insère [[X]]. Le nœud existe donc avant l’enregistrement de la source.
  • Un clic (Ctrl/Cmd+clic dans l’éditeur visuel) sur un lien non résolu ouvre /nodes/new?title=X, le formulaire de création prérempli. Le rattrapage relie les sources quand ce nœud est enregistré.
  • Ni type par défaut ni dossier de destination au jalon 1 : choisir note reviendrait à coder le domaine. Un futur modèle de nœud (tranche « Templates de nœud en YAML ») pourra le déclarer.

Lecture et rendu

  • NodeView.wikiLinks : Map<clé, NodeReference> des cibles des liens wiki indexés de la source (indicateur deleted compris), construite à partir de node_link sur le modèle de references. Une clé absente de la table signifie « non résolu », ou « pas encore enregistré ». Il n’y a pas d’endpoint de résolution : pour un lien saisi mais pas encore enregistré, le front cherche par titre (GET /api/nodes?q=) au moment du clic.

  • GET /api/nodes?q= place en tête les titres de clé égale à la saisie (du plus ancien au plus récent, comme la résolution), puis les titres qui commencent par la saisie, puis les autres.

  • Rendu (renderMarkdown(source, wikiLinks), extension de l’instance marked dédiée, puis DOMPurify sans changement de politique) :

    • lien résolu : <a href="/nodes/{id}" data-wiki-link="resolved">, routé par MarkdownView ;
    • cible en corbeille : <span data-wiki-link="deleted">, non navigable (l’éditeur répondrait 404) ;
    • lien non résolu : <a href="/nodes/new?title=…" data-wiki-link="unresolved">.

    Le texte affiché est l’alias, à défaut la cible écrite (suivie de > section s’il y en a une), échappé. Les styles passent par les sélecteurs d’attribut [data-wiki-link], car le hook retire les classes.

  • Éditeur visuel : la partie [[…]] de PreservedInline est remplacée par un nœud atomique wikiLink (attribut raw restitué tel quel). Il a le même aspect que le rendu, et Ctrl/Cmd+clic navigue. ![[…]], [!type] et [^n] restent dans PreservedInline.

  • Autocomplétion : @tiptap/suggestion 3.32.0 dans l’éditeur visuel uniquement. Le mode source (textarea) n’en a pas au jalon 1.

#tags du corps

  • Reportés à une tranche dédiée. Ils réutiliseront WikiLinks (mêmes zones exclues) et la forme canonique de l’ADR 0006, avec une colonne node_tag.origin (manual | body), pour que PUT /tags ne retire que les tags saisis à la main.

Alternatives écartées

  • Scanner Markdown écrit à la main en Java (blocs clôturés, code indenté, HTML) : il faudrait réimplémenter les conteneurs de CommonMark (listes, citations, indentation), et c’est là que le front et le serveur divergeraient. commonmark-java suit la même spécification que marked.
  • Extraction des liens par le front, envoyée avec le corps : un client de l’API (script, import, règle) contournerait l’index. Le serveur doit rester l’autorité.
  • Résolution dynamique à la lecture, sans index : les backlinks exigeraient d’analyser tous les corps à chaque consultation.
  • Liens « collants » (la clé écrite est mémorisée sur la ligne, la cible est suivie malgré le renommage) : le texte et la cible divergent ([[Ancien]] mène à « Nouveau »), le même texte n’a pas le même sens d’une note à l’autre, et l’export n’est plus lisible par Obsidian.
  • Forme par identifiant [[<uuid>|Titre]] : elle lèverait les ambiguïtés et résisterait au renommage, mais rend la source illisible. Elle reste possible plus tard, de façon additive.
  • Titres uniques (index unique sur la clé) : des titres en double sont légitimes (factures mensuelles, comptes rendus).
  • Doublon ambigu laissé non résolu : créer un second « Réunion » casserait tous les liens existants.
  • target_id nullable dans node_link pour les liens non résolus : change la sémantique d’une table partagée avec les liens typés (jointures, unicité, contraintes).
  • Rattrapage par balayage des corps (body like '%[[%') à chaque création : son coût grandit avec la base, à chaque capture rapide.
  • Un seul événement wiki.updated {before, after} (comme tags.updated) : chaque ligne node_link a son identité et son couple ajout/retrait, comme les liens typés. Les règles du jalon 3 sur « lien créé » n’écoutent qu’un type d’événement.
  • Fichier config/links/wiki.yaml : il laisserait croire que source, target ou cardinality s’appliquent, et un fichier invalide ou absent changerait le comportement d’un mécanisme du moteur.
  • Création du nœud à l’enregistrement de la source, pour toutes les clés non résolues : les liens laissés volontairement non résolus (pratique courante dans Obsidian) deviendraient des nœuds vides.
  • Type note par défaut codé : c’est un nom du domaine.
  • Endpoint POST /api/nodes/resolve : il n’est pas nécessaire, puisque NodeView.wikiLinks couvre l’état enregistré et la recherche avec correspondance exacte en tête couvre le reste.
  • Indexer les transclusions ![[…]] dès maintenant : leur cible (nœud ou fichier) et leur type de lien relèvent de la tranche transclusion (V2). Réindexer plus tard ne coûte qu’un traitement par lot.

Conséquences

  • Une nouvelle dépendance backend : org.commonmark:commonmark 0.30.1, sans dépendance transitive. Deux nouvelles dépendances front dans le chunk différé de l’éditeur : @tiptap/suggestion 3.32.0 (épinglé exactement comme les autres @tiptap/*) et @floating-ui/dom ~1.8.0.
  • Migration V4 : colonne node.title_key (remplie en SQL pour les données existantes, puis maintenue par Java), index partiel (title_key, created_at, id) where deleted_at is null, table node_wiki_unresolved. L’application n’est pas encore déployée : les corps écrits avant cette tranche ne sont indexés qu’au prochain enregistrement.
  • Le renommage modifie le corps d’autres nœuds. Une source ouverte dans un autre onglet puis enregistrée réécrit l’ancien texte (le dernier enregistrement gagne, ADR 0008), et le lien redevient non résolu.
  • Avec des titres en double, un [[Titre]] mène au plus ancien. L’autocomplétion le signale, mais ne permet pas de viser un autre nœud tant que la forme par identifiant n’existe pas.
  • La corbeille ne coupe pas les liens wiki existants (même tolérance que les liens typés et les références, ADR 0005). Les nouveaux liens vers ce titre restent non résolus.
  • Jalon 3 : la restauration depuis la corbeille devra rattraper les lignes non résolues de la clé restaurée, et la purge devra repasser les clés de ses sources en non résolues (la cascade supprime les lignes node_link sans événement).
  • Les divergences possibles entre marked et commonmark-java (destination d’un lien Markdown contenant [[, URL nue GFM, | dans un span de code d’un tableau) sont listées dans la table de cas comme non garanties.
  • node.updated peut désormais porter reason : les consommateurs (historique, règles) distinguent une modification saisie d’une réécriture par le moteur.

Reporté

  • Défilement vers la section (#section) et vers un bloc (#^id).
  • Transclusion ![[…]] (rendu, indexation, réécriture au renommage).
  • Forme par identifiant [[<uuid>|Titre]].
  • Autocomplétion dans le mode source.
  • #tags du corps (tranche dédiée, voir ci-dessus).
  • Mentions non liées (V2).
  • Type et emplacement du nœud créé depuis un lien, à déclarer par un modèle de nœud.
  • Outil de réindexation complète (utile à l’import Obsidian, V2). Soldé par l’ADR 0020 (2026-10-15).