Second Brain documentation

ADR 0032 – Plusieurs fichiers par nœud : table `node_file` ordonnée, migration sans perte

Accepté (2026-10-15, mode autonome)

← Toutes les décisions

ADR 0032 – Plusieurs fichiers par nœud : table node_file ordonnée, migration sans perte

Statut : Accepté (2026-10-15, mode autonome)

Décisions prises en mode autonome (demande d’Aurélien, 2026-10-15 : « plusieurs fichiers par nœud : ajout, retrait, ordre et aperçu ; section Fichiers du panneau de contexte ; export inclus ; migration sans perte des fichiers uniques existants, testée ; sauvegarde et restauration vérifiées »). Amende l’ADR 0012 (« un fichier au plus par nœud ») et l’ADR 0018 (format de l’export, version 2).

Contexte

L’ADR 0012 limitait un nœud à un fichier (node.file_ref, node.file_name, node.file_media_type) et écartait la table node_file en notant qu’« ajouter une table plus tard coûte peu ». L’usage montre le besoin : une facture et son bon de commande, un courrier et ses annexes, un projet et ses pièces. Créer un nœud enfant par pièce alourdit l’arborescence pour des fichiers qui n’ont pas de vie propre.

Ce qui ne change pas : le contenu adressé par SHA-256 (file_blob, disque sous app.files.path), la mise en attente hors transaction, le verrou file.gc, la détection Tika, la politique de service durcie (FileServingPolicy), l’absence de suppression physique avant la purge du jalon 3, et le dépôt « un nœud par fichier » sur l’accueil, la liste, l’inbox et le panneau des enfants.

Décision

Modèle

Migration V10__fichiers_multiples.sql :

create table node_file (
    id         uuid        primary key,
    node_id    uuid        not null references node (id) on delete cascade,
    sha256     text        not null references file_blob (sha256),
    name       text        not null check (name <> ''),
    media_type text        not null check (media_type <> ''),
    position   integer     not null check (position >= 0),
    created_at timestamptz not null default now(),
    constraint node_file_position_uq unique (node_id, position) deferrable initially deferred,
    constraint node_file_same_uq unique (node_id, sha256, name)
);
create index node_file_sha256_idx on node_file (sha256);
  • Une ligne par fichier rattaché. Le nom d’origine et le type détecté restent propres au rattachement (ADR 0012), la taille est lue sur file_blob.
  • position donne l’ordre choisi par l’utilisateur. Un ajout prend max(position) + 1 sous le verrou du nœud ; un retrait laisse un trou, sans conséquence (on trie par position). La contrainte d’unicité est différée pour que la réécriture de l’ordre se fasse sans état intermédiaire interdit.
  • node_file_same_uq : le même contenu sous le même nom n’est rattaché qu’une fois à un nœud ; le service rend alors le nœud inchangé, sans événement (comme l’ADR 0012). Le même contenu sous un autre nom est un second fichier.
  • restrict sur file_blob (clé étrangère par défaut) : un contenu rattaché ne peut pas être supprimé, garde-fou de la purge du jalon 3. on delete cascade sur node, comme node_aspect : seule la purge supprime des nœuds.
  • Migration sans perte dans la même migration : chaque node.file_ref non nul devient une ligne node_file (id = gen_random_uuid(), même sha256, même nom, même type, position = 0, created_at = node.updated_at), puis les trois colonnes de node, leur contrainte, leur index et leur clé étrangère sont supprimés. Il n’y a plus qu’une source de vérité. Un test d’intégration applique les migrations jusqu’à V9, écrit des nœuds avec et sans fichier, migre et compare.
  • Sauvegardes (ADR 0019) : inchangées. Elles copient pg_dump puis les contenus listés par file_blob, que node_file référence. Une sauvegarde antérieure se restaure telle quelle puis Flyway applique V10 au démarrage : c’est le même chemin que la migration testée.

API

