docs(knowledge): capitalisation backend — intégration du triage local (mai-juin 2026)

Triage et intégration des propositions backend du buffer 95_a_capitaliser.md
(lot local RL799_V2 + app-alexandrie, mai-juin 2026), distinct de la capitalisation
remote antérieure (triage 2026-05-02).

~73 entrées intégrées sur knowledge/backend/, dont :
- patterns/auth.md : série "membrane d'auth fédérée BFF/OIDC" (9 patterns) + jose algo whitelist
- patterns/prisma.md : recette fusionnée "Migration String/Int → enum" (backfill + Cas A/B/C),
  row réactivable, endpoint replace atomique, updateMany conditionnel, etc.
- risques/general.md : 19 risques (epoch s vs ms, keepAliveTimeout=0, upsert+filtre liste,
  fail-safe catch-all, retrait asymétrique front/back, anti-énumération rate-limit, etc.)
- patterns/general, async, nestjs, contracts, tests + risques/auth, contracts, prisma, redis, stripe, tests
- compléments d'entrées existantes (authorize-after-fetch, P3014, cursor opaque, DI swc, Stripe v20...)
- README patterns/risques mis à jour

Doublons internes corrigés en relecture (suppression-champ .map() → general seul ;
e2e DB-based → tests.md seul). Doublons hors backend / entrées projet / rejets non intégrés.
Source 95_a_capitaliser.md non purgée à ce stade (purge en fin de capitalisation complète).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MaksTinyWorkshop
2026-06-25 11:25:02 +02:00
parent ef24d85d57
commit f1b783407a
18 changed files with 2896 additions and 24 deletions
+165
View File
@@ -278,3 +278,168 @@ Tout pipeline qui écrit N fichiers avec rollback (suppression des déjà-écrit
- que l'erreur est bien propagée à l'appelant.
Quand le nombre d'artefacts change (ajout d'un variant), mettre à jour ce test pour couvrir le nouveau N.
---
<a id="pattern-promise-all-queries-db-independantes"></a>
## Pattern : Promise.all des queries DB indépendantes dans un service handler
- Objectif : réduire la latence d'un service handler dont les queries DB sont posées en série alors qu'elles n'ont aucune dépendance de données.
- Contexte : service handler API avec plusieurs `await` chaînés (lectures DB indépendantes).
- Quand l'utiliser : >2 `await` dans le corps du handler, dont les arguments ne dérivent pas du résultat précédent.
- Quand l'éviter :
- contrôles d'autorisation (auth → user lookup → permission) : garder en série pour fail-fast et ne pas gaspiller de queries sur une requête non autorisée
- boucles `for (...) await` à pagination progressive / arrêt anticipé : volontairement séquentielles
- Avantage :
- latence totale `~max()` au lieu de `~sum()`
- gain proportionnellement **plus grand** en prod réseau (chaque round-trip cumule en série, s'absorbe en parallèle)
- Limites / vigilance :
- les queries conditionnelles doivent être emballées en `Promise.resolve(default)` pour garder le type `Promise<T>` dans le tuple
- la transformation des résultats reste APRÈS le `Promise.all` ; les dérivations synchrones des seuls paramètres d'entrée peuvent passer AVANT
- Validé le : 11-05-2026
- Contexte technique : Next.js 16 API + Prisma 7 — RL799_V2
### Anti-pattern (séquentiel)
```ts
const odjItems = soireeId ? await findOdjItemsBySoiree(soireeId) : [];
const issues = await findLatestIssuesByTenueIds(tenueIds);
const soireeContext = soireeId ? await getSoireeContext(soireeId) : null;
const convocations = await findConvocationsByTenuesAndGrade(tenueIds, userId);
// total : ~sum(latence_de_chaque_query)
```
Symptômes : endpoint dans le top des plus lents, `curl -w "%{time_total}"` corrélé linéairement au nombre d'`await`, chaque query individuelle rapide (~50-70 ms) sans N+1 — c'est l'orchestration qui est en cause.
### Pattern (parallèle)
```ts
const [odjItems, issues, soireeContext, convocations] = await Promise.all([
soireeId ? findOdjItemsBySoiree(soireeId) : Promise.resolve([]),
findLatestIssuesByTenueIds(tenueIds),
soireeId ? getSoireeContext(soireeId) : Promise.resolve(null),
findConvocationsByTenuesAndGrade(tenueIds, userId),
]);
// total : ~max(latence_de_chaque_query)
```
### Détection
1. Mesurer (`curl -w "%{time_total}"` / `console.time()`), cibler les endpoints > 150 ms en local DB chaude.
2. Compter les `await` du handler ; >2 hors contrôles d'autorisation = suspect.
3. Pour chaque `await N+1`, vérifier si l'argument dérive du résultat de `await N`. Si NON → parallélisable.
### Tests
Le contrat de l'endpoint ne change pas. Les tests qui mockent les repositories passent sans modification. Si un test casse, c'est qu'il sur-spécifiait l'ordre d'exécution (anti-pattern de test).
Cas vécu : `dashboardService.handleProchaineTenue` — 4 queries séquentielles → `Promise.all`. 240 ms → 170 ms (-29% local, -40 à -50% projeté prod réseau), 1526/1526 tests verts sans modification.
---
<a id="pattern-cron-multi-instance-lock-redis"></a>
## Pattern : Cron multi-instance idempotent via lock Redis `SET NX EX`
- Objectif : garantir qu'un `@Cron` ne s'exécute qu'**une seule fois** en environnement multi-pods, sans introduire de scheduler distribué (Bull, etc.).
- Contexte : `@Cron` (`@nestjs/schedule`) déployé sur N instances avec effets de bord (notifications, mails de campagne).
- Quand l'utiliser : tout cron avec effet de bord non idempotent en déploiement multi-instance.
- Quand l'éviter : instance unique garantie, ou job purement idempotent.
- Avantage :
- aucune dépendance supplémentaire
- auto-libération du lock si le pod crashe (TTL)
- Limites / vigilance :
- TTL > durée du job mais < intervalle entre deux exécutions
- Redis down → renvoyer `false` (s'abstenir : ne pas risquer une double exécution d'effets de bord)
- expliciter l'expression cron ET la timezone — les raccourcis `CronExpression.*` partent en UTC serveur
- Validé le : 04-06-2026
- Contexte technique : NestJS / `@nestjs/schedule` / Redis — app-alexandrie
### Implémentation
```typescript
// Lock distribué : n'exécuter le corps QUE si SET NX renvoie 'OK'
async acquireLock(key: string, ttlSec: number): Promise<boolean> {
const res = await client.set(key, '1', { NX: true, EX: ttlSec });
return res === 'OK';
}
@Cron('0 8 * * 1', { timeZone: 'Europe/Paris' }) // expression + TZ explicites
async runWeeklyJob() {
if (!(await this.acquireLock('cron:weekly-job', 600))) return; // un autre pod a gagné, ou Redis down
// ... corps du job ...
}
```
### Règle
Expliciter l'expression cron ET la timezone (`{ timeZone: 'Europe/Paris' }`) : les raccourcis `CronExpression.EVERY_WEEK` / `EVERY_DAY_AT_9AM` s'exécutent en UTC (heure serveur) → décalage vs l'intention métier.
---
<a id="pattern-verrou-une-action-par-entite-updatemany"></a>
## Pattern : Verrou atomique « une action par entité » via `updateMany` conditionnel
- Objectif : garantir qu'une action ne peut être déclenchée qu'**au plus une fois** par entité (relance, remboursement, envoi de mail de campagne, confirmation), résistant aux requêtes concurrentes.
- Contexte : endpoint qui déclenche un effet de bord one-shot marqué par un champ (`sentAt`, `refundedAt`...).
- Quand l'utiliser : tout flow « au plus une fois par entité » exposé à des appels concurrents.
- Quand l'éviter : action naturellement idempotente côté effet de bord.
- Distinction avec le lock Redis cron : ici le verrou est porté par la **DB** (champ d'état de l'entité, atomicité de l'`UPDATE ... WHERE`), pas par un lock Redis externe ; il protège une action par entité, pas une exécution unique de job multi-pods.
- Avantage :
- guard précoce (fast-path) qui évite le coût des requêtes intermédiaires pour les appels normaux
- `updateMany` conditionnel atomique qui tranche la race condition
- Limites / vigilance :
- le `if (alreadySent) return 409` initial seul **ne suffit pas** : deux requêtes concurrentes le passent toutes les deux
- `lock.count === 0` = une autre requête a gagné la course → 409
- Validé le : 20-06-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```typescript
// Guard précoce (optimistic fast-path, non atomique — suffit pour 99% des cas)
if (entity.sentAt) return errorResponse(409, 'ALREADY_SENT', '...');
// ... traitement coûteux ...
// Verrou atomique : UPDATE conditionnel WHERE sentAt IS NULL
const lock = await prisma.entity.updateMany({
where: { id: entityId, sentAt: null },
data: { sentAt: new Date() },
});
if (lock.count === 0) {
return errorResponse(409, 'ALREADY_SENT', '...');
}
```
---
<a id="pattern-persist-then-send-statut-apres-effet"></a>
## Pattern : Effet de bord externe avec statut persistant — « persist-then-send »
- Objectif : ne pas faire mentir un statut métier qui reflète l'état d'un effet de bord externe, en cas d'échec partiel.
- Contexte : opération qui (1) persiste une donnée nécessaire à une vérification ultérieure (token hash, idempotency key), (2) déclenche un effet externe best-effort (mail, webhook), (3) expose un statut (`pending|emailed|active`).
- Quand l'utiliser : dès que le statut exposé doit refléter l'état RÉEL de l'effet externe et que ce dernier peut échouer.
- Quand l'éviter : pas d'effet externe, ou statut sans valeur métier (rejouabilité non requise).
- Avantage :
- tout artefact émis reste traçable côté serveur (donnée vérifiable posée d'abord)
- le statut reste `pending` (rejouable) si l'effet externe échoue
- Limites / vigilance :
- exige DEUX écritures (poser la donnée vérifiable, puis marquer le statut) — pas un seul `update` atomique
- Validé le : 16-06-2026
- Contexte technique : Prisma / Resend / Keycloak — RL799_V2
### Règle
Séparer en deux écritures : d'abord poser la donnée vérifiable SANS toucher au statut, PUIS — seulement si l'effet externe réussit — faire passer le statut. Ordre `persist token → send → mark status`.
```txt
// ✅ token toujours vérifiable même si le mail rate ; statut = état réel (reste pending → rejouable)
setOnboardingToken(...) // hash + expiresAt, statut INCHANGÉ
await executeActionsEmail() // effet externe (attendre le 204)
markOnboardingEmailed(...) // statut passé à 'emailed' APRÈS confirmation
// ❌ un seul update atomique avant l'envoi : le statut ment si Resend/Keycloak est down
update({ status: 'emailed', tokenHash, expiresAt })
```
Cas vécu : RL799 Lot B onboarding Keycloak — `setOnboardingToken` (statut inchangé) puis `markOnboardingEmailed` (statut après 204 confirmé de `executeActionsEmail`).