volontariapp-file-storage-flow
Playbook pour tout travail sur le stockage de fichiers (upload, scan, réservation, rattachement, nettoyage) - ms-storage, worker-storage, post-processor-storage, et rattachement d'une image à une entité métier (post, event, avatar, badge).
- License 许可
- MIT
- 最近更新
- 2026-11-10
- Stars
- ★ 0
- Forks
- 0
01 前言 Front Matter
- name
- volontariapp-file-storage-flow
- tags
- [ "storage", "upload", "files" ]
- type
- Agent Skill
- paths
- [ "docs/stockage-fichiers/**", "npm-packages/packages/domain-storage/src/**", "ms-storage/src/**" ]
- title
- File Storage Flow
- status
- stable
- sources
- [ { "id": "storage-docs", "title": "Architecture du stockage de fichiers", "resource": "docs/stockage-fichiers" }, { "id": "domain-storage", "title": "@volontariapp/domain-storage", "resource": "npm-packages/packages/domain-storage/src" } ]
- verified
- [ { "at": "2026-10-16T23:31:30Z", "by": "claude-code/agent", "digest": "c2af7436c571d364" }, { "at": "2026-10-16T23:48:47Z", "by": "claude-code/agent", "digest": "c2af7436c571d364" }, { "at": "2026-10-17T00:15:13Z", "by": "claude-code/agent", "digest": "c2af7436c571d364" }, { "at": "2026-10-28T19:34:14Z", "by": "claude-code/claude-sonnet-5-5", "digest": "3e7dfcb890237eed" }, { "at": "2026-11-05T17:34:09Z", "by": "claude-code/agent", "digest": "da39a3ee5e6b4b0d" } ]
- generated
- { "at": "2026-10-06T08:22:12Z", "by": "claude-code/claude-opus-5-5" }
- mesh_keys
- [ "storage", "file_id", "upload", "confirmfileattachment", "quarantine" ]
- description
- Playbook pour tout travail sur le stockage de fichiers (upload, scan, réservation, rattachement, nettoyage) - ms-storage, worker-storage, post-processor-storage, et rattachement d'une image à une entité métier (post, event, avatar, badge).
02 资源文件 3
- 参考文档index.md619 B
- 参考文档log.md8.8 KB
- 参考文档references/lessons.md13.7 KB
03 SKILL.md 正文
Playbook : Stockage de Fichiers
La référence d'architecture est docs/stockage-fichiers/ (repo Volontariapp/docs). Ce playbook en est le résumé opérationnel. Lis le document du flux concerné avant de coder.
[!IMPORTANT] Statut au 2026-11-05 : les flux principaux (avatar, post, événement) et la purge des orphelins sont implémentés de bout en bout. Chaque libération (
releaseForOwner,releaseForEntity,releaseFile, domain-storage 0.14.0) écritstorage.cleanup_filesdans sa transaction ;worker-storageporteCleanupFilesHandler(objets supprimés, puismarkDeleted) ; filet de sécurité :requestPurge, appelée par la commande ms-storagedist/commands/request-file-purge.js, que lance le CronJobms-storage-purge(deploy) toutes les 15 minutes. Scénarios E2E : purge d'un compte supprimé (user-deletion-scenarios) et d'un post ou d'un avatar remplacé (storage-release-scenarios). Détail :references/lessons.md. Le paragraphe suivant est l'historique de construction écrit au 2026-10-06, partiellement dépassé : lire d'abord ce résumé.ms-storagedémarre en hybride (gRPC surmicroServices.msStorageUrl,0.0.0.0:5006, plus HTTP/healthsur 3006,GrpcInternalGuardglobal) et expose les handlersGenerateUploadUrl,ConfirmUpload,ConfirmFileAttachment,DeleteFileetGetFileMetadata(ms-storage/src/modules/storage/controllers/) ;api-gatewayexposePOST /storage/upload-url,POST /storage/files/:id/confirmetGET /storage/files/:id, etpost-processor-storagerattache et libère les fichiers (création, remplacement de couverture ou d'avatar, suppression,user.deleted) ;deployporte MinIO, ClamAV et la base dems-storage. Historique 2026-10-06 (dépassé pourms-storage,api-gatewayetdeploy, valable pourdomain-storage) : la migration de production dems-storagems-storage/src/migrations/domain/1791300000000-CreateFilesAndReleasedEntities.tscréefiles(7 index dont 5 partiels) etreleased_entities; son test (yarn test:migration, opt-in viaMS_STORAGE_MIGRATION_TEST_DB_HOST, base locale jetable qu'il vide) compare le schéma àFileModel; sa configuration portes3.privateBucket,s3.publicEndpoint(signature uniquement),s3.publicBaseUrletscanner.host/scanner.port/scanner.timeoutMs(validés, variablesS3_PRIVATE_BUCKET,S3_PUBLIC_ENDPOINT,S3_PUBLIC_BASE_URL,SCANNER_*),S3Moduleest importé dansAppModule;domain-storage(tickets 1.6 à 1.10, mergés surmain: 0.7.0 publiée, 0.8.0 en cours de publication) a les enumsScanStatus/ValidationMode/RejectionReason,FileStatus.RESERVED,VALIDATION_POLICY_BY_ENTITYetresolveValidationMode(SVG retiré) et les helpers pursbuildQuarantineObjectKey/buildPublicObjectKey/buildPublicFileUrl(src/helpers/) et les modèles TypeORMFileModel(tablefiles) etReleasedEntityModel(tablereleased_entities, colonnes en snake_case explicites,src/models/, sous-chemin./models), et unPostgresFileRepositorypartiel (sous-chemin./repositories, jamais réexporté par la racine,src/repositories/), refactoré en 0.9.0 sur le pattern dedomain-user:FileEntity(classe puresrc/entities/, exportée par la racine ;FileEntity.createporte les invariants : MIME autorisé, mode SYNC/ASYNC depuis la taille déclarée, clé de quarantaine,uploadExpiresAt= création (toujours l'heure courante, jamais fournie par l'appelant) +presignedUrlTtlSeconds(au plusMAX_PRESIGNED_URL_TTL_SECONDS, 7 jours, sinonBadRequestError) + 5 min) etReleasedEntityEntity,registerStorageMappings()(src/models/mapper.ts, appelé à l'import du sous-chemin./models, enregistre aussi les paires outbox ; contrairement àdomain-postetdomain-event, qui l'appellent à l'import de leur racine, la racine de domain-storage ne doit pas charger typeorm ;domain-userlaisse l'appel au consommateur),IFileRepository(renvoie des entités),PostgresFileRepository extends BaseRepository<FileModel, FileEntity>(@Injectable, constructeur@InjectRepository(FileModel) Repository<FileModel>, plus deDataSource), outbox écrite viaJobsOutboxRepository/EventQueueRepositoryde@volontariapp/outboxsurmanager.getRepository(...)de la transaction ;typeormest résolu en deux copies (Yarn instancie un peer par jeu de peers) : letsconfig.jsonde domain-storage mappetypeormvers la copie hissée pour queRepository/EntityManagerrestent assignables à ceux dedatabase/outbox(sans ça TS2345 "Property 'findOptions' is protected") ; le TS2589 invoqué avant n'a pas pu être reproduit. Méthodes :createPending,confirmUpload,switchToAsync,resetToAwaitingUpload,completeScan,rejectScan. Chaque transition est unUPDATE ... RETURNINGconditionnel en READ COMMITTED, renvoienull/falsesi aucune ligne ne correspond, et écritjobs_outbox(storage.scan_file) ouevent_queue(storage.file_scanned/storage.file_rejected, seulement si la ligne estATTACHED) dans la même transaction. Les tests d'intégration (yarn test:integration, basepostgres-storagesurlocalhost:5437, qui fait undropDatabase(): refusé hors hôte local) chargent la migration de testsrc/test/migrations/domain/puis les migrations communes copiées de domain-post (src/test/migrations/common/). Limite connue (revérifiée le 2026-10-28 surorigin/main, domain-storage 0.12.9) :@volontariapp/sharedporte désormaisStreams.STORAGE_FILE_SCANNED,STORAGE_FILE_REJECTEDetSTORAGE_ATTACHMENT_REJECTED, maisFILE_SCAN_RESULT_TARGET_SERVICES(src/repositories/file-outbox.builders.ts) est toujours[]: les événements storage sont donc écrits avectargetServicesvide et le pusher les ignore tant que la constante n'est pas renseignée. ATTENTION : avectargetServicesvide, le pusher d'outbox saute la ligne sans erreur et le consommateur la marqueCOMPLETED: l'événement est perdu en silence et le média du post restePENDING, donc ne pas branchercompleteScan/rejectScandansms-storageouworker-storageavant queStorageStreamexiste.reserve(ticket 1.10, domain-storage 0.8.0) existe :reserve({ fileIds, entityType, entityId, ownerId })dedoublonne, refuse au-dela demaxPerEntity(TooManyFilesException, ids distincts de l'appel, pas le cumul en base : un avatar remplace peut coexister avec l'ancienATTACHED), verrouille enSELECT ... FOR UPDATE ORDER BY id, classe chaque fichier avec la regle pureclassifyFileForAttachment(src/policies/attachment-validation.rule.ts, a reutiliser telle quelle dansattachFromConfirmationEvent: verdictsRESERVE/ALREADY_HELD/NOT_FOUND/REFUSED+AttachmentRefusalReason), puis un seulUPDATE ... WHERE status = 'PENDING'pour tout reserver ; tout ou rien,FileNotFoundException(404, prioritaire) ouFileAttachmentRefusedException(422,codeFILE_ATTACHMENT_REFUSED,details.reason; songrpcCodeest FAILED_PRECONDITION : elle étendBaseApiErrordirectement, carUnprocessableEntityErrorfixe INVALID_ARGUMENT et le filtre global renvoiegrpcCodetel quel, il n'existe aucun mapping parcode) ; aucun evenement ecrit.attachFromConfirmationEvent,releaseForEntity,releaseFile,releaseForOwner(ticket 1.11, domain-storage 0.10.0, PR en cours de publication) existent :attachFromConfirmationEvent({ fileIds, entityType, entityId, ownerId })tourne dans une transaction souspg_advisory_xact_lock(hashtext(entity_type:entity_id))(verrou par entité, doc 11 P1) ; si l'entité a une pierre tombale, les fichiers nommésPENDING/RESERVEDdu propriétaire passentORPHANEDsans événement ; sinonSELECT ... FOR UPDATE ORDER BY id, classement parclassifyFileForConfirmation(src/policies/attachment-validation.rule.ts, qui délègue àclassifyFileForAttachmentpour tout fichier que l'entité ne tient pas déjà ; un fichier déjà tenu par la même entité est jugé sur son statut seul :RESERVEDà rattacher,ATTACHEDrejeu,ORPHANED/DELETEDlibération arrivée avant), puis un seulUPDATE ... RETURNINGversATTACHED; un fichier rattaché dont le scan est terminal écritstorage.file_scanned/storage.file_rejected, un fichier refusé écritstorage.attachment_rejected(raisonsNOT_FOUND,WRONG_ENTITY_TYPE,NOT_CONFIRMED,CONTENT_REJECTED,ALREADY_ATTACHED, viatoAttachmentRejectionReason), un rejeu n'écrit rien ; résultatFileAttachmentResult[](FileAttachmentOutcome) ; une pierre tombale ne produit pas deattachment_rejected(messaging 2.19.0 n'a pasENTITY_RELEASED) ; un rejeu d'un refus réécrit unattachment_rejected(pas d'état pour dédupliquer).releaseForEntity({ entityType, entityId })écrit la pierre tombale (ReleasedEntityEntity,ON CONFLICT DO NOTHING) et passeRESERVED/ATTACHEDde l'entité enORPHANED, même verrou, aucun événement.releaseFile({ fileId, entityType, entityId, ownerId?, newFileId? }): unUPDATEconditionnel depuisPENDING(propriétaire obligatoire,entity_idrenseigné),RESERVEDouATTACHED(condition sur l'entité, propriétaire facultatif pour un événement sans propriétaire comme l'icône de badge, doc 11 P7), ignoré sinewFileIdest le même fichier.releaseForOwner({ ownerId })passe enORPHANEDtoutPENDING/RESERVED/ATTACHEDdu propriétaire saufBADGE_ICON(OWNER_RELEASE_EXCLUDED_ENTITY_TYPES), sans pierre tombale. Ces événements storage (file_scanned,file_rejectedet désormaisattachment_rejected) sont tous écrits avectargetServicesvide tant queStorageStreamn'existe pas : ne pas brancher ces méthodes danspost-processor-storageavant le ticket 1.14.user.badge_createdne porte pas deuserId: le ticket 4.x doit décider du propriétaire. Le client mobile appelle le stockage parnativapp/src/api/storage/etuse-post-images.ts; le symboleuseUploadFilen'a pas été trouvé dansnativapp/src. Toujours partir de01-etat-des-lieux.mdet de10-plan-implementation.md(vagues, chacune terminée par la règle du STOP).
| Besoin | Document |
|---|---|
| État réel du code | docs/stockage-fichiers/01-etat-des-lieux.md |
| Responsabilités, packages, buckets, modes, pipeline, ids d'entité, visibilité | docs/stockage-fichiers/02-architecture-cible.md |
FileStatus, ScanStatus, réservation, règle d'émission, commutativité |
docs/stockage-fichiers/03-cycle-de-vie-fichier.md |
| Images d'un post (ASYNC) | docs/stockage-fichiers/04-flux-post.md |
| Photo d'un événement (ASYNC) | docs/stockage-fichiers/05-flux-event.md |
| Avatar et icône de badge (SYNC) | docs/stockage-fichiers/06-flux-avatar.md |
| Libération, suppression de compte, purge | docs/stockage-fichiers/07-nettoyage-et-orphelins.md |
| Proto, messaging, shared, domain-*, SQL, configuration | docs/stockage-fichiers/08-contrats-et-evolutions.md |
| Menaces, codes d'erreur | docs/stockage-fichiers/09-securite.md |
| Ordre des tickets | docs/stockage-fichiers/10-plan-implementation.md |
| Comportement en panne | docs/stockage-fichiers/11-scenarios-de-panne.md |
Invariants (non négociables)
- Aucun octet de fichier ne transite par
api-gatewayni par unms-*métier. Le client uploade directement sur S3 via un POST signé, signé avecs3.publicEndpoint. - Tout upload arrive en quarantaine (
volontariapp-private, cléquarantine/{fileId}). Seule une version traitée (magic bytes, clamd, ré-encodage) est écrite dans le bucket public, qui n'autorise ques3:GetObjectanonyme. - Traitement SYNC par défaut, ASYNC si le fichier dépasse le seuil SYNC de son
EntityTypeou si le pipeline synchrone échoue techniquement. Le mode vient dedomain-storage(resolveValidationMode, taille déclarée), jamais du client. Avatar et badge : toujours SYNC, 503 sur erreur technique. SVG refusé. - Les services métier stockent des
file_id, jamais d'URL. L'URL publique se calcule avecbuildPublicFileUrl(domain-storage). owner_idvient de l'INTERNAL_TOKEN(CurrentUser), jamais du payload. Le client storage partagé propagex-internal-tokendans lesMetadata.- Rattachement synchrone par défaut :
ConfirmFileAttachment(deadline 2 s), avant l'écriture de l'entité et avant toutwithFallback. SurUNAVAILABLE/DEADLINE_EXCEEDED: post et event passent en rattachement asynchrone (entité écrite avec statut médiaPENDING, validation parpp-storage), avatar et badge répondent 503. Les erreursNOT_FOUND/FAILED_PRECONDITIONne déclenchent jamais de fallback. Une politique SYNC exigescan_status = CLEAN, une politique ASYNC accepteSCANNING.VerifyFilesExistest déprécié. - L'id d'entité est calculé avant l'écriture :
UUID v5(ownerId, idempotencyKey)à la création. Il est transporté dans les payloads de fallback. La création est idempotente :INSERT ... ON CONFLICT (id) DO NOTHING, sans réémettre l'événement de création si 0 ligne (jamaismanager.save). - L'événement de création ou de remplacement est écrit dans
event_queuedans la même transaction que l'entité, avec lesfileIdset le propriétaire. C'est lui qui confirme le rattachement (RESERVEDouPENDINGvalidé versATTACHED). La règle de validation est unique, dansPostgresFileRepository, partagée parreserveetattachFromConfirmationEvent. - Rattachement depuis
RESERVED(même entité) ouPENDINGvalidé, libération par entité avec pierre tombale (released_entities), libération d'un fichier nommé depuisPENDING/RESERVED/ATTACHED,ORPHANEDabsorbant. Jamais de condition de date sur le rattachement. Aucun*_replacedsi le fichier ne change pas (newFileId = oldFileId). storage.file_scanned/storage.file_rejectedne sont émis que pour un fichierATTACHEDauscan_statusterminal, par la transition qui arrive en second (fin de traitement ou rattachement). Conditions : READ COMMITTED, un seulUPDATE ... RETURNINGpar transition, écritureevent_queuedans la même transaction.- Fin de traitement par
UPDATEconditionnel (scan_status = 'SCANNING' AND status NOT IN ('ORPHANED','DELETED')). Si 0 ligne, supprimer l'objet public écrit, sauf exécution en double d'un fichier toujoursCLEANet non libéré. En SYNC, une erreur technique remet le fichier enAWAITING_UPLOAD(échéance repoussée de 15 min) avant le 503. La purge relance unSCANNINGbloqué une seule fois, viarescan_scheduled_at. - Un post-processor métier qui ne trouve plus l'entité acquitte avec un log
warn: la règle d'émission garantit qu'elle existait, elle a donc été supprimée depuis. FileStatusetScanStatussont deux axes distincts.media_status/cover_statussont distincts desaga_status.ws-servicen'est jamais un maillon métier.- Persistance
filesdansdomain-storage(FileModel,PostgresFileRepository,./repositories), partagée parms-storage,worker-storageetpost-processor-storage. Le pipeline S3 / clamd / ré-encodage vit dans le package d'infrastructure storage. - La table d'événements s'appelle
event_queue, pasevent_outbox.
Recette : rattacher une image à un nouveau type d'entité
Exemple : ajouter une image à une "organisation".
proto-registry(skillvolontariapp-proto-contract-evolution) :- nouvelle valeur
EntityType(additive) ; - numéros de champs fixés au-dessus du maximum actuel,
reservedsur tout numéro supprimé sans réservation ; optional string <x>_file_iddans l'entité, et dans la commande de création avecstring idempotency_key;- pour une mise à jour par
Entity+update_mask, le champ va uniquement dans l'entité ; - statut média (enum préfixé,
_UNSPECIFIED = 0) si le mode est ASYNC ; - STOP.
- nouvelle valeur
npm-packages(skillvolontariapp-shared-npm-package-change) :domain-storage: entrée dansVALIDATION_POLICY_BY_ENTITY(mode, taille, MIME, format, maximum par entité) et préfixe de clé publique ;messaging: événements<domaine>.<x>_replaced{ <entity>Id, newFileId?, oldFileId? }et, si absents,created/deleted; payloads de fallback avec l'id calculé ;shared: streams correspondants ;domain-<x>: id fourni à la création, colonne<x>_file_id(+ statut si ASYNC), écriture de l'entité et de l'événement dans la même transaction, lecture de l'ancienfile_idsousSELECT ... FOR UPDATElors d'un remplacement ;- STOP.
- Service métier : calcul de l'id, appel
ConfirmFileAttachmentvia le client storage partagé avantwithFallback, statut initial selon lescan_statusrenvoyé. Décider si l'entité supporte le rattachement asynchrone (elle doit alors avoir un statut média) ou répond 503 quandms-storageest indisponible. post-processor-storage(skillvolontariapp-implement-async-event-flow) : consommer création, remplacement, suppression et échec de saga de l'entité. Les échecs de saga (*.creation_failed) sont émis parws-servicesur les streams partagésws:*-created-feedback: filtrer par type.- Si l'entité a un statut média : le post-processor du domaine consomme
storage.file_scanned/storage.file_rejected/storage.attachment_rejected, filtre surentityType, ignore lesfileIdqui ne sont plus courants, met à jour le statut de façon idempotente, acquitte avecwarnsi l'entité a été supprimée. api-gateway: relayer<x>FileIdetidempotencyKey, exposer l'URL calculée parbuildPublicFileUrl.nativapp: réutiliseruseUploadFile(entityType), générer uneidempotencyKeypar formulaire, gérerSCANNING/REJECTEDsi ASYNC.- Suppression de compte : décider si les fichiers de ce type sont libérés par
user.deleted(par défaut oui, sauf ressources de la plateforme commeBADGE_ICON).
Checklist de revue
- [ ] Aucun
any, aucunas unknown as, aucun cast sur les erreurs gRPC (type guard dans le client storage partagé). - [ ] Aucune duplication du pipeline entre
ms-storage(SYNC) etworker-storage(ASYNC), ni de la persistancefiles. - [ ] Codes d'erreur conformes à
09-securite.md(aucun 500 pour un fichier invalide,NOT_FOUNDpour le fichier d'un autre). - [ ] Toute transition est un
UPDATEconditionnel, idempotent et loggé via@volontariapp/logger. - [ ] Les deux ordres "scan puis rattachement" et "rattachement puis scan" sont couverts par des tests, pour les chemins de rattachement synchrone et asynchrone.
- [ ] Les scénarios de
11-scenarios-de-panne.mdconcernés par le changement sont testés (clamd arrêté,ms-storagearrêté). - [ ] Mocks dans
*.mock.ts, factories dans*.factory.ts,jest.spyOn+restoreAllMocks(). - [ ] Noms de fichiers et de répertoires en kebab-case.
Diagnostic
- Fichier bloqué en
SCANNING: vérifierjobs_outbox/job_auditdems_storage(skillvolontariapp-trace-async-flow). La purge relance un job après 2 h et rejette après 12 h. - Fichier resté
RESERVED: l'événement de confirmation n'a pas été émis (entité jamais écrite, fallback en attente) oupp-storagene le consomme pas. Libéré automatiquement après 7 jours. - Entité restée
PENDINGalors que le fichier estCLEAN: vérifier que le fichier est bienATTACHED, puis questorage.file_scanneda été écrit dansevent_queue. - Qui produit / consomme un événement :
analyze_impact({ target: "storage.file_scanned" })(mesh-mcp). - Qui appelle
StorageService:analyze_grpc({ target: "StorageService" })(mesh-mcp).
04 同仓库更多 Skills
volontariapp-user-account-deletion
Suppression de compte en deux temps (PENDING_DELETION): requestDeletionWithUserDeleted, finalizeDeletion, fan-out de user.deleted vers les domaines et gather ws. A utiliser pour tout changement de domain-user touchant la suppression, le statut ou le cycle de vie d'un user.
volontariapp-proto-contract-evolution
Modifier un contrat Protobuf de proto-registry (champ, message, enum, RPC) sans casser la compatibilité wire, puis suivre la cascade proto-registry, PR automatique dans npm-packages, publication, consommateurs. À utiliser avant toute édition d'un .proto.
volontariapp-meta-scripts
Utiliser et faire evoluer la CLI Node de meta (setup, doctor, migrate, certs, repos, bump) et le manifeste repos.json, source unique des repos et unites du workspace.
volontariapp-implement-async-event-flow
Guide pas-à-pas pour concevoir et implémenter un flux asynchrone complet (messaging -> outbox -> post-processors -> ws-service -> client).
research
Investigate a question against high-trust primary sources and capture the findings as a Markdown file in the repo. Use when the user wants a topic researched, docs or API facts gathered, or reading legwork delegated to a background agent.
prototype
Build a throwaway prototype to answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.