Méthode Chemin Effet Réponse
POST /api/nodes/files inchangé : crée un nœud pour un fichier déposé 201 NodeView
POST /api/nodes/{id}/files (multipart : file) ajoute un fichier en dernière position ; même contenu et même nom : rien 200 NodeView
DELETE /api/nodes/{id}/files/{fileId} retire ce fichier (le contenu reste stocké) 200 NodeView, 404 si le fichier n’est pas sur ce nœud
PUT /api/nodes/{id}/files/order ({ fileIds }) réordonne ; fileIds doit contenir exactement les fichiers du nœud 200 NodeView, 400 errors.fileIds sinon
GET /api/nodes/{id}/files/{fileId} contenu en flux, mêmes en-têtes que l’ADR 0012 200, 206, 304, 404
GET /api/nodes/{id}/file compatibilité : premier fichier dans l’ordre idem
  • PUT et DELETE /api/nodes/{id}/file disparaissent : « remplacer » n’a plus de sens unique. GET /api/nodes/{id}/file reste, parce que des corps Markdown contiennent déjà ![légende](/api/nodes/<id>/file) (ADR 0012).
  • FileView gagne id. NodeView.file devient NodeView.files (liste ordonnée, toujours présente, vide sans fichier). NodeSummary garde file (le premier fichier, pour l’icône et l’aperçu des listes) et gagne fileCount, lus par une jointure latérale qui ne coûte qu’une ligne par nœud.
  • Les identifiants de fichier sont vérifiés comme UUID et toujours avec le nœud (node_id = ? and id = ?) : un fichier n’est accessible que par son nœud, dont la corbeille masque tous les fichiers.

Événements

  • file.attached {after} à chaque ajout ; file.removed {before} à chaque retrait ; la charge utile gagne id ({id, sha256, name, size, mediaType}).
  • file.reordered {before, after} : listes d’identifiants. Un ordre identique ne produit ni événement ni mise à jour.
  • file.replaced n’est plus émis ; les événements déjà écrits restent lisibles.
  • Toute écriture fait touch sur node.updated_at.

Export (format 2)

  • formatVersion passe à 2.
  • Frontmatter : file (objet) devient files (liste ordonnée, absente sans fichier), chaque entrée avec name, path, mediaType, size, sha256 ; path est omis si le contenu manque, comme avant.
  • Chaque fichier va dans nodes/<Nom>/, sous segment(nom, 80). Deux noms qui se confondent (casse comprise) reçoivent (2), (3)… avant l’extension, dans l’ordre des fichiers.
  • export.json : filePath devient filePaths (liste) ; counts.files et le résumé comptent des fichiers, plus des nœuds.

Front

  • Panneau « Fichiers » de la fiche et section Fichiers du panneau de contexte : liste ordonnée (nom, type, taille), aperçu du fichier choisi (PDF, image, texte, comme l’ADR 0012), Ouvrir, Télécharger, Retirer (avec confirmation), Monter / Descendre pour l’ordre (boutons, utilisables au clavier et sur mobile), zone de dépôt et bouton « Ajouter des fichiers » acceptant plusieurs fichiers (envois séquentiels pour garder l’ordre de sélection).
  • nodeFileUrl(nodeId, fileId) vérifie les deux UUID ; le contournement bypassSecurityTrustResourceUrl de l’ADR 0012 s’applique à cette seule valeur.

Alternatives écartées

  • Garder node.file_ref à côté de node_file : deux sources de vérité, et chaque lecture devrait les fusionner.
  • Pièces jointes comme nœuds enfants (solution de l’ADR 0012) : reste possible, mais impose un titre, un type et une place dans l’arbre à chaque annexe.
  • Ordre par glisser-déposer : demande une bibliothèque (CDK drag-drop) ou du code d’accessibilité ; les boutons suffisent et marchent au clavier. À reprendre si l’usage le demande.
  • Ordre par created_at : ne permet pas de choisir la pièce principale.
  • Format d’export 1 conservé avec file = premier fichier : perdrait les suivants ; aucun import n’existe encore (V2), le changement de version suffit.

Conséquences

  • Un nœud peut porter autant de fichiers que voulu ; le dépôt sur l’accueil et les listes crée toujours un nœud par fichier.
  • Le client généré change (files, fileCount, nouvelles opérations) ; les écrans qui lisaient NodeView.file lisent files.
  • Les liens ![…](/api/nodes/<id>/file) déjà écrits continuent de montrer le premier fichier ; si l’ordre change, ils suivent le nouveau premier fichier.
  • Le déclencheur « fichier ajouté » des règles (jalon 3) correspondra à file.attached.