ADR 0032 – Plusieurs fichiers par nœud : table `node_file` ordonnée, migration sans perte
Accepté (2026-10-15, mode autonome)
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. positiondonne l’ordre choisi par l’utilisateur. Un ajout prendmax(position) + 1sous le verrou du nœud ; un retrait laisse un trou, sans conséquence (on trie parposition). 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.restrictsurfile_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 cascadesurnode, commenode_aspect: seule la purge supprime des nœuds.- Migration sans perte dans la même migration : chaque
node.file_refnon nul devient une lignenode_file(id = gen_random_uuid(), mêmesha256, même nom, même type,position = 0,created_at = node.updated_at), puis les trois colonnes denode, 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_dumppuis les contenus listés parfile_blob, quenode_fileré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 |
PUTetDELETE/api/nodes/{id}/filedisparaissent : « remplacer » n’a plus de sens unique.GET /api/nodes/{id}/filereste, parce que des corps Markdown contiennent déjà(ADR 0012).FileViewgagneid.NodeView.filedevientNodeView.files(liste ordonnée, toujours présente, vide sans fichier).NodeSummarygardefile(le premier fichier, pour l’icône et l’aperçu des listes) et gagnefileCount, 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 gagneid({id, sha256, name, size, mediaType}).file.reordered{before, after}: listes d’identifiants. Un ordre identique ne produit ni événement ni mise à jour.file.replacedn’est plus émis ; les événements déjà écrits restent lisibles.- Toute écriture fait
touchsurnode.updated_at.
Export (format 2)
formatVersionpasse à 2.- Frontmatter :
file(objet) devientfiles(liste ordonnée, absente sans fichier), chaque entrée avecname,path,mediaType,size,sha256;pathest omis si le contenu manque, comme avant. - Chaque fichier va dans
nodes/<Nom>/, soussegment(nom, 80). Deux noms qui se confondent (casse comprise) reçoivent(2),(3)… avant l’extension, dans l’ordre des fichiers. export.json:filePathdevientfilePaths(liste) ;counts.fileset 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 contournementbypassSecurityTrustResourceUrlde l’ADR 0012 s’applique à cette seule valeur.
Alternatives écartées
- Garder
node.file_refà côté denode_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 lisaientNodeView.filelisentfiles. - Les liens
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.